<?php
/**
 * Persistent scan orchestration.
 *
 * @package LocalGuardScanner
 */

namespace LocalGuardScanner\Application\Scan;

use LocalGuardScanner\Application\Coverage\CoverageReporter;
use LocalGuardScanner\Application\Findings\FindingPersister;
use LocalGuardScanner\Application\Findings\MissingFileReconciler;
use LocalGuardScanner\Detection\Database\DatabaseInventory;
use LocalGuardScanner\Detection\DetectorFactory;
use LocalGuardScanner\Detection\Filesystem\FileContentReader;
use LocalGuardScanner\Detection\Filesystem\FileEnumerator;
use LocalGuardScanner\Detection\Filesystem\PathNormalizer;
use LocalGuardScanner\Detection\Filesystem\StreamingHasher;
use LocalGuardScanner\Detection\Integrity\FileTrustEvaluator;
use LocalGuardScanner\Detection\Plugin\PluginAdvisoryDetector;
use LocalGuardScanner\Domain\Detection\ScannableObject;
use LocalGuardScanner\Domain\Detection\Evidence;
use LocalGuardScanner\Domain\Scan\InspectionScope;
use LocalGuardScanner\Infrastructure\Database\FindingRepository;
use LocalGuardScanner\Infrastructure\Database\InventoryRepository;
use LocalGuardScanner\Infrastructure\Database\ScanRepository;
use LocalGuardScanner\Infrastructure\WordPress\CronScheduler;
use LocalGuardScanner\Infrastructure\WordPress\InstalledPluginInventory;
use LocalGuardScanner\Infrastructure\WordPress\MailNotifier;
use LocalGuardScanner\Infrastructure\WordPress\Settings;
use RuntimeException;
use Throwable;

/**
 * Advances one bounded phase per request and persists every checkpoint.
 */
final class ScanOrchestrator {
	/** @var ScanRepository */
	private $scans;
	/** @var InventoryRepository */
	private $inventory;
	/** @var FindingRepository */
	private $findings;
	/** @var ScanLock */
	private $lock;
	/** @var DetectorFactory */
	private $file_detectors;
	/** @var DatabaseInventory */
	private $database_inventory;
	/** @var DatabaseScanService */
	private $database_scanner;
	/** @var FindingPersister */
	private $finding_persister;
	/** @var MailNotifier */
	private $notifier;
	/** @var Settings */
	private $settings;

	/**
	 * @param ScanRepository      $scans              Scan repository.
	 * @param InventoryRepository $inventory          Inventory repository.
	 * @param FindingRepository   $findings           Finding repository.
	 * @param ScanLock            $lock               Global scan lock.
	 * @param DetectorFactory     $file_detectors     File detector factory.
	 * @param DatabaseInventory   $database_inventory Database inventory.
	 * @param DatabaseScanService $database_scanner   Database scanner.
	 * @param FindingPersister    $finding_persister  Finding persister.
	 * @param MailNotifier        $notifier           Mail notifier.
	 * @param Settings            $settings           Settings.
	 */
	public function __construct( ScanRepository $scans, InventoryRepository $inventory, FindingRepository $findings, ScanLock $lock, DetectorFactory $file_detectors, DatabaseInventory $database_inventory, DatabaseScanService $database_scanner, FindingPersister $finding_persister, MailNotifier $notifier, Settings $settings ) {
		$this->scans              = $scans;
		$this->inventory          = $inventory;
		$this->findings           = $findings;
		$this->lock               = $lock;
		$this->file_detectors     = $file_detectors;
		$this->database_inventory = $database_inventory;
		$this->database_scanner   = $database_scanner;
		$this->finding_persister  = $finding_persister;
		$this->notifier           = $notifier;
		$this->settings           = $settings;
	}

	/**
	 * Start a scan or return the existing active scan ID.
	 *
	 * @param string $trigger manual or automatic.
	 * @return int
	 */
	public function start( $trigger ) {
		try {
			$active = $this->scans->active();
		} catch ( Throwable $error ) {
			throw new ScanFailure( 'LGS-STATE-READ', 'queued' );
		}
		if ( null !== $active ) {
			return (int) $active['id'];
		}
		$owner = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : bin2hex( random_bytes( 16 ) );
		if ( ! $this->lock->acquire( $owner ) ) {
			throw new ScanFailure( 'LGS-LOCK-LOST', 'queued' );
		}
		$now      = current_time( 'mysql', true );
		$settings = $this->settings->all();
		$scope    = new InspectionScope( isset( $settings['exclusions'] ) && is_array( $settings['exclusions'] ) ? $settings['exclusions'] : array() );
		try {
			$scan_id = $this->scans->create( 0, in_array( $trigger, array( 'manual', 'automatic' ), true ) ? $trigger : 'manual', $now );
		} catch ( Throwable $error ) {
			$this->lock->release( $owner );
			throw new ScanFailure( 'LGS-STATE-WRITE', 'queued' );
		}
		$cursor = array(
			'owner'           => $owner,
			'discovery'       => array(),
			'file'            => array(),
			'database'        => array(),
			'reconciliation'  => array(),
			'coverage_scopes' => CoverageReporter::empty_scopes(),
			'exclusions'      => $scope->exclusions(),
			'progress'        => ( new ScanProgress() )->to_array(),
		);
		if ( ! $this->scans->update(
			$scan_id,
			array(
				'status'             => 'discovering',
				'phase'              => 'discovering',
				'cursor_json'        => $this->encode_cursor( $cursor ),
				'progress_json'      => $this->encode_safe_json( $cursor['progress'] ),
				'started_at'         => $now,
				'last_checkpoint_at' => $now,
				'failure_code'       => '',
				'failure_stage'      => '',
			)
		) ) {
			$this->lock->release( $owner );
			throw new ScanFailure( 'LGS-STATE-WRITE', 'queued' );
		}
		CronScheduler::schedule_continuation();
		return $scan_id;
	}

	/**
	 * Run one bounded batch.
	 *
	 * @param int $scan_id Scan ID.
	 * @return array<string,mixed>
	 */
	public function run_slice( $scan_id ) {
		$slice_started = microtime( true );
		$memory_before = memory_get_usage( true );
		try {
			$scan = $this->scans->find( $scan_id );
		} catch ( Throwable $error ) {
			throw new ScanFailure( 'LGS-STATE-READ', 'scan' );
		}
		if ( null === $scan ) {
			throw new ScanFailure( 'LGS-STATE-READ', 'scan' );
		}
		if ( in_array( $scan['status'], array( 'completed', 'partial', 'cancelled', 'failed' ), true ) ) {
			return $scan;
		}
		try {
			$cursor = $this->decode_cursor( isset( $scan['cursor_json'] ) ? $scan['cursor_json'] : '' );
		} catch ( Throwable $error ) {
			$this->reject_invalid_cursor( $scan_id, $scan );
			throw new ScanFailure( 'LGS-CURSOR-INVALID', 'scan' );
		}
		if ( ! $this->lock->heartbeat( $cursor['owner'] ) ) {
			$failure = new ScanFailure( 'LGS-LOCK-LOST', 'scan' );
			if ( ! $this->persist_failure( $scan_id, $scan, $failure ) ) {
				throw new ScanFailure( 'LGS-STATE-WRITE', 'scan' );
			}
			throw $failure;
		}

		try {
			if ( 'discovering' === $scan['status'] ) {
				$this->run_discovery_batch( $scan_id, $cursor );
			} elseif ( 'scanning_files' === $scan['status'] ) {
				$this->run_file_batch( $scan_id, $cursor );
			} elseif ( 'scanning_database' === $scan['status'] ) {
				$this->run_database_batch( $scan_id, $cursor );
			} elseif ( 'classifying' === $scan['status'] ) {
				$this->run_reconciliation_batch( $scan_id, $cursor );
			}
			$this->record_slice_metrics( $scan_id, (string) $scan['phase'], $slice_started, $memory_before );
		} catch ( Throwable $error ) {
			$failure = $error instanceof ScanFailure ? $error : new ScanFailure( 'LGS-UNEXPECTED', 'scan' );
			$stored  = $this->persist_failure( $scan_id, $scan, $failure );
			$this->lock->release( $cursor['owner'] );
			if ( ! $stored ) {
				throw new ScanFailure( 'LGS-STATE-WRITE', 'scan' );
			}
			throw $failure;
		}

		try {
			$updated = $this->scans->find( $scan_id );
		} catch ( Throwable $error ) {
			throw new ScanFailure( 'LGS-STATE-READ', 'scan' );
		}
		if ( null === $updated ) {
			throw new ScanFailure( 'LGS-STATE-READ', 'scan' );
		}
		if ( ! in_array( $updated['status'], array( 'completed', 'partial', 'cancelled', 'failed' ), true ) ) {
			CronScheduler::schedule_continuation();
		}
		return $updated;
	}

	/**
	 * @param int $scan_id Scan ID.
	 * @return bool
	 */
	public function cancel( $scan_id ) {
		$scan = $this->scans->find( $scan_id );
		if ( null === $scan || in_array( $scan['status'], array( 'completed', 'partial', 'cancelled', 'failed' ), true ) ) {
			return false;
		}
		try {
			$cursor = $this->decode_cursor( $scan['cursor_json'] );
		} catch ( Throwable $error ) {
			$this->reject_invalid_cursor( $scan_id, $scan );
			return false;
		}
		if ( ! $this->scans->update(
			$scan_id,
			array(
				'status'      => 'cancelled',
				'phase'       => 'cancelled',
				'finished_at' => current_time( 'mysql', true ),
			)
		) ) {
			return false;
		}
		$this->lock->release( $cursor['owner'] );
		return true;
	}

	/** @return ScanRepository */
	public function scans() {
		return $this->scans;
	}

	/** @return FindingRepository */
	public function findings() {
		return $this->findings;
	}

	/** @return array{rules_version:string,advisory_date:string} */
	public function detection_metadata() {
		$bundle = $this->file_detectors->create( get_bloginfo( 'version' ) );
		return array(
			'rules_version' => $bundle->rules_version(),
			'advisory_date' => $bundle->advisory_date(),
		);
	}

	/**
	 * Count inspectable objects in a bounded filesystem pass.
	 *
	 * @param int                 $scan_id Scan ID.
	 * @param array<string,mixed> $cursor  Signed cursor.
	 * @return void
	 */
	private function run_discovery_batch( $scan_id, array $cursor ) {
		$scope     = $this->inspection_scope( $cursor );
		$settings  = $this->settings->all();
		$limit     = isset( $settings['batch_size'] ) ? max( 25, min( 250, (int) $settings['batch_size'] ) ) : 50;
		$discovery = isset( $cursor['discovery'] ) && is_array( $cursor['discovery'] ) ? $cursor['discovery'] : array();
		$progress  = $this->progress( $cursor );
		$data      = $progress->to_array();
		try {
			if ( ! isset( $discovery['database_objects'] ) ) {
				$discovery['database_objects'] = $this->database_inventory->count_supported_objects( $this->site_ids() );
			}
			$service = new ScanDiscoveryService( new FileEnumerator( new PathNormalizer() ) );
			$batch   = $service->next_batch(
				ABSPATH,
				isset( $discovery['file'] ) && is_array( $discovery['file'] ) ? $discovery['file'] : array(),
				$limit,
				$scope->exclusions()
			);
		} catch ( Throwable $error ) {
			throw new ScanFailure( 'LGS-DISCOVERY', 'discovering' );
		}

		$discovery['file']         = $batch['cursor'];
		$discovery['file_objects'] = isset( $discovery['file_objects'] ) ? (int) $discovery['file_objects'] + $batch['count'] : $batch['count'];
		$discovery['batch_number'] = isset( $discovery['batch_number'] ) ? (int) $discovery['batch_number'] + 1 : 1;
		$data['discovered']        = array(
			'file_objects'     => $discovery['file_objects'],
			'database_objects' => $discovery['database_objects'],
		);
		$data['batch_number']      = $discovery['batch_number'];
		$data['current_area']      = $batch['current_area'];
		$discovery['omissions']    = $this->merge_counts( isset( $discovery['omissions'] ) && is_array( $discovery['omissions'] ) ? $discovery['omissions'] : array(), $batch['omissions'] );
		$data['omissions']         = array_sum( array_map( 'intval', $discovery['omissions'] ) );
		$cursor['discovery']       = $discovery;
		$status                    = 'discovering';
		if ( $batch['done'] ) {
			$data['totals']       = array(
				'file_objects'     => $discovery['file_objects'],
				'database_objects' => $discovery['database_objects'],
			);
			$data['processed']    = array(
				'file_objects'     => 0,
				'database_objects' => 0,
			);
			$data['current_area'] = '';
			$cursor['file']       = array();
			$status               = 'scanning_files';
		}
		$cursor['progress'] = ( new ScanProgress( $data ) )->to_array();
		$this->checkpoint( $scan_id, $status, $cursor );
	}

	/**
	 * @param int                 $scan_id Scan ID.
	 * @param array<string,mixed> $cursor Cursor.
	 * @return void
	 */
	private function run_file_batch( $scan_id, array $cursor ) {
		try {
			$bundle = $this->file_detectors->create( get_bloginfo( 'version' ) );
		} catch ( Throwable $error ) {
			throw new ScanFailure( 'LGS-DETECTOR', 'scanning_files' );
		}
		$service              = new FileScanService(
			new FileEnumerator( new PathNormalizer() ),
			new StreamingHasher(),
			new FileContentReader(),
			$bundle->engine(),
			new FileTrustEvaluator( null !== $bundle->core_manifest() ? $bundle->core_manifest() : array() )
		);
		$settings             = $this->settings->all();
		$scope                = $this->inspection_scope( $cursor );
		$cursor['exclusions'] = $scope->exclusions();
		$batch_size           = isset( $cursor['adaptive_batch_size'] ) ? (int) $cursor['adaptive_batch_size'] : ( isset( $settings['batch_size'] ) ? (int) $settings['batch_size'] : 50 );
		try {
			$batch = $service->scan_batch(
				ABSPATH,
				isset( $cursor['file'] ) && is_array( $cursor['file'] ) ? $cursor['file'] : array(),
				$batch_size,
				$scope->exclusions()
			);
		} catch ( Throwable $error ) {
			throw new ScanFailure( 'LGS-DETECTOR', 'scanning_files' );
		}
		$now    = current_time( 'mysql', true );
		$hashes = array();
		foreach ( $batch->inventory() as $item ) {
			$hashes[ $item['locator'] ] = isset( $item['sha256'] ) ? $item['sha256'] : null;
			if ( ! $this->inventory->upsert(
				array_merge(
					$item,
					array(
						'site_id'         => 0,
						'object_type'     => 'file',
						'metadata_json'   => $this->encode_safe_json( array( 'ctime' => isset( $item['created_at'] ) ? (int) $item['created_at'] : 0 ) ),
						'baseline_status' => isset( $item['trust'] ) && is_string( $item['trust'] ) ? $item['trust'] : 'untrusted',
						'first_seen_scan' => $scan_id,
						'last_seen_scan'  => $scan_id,
					)
				)
			) ) {
				throw new ScanFailure( 'LGS-INV-WRITE', 'scanning_files' );
			}
		}
		foreach ( $batch->evidence() as $locator => $evidence ) {
			if ( ! $this->finding_persister->persist( $scan_id, 0, 'file', $locator, $evidence, $now, isset( $hashes[ $locator ] ) ? $hashes[ $locator ] : null ) ) {
				throw new ScanFailure( 'LGS-FINDING-WRITE', 'scanning_files' );
			}
		}
		$cursor['file']            = $batch->cursor();
		$coverage_scopes           = $this->coverage_scopes( $cursor );
		$coverage_scopes['files']  = $this->merge_counts( $coverage_scopes['files'], $batch->omissions() );
		$cursor['coverage_scopes'] = $coverage_scopes;
		if ( ! $bundle->core_manifest_available() ) {
			$coverage_scopes['files']['core_manifest_unavailable'] = 1;
			$cursor['coverage_scopes']                             = $coverage_scopes;
		}
		if ( $batch->done() ) {
			$coverage_reporter = new CoverageReporter();
			if ( $coverage_reporter->scope_is_verified( $coverage_reporter->for_storage( $coverage_scopes ), 'files' ) && null !== $bundle->core_manifest() ) {
				$limited = $this->persist_missing_core_files( $scan_id, $bundle->core_manifest(), $now, $scope );
				if ( $limited > 0 ) {
					$coverage_scopes['files']  = $this->merge_counts( $coverage_scopes['files'], array( 'core_missing_results_limited' => $limited ) );
					$cursor['coverage_scopes'] = $coverage_scopes;
				}
			}
			$site_ids = $this->site_ids();
			$this->persist_plugin_advisories( $scan_id, $bundle->plugin_advisories(), $site_ids, $now );
			$cursor['database'] = array(
				'site_ids'   => $site_ids,
				'site_index' => 0,
				'section'    => 'users',
				'offset'     => 0,
			);
			$status             = 'scanning_database';
		} else {
			$status = 'scanning_files';
		}
		$progress                               = $this->progress( $cursor )->to_array();
		$progress['processed']['file_objects'] += count( $batch->inventory() );
		$progress['batch_number']              += 1;
		$progress['current_area']               = $this->current_file_area( $batch->cursor() );
		$progress['findings']                   = $this->findings->progress_counts( $scan_id );
		$progress['omissions']                  = $this->coverage_omissions( $cursor );
		$cursor['progress']                     = ( new ScanProgress( $progress ) )->reconcile_totals()->to_array();
		$this->checkpoint( $scan_id, $status, $cursor, $bundle->rules_version() );
	}

	/**
	 * Match the network plugin inventory against the signed offline catalog.
	 *
	 * @param int                                                         $scan_id     Scan ID.
	 * @param \LocalGuardScanner\Detection\Plugin\PluginAdvisory[] $advisories Signed advisories.
	 * @param int[]                                                       $site_ids    Included sites.
	 * @param string                                                      $now         UTC MySQL time.
	 * @return void
	 */
	private function persist_plugin_advisories( $scan_id, array $advisories, array $site_ids, $now ) {
		if ( empty( $advisories ) ) {
			return;
		}
		$inventory = ( new InstalledPluginInventory() )->collect( $site_ids );
		$matches   = ( new PluginAdvisoryDetector( $advisories ) )->inspect( $inventory['installed'], $inventory['active'] );
		foreach ( $matches as $plugin_file => $evidence ) {
			if ( ! $this->finding_persister->persist( $scan_id, 0, 'plugin', $plugin_file, $evidence, $now ) ) {
				throw new ScanFailure( 'LGS-FINDING-WRITE', 'scanning_files' );
			}
		}
	}

	/**
	 * @param int                 $scan_id Scan ID.
	 * @param array<string,mixed> $cursor Cursor.
	 * @return void
	 */
	private function run_database_batch( $scan_id, array $cursor ) {
		$scope                = $this->inspection_scope( $cursor );
		$cursor['exclusions'] = $scope->exclusions();
		$db                   = isset( $cursor['database'] ) && is_array( $cursor['database'] ) ? $cursor['database'] : array();
		$site_ids             = $this->site_ids();
		$db['site_ids']       = $site_ids;
		$db['site_index']     = isset( $db['site_index'] ) ? max( 0, (int) $db['site_index'] ) : 0;
		$db['section']        = isset( $db['section'] ) && is_string( $db['section'] ) && in_array( $db['section'], array( 'users', 'options', 'content' ), true ) ? $db['section'] : 'users';
		$db['offset']         = isset( $db['offset'] ) ? max( 0, (int) $db['offset'] ) : 0;
		$settings             = $this->settings->all();
		$adaptive             = isset( $cursor['adaptive_batch_size'] ) ? (int) $cursor['adaptive_batch_size'] : ( isset( $settings['batch_size'] ) ? (int) $settings['batch_size'] : 50 );
		$limit                = max( 25, min( 100, $adaptive ) );
		$budget               = ResourceBudget::conservative( $this->php_memory_limit_bytes() );
		$iterations           = 0;
		$processed_in_slice   = 0;
		$coverage_scopes      = $this->coverage_scopes( $cursor );
		$coverage             = $coverage_scopes['database'];

		while ( $iterations < 20 && $budget->can_continue() ) {
			$site_index = (int) $db['site_index'];
			if ( ! isset( $site_ids[ $site_index ] ) ) {
				break;
			}
			$site_id = (int) $site_ids[ $site_index ];
			$section = (string) $db['section'];
			$offset  = (int) $db['offset'];
			try {
				if ( 'users' === $section ) {
					$batch = $this->database_inventory->users_batch( $site_id, $offset, $limit );
				} elseif ( 'options' === $section ) {
					$batch = $this->database_inventory->options_batch( $site_id, $offset, $limit );
				} else {
					$batch = $this->database_inventory->content_batch( $site_id, $offset, $limit );
				}
			} catch ( Throwable $error ) {
				throw new ScanFailure( 'LGS-DETECTOR', 'scanning_database' );
			}
			$this->persist_database_batch( $scan_id, $site_id, $batch );
			$processed_in_slice += count( $batch->items() );
			$coverage            = $this->merge_counts( $coverage, $batch->omissions() );
			$db['offset']        = $batch->cursor();
			if ( $batch->done() ) {
				if ( 'users' === $section ) {
					$db['section'] = 'options';
					$db['offset']  = 0;
				} elseif ( 'options' === $section ) {
					$db['section'] = 'content';
					$db['offset']  = 0;
				} else {
					$db['site_index'] = $site_index + 1;
					$db['section']    = 'users';
					$db['offset']     = 0;
				}
			}
			++$iterations;
		}
		$coverage_scopes['database']                = $coverage;
		$cursor['coverage_scopes']                  = $coverage_scopes;
		$cursor['database']                         = $db;
		$status                                     = isset( $site_ids[ (int) $db['site_index'] ] ) ? 'scanning_database' : 'classifying';
		$progress                                   = $this->progress( $cursor )->to_array();
		$progress['processed']['database_objects'] += $processed_in_slice;
		$progress['batch_number']                  += 1;
		$progress['current_area']                   = 'database/' . (string) $db['section'];
		$progress['findings']                       = $this->findings->progress_counts( $scan_id );
		$progress['omissions']                      = $this->coverage_omissions( $cursor );
		$cursor['progress']                         = ( new ScanProgress( $progress ) )->reconcile_totals()->to_array();
		$this->checkpoint( $scan_id, $status, $cursor );
	}

	/**
	 * @param int                                      $scan_id Scan ID.
	 * @param int                                      $site_id Site ID.
	 * @param \LocalGuardScanner\Detection\Database\DatabaseBatch $batch Batch.
	 * @return void
	 */
	private function persist_database_batch( $scan_id, $site_id, $batch ) {
		$now = current_time( 'mysql', true );
		foreach ( $batch->items() as $item ) {
			$locator_hash      = hash( 'sha256', $item->locator() );
			$previous          = $this->inventory->find( $site_id, $item->type(), $locator_hash );
			$attributes        = $item->attributes();
			$current_hash      = $this->database_object_hash( $item );
			$baseline          = $this->database_metadata( $item );
			$previous_metadata = null;
			if ( is_array( $previous ) && isset( $previous['metadata_json'] ) && is_string( $previous['metadata_json'] ) ) {
				$decoded           = json_decode( $previous['metadata_json'], true );
				$previous_metadata = is_array( $decoded ) ? $decoded : null;
			}
			$attributes['changed_from_baseline'] = is_array( $previous ) && isset( $previous['sha256'] ) && is_string( $previous['sha256'] ) && ! hash_equals( $previous['sha256'], $current_hash );
			if ( 'db_user' === $item->type() && is_array( $previous_metadata ) ) {
				$attributes['privilege_escalated'] = empty( $previous_metadata['is_administrator'] ) && ! empty( $baseline['is_administrator'] );
				$attributes['email_changed']       = isset( $previous_metadata['email_hash'], $baseline['email_hash'] ) && $previous_metadata['email_hash'] !== $baseline['email_hash'] && ( ! empty( $previous_metadata['is_administrator'] ) || ! empty( $baseline['is_administrator'] ) );
			}
			$augmented = new ScannableObject( $item->type(), $item->locator(), $attributes );
			$evidence  = $this->database_scanner->inspect( new \LocalGuardScanner\Detection\Database\DatabaseBatch( array( $augmented ), 0, true ) );
			if ( isset( $evidence[ $item->locator() ] ) ) {
				if ( ! $this->finding_persister->persist( $scan_id, $site_id, $item->type(), $item->locator(), $evidence[ $item->locator() ], $now ) ) {
					throw new ScanFailure( 'LGS-FINDING-WRITE', 'scanning_database' );
				}
			}
			if ( ! $this->inventory->upsert(
				array(
					'site_id'         => $site_id,
					'object_type'     => $item->type(),
					'locator_hash'    => $locator_hash,
					'locator'         => $item->locator(),
					'size_bytes'      => isset( $attributes['size'] ) ? (int) $attributes['size'] : 0,
					'modified_at'     => 0,
					'sha256'          => $current_hash,
					'metadata_json'   => $this->encode_safe_json( $baseline ),
					'baseline_status' => 'untrusted',
					'first_seen_scan' => $scan_id,
					'last_seen_scan'  => $scan_id,
				)
			) ) {
				throw new ScanFailure( 'LGS-DB-WRITE', 'scanning_database' );
			}
		}
	}

	/**
	 * @param int                 $scan_id Scan ID.
	 * @param array<string,mixed> $cursor Cursor.
	 * @return void
	 */
	private function complete( $scan_id, array $cursor ) {
		$coverage                 = $this->coverage_payload( $cursor );
		$status                   = CoverageReporter::is_verified( $coverage ) ? 'completed' : 'partial';
		$now                      = current_time( 'mysql', true );
		$progress                 = $this->progress( $cursor )->to_array();
		$progress['findings']     = $this->findings->progress_counts( $scan_id );
		$progress['current_area'] = '';
		$cursor['progress']       = ( new ScanProgress( $progress ) )->reconcile_totals( true )->to_array();
		try {
			$this->resolve_verified_scopes( $scan_id, $cursor, $coverage, $now );
		} catch ( Throwable $error ) {
			throw new ScanFailure( 'LGS-FINDING-WRITE', 'classifying' );
		}
		if ( ! $this->scans->update(
			$scan_id,
			array(
				'status'             => $status,
				'phase'              => $status,
				'coverage_json'      => $this->encode_safe_json( $coverage ),
				'progress_json'      => $this->encode_safe_json( $cursor['progress'] ),
				'last_checkpoint_at' => $now,
				'failure_code'       => '',
				'failure_stage'      => '',
				'finished_at'        => $now,
			)
		) ) {
			throw new ScanFailure( 'LGS-STATE-WRITE', 'classifying' );
		}
		$alertable = $this->findings->new_high_risk( $scan_id );
		if ( $this->notifier->send( $scan_id, $alertable ) ) {
			$this->findings->mark_notified( array_map( 'intval', wp_list_pluck( $alertable, 'id' ) ), $now );
		}
		$this->lock->release( $cursor['owner'] );
	}

	/**
	 * Confirm individually missing old files before coverage-based resolution.
	 *
	 * @param int                 $scan_id Scan ID.
	 * @param array<string,mixed> $cursor Signed cursor.
	 * @return void
	 */
	private function run_reconciliation_batch( $scan_id, array $cursor ) {
		$settings = $this->settings->all();
		$limit    = isset( $settings['batch_size'] ) ? max( 25, min( 100, (int) $settings['batch_size'] ) ) : 50;
		$state    = isset( $cursor['reconciliation'] ) && is_array( $cursor['reconciliation'] ) ? $cursor['reconciliation'] : array();
		try {
			$state = ( new MissingFileReconciler() )->next_batch(
				$this->findings,
				$scan_id,
				ABSPATH,
				$this->inspection_scope( $cursor ),
				$state,
				$limit,
				current_time( 'mysql', true )
			);
		} catch ( Throwable $error ) {
			throw new ScanFailure( 'LGS-FINDING-RECONCILE', 'classifying' );
		}
		$cursor['reconciliation']   = $state;
		$progress                   = $this->progress( $cursor )->to_array();
		$progress['reconciliation'] = array(
			'checked'  => $state['checked'],
			'resolved' => $state['resolved'],
			'done'     => $state['done'],
		);
		$progress['batch_number']  += 1;
		$progress['current_area']   = '';
		$cursor['progress']         = ( new ScanProgress( $progress ) )->reconcile_totals()->to_array();
		if ( $state['done'] ) {
			$this->complete( $scan_id, $cursor );
			return;
		}
		$this->checkpoint( $scan_id, 'classifying', $cursor );
	}

	/**
	 * @param int                 $scan_id      Scan ID.
	 * @param string              $status       Status and phase.
	 * @param array<string,mixed> $cursor       Cursor.
	 * @param string              $rules_version Rule version.
	 * @return void
	 */
	private function checkpoint( $scan_id, $status, array $cursor, $rules_version = '' ) {
		$now  = current_time( 'mysql', true );
		$data = array(
			'status'             => $status,
			'phase'              => $status,
			'cursor_json'        => $this->encode_cursor( $cursor ),
			'coverage_json'      => $this->encode_safe_json( $this->coverage_payload( $cursor ) ),
			'progress_json'      => $this->encode_safe_json( $this->progress( $cursor )->to_array() ),
			'last_checkpoint_at' => $now,
			'failure_code'       => '',
			'failure_stage'      => '',
		);
		if ( '' !== $rules_version ) {
			$data['rules_version'] = $rules_version;
		}
		if ( ! $this->scans->update( $scan_id, $data ) ) {
			throw new ScanFailure( 'LGS-STATE-WRITE', 'scan' );
		}
	}

	/** @return int[] */
	private function site_ids() {
		if ( ! is_multisite() ) {
			return array( get_current_blog_id() );
		}
		$ids = get_sites(
			array(
				'fields'   => 'ids',
				'number'   => 0,
				'deleted'  => 0,
				'spam'     => 0,
				'archived' => 0,
			)
		);
		return array_values( array_map( 'intval', $ids ) );
	}

	/** @return array<string,mixed> */
	private function decode_cursor( string $json ): array {
		if ( strlen( $json ) > 8 * 1024 * 1024 ) {
			throw new RuntimeException( 'Scan cursor exceeds the safe size limit.' );
		}
		$data = json_decode( $json, true );
		if ( ! is_array( $data ) || ! isset( $data['integrity'] ) || ! is_string( $data['integrity'] ) || ! preg_match( '/^[a-f0-9]{64}$/', $data['integrity'] ) ) {
			throw new RuntimeException( 'Scan cursor is invalid.' );
		}
		$integrity = $data['integrity'];
		unset( $data['integrity'] );
		$expected = hash_hmac( 'sha256', $this->encode_safe_json( $data ), wp_salt( 'auth' ) );
		if ( ! hash_equals( $expected, $integrity ) ) {
			throw new RuntimeException( 'Scan cursor integrity check failed.' );
		}
		if ( ! isset( $data['owner'] ) || ! is_string( $data['owner'] ) || ! preg_match( '/^[a-f0-9-]{32,36}$/i', $data['owner'] ) ) {
			throw new RuntimeException( 'Scan cursor is invalid.' );
		}
		return $data;
	}

	/**
	 * Mark a scan with an untrusted cursor as failed and clear its stale lock.
	 *
	 * @param int                 $scan_id Scan ID.
	 * @param array<string,mixed> $scan    Stored scan.
	 * @return void
	 */
	private function reject_invalid_cursor( $scan_id, array $scan ) {
		$this->scans->update(
			$scan_id,
			array(
				'status'        => 'failed',
				'phase'         => 'failed',
				'error_count'   => isset( $scan['error_count'] ) ? (int) $scan['error_count'] + 1 : 1,
				'failure_code'  => 'LGS-CURSOR-INVALID',
				'failure_stage' => isset( $scan['phase'] ) ? ScanFailureCatalog::sanitize_stage( $scan['phase'] ) : 'scan',
				'finished_at'   => current_time( 'mysql', true ),
			)
		);
		$this->lock->release_current();
	}

	/**
	 * Sign scanner progress so a database-only attacker cannot redirect or skip work.
	 *
	 * @param array<string,mixed> $cursor Cursor.
	 * @return string
	 */
	private function encode_cursor( array $cursor ): string {
		unset( $cursor['integrity'] );
		$payload             = $this->encode_safe_json( $cursor );
		$cursor['integrity'] = hash_hmac( 'sha256', $payload, wp_salt( 'auth' ) );
		return $this->encode_safe_json( $cursor );
	}

	/**
	 * @param array<string,int> $left  Existing counts.
	 * @param array<string,int> $right New counts.
	 * @return array<string,int>
	 */
	private function merge_counts( array $left, array $right ) {
		foreach ( $right as $reason => $count ) {
			$left[ $reason ] = isset( $left[ $reason ] ) ? $left[ $reason ] + (int) $count : (int) $count;
		}
		return $left;
	}

	/**
	 * Return normalized per-scope coverage and conservatively upgrade an active
	 * 1.2.2 cursor without allowing partial resolution from ambiguous reasons.
	 *
	 * @param array<string,mixed> $cursor Signed cursor.
	 * @return array{files:array<string,int>,database:array<string,int>,plugins:array<string,int>}
	 */
	private function coverage_scopes( array &$cursor ): array {
		$reporter = new CoverageReporter();
		if ( isset( $cursor['coverage_scopes'] ) && is_array( $cursor['coverage_scopes'] ) ) {
			$stored = $reporter->for_storage( $cursor['coverage_scopes'] );
			return $stored['scopes'];
		}
		$scopes = CoverageReporter::empty_scopes();
		if ( isset( $cursor['coverage'] ) && is_array( $cursor['coverage'] ) ) {
			$legacy                    = $reporter->summarize( $cursor['coverage'] );
			$scopes['files']           = $legacy['reasons'];
			$cursor['coverage_legacy'] = true;
		}
		$cursor['coverage_scopes'] = $scopes;
		return $scopes;
	}

	/**
	 * @param array<string,mixed> $cursor Signed cursor.
	 * @return array<string,mixed>
	 */
	private function coverage_payload( array $cursor ): array {
		if ( isset( $cursor['coverage_scopes'] ) && is_array( $cursor['coverage_scopes'] ) ) {
			return ( new CoverageReporter() )->for_storage( $cursor['coverage_scopes'] );
		}
		return isset( $cursor['coverage'] ) && is_array( $cursor['coverage'] ) ? $cursor['coverage'] : array();
	}

	/**
	 * @param array<string,mixed> $cursor Signed cursor.
	 * @return int
	 */
	private function coverage_omissions( array $cursor ): int {
		$summary = ( new CoverageReporter() )->summarize( $this->coverage_payload( $cursor ) );
		return max( 0, (int) $summary['omitted'] );
	}

	/**
	 * Resolve only object types whose producer completed with verified coverage.
	 *
	 * @param int                 $scan_id  Scan ID.
	 * @param array<string,mixed> $cursor   Signed cursor.
	 * @param array<string,mixed> $coverage Public coverage payload.
	 * @param string              $now      UTC resolution time.
	 * @return void
	 */
	private function resolve_verified_scopes( $scan_id, array $cursor, array $coverage, $now ) {
		$reporter = new CoverageReporter();
		$excluded = $this->inspection_scope( $cursor )->exclusions();
		if ( ! empty( $cursor['coverage_legacy'] ) ) {
			if ( CoverageReporter::is_verified( $coverage ) ) {
				$this->findings->mark_unseen_resolved( $scan_id, 0, array( 'file', 'plugin' ), $excluded, $now );
				foreach ( $this->site_ids() as $site_id ) {
					$this->findings->mark_unseen_resolved( $scan_id, (int) $site_id, array( 'db_user', 'db_option', 'db_content' ), array(), $now );
				}
			}
			return;
		}
		if ( $reporter->scope_is_verified( $coverage, 'files' ) ) {
			$this->findings->mark_unseen_resolved( $scan_id, 0, array( 'file' ), $excluded, $now );
		}
		if ( $reporter->scope_is_verified( $coverage, 'plugins' ) ) {
			$this->findings->mark_unseen_resolved( $scan_id, 0, array( 'plugin' ), array(), $now );
		}
		if ( $reporter->scope_is_verified( $coverage, 'database' ) ) {
			foreach ( $this->site_ids() as $site_id ) {
				$this->findings->mark_unseen_resolved( $scan_id, (int) $site_id, array( 'db_user', 'db_option', 'db_content' ), array(), $now );
			}
		}
	}

	/** @param array<string,mixed> $cursor Signed cursor. @return ScanProgress */
	private function progress( array $cursor ): ScanProgress {
		return new ScanProgress( isset( $cursor['progress'] ) && is_array( $cursor['progress'] ) ? $cursor['progress'] : array() );
	}

	/**
	 * Return a directory-level area without the current filename.
	 *
	 * @param array<string,mixed> $file_cursor File cursor.
	 * @return string
	 */
	private function current_file_area( array $file_cursor ) {
		if ( isset( $file_cursor['current'] ) && is_string( $file_cursor['current'] ) && '' !== $file_cursor['current'] ) {
			return $file_cursor['current'];
		}
		if ( isset( $file_cursor['queue'][0] ) && is_string( $file_cursor['queue'][0] ) ) {
			return $file_cursor['queue'][0];
		}
		return '';
	}

	/**
	 * Persist a closed failure without storing the internal exception message.
	 *
	 * @param int         $scan_id Scan ID.
	 * @param array<string,mixed> $scan Stored scan.
	 * @param ScanFailure $failure Controlled failure.
	 * @return bool
	 */
	private function persist_failure( $scan_id, array $scan, ScanFailure $failure ): bool {
		return $this->scans->update(
			$scan_id,
			array(
				'status'        => 'failed',
				'phase'         => 'failed',
				'error_count'   => isset( $scan['error_count'] ) ? (int) $scan['error_count'] + 1 : 1,
				'failure_code'  => $failure->failure_code(),
				'failure_stage' => $failure->failure_stage(),
				'finished_at'   => current_time( 'mysql', true ),
			)
		);
	}

	/**
	 * Filesystem scope captured for the scan, with a legacy-cursor fallback.
	 *
	 * @param array<string,mixed> $cursor Signed scan cursor.
	 * @return InspectionScope
	 */
	private function inspection_scope( array $cursor ) {
		if ( isset( $cursor['exclusions'] ) && is_array( $cursor['exclusions'] ) ) {
			return new InspectionScope( $cursor['exclusions'] );
		}
		$settings = $this->settings->all();
		return new InspectionScope( isset( $settings['exclusions'] ) && is_array( $settings['exclusions'] ) ? $settings['exclusions'] : array() );
	}

	/** @return string */
	private function database_object_hash( ScannableObject $item ) {
		$data = $item->attributes();
		if ( isset( $data['value_hash'] ) ) {
			return (string) $data['value_hash'];
		}
		if ( isset( $data['content_hash'] ) ) {
			return (string) $data['content_hash'];
		}
		return hash( 'sha256', $this->encode_safe_json( $this->database_metadata( $item ) ) );
	}

	/** @return array<string,mixed> */
	private function database_metadata( ScannableObject $item ) {
		$data = $item->attributes();
		if ( 'db_user' === $item->type() ) {
			return array(
				'is_administrator'  => ! empty( $data['is_administrator'] ),
				'email_hash'        => isset( $data['email_hash'] ) ? $data['email_hash'] : '',
				'capabilities_hash' => hash( 'sha256', $this->encode_safe_json( isset( $data['capabilities'] ) ? $data['capabilities'] : array() ) ),
			);
		}
		return array( 'object_type' => $item->type() );
	}

	/**
	 * Encode only scanner-owned scalar and array metadata.
	 *
	 * @param mixed $value Safe metadata.
	 * @return string
	 */
	private function encode_safe_json( $value ): string {
		$encoded = wp_json_encode( $value );
		if ( false === $encoded ) {
			throw new RuntimeException( 'Scanner metadata could not be encoded.' );
		}
		return $encoded;
	}

	/** @return int */
	private function php_memory_limit_bytes(): int {
		$value = trim( (string) ini_get( 'memory_limit' ) );
		if ( '-1' === $value || '' === $value ) {
			return memory_get_usage( true ) + 128 * 1024 * 1024;
		}
		$unit   = strtolower( substr( $value, -1 ) );
		$number = (float) $value;
		if ( 'g' === $unit ) {
			$number *= 1024;
			$unit    = 'm';
		}
		if ( 'm' === $unit ) {
			$number *= 1024;
			$unit    = 'k';
		}
		if ( 'k' === $unit ) {
			$number *= 1024;
		}
		return max( memory_get_usage( true ) + 1, (int) $number );
	}

	/**
	 * @param int                  $scan_id Scan ID.
	 * @param array<string,string> $manifest Expected core hashes.
	 * @param string               $now UTC MySQL time.
	 * @param InspectionScope      $scope Frozen filesystem inspection scope.
	 * @return int Number of results omitted by the safety cap.
	 */
	private function persist_missing_core_files( $scan_id, array $manifest, $now, InspectionScope $scope ) {
		$manifest = $scope->filter_manifest( $manifest );
		$seen     = array_fill_keys( $this->inventory->seen_core_file_locators( $scan_id ), true );
		$missing  = array_diff_key( $manifest, $seen );
		foreach ( array_slice( array_keys( $missing ), 0, 500 ) as $path ) {
			if ( ! $this->finding_persister->persist(
				$scan_id,
				0,
				'file',
				$path,
				array( new Evidence( 'core.missing_file', 65, 95, 'wordpress_core_file_missing', $path ) ),
				$now
			) ) {
				throw new ScanFailure( 'LGS-FINDING-WRITE', 'scanning_files' );
			}
		}
		return max( 0, count( $missing ) - 500 );
	}

	/**
	 * Store aggregate batch timing and memory only; never inspected content.
	 *
	 * @param int    $scan_id       Scan ID.
	 * @param string $phase         Slice phase.
	 * @param float  $slice_started Start time.
	 * @param int    $memory_before Initial memory.
	 * @return void
	 */
	private function record_slice_metrics( $scan_id, $phase, $slice_started, $memory_before ) {
		$scan = $this->scans->find( $scan_id );
		if ( null === $scan ) {
			return;
		}
		$stats = array();
		if ( isset( $scan['stats_json'] ) && is_string( $scan['stats_json'] ) ) {
			$decoded = json_decode( $scan['stats_json'], true );
			$stats   = is_array( $decoded ) ? $decoded : array();
		}
		$phase_key                        = strtolower( (string) preg_replace( '/[^a-z0-9_\-]/i', '', $phase ) );
		$duration_ms                      = max( 0, (int) round( ( microtime( true ) - $slice_started ) * 1000 ) );
		$memory_peak                      = max( $memory_before, memory_get_peak_usage( true ) );
		$phase_stats                      = isset( $stats['phases'][ $phase_key ] ) && is_array( $stats['phases'][ $phase_key ] ) ? $stats['phases'][ $phase_key ] : array();
		$phase_stats['batches']           = isset( $phase_stats['batches'] ) ? (int) $phase_stats['batches'] + 1 : 1;
		$phase_stats['duration_ms']       = isset( $phase_stats['duration_ms'] ) ? (int) $phase_stats['duration_ms'] + $duration_ms : $duration_ms;
		$phase_stats['last_duration_ms']  = $duration_ms;
		$phase_stats['peak_memory_bytes'] = max( isset( $phase_stats['peak_memory_bytes'] ) ? (int) $phase_stats['peak_memory_bytes'] : 0, $memory_peak );
		$stats['phases'][ $phase_key ]    = $phase_stats;
		$stats['total_batches']           = isset( $stats['total_batches'] ) ? (int) $stats['total_batches'] + 1 : 1;
		$stats['peak_memory_bytes']       = max( isset( $stats['peak_memory_bytes'] ) ? (int) $stats['peak_memory_bytes'] : 0, $memory_peak );

		$data = array( 'stats_json' => $this->encode_safe_json( $stats ) );
		if ( isset( $scan['cursor_json'] ) && is_string( $scan['cursor_json'] ) && '' !== $scan['cursor_json'] ) {
			try {
				$cursor = $this->decode_cursor( $scan['cursor_json'] );
			} catch ( RuntimeException $exception ) {
				$cursor = array();
			}
			if ( ! empty( $cursor ) ) {
				$settings                      = $this->settings->all();
				$maximum                       = isset( $settings['batch_size'] ) ? max( 25, min( 250, (int) $settings['batch_size'] ) ) : 50;
				$current                       = isset( $cursor['adaptive_batch_size'] ) ? (int) $cursor['adaptive_batch_size'] : $maximum;
				$next                          = ( new AdaptiveBatchSizer() )->next( $current, max( 0.001, $duration_ms / 1000 ) );
				$cursor['adaptive_batch_size'] = min( $maximum, $next );
				$data['cursor_json']           = $this->encode_cursor( $cursor );
			}
		}
		$this->scans->update( $scan_id, $data );
	}
}
