<?php
/**
 * Static-scan orchestration: file queue, time-sliced ticks, caching, budgets.
 *
 * Containment disciplines (what killed the dead incumbent):
 *  - queue of FILES in a custom table, never "scan everything in one request";
 *  - each tick processes at most ~8 seconds, then yields;
 *  - browser AJAX drives ticks while the scan page is open; WP-Cron is the fallback;
 *  - findings cached by (slug, file_hash) — re-scans are incremental;
 *  - vendor/minified/oversized files skipped with visible disclosure;
 *  - every file wrapped in try/catch — a bad file is SKIPPED, never fatal.
 *
 * @package Upgrade_Pilot
 */

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

/**
 * Class UPilot_Scanner
 */
class UPilot_Scanner {

	const STATE_OPTION   = 'upilot_scan_state';
	const LOCK_OPTION    = 'upilot_scan_lock';
	const TICK_BUDGET    = 8;   // Seconds per tick.
	const BATCH_SIZE     = 20;  // Files claimed per inner loop.
	const MAX_FILE_BYTES = 512000;
	const MAX_FILES_PER_PLUGIN = 2000;
	const SKIP_DIRS      = array( 'vendor', 'node_modules', '.git', 'tests', 'test' );

	/**
	 * Ruleset.
	 *
	 * @var UPilot_Ruleset
	 */
	private $ruleset;

	/**
	 * Constructor.
	 *
	 * @param UPilot_Ruleset $ruleset Rule engine.
	 */
	public function __construct( UPilot_Ruleset $ruleset ) {
		$this->ruleset = $ruleset;
		add_action( 'upilot_queue_tick', array( $this, 'cron_tick' ) );
	}

	/**
	 * Current scan state.
	 *
	 * @return array
	 */
	public function get_state() {
		$state = UPilot_Store::get_option( self::STATE_OPTION, array() );
		return is_array( $state ) ? $state : array();
	}

	/**
	 * Insert one batch of queue rows.
	 *
	 * The batch goes through a SINGLE $wpdb->prepare(). The VALUES list is built
	 * from a counted array of literal '(%d,%s,%s,%s)' groups — never from data —
	 * and every value is bound.
	 *
	 * The previous version concatenated rows that had each been prepared
	 * individually. That was safe, but it is a concatenated query string reaching
	 * $wpdb->query(), which is exactly what WordPress.DB.PreparedSQL.NotPrepared
	 * exists to catch, and it did: Plugin Check reported it as an ERROR. Handing a
	 * reviewer an unexplained SQL-injection error is not worth the cleverness.
	 *
	 * @param string   $table  Fully-qualified table name. Ours; never user input.
	 * @param string[] $holes  One '(%d,%s,%s,%s)' group per row.
	 * @param array    $values Flat list of bound values, four per row.
	 */
	private function flush_queue_batch( $table, $holes, $values ) {
		if ( empty( $holes ) ) {
			return;
		}
		global $wpdb;

		// $placeholders is a counted run of the LITERAL string '(%d,%s,%s,%s)', one
		// group per row, joined with commas. It is built from count($holes), never
		// from data. Every actual value is bound by prepare() below. This is the
		// pattern WordPress.org documents for arrays of placeholders: implode over a
		// counted list of literal placeholders.
		//
		// Written INLINE inside prepare() rather than assembled into a $sql variable
		// first. The two are identical at runtime, but the variable form makes Plugin
		// Check say "Unescaped parameter $sql used in $wpdb->query()", and a line that
		// reads like an SQL-injection finding is not worth leaving in a security
		// report, however safe it is. UPilot_Snapshot::prune() does the same job the
		// same way.
		//
		// A bulk insert is not optional here: the queue is one row per PHP file on the
		// site, which is tens of thousands of rows on a large install. One INSERT per
		// row would be correct and unflagged, and would also blow the scan's time
		// budget on exactly the shared hosting this plugin promises not to time out on.
		$placeholders = implode( ',', $holes );

		$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- our own queue table; a bulk insert has nothing to cache.
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $table is UPilot_Store::table(), a hard-coded literal at every call site; $placeholders is a counted run of literal %d/%s. PHPCS cannot count a placeholder list whose length is dynamic, which is what UnfinishedPrepare is objecting to; every value IS bound, on the next line.
				"INSERT INTO {$table} (scan_id, slug, file, status) VALUES {$placeholders}",
				$values
			)
		);
	}

	/**
	 * Begin a new scan: enumerate files, fill the queue.
	 *
	 * @return array New state.
	 */
	public function start_scan() {
		global $wpdb;

		$state = $this->get_state();
		if ( ! empty( $state['status'] ) && 'running' === $state['status'] ) {
			return $state; // Already running.
		}

		$scan_id = time();
		$table   = UPilot_Store::table( 'scan_queue' );

		// Clear any previous queue rows.
		$wpdb->query( "DELETE FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- own table, full reset.

		$plugin_root = trailingslashit( WP_PLUGIN_DIR );
		$partial     = array();
		$total       = 0;

		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$dirs = array();
		foreach ( array_keys( get_plugins() ) as $file ) {
			$dir = dirname( $file );
			if ( '.' === $dir ) {
				// Single-file plugin.
				$dirs[ basename( $file ) ] = array( $plugin_root . $file );
				continue;
			}
			if ( plugin_basename( UPILOT_FILE ) === $file ) {
				continue; // Never scan self (we know we're compatible; reduces noise).
			}
			if ( ! isset( $dirs[ $dir ] ) ) {
				$dirs[ $dir ] = $this->enumerate_files( $plugin_root . $dir, $partial, $dir );
			}
		}

		$now    = current_time( 'mysql', true );
		$holes  = array();
		$values = array();
		foreach ( $dirs as $slug => $files ) {
			foreach ( $files as $path ) {
				$holes[] = '(%d,%s,%s,%s)';
				array_push( $values, $scan_id, $slug, $path, 'pending' );
				$total++;
				if ( count( $holes ) >= 200 ) {
					$this->flush_queue_batch( $table, $holes, $values );
					$holes  = array();
					$values = array();
				}
			}
		}
		$this->flush_queue_batch( $table, $holes, $values );

		$state = array(
			'scan_id'    => $scan_id,
			'status'     => 'running',
			'started_at' => $now,
			'total'      => $total,
			'done'       => 0,
			'skipped'    => 0,
			'partial'    => $partial,
		);
		UPilot_Store::update_option( self::STATE_OPTION, $state, false );

		// Cron fallback keeps the queue moving when nobody watches the screen.
		if ( ! wp_next_scheduled( 'upilot_queue_tick' ) ) {
			wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'upilot_queue_tick' );
		}

		return $state;
	}

	/**
	 * Cron entry point: run a tick, reschedule if still running.
	 */
	public function cron_tick() {
		$state = $this->process_tick();
		if ( ! empty( $state['status'] ) && 'running' === $state['status'] && ! wp_next_scheduled( 'upilot_queue_tick' ) ) {
			wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'upilot_queue_tick' );
		}
	}

	/**
	 * Process queue items for up to TICK_BUDGET seconds. Single-flight via lock.
	 *
	 * @return array Updated state.
	 */
	public function process_tick() {
		global $wpdb;

		$state = $this->get_state();
		if ( empty( $state['status'] ) || 'running' !== $state['status'] ) {
			return $state;
		}

		if ( ! $this->acquire_lock() ) {
			return $state; // Another worker is on it.
		}

		$table    = UPilot_Store::table( 'scan_queue' );
		$findings = UPilot_Store::table( 'findings' );
		$deadline = microtime( true ) + self::TICK_BUDGET;
		$scan_id  = (int) $state['scan_id'];

		try {
			while ( microtime( true ) < $deadline ) {
				$batch = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own queue table; the queue IS the state, caching it would serve stale work.
					$wpdb->prepare(
						"SELECT id, slug, file FROM {$table} WHERE scan_id = %d AND status = 'pending' LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
						$scan_id,
						self::BATCH_SIZE
					)
				);
				if ( empty( $batch ) ) {
					$state['status']  = 'done';
					$state['done_at'] = current_time( 'mysql', true );
					// Prune findings that no current file re-confirmed: rows from earlier
					// scans whose files were deleted/renamed are garbage by construction
					// (unchanged files were re-stamped to this scan_id).
					$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own findings table; end-of-scan garbage collection.
						$wpdb->prepare(
							"DELETE FROM {$findings} WHERE scan_id <> %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
							$scan_id
						)
					);
					break;
				}

				foreach ( $batch as $row ) {
					if ( microtime( true ) >= $deadline ) {
						break;
					}
					$status = 'done';
					try {
						$this->scan_file( $scan_id, $row->slug, $row->file );
					} catch ( Throwable $e ) {
						$status = 'skipped';
						$state['skipped'] = isset( $state['skipped'] ) ? (int) $state['skipped'] + 1 : 1;
					}
					$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own queue table; per-file progress mark.
						$table,
						array( 'status' => $status ),
						array( 'id' => $row->id ),
						array( '%s' ),
						array( '%d' )
					);
					$state['done'] = isset( $state['done'] ) ? (int) $state['done'] + 1 : 1;
				}

				UPilot_Store::update_option( self::STATE_OPTION, $state, false );
			}

			UPilot_Store::update_option( self::STATE_OPTION, $state, false );
		} finally {
			$this->release_lock();
		}

		return $state;
	}

	/**
	 * Scan one file: cache check, tokenize, store findings.
	 *
	 * @param int    $scan_id Scan id.
	 * @param string $slug    Plugin directory slug.
	 * @param string $path    Absolute file path.
	 */
	private function scan_file( $scan_id, $slug, $path ) {
		global $wpdb;

		if ( ! is_readable( $path ) ) {
			return;
		}

		$hash  = (string) md5_file( $path );
		$table = UPilot_Store::table( 'findings' );
		$rel   = ltrim( str_replace( trailingslashit( WP_PLUGIN_DIR ), '', $path ), '/\\' );

		// Cache: if we already have findings for this exact file content, re-stamp them to this scan.
		$existing = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own findings table; the (slug,file,hash) row IS the cache.
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$table} WHERE slug = %s AND file = %s AND file_hash = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
				$slug,
				$rel,
				$hash
			)
		);
		if ( null !== $existing && (int) $existing > 0 ) {
			$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own findings table; re-stamps cached rows to this scan.
				$wpdb->prepare(
					"UPDATE {$table} SET scan_id = %d WHERE slug = %s AND file = %s AND file_hash = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
					$scan_id,
					$slug,
					$rel,
					$hash
				)
			);
			return;
		}

		// Drop stale rows for this file (content changed).
		$wpdb->delete( $table, array( 'slug' => $slug, 'file' => $rel ), array( '%s', '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own findings table; invalidates rows for a changed file.

		// The size cap was enforced when the queue was built, but a file can grow
		// between enumeration and this (possibly much later) cron tick. Cap the
		// read itself so a file that grew cannot exhaust memory; one that did is
		// skipped, the same disposition an oversized file gets at queue time.
		$src = file_get_contents( $path, false, null, 0, self::MAX_FILE_BYTES + 1 ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- local file read inside the scanner.
		if ( false === $src || '' === $src ) {
			return;
		}
		if ( strlen( $src ) > self::MAX_FILE_BYTES ) {
			throw new RuntimeException( 'File exceeded the scan size cap after the queue was built.' );
		}

		$results = $this->ruleset->scan_source( $src, $rel );

		$version = $this->plugin_version_for_slug( $slug );

		// Clean files get a sentinel row so the (slug,file,hash) cache also
		// covers the ~99% zero-finding case — otherwise every re-scan re-reads
		// and re-tokenizes the entire corpus.
		if ( empty( $results ) ) {
			$results = array(
				array(
					'rule_id'     => '__clean__',
					'line'        => 0,
					'severity'    => 'clean',
					'php_version' => '',
					'message'     => '',
				),
			);
		}

		foreach ( $results as $f ) {
			$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- own findings table.
				$table,
				array(
					'scan_id'      => $scan_id,
					'slug'         => $slug,
					'item_version' => $version,
					'file'         => $rel,
					'file_hash'    => $hash,
					'line'         => $f['line'],
					'rule_id'      => $f['rule_id'],
					'severity'     => $f['severity'],
					'php_version'  => $f['php_version'],
					'message'      => $f['message'],
					'suppressed'   => 0,
				),
				array( '%d', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%d' )
			);
		}
	}

	/**
	 * Findings for the latest scan, grouped by plugin slug (suppression map applied).
	 *
	 * @param string $target_php Optional target PHP version: findings for NEWER
	 *                           PHP versions are counted separately (above_target)
	 *                           and never drive the verdict.
	 * @return array{by_slug:array,counts:array{error:int,warning:int,suppressed:int,above_target:int}}
	 */
	public function get_results( $target_php = '' ) {
		global $wpdb;

		$empty = array(
			'by_slug' => array(),
			'counts'  => array(
				'error'        => 0,
				'warning'      => 0,
				'suppressed'   => 0,
				'above_target' => 0,
			),
		);

		$state = $this->get_state();
		if ( empty( $state['scan_id'] ) ) {
			return $empty;
		}

		$table = UPilot_Store::table( 'findings' );
		$rows  = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own findings table; report assembly reads the live scan results.
			$wpdb->prepare(
				"SELECT slug, item_version, file, line, rule_id, severity, php_version, message FROM {$table} WHERE scan_id = %d AND rule_id <> '__clean__' ORDER BY slug, file, line", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
				(int) $state['scan_id']
			),
			ARRAY_A
		);

		$suppressed_map = UPilot_Store::get_option( 'upilot_suppressed', array() );
		if ( ! is_array( $suppressed_map ) ) {
			$suppressed_map = array();
		}

		$by_slug = array();
		$counts  = $empty['counts'];

		foreach ( (array) $rows as $row ) {
			// Suppression keys were stored sanitize_key()d — normalise the lookup
			// identically so suppression works for every slug.
			$key = sanitize_key( $row['slug'] ) . '|' . sanitize_key( $row['rule_id'] );
			if ( isset( $suppressed_map[ $key ] ) || isset( $suppressed_map[ sanitize_key( $row['slug'] ) . '|*' ] ) ) {
				$counts['suppressed']++;
				$row['suppressed']   = 1;
				$row['above_target'] = 0;
			} elseif ( '' !== $target_php && '' !== $row['php_version'] && version_compare( $row['php_version'], $target_php, '>' ) ) {
				// Real finding, but only relevant beyond the user's chosen target.
				$counts['above_target']++;
				$row['suppressed']   = 0;
				$row['above_target'] = 1;
			} else {
				$row['suppressed']   = 0;
				$row['above_target'] = 0;
				$counts[ 'error' === $row['severity'] ? 'error' : 'warning' ]++;
			}
			$by_slug[ $row['slug'] ][] = $row;
		}

		return array(
			'by_slug' => $by_slug,
			'counts'  => $counts,
		);
	}

	/**
	 * Enumerate scannable PHP files under a plugin directory (budgeted).
	 *
	 * @param string $dir     Absolute plugin dir.
	 * @param array  $partial By-ref map of partially scanned slugs.
	 * @param string $slug    Plugin slug.
	 * @return string[]
	 */
	private function enumerate_files( $dir, &$partial, $slug ) {
		$files = array();
		if ( ! is_dir( $dir ) ) {
			return $files;
		}

		try {
			$iterator = new RecursiveIteratorIterator(
				new RecursiveCallbackFilterIterator(
					new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ),
					function ( $current ) {
						if ( $current->isDir() ) {
							return ! in_array( strtolower( $current->getFilename() ), self::SKIP_DIRS, true );
						}
						return 'php' === strtolower( $current->getExtension() );
					}
				)
			);

			foreach ( $iterator as $file ) {
				if ( count( $files ) >= self::MAX_FILES_PER_PLUGIN ) {
					$partial[ $slug ] = 'file_cap';
					break;
				}
				if ( $file->getSize() > self::MAX_FILE_BYTES ) {
					if ( ! isset( $partial[ $slug ] ) ) {
						$partial[ $slug ] = 'size_skips';
					}
					continue;
				}
				$files[] = $file->getPathname();
			}
		} catch ( Throwable $e ) {
			$partial[ $slug ] = 'enumeration_error';
		}

		return $files;
	}

	/**
	 * Resolve installed version for a plugin directory slug.
	 *
	 * @param string $slug Directory slug.
	 * @return string
	 */
	private function plugin_version_for_slug( $slug ) {
		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}
		foreach ( get_plugins() as $file => $headers ) {
			if ( dirname( $file ) === $slug || basename( $file ) === $slug ) {
				return isset( $headers['Version'] ) ? (string) $headers['Version'] : '';
			}
		}
		return '';
	}

	/**
	 * Acquire the single-flight lock.
	 *
	 * The atomicity here is not incidental — it is the only thing stopping two
	 * workers (a browser tick and a cron tick) from scanning the same queue at
	 * once. It rests on wp_options carrying `UNIQUE KEY option_name`, which makes
	 * a duplicate add_option() fail for the loser of the race.
	 *
	 * wp_sitemeta has only a NON-unique `KEY meta_key`, so add_site_option() is a
	 * get-then-insert with a window between the two, and cannot be used as a lock.
	 * UPilot_Store::add_option_atomic() therefore keeps this one value in the main
	 * site's wp_options even on a network — one lock, network-wide, genuinely atomic.
	 *
	 * @return bool
	 */
	private function acquire_lock() {
		if ( UPilot_Store::add_option_atomic( self::LOCK_OPTION, (string) time() ) ) {
			return true;
		}
		// Stale-lock takeover after 60s (a worker died mid-tick).
		$ts = (int) UPilot_Store::get_option_atomic( self::LOCK_OPTION );
		if ( $ts && ( time() - $ts ) > 60 ) {
			UPilot_Store::update_option_atomic( self::LOCK_OPTION, (string) time() );
			return true;
		}
		return false;
	}

	/**
	 * Release the single-flight lock.
	 */
	private function release_lock() {
		UPilot_Store::delete_option_atomic( self::LOCK_OPTION );
	}
}
