<?php
/**
 * Adapter that extends scr34m/php-malware-scanner to use Security Ninja patterns, whitelist, and result collection.
 *
 * @package WPSecurityNinja\Plugin
 */

namespace WPSecurityNinja\Plugin;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

// Load the scr34m library (use plugin dir so path is correct in all contexts).
if ( ! class_exists( 'MalwareScanner', false ) ) {
	$vendor_scan = defined( 'WF_SN_PLUGIN_DIR' )
		? WF_SN_PLUGIN_DIR . 'vendor/scr34m/php-malware-scanner/scan.php'
		: dirname( __DIR__ ) . '/vendor/scr34m/php-malware-scanner/scan.php';
	if ( is_readable( $vendor_scan ) ) {
		require_once $vendor_scan;
	}
}

if ( ! class_exists( 'MalwareScanner', false ) ) {
	wp_die(
		'Malware Scanner library not found. Run composer install in the plugin directory.',
		'Security Ninja',
		array(
			'response'  => 503,
			'back_link' => true,
		)
	);
}

/**
 * Class MalwareScannerAdapter
 */
class MalwareScannerAdapter extends \MalwareScanner {

	/**
	 * Local scan statistics used by the adapter.
	 *
	 * @var array
	 */
	protected $stat = array();

	/**
	 * Pattern sets loaded from uploads (raw, case-insensitive raw, and regex).
	 *
	 * @var array
	 */
	protected $patterns_raw          = array();
	protected $patterns_iraw         = array();
	protected $patterns_re           = array();
	protected $patterns_b64functions = array();
	protected $patterns_b64keywords  = array();

	/**
	 * Hash whitelist for the adapter layer.
	 *
	 * @var array
	 */
	protected $whitelist = array();

	/**
	 * Flags mirrored for adapter logic; parent maintains its own copies.
	 *
	 * @var bool
	 */
	protected $flagBase64         = false;
	protected $flagExtraCheck     = false;
	protected $flagFollowSymlink  = false;
	protected $flagScanEverything = false;

	/**
	 * File extensions considered for scanning.
	 *
	 * @var array
	 */
	protected $extension = array();

	/**
	 * Mirror of parent's ignore patterns for traversal (parent::$ignore is private).
	 *
	 * @var array<int, string>
	 */
	protected $adapter_ignore = array();

	/**
	 * Constructor; run as library (no CLI).
	 */
	public function __construct() {
		parent::__construct( false );
	}

	/**
	 * Load patterns from uploads .dat files via Util.
	 */
	public function initializePatterns() {
		$loaded                      = Wf_Sn_Ms_Util::load_patterns_from_uploads( $this->flagBase64 );
		$this->patterns_raw          = $loaded['patterns_raw'];
		$this->patterns_iraw         = $loaded['patterns_iraw'];
		$this->patterns_re           = $loaded['patterns_re'];
		$this->patterns_b64functions = $loaded['patterns_b64functions'];
		$this->patterns_b64keywords  = $loaded['patterns_b64keywords'];

		if ( $this->flagExtraCheck ) {
			$this->patterns_raw['googleBot'] = '# ';
			$this->patterns_raw['htaccess']  = '# ';
		}
	}

	/**
	 * Mirror base64 flag locally and in the parent.
	 *
	 * @param bool $value Flag value.
	 */
	public function setFlagBase64( $value ) {
		$this->flagBase64 = (bool) $value;
		return parent::setFlagBase64( $value );
	}

	/**
	 * Mirror extra-check flag locally and in the parent.
	 *
	 * @param bool $value Flag value.
	 */
	public function setFlagExtraCheck( $value ) {
		$this->flagExtraCheck = (bool) $value;
		return parent::setFlagExtraCheck( $value );
	}

	/**
	 * Mirror follow-symlink flag locally and in the parent.
	 *
	 * @param bool $value Flag value.
	 */
	public function setFlagFollowSymlink( $value ) {
		$this->flagFollowSymlink = (bool) $value;
		return parent::setFlagFollowSymlink( $value );
	}

	/**
	 * Mirror scan-everything flag locally and in the parent.
	 *
	 * @param bool $value Flag value.
	 */
	public function setFlagScanEverything( $value ) {
		$this->flagScanEverything = (bool) $value;
		return parent::setFlagScanEverything( $value );
	}

	/**
	 * Mirror extensions list locally and in the parent.
	 *
	 * @param array $extensions File extensions.
	 */
	public function setExtensions( array $extensions ) {
		$this->extension = array();
		foreach ( $extensions as $ext ) {
			if ( $ext && '.' !== $ext[0] ) {
				$ext = '.' . $ext;
			}
			$this->extension[] = strtolower( $ext );
		}

		return parent::setExtensions( $extensions );
	}

	/**
	 * Store ignore patterns for adapter traversal (parent keeps its own private list).
	 *
	 * @param array<int, string> $patterns Patterns passed to MalwareScanner::setIgnore().
	 */
	public function setIgnore( array $patterns ) {
		$this->adapter_ignore = $patterns;

		return parent::setIgnore( $patterns );
	}

	/**
	 * Same glob-to-regex matching as scr34m MalwareScanner::pathMatches (private).
	 *
	 * @param string $path    Full filesystem path (normalized slashes).
	 * @param string $pattern Ignore pattern from setIgnore.
	 */
	protected function adapter_path_matches( $path, $pattern ) {
		$expr = preg_replace_callback(
			'/[\\\\^$.[\\]|()?*+{}\\-\\/]/',
			function ( $matches ) {
				switch ( $matches[0] ) {
					case '*':
						return '.*';
					case '?':
						return '.';
					default:
						return '\\' . $matches[0];
				}
			},
			$pattern
		);

		$expr = '/' . $expr . '/';

		return (bool) preg_match( $expr, $path );
	}

	/**
	 * Whether this path should be skipped (matches setIgnore patterns).
	 *
	 * @param string $pathname Full path to file or directory (no trailing slash required).
	 */
	protected function adapter_is_ignored_path( $pathname ) {
		if ( empty( $this->adapter_ignore ) ) {
			return false;
		}

		$pathname = str_replace( '\\', '/', $pathname );

		foreach ( $this->adapter_ignore as $pattern ) {
			if ( $this->adapter_path_matches( $pathname, $pattern ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Collect results into stat['files'] and skip if advanced whitelist matches. No output.
	 *
	 * @param bool   $found       Whether malware was found.
	 * @param string $path        File path.
	 * @param string $pattern     Matched pattern.
	 * @param string $comment     Pattern comment.
	 * @param string $hash        File hash.
	 * @param int    $lineNumber  Line number.
	 * @param bool   $inWhitelist Whether in hash whitelist.
	 */
	protected function printPath( $found, $path, $pattern, $comment, $hash, $lineNumber, $inWhitelist = false ) {
		if ( ! $pattern ) {
			return;
		}
		if ( Wf_Sn_Ms_Util::matches_advanced_whitelist_pattern( $path, $pattern ) ) {
			return;
		}

		$state        = 'ER';
		$changed_time = filectime( $path );
		$ctime        = date( 'H:i d-m-Y', $changed_time );
		$matched_line = '';
		if ( $lineNumber > 0 && is_readable( $path ) ) {
			$lines = file( $path );
			if ( isset( $lines[ $lineNumber - 1 ] ) ) {
				$matched_line = esc_html( substr( $lines[ $lineNumber - 1 ], 0, 210 ) );
			}
		}

		$result = array(
			'state'       => $state,
			'ctime'       => $ctime,
			'hash'        => $hash,
			'path'        => $path,
			'pattern'     => $pattern,
			'comment'     => $comment,
			'line'        => $lineNumber,
			'matchedline' => $matched_line,
		);

		if ( ! isset( $this->stat['files'] ) || ! is_array( $this->stat['files'] ) ) {
			$this->stat['files'] = array();
		}
		$this->stat['files'][] = $result;
	}

	/**
	 * Max file size (bytes) to pass to the library; larger files are skipped to avoid memory exhaustion.
	 *
	 * @var int
	 */
	const MAX_SCAN_FILE_BYTES = 2097152; // 2 MB.

	/**
	 * Default file extensions to scan when none are set (covers common PHP and script types; size check prevents OOM).
	 *
	 * @return array List of lowercase extensions including leading dot.
	 */
	public static function get_default_scannable_extensions() {
		$default = array( '.php', '.phtml', '.php5', '.phar', '.phps' );
		return apply_filters( 'securityninja_malware_scannable_extensions', $default );
	}

	/**
	 * Write progress to transient then process directory.
	 *
	 * This implementation is self-contained and does not rely on private
	 * parent helpers like isIgnored() or process().
	 *
	 * @param string $dir Directory path.
	 */
	protected function process( $dir ) {
		Wf_Sn_Ms_Util::set_scan_progress(
			$dir,
			array(
				'files_scanned'       => isset( $this->stat['files_scanned'] ) ? $this->stat['files_scanned'] : 0,
				'directories_scanned' => isset( $this->stat['directories'] ) ? $this->stat['directories'] : 0,
			)
		);

		$dh = opendir( $dir );
		if ( ! $dh ) {
			return;
		}

		// Track basic stats locally; parent::$stat remains internal.
		if ( ! isset( $this->stat['directories'] ) ) {
			$this->stat['directories'] = 0;
		}
		++$this->stat['directories'];
		$this->stat['last_processed_dir'] = $dir;

		// Only scan scannable extensions (library uses file_get_contents). Size check below prevents OOM on large files.
		$extensions = ! empty( $this->extension ) ? $this->extension : self::get_default_scannable_extensions();

		while ( ( $file = readdir( $dh ) ) !== false ) {
			if ( '.' === $file || '..' === $file ) {
				continue;
			}

			$path = $dir . $file;

			if ( $this->adapter_is_ignored_path( $path ) ) {
				continue;
			}

			if ( ! $this->flagFollowSymlink && is_link( $path ) ) {
				continue;
			}

			if ( is_dir( $path ) ) {
				$this->process( trailingslashit( $path ) );
			} elseif ( is_file( $path ) ) {
				// Skip files over size limit to avoid memory exhaustion (e.g. zips, backups).
				if ( filesize( $path ) > self::MAX_SCAN_FILE_BYTES ) {
					continue;
				}
				$ext = strtolower( strpos( $file, '.' ) !== false ? substr( $file, strrpos( $file, '.' ) ) : '' );
				if ( $ext && ( $this->flagScanEverything || in_array( $ext, $extensions, true ) ) ) {
					$this->scan( $path );
				}
			}
		}

		closedir( $dh );
	}

	/**
	 * Skip path-whitelisted files; otherwise run library scan.
	 *
	 * @param string $path File path.
	 * @return bool True if infected, false otherwise.
	 */
	public function scan( $path ) {
		if ( Wf_Sn_Ms_Util::is_path_whitelisted( $path ) ) {
			return false;
		}

		// Track files scanned in adapter stats so UI numbers are accurate.
		if ( ! isset( $this->stat['files_scanned'] ) ) {
			$this->stat['files_scanned'] = 0;
		}
		++$this->stat['files_scanned'];

		$infected = parent::scan( $path );

		if ( $infected ) {
			if ( ! isset( $this->stat['files_infected'] ) ) {
				$this->stat['files_infected'] = 0;
			}
			++$this->stat['files_infected'];
		}

		return $infected;
	}

	/**
	 * Run a scan and return result array (same shape as legacy sn-scan).
	 *
	 * @param string $dir Root directory to scan.
	 * @return array|false Result array or false if dir invalid.
	 */
	public function do_scan( $dir ) {
		$dir = rtrim( $dir, '/' );
		if ( ! is_dir( $dir ) ) {
			return false;
		}

		$this->stat = array(
			'directories'    => 0,
			'files_scanned'  => 0,
			'files_infected' => 0,
			'files'          => array(),
			'status'         => 'running',
		);

		$this->initializePatterns();

		$whitelist_hashes = array();
		$user_whitelist   = get_option( 'wf_sn_ms_whitelist', array() );
		if ( is_array( $user_whitelist ) ) {
			foreach ( $user_whitelist as $item ) {
				if ( is_array( $item ) && ! empty( $item['hash'] ) ) {
					$whitelist_hashes[] = $item['hash'];
				}
			}
		}
		$this->whitelist = array_values( array_unique( $whitelist_hashes ) );

		// Core paths (wp-admin, wp-includes) are already in ignore list via setIgnore() in the module.
		$start = time();
		$this->process( $dir . '/' );
		$end = time();

		$this->stat['status'] = 'finished';
		if ( ! isset( $this->stat['files'] ) || ! is_array( $this->stat['files'] ) ) {
			$this->stat['files'] = array();
		}

		$patterns_used = count( $this->patterns_raw ) + count( $this->patterns_iraw ) + count( $this->patterns_re );

		return array(
			'start_time'          => date( 'Y-m-d h:i:s', $start ),
			'end_time'            => date( 'Y-m-d h:i:s', $end ),
			'total_time'          => ( $end - $start ),
			'base_directory'      => $dir,
			'directories_scanned' => $this->stat['directories'],
			'files_scanned'       => $this->stat['files_scanned'],
			'malware_identified'  => $this->stat['files_infected'],
			'files'               => $this->stat['files'],
			'scan_status'         => $this->stat['status'],
			'patterns_used'       => $patterns_used,
			'whitelist_entries'   => count( $this->whitelist ),
			'memory_usage'        => memory_get_peak_usage( true ),
			'php_version'         => PHP_VERSION,
		);
	}
}
