<?php

declare(strict_types=1);

namespace SiteFort\Modules\Scanner\Queue;

use SiteFort\Includes\DataDirectoryProtection;
use SiteFort\Modules\Scanner\FileIntegrity\FileCollector;
use SiteFort\Modules\Scanner\FileIntegrity\HashService;
use SiteFort\Modules\Scanner\FileIntegrity\FSMEngine;
use SiteFort\Modules\Cloud\SecureApi;
use SiteFort\Modules\Scanner\Repository\FileStateRepository;
use SiteFort\Modules\Scanner\Repository\FileIndexRepository;
use SiteFort\Modules\Scanner\Cloud\CloudScanService;
use SiteFort\Modules\Scanner\Repository\CloudJobRepository;
use SiteFort\Modules\Scanner\Repository\IssueRepository;
use SiteFort\Modules\Scanner\ScanController;
use SiteFort\Modules\Scanner\Service\ScannerSettings;
use SiteFort\Modules\Scanner\Service\IssueType;
use SiteFort\Modules\Scanner\Queue\CloudResultProcessor;

// If this file is called directly, abort.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Coordinates the scanner queue phases for a scan run.
 */
class ScanOrchestrator {
	private const RR_PHASES = [ 'discover', 'hash', 'cloud_check', 'upload', 'poll' ];
	private const MAX_HASH_CHECK_BATCH = 250;
	private const AWAITING_UPLOAD_REQUEUE_AFTER = 300; // seconds
	private const MAX_CLOUD_ERRORS = 5;
	private const MAX_ACTIVE_CLOUD_JOBS = 12;
	private const PARTIAL_UPLOAD_FLUSH_AFTER = 20;
	private const PROGRESS_LOG_INTERVAL = 5;
	private const UPLOAD_TASK_PRIORITY = 70;
	private const STARTUP_MARKER_SLUG = 'scanner_startup';
	private const DEEP_ANALYSIS_CREDIT_TRANSIENT_PREFIX = 'sitefort_deep_analysis_credit_exhausted_';
	private const DEEP_ANALYSIS_CREDIT_SUMMARY_LOG_PREFIX = 'sitefort_deep_analysis_credit_summary_logged_';
	private const DEEP_ANALYSIS_CREDIT_SKIP_REASON = FileStateRepository::SKIP_REASON_DEEP_ANALYSIS_CREDIT_EXHAUSTED;
	private const DEEP_ANALYSIS_CREDIT_SKIP_MESSAGE = 'Deep threat analysis requires cloud credits. Credits are exhausted, so this file was skipped for deep analysis while the rest of the scan continues.';
	private const CLOUD_RETRY_DEFER_TRANSIENT_PREFIX = 'sitefort_cloud_retry_defer_until_';
	private const CLOUD_REQUEST_MARGIN_SECONDS = 3;
	private const MANIFEST_PENDING_DEFAULT_RETRY_AFTER = 30;
	private const MANIFEST_PENDING_MAX_RETRY_AFTER = 60;
	private const DEFAULT_MAX_SCAN_DURATION = 6 * HOUR_IN_SECONDS;

	/**
	 * FSM Engine.
	 *
	 * @var FSMEngine
	 */
	private $fsm;

	/**
	 * File state repository.
	 *
	 * @var FileStateRepository
	 */
	private $file_repository;

	/**
	 * File index repository.
	 *
	 * @var FileIndexRepository
	 */
	private $file_index_repository;

	/**
	 * Cloud job repository.
	 *
	 * @var CloudJobRepository
	 */
	private $job_repository;

	/**
	 * File collector.
	 *
	 * @var FileCollector
	 */
	private $collector;

	/**
	 * Hash service (for hashing phase only).
	 *
	 * @var HashService
	 */
	private $hash_service;

	/**
	 * Cloud scan service
	 *
	 * @var CloudScanService
	 */
	private $cloud_service;

	/**
	 * Current scan ID.
	 *
	 * @var string
	 */
	private $scan_id;

	/**
	 * Scan Controller.
	 *
	 * @var ScanController|null
	 */
	private $controller;

	/**
	 * Current cloud job ID
	 *
	 * @var string|null
	 */
	private $current_job_id;

	/**
	 * Cached scan start time (epoch seconds).
	 *
	 * @var int|null
	 */
	private $scan_start_time;

	/**
	 * Cached scan type (standard|deep), lazily resolved from the queue.
	 *
	 * @var string|null
	 */
	private $scan_type;

	/**
	 * Cached scan max duration (seconds).
	 *
	 * @var int|null
	 */
	private $scan_max_duration;

	/**
	 * Cached stage execution limit (seconds).
	 *
	 * @var int|null
	 */
	private $stage_execution_limit;

	/**
	 * AIMD Controller.
	 *
	 * @var \SiteFort\Modules\Scanner\Service\AIMDController
	 */
	private $aimd;

	/**
	 * Cloud result processor (extracted responsibility).
	 *
	 * @var CloudResultProcessor
	 */
	private CloudResultProcessor $result_processor;

	/**
	 * Last pressure level that was logged (transition-only logging).
	 *
	 * @var string|null
	 */
	private static $last_logged_pressure_level = null;

	// --- Per-tick in-memory caches to reduce transient/DB round-trips ---
	/** @var string|null RR phase cached in memory; persisted to transient once per tick. */
	private $rr_phase_cache = null;
	/** @var bool|null Once true, file index stays complete for the rest of the tick. */
	private $file_index_complete_cache = null;
	/** @var bool|null Hashing-complete result cached with TTL. */
	private $hashing_complete_cache = null;
	/** @var int Timestamp of last hashing-complete DB check. */
	private $hashing_complete_cache_time = 0;
	/** @var array|null Cached ScanQueue::get() result with TTL. */
	private $queue_cache = null;
	/** @var int Timestamp of last ScanQueue::get() call. */
	private $queue_cache_time = 0;
	/** @var int Epoch timestamp when cloud hash checks may resume. */
	private int $cloud_retry_defer_until = 0;
	private int $slice_started_at = 0;
	private int $slice_budget_seconds = 0;

	/**
	 * @param string $scan_id Scan UUID.
	 */
	public function __construct( string $scan_id, ?ScanController $controller = null ) {
		$this->scan_id         = $scan_id;
		$this->controller      = $controller;
		$this->fsm             = new FSMEngine();
		$this->file_repository = new FileStateRepository();
		$this->file_index_repository = new FileIndexRepository();
		$this->job_repository  = new CloudJobRepository();
		$this->collector       = new FileCollector();
		$this->hash_service    = new HashService();
		$this->cloud_service   = new CloudScanService( $scan_id );
		$this->current_job_id  = $this->get_active_job_id();

		// Initialize AIMD Controller
		if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Service\\AIMDController' ) ) {
			require_once __DIR__ . '/../Service/AIMDController.php';
		}
		$this->aimd             = new \SiteFort\Modules\Scanner\Service\AIMDController( $scan_id );
		$this->result_processor = new CloudResultProcessor( $scan_id, $this->fsm );
	}

	/**
	 * Override the per-slice execution limit.
	 *
	 * @param int $seconds
	 * @return void
	 */
	public function set_execution_limit( int $seconds ): void {
		$this->stage_execution_limit = max( 1, $seconds );
	}

	/**
	 * Reset per-tick memo caches when the instance is reused across tasks.
	 * file_index_complete_cache is monotonic and survives.
	 *
	 * @return void
	 */
	public function reset_tick_caches(): void {
		$this->rr_phase_cache              = null;
		$this->hashing_complete_cache      = null;
		$this->hashing_complete_cache_time = 0;
		$this->queue_cache                 = null;
		$this->queue_cache_time            = 0;
	}

	/**
	 * Earliest time the next file worker should run.
	 *
	 * Local discovery and hashing may continue immediately. Pure cloud-check
	 * retries wait for the persisted backoff window to avoid counting local
	 * backoff as repeated cloud failures.
	 *
	 * @return int Unix timestamp.
	 */
	public function get_next_file_worker_available_at(): int {
		if ( $this->has_local_file_worker_work() ) {
			return time();
		}

		$now         = time();
		$candidates  = [];
		$defer_until = $this->get_cloud_retry_defer_until();
		if ( $defer_until > $now ) {
			$candidates[] = $defer_until;
		}

		$manifest_recheck_at = $this->get_next_manifest_recheck_available_at();
		if ( $manifest_recheck_at > $now ) {
			$candidates[] = $manifest_recheck_at;
		}

		return ! empty( $candidates ) ? min( $candidates ) : $now;
	}

	/**
	 * Earliest delayed manifest-refresh hash recheck for this scan.
	 */
	private function get_next_manifest_recheck_available_at(): int {
		return $this->fsm->get_next_deferred_manifest_recheck_at( $this->scan_id, time() );
	}

	/**
	 * Current upload task payload derived from server pressure.
	 *
	 * @param array<string,mixed> $payload
	 * @return array{max_files:int,max_size_mb:int}
	 */
	public function get_upload_batch_payload( array $payload = [] ): array {
		$policy = $this->build_upload_policy( $payload );

		return [
			'max_files'   => $policy['max_files'],
			'max_size_mb' => $policy['max_size_mb'],
		];
	}

	public function get_upload_task_priority(): int {
		return self::UPLOAD_TASK_PRIORITY;
	}

	/**
	 * Build one upload enqueue decision.
	 *
	 * @param array<string,mixed> $payload
	 * @return array{has_candidates:bool,payload:array{max_files:int,max_size_mb:int},available_at:int,priority:int}
	 */
	public function get_upload_task_plan( array $payload = [] ): array {
		$policy = $this->build_upload_policy( $payload );
		$upload_payload = [
			'max_files'   => $policy['max_files'],
			'max_size_mb' => $policy['max_size_mb'],
		];
		$has_candidates = false;
		$candidate_ready = false;
		$available_at = $policy['next_available_at'];

		if ( ! $this->guard_deep_analysis_upload_credit_exhausted() ) {
			$candidate_plan = $this->build_upload_candidate_plan( $upload_payload );
			$has_candidates = $candidate_plan['has_pending'];
			$candidate_ready = ! empty( $candidate_plan['files'] );
			if ( $has_candidates && ! $candidate_ready ) {
				$available_at = max( $available_at, $candidate_plan['available_at'] );
			}
		}

		return [
			'has_candidates' => $has_candidates,
			'payload'        => $upload_payload,
			'available_at'   => $available_at,
			'priority'       => self::UPLOAD_TASK_PRIORITY,
		];
	}

	public function get_next_upload_available_at( array $payload = [] ): int {
		$policy = $this->build_upload_policy( $payload );
		return $policy['next_available_at'];
	}

	/**
	 * Run one bounded task-queue slice for discovery, hashing, and cloud hash checks.
	 *
	 * Uploading, cloud polling, and system checks are handled by dedicated tasks.
	 *
	 * @return array<string,mixed>
	 */
	public function run_task_queue_scan_worker_slice(): array {
		$stats = $this->build_stats();

		if ( $this->handle_upload_failure_abort() ) {
			return $stats;
		}

		if ( $this->should_stop_for_scan_duration() ) {
			$this->fail_scan_due_to_timeout();
			return $stats;
		}

		if ( ! $this->run_startup_preflight() ) {
			return $stats;
		}

		$allowed_phases = [ 'discover', 'hash', 'cloud_check' ];
		$start_time = time();
		$exec_budget = $this->get_stage_execution_budget();
		$this->set_slice_deadline( $start_time, $exec_budget );
		$this->aimd->evaluate_aimd_cycle();
		$last_heartbeat_at = $start_time;

		while ( ( time() - $start_time ) < $exec_budget ) {
			if ( $this->should_stop_for_scan_duration() ) {
				$this->clear_slice_deadline();
				$this->fail_scan_due_to_timeout();
				return $stats;
			}

			if ( ( time() - $last_heartbeat_at ) >= 20 ) {
				ScanQueue::heartbeat();
				$last_heartbeat_at = time();
			}

			$did_work = false;
			$phase = $this->get_rr_phase();
			if ( ! in_array( $phase, $allowed_phases, true ) ) {
				$phase = $allowed_phases[0];
			}

			for ( $attempt = 0; $attempt < count( $allowed_phases ); $attempt++ ) {
				switch ( $phase ) {
					case 'discover':
						$did_work = $this->run_discovery_phase_rr( $stats );
						break;
					case 'cloud_check':
						$did_work = $this->run_cloud_check_phase_rr( $stats );
						break;
					case 'hash':
					default:
						$did_work = $this->run_hash_phase_rr( $stats );
						break;
				}

				$phase = $this->advance_rr_phase_for_set( $phase, $allowed_phases );
				$this->set_rr_phase( $phase );

				if ( $did_work ) {
					$this->persist_rr_phase();
					break;
				}
			}

			if ( $this->is_current_scan_terminal() ) {
				break;
			}

			if ( ! $did_work ) {
				break;
			}
		}

		$this->persist_rr_phase();
		$this->clear_slice_deadline();
		return $stats;
	}

	/**
	 * Build the default stats payload for a scanner slice.
	 *
	 * @return array<string,mixed>
	 */
	private function build_stats(): array {
		return [
			'discovered'   => 0,
			'hashed'       => 0,
			'cloud_checks' => 0,
			'uploaded'     => 0,
			'scanned'      => 0,
			'verified'     => $this->get_verified_count(),
			'complete'     => false,
		];
	}

	/**
	 * Fail the scan when upload retries exceeded the AIMD threshold.
	 *
	 * @return bool True when the scan was aborted.
	 */
	private function handle_upload_failure_abort(): bool {
		if ( ! $this->aimd->should_abort_scan() ) {
			return false;
		}

		$msg = 'Scan aborted due to persistent upload failures. Please check your connection or server logs.';

		if ( class_exists( '\SiteFort\Modules\Scanner\Service\ScanAlertService' ) ) {
			\SiteFort\Modules\Scanner\Service\ScanAlertService::send_alert(
				'upload_failure',
				$msg,
				[ 'failures' => $this->aimd->get_failure_count() ]
			);
		} else {
			sitefort_log( '[SiteFort] ' . $msg );
		}

		if ( class_exists( '\SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
			\SiteFort\Modules\Scanner\Service\ScanLogger::log( $msg, 3, 'error' );
		}

		CloudResultProcessor::log_skipped_file(
			'(scan)',
			'Scan aborted due to persistent upload failures.',
			[ 'reason' => 'upload_failures_exceeded' ],
			$this->scan_id
		);

		ScanQueue::fail(
			$msg,
			[
				'error_code'        => 'upload_failures_exceeded',
				'stage'             => 'upload',
				'retryable'         => true,
				'status_code'       => 503,
				'technical_message' => sprintf( 'AIMD abort after %d upload failures', (int) $this->aimd->get_failure_count() ),
			]
		);

		return true;
	}

	/**
	 * Run one-time startup checks required before the scanner loop starts.
	 *
	 * @return bool True when startup preflight passed.
	 */
	private function run_startup_preflight(): bool {
		if ( $this->has_startup_logged() ) {
			return true;
		}

		$this->aimd->log_startup_state();

		$disk_free = @disk_free_space( ABSPATH );
		if ( false !== $disk_free && $disk_free < 50 * 1024 * 1024 ) {
			$disk_mb = (int) floor( $disk_free / ( 1024 * 1024 ) );
			$msg     = "Scan aborted: only {$disk_mb}MB disk space available. Minimum 50MB required.";
			if ( class_exists( '\SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
				\SiteFort\Modules\Scanner\Service\ScanLogger::log( $msg, 3, 'error' );
			}
			ScanQueue::fail(
				$msg,
				[
					'error_code'  => 'insufficient_disk_space',
					'stage'       => 'preflight',
					'retryable'   => false,
					'status_code' => 507,
				]
			);
			return false;
		}

		$this->run_site_health_preflight();

		$this->mark_startup_logged();
		return true;
	}

	/**
	 * Stage advisory site-health findings once per scan (behind the startup marker).
	 */
	private function run_site_health_preflight(): void {
		if ( ! class_exists( '\SiteFort\Modules\Scanner\SiteHealth' ) ) {
			require_once dirname( __DIR__ ) . '/SiteHealth.php';
		}

		try {
			$result = ( new \SiteFort\Modules\Scanner\SiteHealth() )->run();
			$issues = is_array( $result['issues'] ?? null ) ? $result['issues'] : [];
			foreach ( $issues as $issue ) {
				$issue['scan_id'] = $this->scan_id;
				IssueRepository::stage( $issue );
			}
		} catch ( \Throwable $e ) {
			sitefort_log( '[SiteFort] Site health preflight failed: ' . sanitize_text_field( $e->getMessage() ) );
		}
	}

	/**
	 * True when discovery/hash/check-hash still have work remaining.
	 *
	 * @return bool
	 */
	public function has_file_worker_work(): bool {
		if ( ! $this->is_file_index_complete() ) {
			return true;
		}

		return $this->fsm->has_files_in_states(
			$this->scan_id,
			[ 'discovered', 'hashing', 'hash_complete', 'cloud_hash_check' ]
		);
	}

	/**
	 * Run one upload batch task.
	 *
	 * @param array<string,mixed> $payload
	 * @param bool                $refresh_active_jobs
	 * @return array<string,mixed>
	 */
	public function run_upload_batch_task( array $payload = [], bool $refresh_active_jobs = false ): array {
		if ( $this->handle_upload_failure_abort() ) {
			return [
				'uploaded_count' => 0,
				'job_id'         => null,
				'has_more'       => false,
				'scan_aborted'   => true,
			];
		}

		if ( $this->guard_deep_analysis_upload_credit_exhausted() ) {
			return [
				'uploaded_count'    => 0,
				'job_id'            => null,
				'has_more'          => false,
				'next_available_at' => time(),
				'payload'           => [
					'max_files'   => CloudScanService::MAX_FILES_PER_BATCH,
					'max_size_mb' => 4,
				],
			];
		}

		$policy = $this->build_upload_policy( $payload, $refresh_active_jobs );
		$payload = [
			'max_files'   => $policy['max_files'],
			'max_size_mb' => $policy['max_size_mb'],
		];

		if ( ! $policy['can_upload_now'] ) {
			return [
				'uploaded_count'           => 0,
				'job_id'                   => null,
				'has_more'                 => (int) ( $policy['pending_upload_count'] ?? 0 ) > 0,
				'next_available_at'        => $policy['next_available_at'],
				'active_cloud_jobs'        => $policy['active_jobs'],
				'active_cloud_job_target'  => $policy['target_active_jobs'],
				'payload'                  => $payload,
			];
		}

		$candidate_plan = $this->build_upload_candidate_plan( $payload );
		$files = $candidate_plan['files'];

		if ( empty( $files ) ) {
			return [
				'uploaded_count'    => 0,
				'job_id'            => null,
				'has_more'          => $candidate_plan['has_pending'],
				'next_available_at' => max( $policy['next_available_at'], $candidate_plan['available_at'] ),
				'payload'           => $payload,
			];
		}

		$previous_job_id = $this->current_job_id;
		$uploaded_count = $this->run_upload_phase( $files );
		$job_id = null;
		if ( $uploaded_count > 0 && ! empty( $this->current_job_id ) && $this->current_job_id !== $previous_job_id ) {
			$job_id = $this->current_job_id;
		}

		$next_policy = $this->build_upload_policy( $payload, true );
		$next_payload = [
			'max_files'   => $next_policy['max_files'],
			'max_size_mb' => $next_policy['max_size_mb'],
		];
		$next_candidate_plan = $this->build_upload_candidate_plan( $next_payload );
		$has_pending_upload = $next_candidate_plan['has_pending'];
		$next_ready = ! empty( $next_candidate_plan['files'] );
		$next_available_at = $next_policy['next_available_at'];
		if ( $has_pending_upload && $next_policy['can_upload_now'] && ! $next_ready ) {
			$next_available_at = max( $next_available_at, $next_candidate_plan['available_at'] );
		}

		return [
			'uploaded_count'          => $uploaded_count,
			'job_id'                  => $job_id,
			'has_more'                => $has_pending_upload,
			'next_available_at'       => $next_available_at,
			'active_cloud_jobs'       => $next_policy['active_jobs'],
			'active_cloud_job_target' => $next_policy['target_active_jobs'],
			'payload'                 => $next_payload,
		];
	}

	/**
	 * Poll a specific set of cloud job IDs.
	 *
	 * @param array<int,string> $job_ids
	 * @return array<string,mixed>|\WP_Error
	 */
	public function poll_job_ids( array $job_ids ) {
		$job_ids = array_values(
			array_filter(
				array_unique(
					array_map(
						static fn( $job_id ): string => sanitize_text_field( (string) $job_id ),
						$job_ids
					)
				)
			)
		);

		if ( empty( $job_ids ) ) {
			return [
				'processed'             => 0,
				'pending_job_ids'       => [],
				'completed_job_ids'     => [],
				'failed_job_ids'        => [],
				'requeued_upload'       => false,
				'youngest_pending_age' => null,
			];
		}

		$jobs = [];
		foreach ( $job_ids as $job_id ) {
			$job = $this->job_repository->get_by_job_id( $job_id );
			if ( is_array( $job ) && (string) ( $job['scan_id'] ?? '' ) === $this->scan_id ) {
				$jobs[] = $job;
			}
		}

		if ( empty( $jobs ) ) {
			return [
				'processed'             => 0,
				'pending_job_ids'       => [],
				'completed_job_ids'     => [],
				'failed_job_ids'        => $job_ids,
				'requeued_upload'       => false,
				'youngest_pending_age' => null,
			];
		}

		return $this->process_polled_jobs( $jobs );
	}

	/**
	 * Poll active cloud jobs for this scan.
	 *
	 * @return array<string,mixed>|\WP_Error
	 */
	public function poll_active_jobs() {
		$jobs = $this->job_repository->get_active_jobs_for_scan( $this->scan_id, 50 );
		if ( empty( $jobs ) ) {
			return [
				'processed'             => 0,
				'pending_job_ids'       => [],
				'completed_job_ids'     => [],
				'failed_job_ids'        => [],
				'requeued_upload'       => false,
				'youngest_pending_age' => null,
			];
		}

		return $this->process_polled_jobs( $jobs );
	}

	/**
	 * Finalize gate for the task queue path.
	 *
	 * @return bool
	 */
	public function is_ready_for_finalize(): bool {
		return $this->check_completion();
	}

	/**
	 * Recover stale active-scan runtime state for the durable task queue path.
	 *
	 * This is intentionally invoked under the scan-row CAS lease so stale upload
	 * recovery and file/cloud reconciliation do not race with active tasks.
	 *
	 * @return array<string,mixed>
	 */
	public function recover_task_queue_runtime_state(): array {
		return [
			'stale_uploads'          => $this->recover_stale_uploading_files(),
			'cloud_states_recovered' => $this->reconcile_completed_jobs(),
		];
	}

	/**
	 * Round-robin phase helpers.
	 */
	private function get_rr_phase(): string {
		if ( $this->rr_phase_cache !== null ) {
			return $this->rr_phase_cache;
		}
		$phase = get_transient( 'sitefort_rr_phase_' . $this->scan_id );
		if ( ! in_array( $phase, self::RR_PHASES, true ) ) {
			$phase = self::RR_PHASES[0];
		}
		$this->rr_phase_cache = $phase;
		return $phase;
	}

	private function set_rr_phase( string $phase ): void {
		$this->rr_phase_cache = $phase;
	}

	private function persist_rr_phase(): void {
		if ( $this->rr_phase_cache !== null ) {
			set_transient( 'sitefort_rr_phase_' . $this->scan_id, $this->rr_phase_cache, HOUR_IN_SECONDS );
		}
	}

	private function get_cached_queue(): ?array {
		$now = time();
		if ( $this->queue_cache !== null && ( $now - $this->queue_cache_time ) < 3 ) {
			return $this->queue_cache;
		}
		$this->queue_cache      = ScanQueue::get();
		$this->queue_cache_time = $now;
		return $this->queue_cache;
	}

	/**
	 * Invalidate local queue cache so follow-up reads observe latest DB state.
	 *
	 * @return void
	 */
	private function reset_queue_cache(): void {
		$this->queue_cache = null;
		$this->queue_cache_time = 0;
	}

	/**
	 * Determine whether startup state was already logged for this scan.
	 *
	 * Uses queue component_status (durable DB state) instead of transients.
	 *
	 * @return bool
	 */
	private function has_startup_logged(): bool {
		$queue = $this->get_cached_queue();
		if ( ! $queue ) {
			return false;
		}

		$stored = $queue['component_status'] ?? [];
		if ( ! is_array( $stored ) ) {
			return false;
		}

		$entry = $stored[ self::STARTUP_MARKER_SLUG ] ?? null;
		$status = '';
		if ( is_array( $entry ) ) {
			$status = (string) ( $entry['status'] ?? '' );
		} elseif ( is_string( $entry ) ) {
			$status = $entry;
		}

		return $status === 'complete';
	}

	/**
	 * Persist startup marker for this scan.
	 *
	 * @return void
	 */
	private function mark_startup_logged(): void {
		ScanQueue::set_component_status( self::STARTUP_MARKER_SLUG, 'complete' );
		$this->reset_queue_cache();
	}

	/**
	 * Advance within a subset of RR phases.
	 *
	 * @param string   $current
	 * @param string[] $phases
	 * @return string
	 */
	private function advance_rr_phase_for_set( string $current, array $phases ): string {
		$idx = array_search( $current, $phases, true );
		if ( false === $idx ) {
			return $phases[0];
		}

		return $phases[ ( $idx + 1 ) % count( $phases ) ];
	}

	/**
	 * Requeue upload tasks left in-flight after a worker crash or timeout.
	 *
	 * @param int $stuck_after
	 * @param int $limit
	 * @return int
	 */
	private function recover_stale_uploading_files( int $stuck_after = 300, int $limit = 100 ): int {
		global $wpdb;

		$cutoff_time = time() - max( 60, $stuck_after );
		$limit       = max( 1, $limit );
		$table       = $this->table_identifier( $wpdb->prefix . 'sitefort_file_states' );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Uses a validated plugin-owned table identifier and prepared values.
		$files = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, state, upload_attempt, file_path
				FROM {$table}
				WHERE scan_id = %s
				AND state = 'uploading'
				AND updated_at < %d
				ORDER BY updated_at ASC
				LIMIT %d",
				$this->scan_id,
				$cutoff_time,
				$limit
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( empty( $files ) ) {
			return 0;
		}

		$recovered = 0;

		foreach ( $files as $file ) {
			$attempts = (int) ( $file['upload_attempt'] ?? 0 );

			if ( $attempts < 3 ) {
				$transition = $this->fsm->transition(
					(int) $file['id'],
					'uploading',
					'pending_upload',
					[
						'cloud_job_id'  => null,
						'error_message' => 'Upload timeout - retrying',
					]
				);
			} else {
				$transition = $this->fsm->transition(
					(int) $file['id'],
					'uploading',
					'error',
					[
						'cloud_job_id'  => null,
						'error_code'    => 'upload_timeout',
						'error_message' => 'Upload stuck for >5 minutes',
					]
				);

				if ( ! is_wp_error( $transition ) ) {
					CloudResultProcessor::log_skipped_file(
						(string) ( $file['file_path'] ?? '' ),
						'Upload failed: cloud upload did not complete within the recovery window',
						[ 'error_code' => 'upload_timeout', 'upload_attempts' => $attempts ],
						$this->scan_id
					);
				}
			}

			if ( ! is_wp_error( $transition ) ) {
				$recovered++;
			}
		}

		return $recovered;
	}

	/**
	 * Round-robin phases: one task per loop.
	 */
	private function run_discovery_phase_rr( array &$stats ): bool {
		if ( $this->is_file_index_complete() ) {
			return false;
		}

		$batch_limit = max( 1, (int) $this->aimd->get_discovery_window_for_phase() );

		$discovered = $this->run_discovery_phase( $batch_limit );

		if ( $discovered > 0 ) {
			$stats['discovered'] += $discovered;
			return true;
		}

		return false;
	}

	private function run_hash_phase_rr( array &$stats ): bool {
		$metrics     = $this->aimd->sense_resources();
		$pressure    = $this->aimd->get_pressure_state_from_metrics( $metrics );
		$buffer_size = $metrics['buffer_size'];

		$this->log_pressure_state( $pressure, $metrics );

		// Stop hashing if all files are hashed or upload buffer is backed up.
		if ( $this->check_hashing_complete() || $buffer_size >= 20 * 1024 * 1024 ) {
			return false;
		}

		// AIMD controls window: returns MIN_HASH_WINDOW under critical, not 0.
		$hashed = $this->run_hashing_phase( $pressure, $metrics );
		if ( $hashed > 0 ) {
			$stats['hashed'] += $hashed;
			$this->log_scan_progress();
			return true;
		}

		return false;
	}

	private function cloud_retry_defer_key(): string {
		return self::CLOUD_RETRY_DEFER_TRANSIENT_PREFIX . $this->scan_id;
	}

	private function get_cloud_retry_defer_until(): int {
		if ( $this->cloud_retry_defer_until > time() ) {
			return $this->cloud_retry_defer_until;
		}

		$stored = get_transient( $this->cloud_retry_defer_key() );
		if ( is_numeric( $stored ) ) {
			$this->cloud_retry_defer_until = (int) $stored;
			return $this->cloud_retry_defer_until;
		}

		$this->cloud_retry_defer_until = 0;
		return 0;
	}

	private function is_cloud_retry_deferred(): bool {
		return $this->get_cloud_retry_defer_until() > time();
	}

	private function defer_cloud_retry( \WP_Error $error, int $error_count = 0 ): void {
		$retry_after = $this->extract_retry_after( $error );
		if ( $retry_after <= 0 ) {
			$attempt = max( 1, $error_count );
			$retry_after = (int) min( 60, 4 * pow( 2, $attempt - 1 ) );
		}

		$retry_after = max( 1, min( 300, $retry_after ) );
		$defer_until = time() + $retry_after;

		$this->cloud_retry_defer_until = $defer_until;
		set_transient( $this->cloud_retry_defer_key(), $defer_until, $retry_after );
	}

	private function clear_cloud_retry_defer(): void {
		$this->cloud_retry_defer_until = 0;
		delete_transient( $this->cloud_retry_defer_key() );
	}

	/**
	 * Normalize WP_Error payloads from SecureApi.
	 *
	 * SecureApi stores HTTP/API metadata under the "api_error" code, but older
	 * internal errors may attach data to the primary code or omit it entirely.
	 *
	 * @return array<string,mixed>
	 */
	private function normalize_cloud_error_data( \WP_Error $error ): array {
		$error_data = $error->get_error_data( 'api_error' );
		if ( empty( $error_data ) ) {
			$error_data = $error->get_error_data();
		}

		return is_array( $error_data ) ? $error_data : [];
	}

	/**
	 * Extract the API's machine-readable error code from every supported shape.
	 */
	private function extract_cloud_api_error_code( \WP_Error $error, array $error_data = [] ): string {
		if ( empty( $error_data ) ) {
			$error_data = $this->normalize_cloud_error_data( $error );
		}

		$detail = is_array( $error_data['detail'] ?? null ) ? $error_data['detail'] : [];
		$response = is_array( $error_data['response'] ?? null ) ? $error_data['response'] : [];
		$candidates = [
			$detail['error_code'] ?? null,
			$response['error_code'] ?? null,
			$error_data['error_code'] ?? null,
			$error->get_error_code(),
		];

		foreach ( $candidates as $candidate ) {
			$code = sanitize_key( (string) $candidate );
			if ( $code !== '' ) {
				return $code;
			}
		}

		return '';
	}

	/**
	 * Extract credit metadata from API errors without assuming a single payload shape.
	 *
	 * @return array<string,mixed>
	 */
	private function extract_cloud_credit_context( array $error_data ): array {
		$detail = is_array( $error_data['detail'] ?? null ) ? $error_data['detail'] : [];
		$response = is_array( $error_data['response'] ?? null ) ? $error_data['response'] : [];

		return [
			'credits_remaining' => $detail['credits_remaining'] ?? ( $response['credits_remaining'] ?? null ),
			'credits_total'     => $detail['credits_total'] ?? ( $response['credits_total'] ?? null ),
			'credit_reset_at'   => $detail['credit_reset_at'] ?? ( $response['credit_reset_at'] ?? null ),
		];
	}

	private function extract_retry_after( \WP_Error $error ): int {
		$error_data = $this->normalize_cloud_error_data( $error );
		if ( empty( $error_data ) ) {
			return 0;
		}

		if ( isset( $error_data['retry_after'] ) ) {
			return absint( $error_data['retry_after'] );
		}

		$detail = is_array( $error_data['detail'] ?? null ) ? $error_data['detail'] : [];
		if ( isset( $detail['retry_after'] ) ) {
			return absint( $detail['retry_after'] );
		}

		return 0;
	}

	private function has_local_file_worker_work(): bool {
		if ( ! $this->is_file_index_complete() ) {
			return true;
		}

		return $this->fsm->has_files_in_states(
			$this->scan_id,
			[ 'discovered', 'hashing' ]
		);
	}

	private function is_current_scan_terminal(): bool {
		$queue = ScanQueue::get_job( $this->scan_id );
		return is_array( $queue ) && ! in_array( (string) ( $queue['status'] ?? '' ), [ 'pending', 'running' ], true );
	}

	private function run_cloud_check_phase_rr( array &$stats ): bool {
		if ( $this->is_cloud_retry_deferred() ) {
			return false;
		}

		if ( ! $this->has_cloud_request_budget() ) {
			return false;
		}

		$metrics  = $this->aimd->sense_resources();
		$pressure = $this->aimd->get_pressure_state_from_metrics( $metrics );

		$checked = $this->run_cloud_check_phase( $pressure, $metrics );
		if ( $checked > 0 ) {
			$stats['cloud_checks'] += $checked;
			return true;
		}

		return false;
	}

	private function set_slice_deadline( int $started_at, int $budget_seconds ): void {
		$this->slice_started_at = max( 0, $started_at );
		$this->slice_budget_seconds = max( 0, $budget_seconds );
	}

	private function clear_slice_deadline(): void {
		$this->slice_started_at = 0;
		$this->slice_budget_seconds = 0;
	}

	private function has_cloud_request_budget(): bool {
		if ( $this->slice_started_at <= 0 || $this->slice_budget_seconds <= 0 ) {
			return true;
		}

		$required = SecureApi::SCANNER_WORKER_TIMEOUT_SECONDS + self::CLOUD_REQUEST_MARGIN_SECONDS;
		if ( $this->slice_budget_seconds < $required && $this->slice_budget_seconds >= SecureApi::SCANNER_WORKER_TIMEOUT_SECONDS ) {
			$required = $this->slice_budget_seconds;
		}

		$remaining = $this->slice_budget_seconds - ( time() - $this->slice_started_at );
		return $remaining >= $required;
	}

	/**
	 * Phase 1: File Indexing (Discovery).
	 * Discover new files and insert into FSM as 'discovered'.
	 * Supports incremental indexing via batch_limit.
	 *
	 * @param int $batch_limit Limit to keep indexing bounded.
	 * @return int Number of files discovered in this batch.
	 */
	private function run_discovery_phase( int $batch_limit ): int {
		// Check if file index is marked as complete
		if ( $this->is_file_index_complete() ) {
			return 0;
		}

		// Resolve scan type from queue metadata (standard or deep).
		$queue = $this->get_cached_queue();
		$scan_type = ( $queue && isset( $queue['scan_type'] ) ) ? $queue['scan_type'] : 'standard';

		// Fetch User Exclusions
		if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Service\\ScannerSettings' ) ) {
			require_once dirname( __DIR__ ) . '/Service/ScannerSettings.php';
		}
		$settings = ScannerSettings::get_all();
		$user_exclusions = [];
		if ( ! empty( $settings['scanExclusions'] ) ) {
			$lines = explode( "\n", $settings['scanExclusions'] );
			foreach ( $lines as $line ) {
				$line = trim( $line );
				if ( ! empty( $line ) ) {
					$user_exclusions[] = $line;
				}
			}
		}

		$options = [
			'exclusions'  => $user_exclusions,
			'batch_limit' => $batch_limit,
		];

		$discovered = $this->collector->collect( $this->scan_id, $scan_type, $options );

		return $discovered;
	}

	/**
	 * Get scan start timestamp from DB or cache.
	 *
	 * @return int Unix timestamp (seconds).
	 */
	private function get_scan_start_time(): int {
		if ( $this->scan_start_time !== null ) {
			return $this->scan_start_time;
		}

		$cached = get_transient( 'sitefort_scan_start_' . $this->scan_id );
		if ( is_numeric( $cached ) && (int) $cached > 0 ) {
			$this->scan_start_time = (int) $cached;
			return $this->scan_start_time;
		}

		global $wpdb;
		$table = $this->table_identifier( $wpdb->prefix . 'sitefort_scan_queue' );
		$start_time = 0;
		try {
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Uses a validated plugin-owned table identifier and a prepared job_id value.
			$start_time = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT created_at FROM {$table} WHERE job_id = %s",
					$this->scan_id
				)
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
		} catch ( \Throwable $e ) {
			$start_time = 0;
		}

		$start_time = absint( $start_time );
		if ( $start_time <= 0 ) {
			$start_time = time();
		}

		set_transient( 'sitefort_scan_start_' . $this->scan_id, $start_time, HOUR_IN_SECONDS );
		$this->scan_start_time = $start_time;
		return $this->scan_start_time;
	}

	/**
	 * Get scan max duration (seconds) from settings.
	 *
	 * Without an explicit limit a built-in ceiling applies; return 0 from the
	 * filter to disable it.
	 *
	 * @return int Max duration in seconds, or 0 if unlimited.
	 */
	private function get_scan_max_duration(): int {
		if ( $this->scan_max_duration !== null ) {
			return $this->scan_max_duration;
		}

		if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Service\\ScannerSettings' ) ) {
			require_once dirname( __DIR__ ) . '/Service/ScannerSettings.php';
		}

		$settings = ScannerSettings::get_all();
		$max = absint( $settings['scanMaxDuration'] ?? 0 );
		if ( $max <= 0 ) {
			/**
			 * Fallback scan-duration ceiling when no explicit limit is set.
			 *
			 * @param int $seconds Default 6 hours.
			 */
			$max = absint( apply_filters( 'sitefort_scanner_default_max_duration', self::DEFAULT_MAX_SCAN_DURATION ) );
		}

		$this->scan_max_duration = $max;
		return $this->scan_max_duration;
	}

	/**
	 * Get per-stage execution limit (seconds) from settings and PHP limit.
	 *
	 * @return int Stage execution limit in seconds.
	 */
	private function get_stage_execution_limit(): int {
		if ( $this->stage_execution_limit !== null ) {
			return $this->stage_execution_limit;
		}

		if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Service\\ScannerSettings' ) ) {
			require_once dirname( __DIR__ ) . '/Service/ScannerSettings.php';
		}

		$settings = ScannerSettings::get_all();
		$user_limit = absint( $settings['stageMaxExecutionTime'] ?? 0 );

		$ini_max = (int) ini_get( 'max_execution_time' );
		if ( $ini_max <= 0 ) {
			$ini_max = 30;
		}

		$limit = $ini_max;
		if ( $user_limit > 0 ) {
			$limit = min( $limit, $user_limit );
		}

		$this->stage_execution_limit = max( 1, $limit );
		return $this->stage_execution_limit;
	}

	/**
	 * Compute execution budget with safety margin.
	 *
	 * @return int Budgeted time in seconds.
	 */
	private function get_stage_execution_budget(): int {
		$limit         = $this->get_stage_execution_limit();
		$safety_margin = min( 5, (int) floor( $limit * 0.2 ) );
		return max( 1, $limit - $safety_margin );
	}

	/**
	 * Check if scan exceeded configured max duration.
	 *
	 * @return bool
	 */
	private function should_stop_for_scan_duration(): bool {
		$max = $this->get_scan_max_duration();
		if ( $max <= 0 ) {
			return false;
		}
		$start = $this->get_scan_start_time();
		return ( time() - $start ) >= $max;
	}

	/**
	 * Fail scan due to max duration exceeded.
	 *
	 * @return void
	 */
	private function fail_scan_due_to_timeout(): void {
		$msg = 'Scan time limit exceeded.';

		if ( ! class_exists( '\SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
			require_once __DIR__ . '/../Service/ScanLogger.php';
		}
		if ( class_exists( '\SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
			\SiteFort\Modules\Scanner\Service\ScanLogger::log( $msg, 3, 'error' );
		}

		if ( class_exists( '\SiteFort\Modules\Scanner\Repository\IssueRepository' ) ) {
			CloudResultProcessor::log_skipped_file( '(scan)', $msg, [ 'reason' => 'scan_timeout' ], $this->scan_id );
		}

		if ( class_exists( '\SiteFort\Modules\Scanner\Queue\ScanQueue' ) ) {
			\SiteFort\Modules\Scanner\Queue\ScanQueue::fail(
				$msg,
				[
					'error_code'        => 'scan_timeout',
					'stage'             => 'scan_runtime',
					'retryable'         => true,
					'status_code'       => 408,
					'technical_message' => 'Scan max duration exceeded.',
				]
			);
		}
	}

	/**
	 * Phase 2: Hashing (AIMD Controlled).
	 * Applies unified pressure gating to prevent overload.
	 *
	 * @param array|null $pressure Pressure state.
	 * @param array|null $metrics Resource metrics for size-aware cost.
	 * @return int
	 */
	private function run_hashing_phase( ?array $pressure = null, ?array $metrics = null ): int {
		$window = $this->aimd->get_hash_window_for_phase( $pressure, $metrics );
		if ( $window <= 0 ) {
			return 0;
		}
		return $this->hash_service->process_hashing_batch( $this->scan_id, $window, $this->get_scan_type() );
	}

	/**
	 * Lazily resolve and cache the scan type for this orchestrator instance.
	 *
	 * Reads from the queue once; subsequent calls return the cached value.
	 *
	 * @return string Scan type constant (standard|deep).
	 */
	private function get_scan_type(): string {
		if ( $this->scan_type !== null ) {
			return $this->scan_type;
		}
		$queue            = $this->get_cached_queue();
		$this->scan_type  = ( $queue && isset( $queue['scan_type'] ) ) ? (string) $queue['scan_type'] : 'standard';
		return $this->scan_type;
	}

	/**
	 * Phase 3: Cloud Hash Check (AIMD Controlled).
	 * Applies unified pressure gating to avoid network amplification under load.
	 *
	 * @param array|null $pressure Pressure state.
	 * @param array|null $metrics Resource metrics for size-aware cost.
	 * @return int
	 */
	private function run_cloud_check_phase( ?array $pressure = null, ?array $metrics = null ): int {
		// Use a smaller cap than discovery to balance load
		$limit = min( $this->aimd->get_hash_window_for_phase( $pressure, $metrics ), self::MAX_HASH_CHECK_BATCH );
		if ( $limit <= 0 ) {
			return 0;
		}
		
		$files = $this->fsm->get_cloud_check_ready_files_by_state( $this->scan_id, 'hash_complete', $limit );

		if ( count( $files ) < $limit ) {
			$stuck = $this->fsm->get_cloud_check_ready_files_by_state( $this->scan_id, 'cloud_hash_check', $limit - count( $files ) );
			if ( ! empty( $stuck ) ) {
				$files = array_merge( $files, $stuck );
			}
		}

		if ( empty( $files ) ) {
			return 0;
		}
		
		// Buffering rule: flush when batch is full OR when at least 50 files are ready,
		// or when hashing has gone idle (final flush).
		$hashing_idle = $this->is_hash_production_idle();
		$file_index_complete = $this->is_file_index_complete();
		$min_flush = (int) max( 1, $limit / 5 ); // Flush at 20% of window, not 100%
		if ( count( $files ) < $min_flush && ( ! $hashing_idle || ! $file_index_complete ) ) {
			return 0;
		}

		$files = $this->refresh_stale_hash_snapshots( $files );
		if ( empty( $files ) ) {
			return 0;
		}

		$result = $this->cloud_service->check_hash( $files );

		if ( is_wp_error( $result ) ) {
			$error_data  = $this->normalize_cloud_error_data( $result );
			$status_code = (int) ( $error_data['status'] ?? 0 );

			$api_error_code = $this->extract_cloud_api_error_code( $result, $error_data );

			// Rate limit (429) and transient hash-registration contention (409
			// hash_registration_busy) both carry retry_after and clear on their own - defer and
			// retry rather than failing the scan.
			if ( $result->get_error_code() === 'rate_limited' || 429 === (int) $status_code
				|| 409 === (int) $status_code || 'hash_registration_busy' === $api_error_code ) {
				$this->defer_cloud_retry( $result );
				return 0;
			}

			if ( $api_error_code === 'credits_exhausted' ) {
				$this->handle_cloud_error( $result, 'hash_check' );
				return 0;
			}

			$error_count = $this->handle_cloud_error( $result, 'hash_check' );
			if ( ! $this->is_current_scan_terminal() ) {
				$this->defer_cloud_retry( $result, $error_count );
			}
			return 0;
		}
		
		\SiteFort\Modules\Scanner\Queue\ScanQueue::reset_cloud_errors();
		$this->clear_cloud_retry_defer();
		
		$hash_results = $result['hash_results'] ?? [];
		$pinned_version = ScanQueue::get_pinned_signature_version( $this->scan_id );
		$signature_version = $pinned_version ?: ( $result['signature_version'] ?? 'unknown' );

		// Clean-verdict fast path groups: status => from_state => file ids.
		$clean_groups = [];
		$clean_paths  = [];

		foreach ( $files as $file ) {
			$file_path = $file['file_path'];
			$verdict   = $hash_results[ $file_path ] ?? null;
			$file_hash_status = sanitize_key( (string) ( $file['hash_status'] ?? '' ) );
			$file_error_code  = sanitize_key( (string) ( $file['error_code'] ?? '' ) );
			$is_fake_image_candidate = $file_hash_status === IssueType::FAKE_IMAGE
				|| strpos( $file_hash_status, 'fake_image_' ) === 0
				|| $file_error_code === 'image_type_mismatch';

			if ( ! $verdict ) {
				$this->transition_upload_required_file( $file, (string) $file['state'] );
				continue;
			}

			$status = $verdict['status'] ?? 'unknown';

			// Plain verified/safe verdicts are bulk-finalized after the loop.
			// Special cases (fake image, runtime artifact, stale signature,
			// proof failure) fall through to the per-file path.
			if (
				! $is_fake_image_candidate
				&& in_array( $status, [ 'verified', 'safe' ], true )
				&& ! DataDirectoryProtection::is_runtime_artifact_path( $file_path )
			) {
				$safe_signature_stale = 'safe' === $status && (
					empty( $signature_version )
					|| 'unknown' === $signature_version
					|| ( ! empty( $verdict['signature_version'] ) && $verdict['signature_version'] !== $signature_version )
				);

				if ( ! $safe_signature_stale && $this->cloud_service->cache_clean_verdict( $file, $verdict, $status, (string) $signature_version ) ) {
					$clean_groups[ $status ][ (string) $file['state'] ][] = (int) $file['id'];
					$clean_paths[] = $file_path;
					if ( 'verified' === $status ) {
						do_action( 'sitefort_verified_file_cached', $file['file_path'], $file['id'] );
					}
					continue;
				}
			}

			// Persist hash verdict to guarantee repair eligibility survives deep-scan path.
			$verdict_payload = $this->merge_manifest_pending_recheck_metadata( $file, $verdict );
			if ( $file['state'] !== 'cloud_hash_check' ) {
				$transition = $this->fsm->transition(
					(int) $file['id'],
					'hash_complete',
					'cloud_hash_check',
					[
						'hash_status'           => $status,
						'scanned_with_signature'=> $signature_version,
						'scan_result'           => wp_json_encode( $verdict_payload ),
						'error_code'            => null,
						'error_message'         => null,
						'available_at'          => 0,
					]
				);
				if ( is_wp_error( $transition ) ) {
					sitefort_log( sprintf(
						'[SiteFort] Cloud hash state transition failed for %s: %s',
						$file_path !== '' ? $file_path : '(unknown)',
						$transition->get_error_message()
					) );
					continue;
				}
			} else {
				$this->file_repository->update(
					(int) $file['id'],
					[
						'hash_status'           => $status,
						'scanned_with_signature'=> $signature_version,
						'scan_result'           => wp_json_encode( $verdict_payload ),
						'error_code'            => null,
						'error_message'         => null,
						'available_at'          => 0,
					]
				);
			}

			if ( DataDirectoryProtection::is_runtime_artifact_path( $file_path ) && $status !== 'malicious' ) {
				$this->transition_upload_required_file( $file, 'cloud_hash_check', [ 'hash_status' => $status ] );
				continue;
			}

			switch ( $status ) {
				case 'verified':
					if ( $is_fake_image_candidate ) {
						$this->transition_upload_required_file(
							$file,
							'cloud_hash_check',
							[
								'hash_status'   => 'fake_image_verified_hash',
								'error_code'    => 'image_type_mismatch',
								'error_message' => 'Image validation failed locally; verified hash response bypassed for full cloud analysis',
							]
						);
						break;
					}
					$this->cloud_service->handle_verified_file( $file, $verdict );
					break;
				case 'modified':
					if ( $is_fake_image_candidate ) {
						$this->transition_upload_required_file(
							$file,
							'cloud_hash_check',
							[
								'hash_status'   => 'fake_image_modified_hash',
								'error_code'    => 'image_type_mismatch',
								'error_message' => 'Image validation failed locally; modified hash response bypassed for full cloud analysis',
							]
						);
						break;
					}
					$this->cloud_service->handle_modified_file( $file, $verdict );
					$this->transition_upload_required_file( $file, 'cloud_hash_check', [ 'hash_status' => $status ] );
					break;
				case 'unknown':
					if ( $is_fake_image_candidate ) {
						$this->transition_upload_required_file(
							$file,
							'cloud_hash_check',
							[
								'hash_status'   => 'fake_image_unknown_hash',
								'error_code'    => 'image_type_mismatch',
								'error_message' => 'Image validation failed locally; unknown hash response bypassed for full cloud analysis',
							]
						);
						break;
					}
					$this->cloud_service->handle_unknown_file( $file, $verdict );
					$this->transition_upload_required_file( $file, 'cloud_hash_check', [ 'hash_status' => $status ] );
					break;
				case 'safe':
					if ( $is_fake_image_candidate ) {
						$this->transition_upload_required_file(
							$file,
							'cloud_hash_check',
							[
								'hash_status'   => 'fake_image_safe_hash',
								'error_code'    => 'image_type_mismatch',
								'error_message' => 'Image validation failed locally; safe hash response bypassed for full cloud analysis',
							]
						);
						break;
					}
					if ( empty( $signature_version ) || $signature_version === 'unknown' ) {
						$this->transition_upload_required_file( $file, 'cloud_hash_check', [ 'hash_status' => 'safe_stale' ] );
						break;
					}
					if ( ! empty( $verdict['signature_version'] ) && $verdict['signature_version'] !== $signature_version ) {
						$this->transition_upload_required_file( $file, 'cloud_hash_check', [ 'hash_status' => 'safe_stale' ] );
						break;
					}
					$this->cloud_service->handle_safe_file( $file, $verdict, $signature_version );
					break;
				case 'malicious':
					$this->cloud_service->handle_malicious_file( $file, $verdict );
					break;
				case 'low_confidence_unknown':
					if ( $is_fake_image_candidate || ! empty( $verdict['needs_upload'] ) ) {
						$this->transition_upload_required_file( $file, 'cloud_hash_check', [ 'hash_status' => $status ] );
						break;
					}
					$this->skip_cloud_policy_file( $file, 'cloud_hash_check', $status, $verdict );
					break;
				case 'manifest_pending':
					if ( $is_fake_image_candidate || ! empty( $verdict['needs_upload'] ) ) {
						$this->transition_upload_required_file( $file, 'cloud_hash_check', [ 'hash_status' => $status ] );
						break;
					}
					$this->defer_manifest_pending_file( $file, $verdict_payload );
					break;
				default:
					if ( empty( $verdict['needs_upload'] ) ) {
						$this->skip_cloud_policy_file( $file, 'cloud_hash_check', $status, $verdict );
						break;
					}
					$this->transition_upload_required_file( $file, 'cloud_hash_check', [ 'hash_status' => $status ] );
					break;
				}
			}

		// One bulk UPDATE per status/from-state group, then chunked finding cleanup.
		foreach ( $clean_groups as $clean_status => $state_groups ) {
			foreach ( $state_groups as $from_state => $ids ) {
				$updated = $this->fsm->bulk_transition_with_meta(
					$ids,
					(string) $from_state,
					(string) $clean_status,
					[
						'hash_status'            => (string) $clean_status,
						'scanned_with_signature' => (string) $signature_version,
						'scan_result'            => null,
						'error_code'             => null,
						'error_message'          => null,
						'available_at'           => 0,
					]
				);
				if ( $updated !== count( $ids ) ) {
					sitefort_log( sprintf(
						'[SiteFort] Clean-verdict bulk transition updated %d of %d files (%s -> %s); missed rows will be retried next slice.',
						$updated,
						count( $ids ),
						(string) $from_state,
						(string) $clean_status
					) );
				}
			}
		}
		if ( ! empty( $clean_paths ) ) {
			$this->cloud_service->clear_integrity_findings_for_files( $clean_paths );
		}

		// Accumulate unindexed WP core versions seen during this batch.
		$unindexed = $result['unindexed_wp_versions'] ?? [];
		if ( ! empty( $unindexed ) ) {
			$tkey     = 'sitefort_unindexed_wpv_' . $this->scan_id;
			$existing = (array) ( get_transient( $tkey ) ?: [] );
			$merged   = CloudScanService::normalize_unindexed_wp_versions( array_merge( $existing, $unindexed ) );
			set_transient( $tkey, $merged, 7 * DAY_IN_SECONDS );
		}

		// Credit exhaustion only disables post-hash deep analysis. Hash-check and
		// unrelated scanner components continue, and upload-required files become
		// terminal skipped states with an explicit deep-analysis reason.
		if ( ! empty( $result['credit_limit_reached'] ) ) {
			$this->apply_deep_analysis_credit_exhaustion(
				[
					'credits_remaining'  => $result['credits_remaining'] ?? null,
					'credits_total'      => $result['credits_total'] ?? null,
					'credit_reset_at'    => $result['credit_reset_at'] ?? null,
					'credit_block_reason' => $result['credit_block_reason'] ?? null,
				]
			);
		}

		return count( $files );
	}

	/**
	 * Queue a file for upload, or mark SiteFort runtime data skipped.
	 *
	 * @param array<string,mixed> $file
	 * @param string              $from_state
	 * @param array<string,mixed> $data
	 * @return void
	 */
	private function transition_upload_required_file( array $file, string $from_state, array $data = [] ): void {
		$file_id = (int) ( $file['id'] ?? 0 );
		if ( $file_id <= 0 ) {
			return;
		}

		$file_path = (string) ( $file['file_path'] ?? '' );
		if ( DataDirectoryProtection::is_runtime_artifact_path( $file_path ) ) {
			$this->skip_runtime_artifact_file( $file, $from_state, $data );
			return;
		}

		$transition = $this->fsm->transition( $file_id, $from_state, 'pending_upload', $data );
		if ( is_wp_error( $transition ) ) {
			sitefort_log( sprintf(
				'[SiteFort] Upload-required transition failed for %s from %s: %s',
				$file_path !== '' ? $file_path : '(unknown)',
				$from_state,
				$transition->get_error_message()
			) );
		}
	}

	private function decode_file_scan_result( array $file ): array {
		if ( empty( $file['scan_result'] ) ) {
			return [];
		}

		$decoded = json_decode( (string) $file['scan_result'], true );
		return is_array( $decoded ) ? $decoded : [];
	}

	private function get_manifest_pending_attempt_count( array $file, array $verdict = [] ): int {
		$previous = $this->decode_file_scan_result( $file );
		$attempts = max(
			0,
			(int) ( $previous['manifest_pending_attempts'] ?? 0 ),
			(int) ( $verdict['manifest_pending_attempts'] ?? 0 )
		);

		$locally_pending = sanitize_key( (string) ( $file['hash_status'] ?? '' ) ) === 'manifest_pending'
			|| in_array(
				sanitize_key( (string) ( $file['error_code'] ?? '' ) ),
				[ 'repo_manifest_refresh_pending', 'repo_manifest_refresh_retry_limit' ],
				true
		);
		if ( $attempts <= 0 && $locally_pending ) {
			return CloudScanService::MANIFEST_PENDING_MAX_RETRIES;
		}

		return min( CloudScanService::MANIFEST_PENDING_MAX_RETRIES, $attempts );
	}

	private function get_manifest_pending_retry_after( array $verdict ): int {
		$retry_after = isset( $verdict['retry_after'] )
			? (int) $verdict['retry_after']
			: self::MANIFEST_PENDING_DEFAULT_RETRY_AFTER;
		if ( $retry_after <= 0 ) {
			$retry_after = self::MANIFEST_PENDING_DEFAULT_RETRY_AFTER;
		}

		return min( self::MANIFEST_PENDING_MAX_RETRY_AFTER, max( 1, $retry_after ) );
	}

	private function merge_manifest_pending_recheck_metadata( array $file, array $verdict ): array {
		$attempts = $this->get_manifest_pending_attempt_count( $file, $verdict );
		if ( $attempts > 0 ) {
			$verdict['manifest_pending_attempts'] = $attempts;
			$verdict['manifest_pending_max_retries'] = CloudScanService::MANIFEST_PENDING_MAX_RETRIES;
		}

		return $verdict;
	}

	/**
	 * @param array<string,mixed> $file
	 * @param string              $from_state
	 * @param array<string,mixed> $data
	 * @return bool
	 */
	private function skip_runtime_artifact_file( array $file, string $from_state, array $data = [] ): bool {
		$file_id = (int) ( $file['id'] ?? 0 );
		if ( $file_id <= 0 ) {
			return false;
		}

		$file_path = (string) ( $file['file_path'] ?? '' );
		$transition = $this->fsm->transition(
			$file_id,
			$from_state,
			'scan_skipped',
			array_merge(
				$data,
				[
					'skip_reason'   => DataDirectoryProtection::SCAN_SKIP_REASON,
					'error_code'    => DataDirectoryProtection::SCAN_ERROR_CODE,
					'error_message' => DataDirectoryProtection::SCAN_SKIP_MESSAGE,
					'cloud_job_id'  => null,
				]
			)
		);
		if ( is_wp_error( $transition ) ) {
			sitefort_log( sprintf(
				'[SiteFort] Runtime artifact skip transition failed for %s from %s: %s',
				$file_path !== '' ? $file_path : '(unknown)',
				$from_state,
				$transition->get_error_message()
			) );
			return false;
		}

		CloudResultProcessor::log_skipped_file(
			$file_path,
			DataDirectoryProtection::SCAN_SKIP_MESSAGE,
			[ 'error_code' => DataDirectoryProtection::SCAN_ERROR_CODE ],
			$this->scan_id
		);
		return true;
	}

	/**
	 * Keep active manifest-refresh deferrals out of upload.
	 */
	private function allow_pending_upload_from_saved_verdict( array $file ): bool {
		$verdict = $this->decode_file_scan_result( $file );
		$status = sanitize_key( (string) ( $verdict['status'] ?? '' ) );
		$hash_status = sanitize_key( (string) ( $file['hash_status'] ?? '' ) );
		$error_code = sanitize_key( (string) ( $file['error_code'] ?? '' ) );

		$is_manifest_pending = $status === 'manifest_pending'
			|| $hash_status === 'manifest_pending'
			|| $error_code === 'repo_manifest_refresh_pending';
		if ( $is_manifest_pending ) {
			$verdict['status']       = 'manifest_pending';
			$verdict['needs_upload'] = false;
			$this->defer_manifest_pending_file_from_state( $file, 'pending_upload', $verdict );
			return false;
		}

		return true;
	}

	/**
	 * Mark a cloud policy-suppressed file terminal without upload or issue creation.
	 */
	private function skip_cloud_policy_file( array $file, string $from_state, string $status, array $verdict = [] ): bool {
		$file_id = (int) ( $file['id'] ?? 0 );
		if ( $file_id <= 0 ) {
			return false;
		}

		$file_path = (string) ( $file['file_path'] ?? '' );
		$reason    = sanitize_key( (string) ( $verdict['reason'] ?? $status ) );
		if ( $reason === '' ) {
			$reason = sanitize_key( $status );
		}

		$message = 'Cloud policy suppressed this low-confidence integrity finding.';
		if ( $status === 'manifest_pending' ) {
			$message = 'Official WordPress.org baseline refresh is pending; rerun scan after refresh completes.';
		} elseif ( $status === 'low_confidence_unknown' ) {
			$message = 'Low-priority manifest miss suppressed pending stronger evidence.';
		}

		// Files the cloud refused for deep analysis because the credit ceiling was hit are
		// accounted as credit skips (not generic policy skips) so the "files not inspected"
		// count on the scanner page stays accurate.
		if ( ! empty( $verdict['deep_scan_blocked'] ) || (string) ( $verdict['blocked_reason'] ?? '' ) === 'limit_reached' ) {
			$reason  = self::DEEP_ANALYSIS_CREDIT_SKIP_REASON;
			$message = self::DEEP_ANALYSIS_CREDIT_SKIP_MESSAGE;
		}

		$transition = $this->fsm->transition(
			$file_id,
			$from_state,
			'scan_skipped',
			[
				'hash_status'   => $status,
				'skip_reason'   => $reason,
				'error_code'    => $reason,
				'error_message' => $message,
				'cloud_job_id'  => null,
				'available_at'  => 0,
			]
		);
		if ( is_wp_error( $transition ) ) {
			sitefort_log( sprintf(
				'[SiteFort] Cloud policy skip transition failed for %s from %s: %s',
				$file_path !== '' ? $file_path : '(unknown)',
				$from_state,
				$transition->get_error_message()
			) );
			return false;
		}

		$this->clear_integrity_findings_for_path( $file_path );
		CloudResultProcessor::log_skipped_file(
			$file_path,
			$message,
			[
				'error_code'  => $reason,
				'hash_status' => $status,
			],
			$this->scan_id
		);
		return true;
	}

	private function defer_manifest_pending_file( array $file, array $verdict = [] ): bool {
		return $this->defer_manifest_pending_file_from_state( $file, 'cloud_hash_check', $verdict );
	}

	/**
	 * Requeue a manifest-pending file for hash-check from the current state.
	 */
	private function defer_manifest_pending_file_from_state( array $file, string $from_state, array $verdict = [] ): bool {
		$file_id = (int) ( $file['id'] ?? 0 );
		if ( $file_id <= 0 ) {
			return false;
		}

		$file_path   = (string) ( $file['file_path'] ?? '' );
		$observed_attempts = $this->get_manifest_pending_attempt_count( $file, $verdict );
		if ( $observed_attempts >= CloudScanService::MANIFEST_PENDING_MAX_RETRIES ) {
			$verdict['status'] = 'low_confidence_unknown';
			$verdict['needs_upload'] = true;
			$verdict['reason'] = 'repo_manifest_refresh_retry_limit';
			$verdict['manifest_pending_attempts'] = $observed_attempts;
			$verdict['manifest_pending_max_retries'] = CloudScanService::MANIFEST_PENDING_MAX_RETRIES;
			$transition = $this->fsm->transition(
				$file_id,
				$from_state,
				'hash_complete',
				[
					'hash_status'   => 'low_confidence_unknown',
					'error_code'    => 'repo_manifest_refresh_retry_limit',
					'error_message' => 'Official WordPress.org baseline refresh did not complete after retry limit; rechecking hash for deep analysis registration.',
					'scan_result'   => wp_json_encode( $verdict ),
					'cloud_job_id'  => null,
					'available_at'  => 0,
				]
			);
			if ( is_wp_error( $transition ) ) {
				sitefort_log( sprintf(
					'[SiteFort] Manifest-pending retry-limit recheck transition failed for %s: %s',
					$file_path !== '' ? $file_path : '(unknown)',
					$transition->get_error_message()
				) );
				return false;
			}
			return true;
		}

		$attempts = min(
			CloudScanService::MANIFEST_PENDING_MAX_RETRIES,
			$observed_attempts + 1
		);
		$verdict['manifest_pending_attempts'] = $attempts;
		$verdict['manifest_pending_max_retries'] = CloudScanService::MANIFEST_PENDING_MAX_RETRIES;

		$retry_after = $this->get_manifest_pending_retry_after( $verdict );
		$verdict['retry_after'] = $retry_after;
		$available_at = time() + $retry_after;
		$message = 'Official WordPress.org baseline refresh is pending; this file will be rechecked automatically.';

		$transition = $this->fsm->transition(
			$file_id,
			$from_state,
			'hash_complete',
			[
				'hash_status'   => 'manifest_pending',
				'error_code'    => 'repo_manifest_refresh_pending',
				'error_message' => $message,
				'scan_result'   => wp_json_encode( $verdict ),
				'cloud_job_id'  => null,
				'available_at'  => $available_at,
			]
		);
		if ( is_wp_error( $transition ) ) {
			sitefort_log( sprintf(
				'[SiteFort] Manifest-pending recheck transition failed for %s: %s',
				$file_path !== '' ? $file_path : '(unknown)',
				$transition->get_error_message()
			) );
			return false;
		}

		$this->clear_integrity_findings_for_path( $file_path );
		if ( class_exists( '\SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
			\SiteFort\Modules\Scanner\Service\ScanLogger::log_throttled_for_scan(
				$this->scan_id,
				'manifest_refresh_pending',
				'[Pending] ' . ( $file_path !== '' ? $file_path : '(unknown)' ) . ' - ' . $message,
				$retry_after,
				1,
				'info'
			);
		}
		return true;
	}

	private function clear_integrity_findings_for_path( string $file_path ): void {
		if ( $file_path === '' ) {
			return;
		}

		$modified_hash = IssueRepository::generateHash( 'modified', $file_path );
		IssueRepository::delete( $modified_hash );
		IssueRepository::deleteStagedByHash( $this->scan_id, $modified_hash );

		$integrity_hash = IssueRepository::generateHash( IssueType::INTEGRITY, $file_path );
		IssueRepository::delete( $integrity_hash );
		IssueRepository::deleteStagedByHash( $this->scan_id, $integrity_hash );
	}

	/**
	 * Rehash files whose component or mtime snapshot drifted before cloud lookup.
	 *
	 * @param array<int,array<string,mixed>> $files
	 * @return array<int,array<string,mixed>>
	 */
	private function refresh_stale_hash_snapshots( array $files ): array {
		$ready = [];

		foreach ( $files as $file ) {
			$path  = (string) ( $file['file_path'] ?? '' );
			$state = (string) ( $file['state'] ?? 'hash_complete' );
			$id    = (int) ( $file['id'] ?? 0 );
			if ( $id <= 0 || $path === '' ) {
				continue;
			}

			$absolute_path = ABSPATH . $path;
			if ( ! is_file( $absolute_path ) || ! is_readable( $absolute_path ) ) {
				$this->fsm->transition(
					$id,
					$state,
					'error',
					[
						'error_code'    => 'file_missing',
						'error_message' => 'File missing or unreadable before cloud hash check',
					]
				);
				continue;
			}

			if ( $this->collector->file_state_snapshot_matches_current( $file ) ) {
				$ready[] = $file;
				continue;
			}

			$refreshed = $this->collector->build_hash_snapshot( $path, $absolute_path );
			if ( empty( $refreshed['stable'] ) || false === ( $refreshed['hash'] ?? false ) ) {
				$this->fsm->transition(
					$id,
					$state,
					'scan_skipped',
					[
						'skip_reason'   => 'changed_during_scan',
						'error_code'    => 'changed_during_scan',
						'error_message' => 'File changed during scan before cloud hash check; rerun after updates finish',
					]
				);
				$this->log_scan_warning( '[Skipped] ' . $path . ' - file changed during scan before cloud hash check' );
				continue;
			}

			$update = [
				'file_hash'              => (string) $refreshed['hash'],
				'file_size'              => (int) $refreshed['size'],
				'file_type'              => (string) ( $refreshed['info']['type'] ?? ( $file['file_type'] ?? 'other' ) ),
				'component_slug'         => (string) ( $refreshed['info']['slug'] ?? '' ),
				'component_version'      => (string) ( $refreshed['info']['version'] ?? '' ),
				'component_locale'       => (string) ( $refreshed['info']['locale'] ?? '' ),
				'file_mtime_at_hash'     => (int) $refreshed['mtime'],
				'hash_status'            => 'unknown',
				'scan_result'            => null,
				'scanned_with_signature' => null,
				'cloud_job_id'           => null,
				'error_code'             => 'snapshot_drift_rehashed',
				'error_message'          => 'File/component snapshot changed before cloud hash check; rehashed with current metadata',
				'available_at'           => 0,
			];
			$preserve_manifest_retry = in_array(
				sanitize_key( (string) ( $file['error_code'] ?? '' ) ),
				[ 'repo_manifest_refresh_pending', 'repo_manifest_refresh_retry_limit' ],
				true
			) && (string) ( $file['file_hash'] ?? '' ) === (string) $refreshed['hash'];
			if ( $preserve_manifest_retry ) {
				$update['hash_status']   = sanitize_key( (string) ( $file['hash_status'] ?? '' ) ) === 'manifest_pending'
					? 'manifest_pending'
					: 'low_confidence_unknown';
				$update['scan_result']   = wp_json_encode( $this->merge_manifest_pending_recheck_metadata( $file, [] ) );
				$update['error_code']    = sanitize_key( (string) ( $file['error_code'] ?? '' ) );
				$update['error_message'] = $update['error_code'] === 'repo_manifest_refresh_retry_limit'
					? 'Official WordPress.org baseline refresh did not complete after retry limit; rechecking hash for deep analysis registration.'
					: 'Official WordPress.org baseline refresh is pending; this file will be rechecked automatically.';
			}

			$result = $this->fsm->transition( $id, $state, 'hash_complete', $update );
			if ( is_wp_error( $result ) ) {
				$this->log_scan_warning( '[SiteFort] Failed to refresh stale hash snapshot for ' . $path . ': ' . $result->get_error_message() );
				continue;
			}

			$ready[] = array_merge( $file, $update, [ 'state' => 'hash_complete' ] );
		}

		return $ready;
	}

	private function log_scan_warning( string $message ): void {
		if ( class_exists( '\SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
			\SiteFort\Modules\Scanner\Service\ScanLogger::log( $message, 2, 'warning' );
		}
	}

	private function deep_analysis_credit_transient_key(): string {
		return self::DEEP_ANALYSIS_CREDIT_TRANSIENT_PREFIX . $this->scan_id;
	}

	private function deep_analysis_credit_summary_log_key(): string {
		return self::DEEP_ANALYSIS_CREDIT_SUMMARY_LOG_PREFIX . $this->scan_id;
	}

	private function is_deep_analysis_credit_exhausted(): bool {
		return (bool) get_transient( $this->deep_analysis_credit_transient_key() );
	}

	private function guard_deep_analysis_upload_credit_exhausted(): bool {
		if ( ! $this->is_deep_analysis_credit_exhausted() ) {
			return false;
		}

		$this->skip_pending_deep_analysis_files_for_credit_exhaustion();
		return true;
	}

	private function mark_deep_analysis_credit_exhausted(): void {
		set_transient( $this->deep_analysis_credit_transient_key(), true, DAY_IN_SECONDS );
		delete_transient( 'sitefort_credit_exhausted_' . $this->scan_id );
	}

	/**
	 * Skip files that already proved they require upload/deep analysis.
	 *
	 * Hashing and check-hash work continue; only the post-hash upload path is
	 * terminalized when the credit window is unavailable.
	 *
	 * @param array<int,array<string,mixed>> $files Files with id/state metadata.
	 * @return int Number of files transitioned.
	 */
	private function skip_files_for_deep_analysis_credit_exhaustion( array $files ): int {
		$skipped_count = 0;

		foreach ( $files as $file ) {
			$file_id = (int) ( $file['id'] ?? 0 );
			$state   = sanitize_key( (string) ( $file['state'] ?? 'pending_upload' ) );
			if ( $file_id <= 0 || $state === '' ) {
				continue;
			}

			$transition = $this->fsm->transition(
				$file_id,
				$state,
				'scan_skipped',
				[
					'skip_reason'   => self::DEEP_ANALYSIS_CREDIT_SKIP_REASON,
					'error_code'    => self::DEEP_ANALYSIS_CREDIT_SKIP_REASON,
					'error_message' => self::DEEP_ANALYSIS_CREDIT_SKIP_MESSAGE,
					'cloud_job_id'  => null,
				]
			);

			if ( ! is_wp_error( $transition ) ) {
				$skipped_count++;
			}
		}

		return $skipped_count;
	}

	private function get_deep_analysis_credit_skipped_count(): int {
		return $this->file_repository->count_by_scan_and_skip_reason(
			$this->scan_id,
			self::DEEP_ANALYSIS_CREDIT_SKIP_REASON
		);
	}

	private function log_deep_analysis_credit_skip( int $skipped_count ): void {
		if ( $skipped_count <= 0 ) {
			return;
		}

		if ( ! class_exists( 'SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
			require_once __DIR__ . '/../Service/ScanLogger.php';
		}

		if ( ! class_exists( 'SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
			return;
		}

		\SiteFort\Modules\Scanner\Service\ScanLogger::log_for_scan(
			$this->scan_id,
			sprintf(
				'Deep threat analysis skipped for %d file(s): cloud credits are exhausted, so these files were not inspected by deep analysis. Remaining scan checks continue.',
				$skipped_count
			),
			2,
			'warning'
		);
	}

	private function skip_pending_deep_analysis_files_for_credit_exhaustion(): int {
		$skipped_count = 0;

		do {
			$files = $this->fsm->get_files_by_state( $this->scan_id, 'pending_upload', 500 );
			if ( empty( $files ) ) {
				break;
			}

			$batch_skipped = $this->skip_files_for_deep_analysis_credit_exhaustion( $files );
			$skipped_count += $batch_skipped;
			if ( $batch_skipped <= 0 ) {
				sitefort_log( sprintf(
					'[SiteFort] Deep-analysis credit skip made no progress for scan_id=%s pending_upload_batch=%d',
					$this->scan_id,
					count( $files )
				) );
				break;
			}
		} while ( count( $files ) === 500 );

		$this->log_deep_analysis_credit_skip( $skipped_count );

		return $skipped_count;
	}

	public function log_deep_analysis_credit_summary_if_needed(): void {
		$skipped_count = $this->get_deep_analysis_credit_skipped_count();
		if ( $skipped_count <= 0 || get_transient( $this->deep_analysis_credit_summary_log_key() ) ) {
			return;
		}

		if ( ! class_exists( 'SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
			require_once __DIR__ . '/../Service/ScanLogger.php';
		}

		if ( ! class_exists( 'SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
			return;
		}

		\SiteFort\Modules\Scanner\Service\ScanLogger::log_for_scan(
			$this->scan_id,
			sprintf(
				'Deep analysis paused summary: %d file(s) required cloud upload analysis but were not inspected because credits are exhausted.',
				$skipped_count
			),
			2,
			'warning'
		);
		set_transient( $this->deep_analysis_credit_summary_log_key(), true, DAY_IN_SECONDS );
	}

	/**
	 * Persist the latest cloud credit fields for the scanner UI.
	 *
	 * @param array<string,mixed> $credit_context
	 * @return void
	 */
	private function persist_credit_exhaustion_fields( array $credit_context = [] ): void {
		if ( ! class_exists( '\SiteFort\Modules\Cloud\Service\CloudSettings' ) ) {
			require_once dirname( dirname( __DIR__ ) ) . '/Cloud/Service/CloudSettings.php';
		}

		if ( ! class_exists( '\SiteFort\Modules\Cloud\Service\CloudSettings' ) ) {
			return;
		}

		$credit_update = [ 'scan_credit_limit_reached' => true ];

		if ( array_key_exists( 'credits_remaining', $credit_context ) && $credit_context['credits_remaining'] !== null ) {
			$credit_update['scan_credits_remaining'] = (int) $credit_context['credits_remaining'];
		}
		if ( array_key_exists( 'credits_total', $credit_context ) && $credit_context['credits_total'] !== null ) {
			$credit_update['scan_credits_total'] = (int) $credit_context['credits_total'];
		}
		if ( array_key_exists( 'credit_reset_at', $credit_context ) && is_string( $credit_context['credit_reset_at'] ) ) {
			$credit_update['scan_credit_reset_at'] = $credit_context['credit_reset_at'];
		}
		if ( array_key_exists( 'credit_block_reason', $credit_context ) && is_string( $credit_context['credit_block_reason'] ) ) {
			$credit_update['scan_credit_block_reason'] = $credit_context['credit_block_reason'];
		}

		\SiteFort\Modules\Cloud\Service\CloudSettings::update_stored_fields( $credit_update );
	}

	private function apply_deep_analysis_credit_exhaustion( array $credit_context = [] ): int {
		$this->persist_credit_exhaustion_fields( $credit_context );
		$this->mark_deep_analysis_credit_exhausted();
		return $this->skip_pending_deep_analysis_files_for_credit_exhaustion();
	}

	/**
	 * Phase 4: Upload (AIMD Controlled).
	 */
	private function run_upload_phase( array $preselected = [] ): int {
		if ( $this->guard_deep_analysis_upload_credit_exhausted() ) {
			return 0;
		}

		$hashing_complete = $this->check_hashing_complete();
		$files = ! empty( $preselected ) ? $preselected : $this->select_upload_candidates( $hashing_complete );

		if ( empty( $files ) ) {
			return 0;
		}

		// Filter out missing/unreadable files to avoid retrying the same item forever
		$valid_files = [];
		foreach ( $files as $file ) {
			$path = (string) ( $file['file_path'] ?? '' );
			$full_path = ABSPATH . $path;
			if ( DataDirectoryProtection::is_runtime_artifact_path( $path ) ) {
				$this->skip_runtime_artifact_file( $file, 'pending_upload' );
				continue;
			}

			if ( ! $this->allow_pending_upload_from_saved_verdict( $file ) ) {
				continue;
			}

			if ( empty( $path ) || ! file_exists( $full_path ) || ! is_readable( $full_path ) ) {
				$this->fsm->transition(
					(int) $file['id'],
					'pending_upload',
					'error',
					[
						'error_code'    => 'file_missing',
						'error_message' => 'File missing or unreadable during upload',
					]
				);
				CloudResultProcessor::log_skipped_file(
					$path ?: '(unknown)',
					'Upload skipped: file missing or unreadable during upload',
					[ 'error_code' => 'file_missing' ],
					$this->scan_id
				);
				continue;
			}

			// Re-verify both file size and file hash immediately before upload so
			// same-size mutations cannot be uploaded under a stale hash label.
			$current_snapshot = $this->collector->build_hash_snapshot( $path, $full_path );
			if ( empty( $current_snapshot['stable'] ) || false === ( $current_snapshot['hash'] ?? false ) ) {
				$this->fsm->transition(
					(int) $file['id'],
					'pending_upload',
					'scan_skipped',
					[
						'skip_reason'   => 'changed_during_scan',
						'error_code'    => 'changed_during_scan',
						'error_message' => 'File changed while verifying upload snapshot; rerun after updates finish',
					]
				);
				CloudResultProcessor::log_skipped_file(
					$path,
					'Upload skipped: file changed while verifying upload snapshot',
					[ 'error_code' => 'changed_during_scan' ],
					$this->scan_id
				);
				continue;
			}

			$current_size = (int) $current_snapshot['size'];
			$current_hash = (string) $current_snapshot['hash'];

			$expected_size = (int) ( $file['file_size'] ?? 0 );
			$expected_hash = (string) ( $file['file_hash'] ?? '' );
			if (
				$expected_size < 0 ||
				$expected_hash === '' ||
				$current_size !== $expected_size ||
				! hash_equals( $expected_hash, $current_hash )
			) {
				// File changed during the scan. Re-queue back to hash_complete with updated hash.
				$this->fsm->transition(
					(int) $file['id'],
					'pending_upload',
					'hash_complete',
					[
						'file_hash'              => $current_hash,
						'file_size'              => $current_size,
						'file_type'              => (string) ( $current_snapshot['info']['type'] ?? ( $file['file_type'] ?? 'other' ) ),
						'component_slug'         => (string) ( $current_snapshot['info']['slug'] ?? '' ),
						'component_version'      => (string) ( $current_snapshot['info']['version'] ?? '' ),
						'component_locale'       => (string) ( $current_snapshot['info']['locale'] ?? '' ),
						'file_mtime_at_hash'     => (int) $current_snapshot['mtime'],
						'hash_status'            => 'unknown',
						'scan_result'            => null,
						'scanned_with_signature' => null,
						'cloud_job_id'           => null,
						'error_code'             => 'changed_during_scan',
						'error_message'          => 'File content changed between hashing and upload; re-queued for re-check',
					]
				);
				continue;
			}

			$valid_files[] = $file;
		}

		if ( empty( $valid_files ) ) {
			return 0;
		}

		foreach ( $valid_files as $file ) {
			$this->fsm->transition(
				(int) $file['id'],
				'pending_upload',
				'uploading',
				[
					'upload_attempt' => ( (int) ( $file['upload_attempt'] ?? 0 ) ) + 1,
				]
			);
		}

		$result = $this->cloud_service->get_secure_link( $valid_files );

		if ( is_wp_error( $result ) ) {
			$error_data = $this->normalize_cloud_error_data( $result );
			$status_code = $error_data['status'] ?? 0;
			$api_error_code = $this->extract_cloud_api_error_code( $result, $error_data );

			if ( $api_error_code === 'credits_exhausted' ) {
				$this->apply_deep_analysis_credit_exhaustion(
					$this->extract_cloud_credit_context( $error_data )
				);
				$selected_skip_count = $this->skip_files_for_deep_analysis_credit_exhaustion(
					array_map(
						static function ( array $file ): array {
							$file['state'] = 'uploading';
							return $file;
						},
						$valid_files
					)
				);
				$this->log_deep_analysis_credit_skip( $selected_skip_count );
				return 0;
			}

			$is_429 = ( 429 === (int) $status_code || '429' === (string) $result->get_error_code() || false !== strpos( $result->get_error_message(), '429' ) );
			// 409 hash_registration_busy is transient contention (another in-flight job briefly
			// holds this file's upload lock). Back off softly like a rate limit instead of burning
			// the abort counter; the files roll back to pending_upload below and retry.
			$is_registration_busy = ( 409 === (int) $status_code || 'hash_registration_busy' === $api_error_code );
			$is_soft_backoff = ( $is_429 || $is_registration_busy );
			$this->aimd->record_upload_failure( $is_soft_backoff );

			sitefort_log( '[SiteFort] Upload Failed: ' . $result->get_error_message() . ( $is_429 ? ' (Rate Limit - Backing off)' : '' ) );

			if ( ! class_exists( '\SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
				require_once __DIR__ . '/../Service/ScanLogger.php';
			}
			if ( class_exists( '\SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
				\SiteFort\Modules\Scanner\Service\ScanLogger::log(
					'Upload failed: ' . $result->get_error_message(),
					$is_429 ? 2 : 3,
					$is_429 ? 'warning' : 'error'
				);
			}

			// Recover strict hash-registration desync by sending files back through
			// hash-check before the next upload request.
			if ( $api_error_code === 'hash_not_registered' ) {
				foreach ( $valid_files as $file ) {
					$transition = $this->fsm->transition(
						(int) $file['id'],
						'uploading',
						'hash_complete',
						[
							'file_hash'              => (string) ( $file['file_hash'] ?? '' ),
							'file_size'              => (int) ( $file['file_size'] ?? 0 ),
							'hash_status'            => 'unknown',
							'scanned_with_signature' => null,
							'cloud_job_id'           => null,
							'error_code'             => 'hash_registry_desync',
							'error_message'          => 'Cloud hash registration missing; re-checking hash before upload retry',
						]
					);

					if ( is_wp_error( $transition ) ) {
						$this->fsm->transition( (int) $file['id'], 'uploading', 'pending_upload' );
					}
				}
				return 0;
			}

			// Roll back files to pending_upload (they were not confirmed uploaded).
			foreach ( $valid_files as $file ) {
				$this->fsm->transition( (int) $file['id'], 'uploading', 'pending_upload' );
			}
			if ( ! $is_soft_backoff ) {
				$this->handle_cloud_error( $result, 'upload' );
			}
			return 0;
		}

		$this->aimd->record_upload_success();
		\SiteFort\Modules\Scanner\Queue\ScanQueue::reset_cloud_errors();

		$job_id = $result['job_id'];
		$uploaded_hashes = $result['uploaded_hashes'] ?? [];

		$this->current_job_id = $job_id;
		set_transient( 'sitefort_cloud_job_' . $this->scan_id, $job_id, HOUR_IN_SECONDS );

		if ( empty( $uploaded_hashes ) ) {
			$this->job_repository->update( $job_id, [ 'status' => 'failed', 'error_message' => 'upload_failed_locally' ] );
		}

		$uploaded_count = 0;
		foreach ( $valid_files as $file ) {
			$file_hash = $file['file_hash'] ?? '';
			if ( in_array( $file_hash, $uploaded_hashes, true ) ) {
				$this->fsm->transition( (int) $file['id'], 'uploading', 'uploaded', [ 'cloud_job_id' => $job_id ] );
				$uploaded_count++;
			} else {
				$this->fsm->transition( (int) $file['id'], 'uploading', 'pending_upload' );
			}
		}

		return $uploaded_count;
	}

	private function resolve_upload_max_size_bytes( array $payload ): int {
		$size_mb = (int) ( $payload['max_size_mb'] ?? 0 );
		if ( $size_mb <= 0 ) {
			$size_mb = 8;
		}

		return $size_mb * 1024 * 1024;
	}

	private function get_active_cloud_job_count(): int {
		return $this->job_repository->count_by_scan_and_statuses(
			$this->scan_id,
			[ 'submitted', 'processing' ]
		);
	}

	private function get_max_active_cloud_jobs(): int {
		$limit = self::MAX_ACTIVE_CLOUD_JOBS;
		if ( function_exists( 'apply_filters' ) ) {
			$limit = (int) apply_filters( 'sitefort_scanner_max_active_cloud_jobs', $limit, $this->scan_id );
		}

		return max( 1, min( 50, $limit ) );
	}

	/**
	 * Build the upload policy from current resource pressure.
	 *
	 * @param array<string,mixed> $payload
	 * @return array{max_files:int,max_size_mb:int,target_active_jobs:int,active_jobs:int,pending_upload_count:int,next_available_at:int,can_upload_now:bool}
	 */
	private function build_upload_policy( array $payload = [], bool $refresh_active_jobs = false ): array {
		$now      = time();
		$hard_cap = $this->get_max_active_cloud_jobs();
		$metrics  = $this->aimd->sense_resources();
		$active   = $refresh_active_jobs ? $this->get_active_cloud_job_count() : max( 0, (int) ( $metrics['cloud_queue'] ?? 0 ) );
		$metrics['cloud_queue'] = $active;
		$pressure = $this->aimd->get_pressure_state_from_metrics( $metrics );
		$free_mb  = (int) ( $metrics['memory_free_mb'] ?? PHP_INT_MAX );
		$memory   = (float) ( $metrics['memory'] ?? 0 );
		$buffer   = (int) ( $metrics['buffer_size'] ?? 0 );
		$pending_upload_count = max( 0, (int) ( $metrics['buffer_count'] ?? 0 ) );
		$window = $this->aimd->get_upload_window_for_phase( $pressure, $metrics );
		$backoff_until = $this->aimd->get_upload_backoff_until();
		$is_low_memory = $free_mb < 32 || $memory >= 75.0;

		$target = min( 6, $hard_cap );
		$max_size_mb = 4;
		$delay = 10;

		$host_pressure = ! empty( $pressure['host_pressure'] );
		$backlog       = $buffer > 20 * 1024 * 1024;

		if ( $pressure['critical'] || $is_low_memory ) {
			$target = 1;
			$max_size_mb = 2;
			$delay = 20;
		} elseif ( $host_pressure && $pressure['moderate'] ) {
			// Genuine host pressure (memory high / upload failures): ease off uploads too.
			$target = min( 4, $hard_cap );
			$max_size_mb = $backlog ? 4 : 2;
			$delay = 15;
		} elseif ( $backlog ) {
			// Backlog with a healthy host: the cloud is the bottleneck, so open the
			// drain to the full ceiling rather than throttling it.
			$target = $hard_cap;
			$max_size_mb = 4;
		} elseif ( $pressure['optimal'] && $free_mb >= 256 && $buffer < 8 * 1024 * 1024 ) {
			$target = $hard_cap;
			$max_size_mb = 8;
		} elseif ( $pressure['optimal'] && $free_mb >= 128 ) {
			$target = min( 8, $hard_cap );
			$max_size_mb = 8;
		}

		$target = max( 1, min( $hard_cap, $target, max( 1, $window ) ) );
		$can_upload_now = $window > 0 && $active < $target;
		$next_available_at = $can_upload_now ? $now : max( $now + $delay, $backoff_until );

		$policy = [
			'max_files'            => CloudScanService::MAX_FILES_PER_BATCH,
			'max_size_mb'          => $max_size_mb,
			'target_active_jobs'   => $target,
			'active_jobs'          => $active,
			'pending_upload_count' => $pending_upload_count,
			'next_available_at'    => $next_available_at,
			'can_upload_now'       => $can_upload_now,
		];

		if ( function_exists( 'apply_filters' ) ) {
			$policy = apply_filters( 'sitefort_scanner_upload_policy', $policy, $this->scan_id, $metrics, $pressure, $payload );
		}

		return $this->normalize_upload_policy( is_array( $policy ) ? $policy : [], $hard_cap, $active, $window, $now );
	}

	/**
	 * @param array<string,mixed> $policy
	 * @return array{max_files:int,max_size_mb:int,target_active_jobs:int,active_jobs:int,pending_upload_count:int,next_available_at:int,can_upload_now:bool}
	 */
	private function normalize_upload_policy( array $policy, int $hard_cap, int $active, int $window, int $now ): array {
		$target = max( 1, min( $hard_cap, (int) ( $policy['target_active_jobs'] ?? 1 ), max( 1, $window ) ) );
		$max_files = max( 1, min( CloudScanService::MAX_FILES_PER_BATCH, (int) ( $policy['max_files'] ?? CloudScanService::MAX_FILES_PER_BATCH ) ) );
		$max_size_mb = max( 1, min( 8, (int) ( $policy['max_size_mb'] ?? 4 ) ) );
		$pending_upload_count = max( 0, (int) ( $policy['pending_upload_count'] ?? 0 ) );
		$filter_allows_upload = ! array_key_exists( 'can_upload_now', $policy ) || (bool) $policy['can_upload_now'];
		$can_upload_now = $filter_allows_upload && $window > 0 && $active < $target;
		$next_available_at = max( $now, (int) ( $policy['next_available_at'] ?? $now ) );

		return [
			'max_files'            => $max_files,
			'max_size_mb'          => $max_size_mb,
			'target_active_jobs'   => $target,
			'active_jobs'          => $active,
			'pending_upload_count' => $pending_upload_count,
			'next_available_at'    => $can_upload_now ? $now : max( $next_available_at, $now + 5 ),
			'can_upload_now'       => $can_upload_now,
		];
	}

	/**
	 * @param array<string,mixed> $payload
	 * @return array{max_files:int,max_size_mb:int}
	 */
	private function normalize_upload_payload( array $payload ): array {
		return [
			'max_files'   => max( 1, min( CloudScanService::MAX_FILES_PER_BATCH, (int) ( $payload['max_files'] ?? CloudScanService::MAX_FILES_PER_BATCH ) ) ),
			'max_size_mb' => max( 1, min( 8, (int) ( $payload['max_size_mb'] ?? 4 ) ) ),
		];
	}

	/**
	 * @param array<string,mixed> $payload
	 * @return array{files:array<int,array<string,mixed>>,has_pending:bool,available_at:int}
	 */
	private function build_upload_candidate_plan( array $payload, ?bool $hashing_complete = null ): array {
		if ( $this->guard_deep_analysis_upload_credit_exhausted() ) {
			return [
				'files'        => [],
				'has_pending'  => false,
				'available_at' => time(),
			];
		}

		$payload = $this->normalize_upload_payload( $payload );
		$max_files = $payload['max_files'];
		$max_size  = $this->resolve_upload_max_size_bytes( $payload );
		$hashing_complete = $hashing_complete ?? $this->check_hashing_complete();

		$candidates = $this->file_repository->get_pending_upload_candidates( $this->scan_id, 200 );
		if ( empty( $candidates ) ) {
			return [
				'files'        => [],
				'has_pending'  => false,
				'available_at' => time(),
			];
		}

		$candidate_count = count( $candidates );
		$selected = [];
		$total_size = 0;
		$has_pending = false;

		foreach ( $candidates as $file ) {
			if ( (int) ( $file['upload_attempt'] ?? 0 ) >= 3 ) {
				$this->fsm->transition(
					(int) $file['id'],
					'pending_upload',
					'error',
					[
						'error_code'    => 'upload_cycle_exhausted',
						'error_message' => 'Cloud upload failed after multiple attempts',
					]
				);
				CloudResultProcessor::log_skipped_file(
					(string) ( $file['file_path'] ?? '' ),
					'Upload failed: cloud rejected file after multiple attempts',
					[ 'error_code' => 'upload_cycle_exhausted', 'upload_attempts' => (int) ( $file['upload_attempt'] ?? 0 ) ],
					$this->scan_id
				);
				continue;
			}

			$has_pending = true;
			$size = (int) ( $file['file_size'] ?? 0 );

			if ( $size > $max_size && empty( $selected ) ) {
				$selected[] = $file;
				$total_size += $size;
				break;
			}

			$selected[] = $file;
			$total_size += $size;

			if ( count( $selected ) >= $max_files || $total_size >= $max_size ) {
				break;
			}
		}

		if ( empty( $selected ) ) {
			return [
				'files'        => [],
				'has_pending'  => $has_pending || $candidate_count >= 200,
				'available_at' => time(),
			];
		}

		if ( ! $hashing_complete && ! $this->should_flush_partial_upload_batch( $selected, $max_files, $total_size, $max_size ) ) {
			return [
				'files'        => [],
				'has_pending'  => true,
				'available_at' => $this->get_partial_upload_available_at( $selected ),
			];
		}

		return [
			'files'        => $selected,
			'has_pending'  => true,
			'available_at' => time(),
		];
	}

	/**
	 * Select upload candidates to respect batching rules.
	 *
	 * @param bool $hashing_complete
	 * @param int  $max_files_override
	 * @param int  $max_size_override
	 * @return array
	 */
	private function select_upload_candidates(
		bool $hashing_complete,
		int $max_files_override = 0,
		int $max_size_override = 0
	): array {
		$payload = [
			'max_files'   => $max_files_override > 0 ? $max_files_override : CloudScanService::MAX_FILES_PER_BATCH,
			'max_size_mb' => $max_size_override > 0 ? (int) ceil( $max_size_override / ( 1024 * 1024 ) ) : 2,
		];

		$plan = $this->build_upload_candidate_plan( $payload, $hashing_complete );
		return $plan['files'];
	}

	private function should_flush_partial_upload_batch( array $selected, int $max_files, int $total_size, int $max_size ): bool {
		if ( empty( $selected ) ) {
			return false;
		}

		if ( count( $selected ) >= $max_files || $total_size >= $max_size ) {
			return true;
		}

		$oldest_ready_at = $this->get_oldest_upload_ready_at( $selected );

		return $oldest_ready_at !== PHP_INT_MAX
			&& ( time() - $oldest_ready_at ) >= self::PARTIAL_UPLOAD_FLUSH_AFTER;
	}

	private function get_partial_upload_available_at( array $selected ): int {
		$oldest_ready_at = $this->get_oldest_upload_ready_at( $selected );

		if ( $oldest_ready_at === PHP_INT_MAX ) {
			return time() + self::PARTIAL_UPLOAD_FLUSH_AFTER;
		}

		return max( time() + 1, $oldest_ready_at + self::PARTIAL_UPLOAD_FLUSH_AFTER );
	}

	private function get_oldest_upload_ready_at( array $selected ): int {
		$oldest_ready_at = PHP_INT_MAX;
		foreach ( $selected as $file ) {
			$ready_at = (int) ( $file['updated_at'] ?? 0 );
			if ( $ready_at <= 0 ) {
				$ready_at = (int) ( $file['created_at'] ?? 0 );
			}
			if ( $ready_at > 0 ) {
				$oldest_ready_at = min( $oldest_ready_at, $ready_at );
			}
		}

		return $oldest_ready_at;
	}

	/**
	 * Helper to check if hashing is done.
	 */
	private function check_hashing_complete(): bool {
		if ( $this->hashing_complete_cache === true ) {
			return true;
		}
		$now = time();
		if ( $this->hashing_complete_cache === false && ( $now - $this->hashing_complete_cache_time ) < 3 ) {
			return false;
		}
		$result = ! $this->fsm->has_files_in_states(
			$this->scan_id,
			[ 'discovered', 'hashing', 'hash_complete', 'cloud_hash_check' ]
		);
		$this->hashing_complete_cache      = $result;
		$this->hashing_complete_cache_time = $now;
		return $result;
	}

	/**
	 * Check if hash production is idle (no discovered or hashing files).
	 * This allows the cloud hash phase to flush a partial batch when no more hashes are coming.
	 */
	private function is_hash_production_idle(): bool {
		// Single query with IN clause instead of 2 separate SELECT queries.
		return ! $this->fsm->has_files_in_states( $this->scan_id, [ 'discovered', 'hashing' ] );
	}

	public function get_verified_count(): int {
		$progress = $this->fsm->get_scan_progress( $this->scan_id );
		return (
			( $progress['completed'] ?? 0 ) +
			( $progress['flagged'] ?? 0 ) +
			( $progress['error'] ?? 0 ) +
			( $progress['verified'] ?? 0 ) +
			( $progress['safe'] ?? 0 ) +
			( $progress['scan_skipped'] ?? 0 )
		);
	}

	/**
	 * Phase 6: Check Completion.
	 * Determine if the scan is complete (all files in terminal states).
	 *
	 * @return bool True if scan is complete.
	 */
	private function check_completion(): bool {
		// CRITICAL: Ensure file indexing is actually finished before declaring scan complete
		if ( ! $this->is_file_index_complete() ) {
			return false;
		}

		// All system checks must reach a terminal state before finalizing the scan;
		// without this gate, the queue can flip to complete while late checks are still
		// pending/active, causing the UI to jump out-of-sequence.
		if ( ! $this->are_system_checks_complete() ) {
			return false;
		}

		// Scan is complete only when every indexed/discovered file is terminal.
		if ( ! $this->is_file_pipeline_complete() ) {
			return false;
		}

		// NOTE: Do NOT commit here. ScanQueue::complete() → publish() handles the
		// atomic swap (hard-clean → commit → swap). Committing here would insert
		// issues that publish()'s hard-clean then deletes, relying on a second commit
		// to re-insert them - a fragile pattern that loses issues on silent SQL errors.
		return true;
	}

	/**
	 * True when every file in the FSM has reached a terminal state.
	 * This means discovery, hashing, uploading, and cloud polling are all done.
	 * Does NOT require system checks (reputation, vulns, etc.) to be complete.
	 */
	private function is_file_pipeline_complete(): bool {
		$progress = $this->fsm->get_scan_progress( $this->scan_id );

		$terminal_states = [
			'completed',
			'flagged',
			'error',
			'verified',
			'safe',
			'scan_skipped',
		];

		$terminal_count = 0;
		foreach ( $terminal_states as $state ) {
			$terminal_count += $progress[ $state ] ?? 0;
		}

		$fsm_total        = (int) ( $progress['total'] ?? 0 );
		$file_index_total = $this->file_index_repository
			? (int) $this->file_index_repository->get_total_files( $this->scan_id )
			: 0;
		$expected_total   = max( $fsm_total, $file_index_total );

		return $expected_total > 0 && $terminal_count >= $expected_total;
	}

	/**
	 * Check whether all system checks are complete (or failed terminally).
	 *
	 * Uses durable queue component_status as source of truth.
	 *
	 * @return bool
	 */
	private function are_system_checks_complete(): bool {
		$checks = [ 'reputation', 'users', 'content', 'sensitive_data', 'vulnerabilities' ];

		$queue = $this->get_cached_queue();
		if ( ! $queue ) {
			return false;
		}

		$component_status = ScanQueue::get_component_status( $queue, false );
		foreach ( $checks as $slug ) {
			$status = $component_status[ $slug ] ?? 'pending';
			if ( ! in_array( $status, [ 'complete', 'failed' ], true ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Resolve file index completion across transient and DB state.
	 *
	 * @return bool
	 */
	private function is_file_index_complete(): bool {
		if ( $this->file_index_complete_cache === true ) {
			return true;
		}

		$transient_key = 'sitefort_file_index_complete_' . $this->scan_id;
		if ( get_transient( $transient_key ) ) {
			$this->file_index_complete_cache = true;
			return true;
		}

		$discovery_key = 'sitefort_discovery_complete_' . $this->scan_id;
		if ( get_transient( $discovery_key ) ) {
			set_transient( $transient_key, true, HOUR_IN_SECONDS );
			$this->file_index_complete_cache = true;
			return true;
		}

		if ( $this->file_index_repository && $this->file_index_repository->is_complete( $this->scan_id ) ) {
			set_transient( $transient_key, true, HOUR_IN_SECONDS );
			$this->file_index_complete_cache = true;
			return true;
		}

		return false;
	}

	/**
	 * Poll and process a batch of known cloud job rows.
	 *
	 * @param array<int,array<string,mixed>> $jobs
	 * @return array<string,mixed>|\WP_Error
	 */
	private function process_polled_jobs( array $jobs ) {
		if ( empty( $jobs ) ) {
			return [
				'processed'             => 0,
				'pending_job_ids'       => [],
				'completed_job_ids'     => [],
				'failed_job_ids'        => [],
				'requeued_upload'       => false,
				'youngest_pending_age' => null,
			];
		}

		$job_ids_to_poll = [];
		$jobs_map = [];
		$now = time();

		foreach ( $jobs as $job ) {
			$job_id = (string) ( $job['job_id'] ?? '' );
			if ( '' === $job_id ) {
				continue;
			}

			$job_ids_to_poll[] = $job_id;
			$jobs_map[ $job_id ] = $job;
		}

		if ( empty( $job_ids_to_poll ) ) {
			return [
				'processed'             => 0,
				'pending_job_ids'       => [],
				'completed_job_ids'     => [],
				'failed_job_ids'        => [],
				'requeued_upload'       => false,
				'youngest_pending_age' => null,
			];
		}

		$batch_results = $this->cloud_service->poll_batch_results( $job_ids_to_poll );
		if ( is_wp_error( $batch_results ) ) {
			$this->handle_cloud_error( $batch_results, 'poll_results' );
			return $batch_results;
		}

		\SiteFort\Modules\Scanner\Queue\ScanQueue::reset_cloud_errors();
		foreach ( $job_ids_to_poll as $job_id ) {
			$this->job_repository->update( $job_id, [ 'last_poll_at' => $now ] );
		}

		$summary = [
			'processed'             => 0,
			'pending_job_ids'       => [],
			'completed_job_ids'     => [],
			'failed_job_ids'        => [],
			'requeued_upload'       => false,
			'youngest_pending_age' => null,
		];

		foreach ( $job_ids_to_poll as $job_id ) {
			$result_data = $batch_results[ $job_id ] ?? null;
			$job = $jobs_map[ $job_id ];
			$status = is_array( $result_data ) ? (string) ( $result_data['status'] ?? 'pending' ) : 'missing';

			if ( 'completed' === $status ) {
				$this->result_processor->process_scan_results( $job_id, $result_data['results'] ?? [] );
				$this->job_repository->update(
					$job_id,
					[
						'status'       => 'completed',
						'completed_at' => time(),
					]
				);
				$summary['processed']++;
				$summary['completed_job_ids'][] = $job_id;
				continue;
			}

			if ( 'failed' === $status ) {
				$this->job_repository->update(
					$job_id,
					[
						'status'        => 'failed',
						'error_message' => $result_data['error'] ?? 'Server reported failure',
						'completed_at'  => time(),
					]
				);
				$this->result_processor->mark_job_files_as_error( $job_id, 'server_failure', $result_data['error'] ?? 'Analysis failed' );
				$summary['failed_job_ids'][] = $job_id;
				continue;
			}

			if ( in_array( $status, [ 'awaiting_upload', 'uploading', 'not_found', 'missing' ], true ) ) {
				$submitted_at = (int) ( $job['submitted_at'] ?? 0 );
				$age = $submitted_at > 0 ? ( time() - $submitted_at ) : 0;

				if ( $submitted_at > 0 && $age >= self::AWAITING_UPLOAD_REQUEUE_AFTER ) {
					$retry_count = (int) ( $job['retry_count'] ?? 0 );
					$max_retries = (int) ( $job['max_retries'] ?? 3 );
					$state_desc = in_array( $status, [ 'not_found', 'missing' ], true )
						? 'not returned by cloud poll'
						: $status;

					if ( $retry_count < $max_retries ) {
						$this->job_repository->update(
							$job_id,
							[
								'status'        => 'failed',
								'error_message' => sprintf( 'Job %s for %ds; re-queueing upload batch', $state_desc, $age ),
								'completed_at'  => time(),
								'retry_count'   => $retry_count + 1,
							]
						);
						$this->requeue_job_files_for_upload( $job_id );
						$this->aimd->record_upload_failure( false );
						$summary['requeued_upload'] = true;
						$summary['failed_job_ids'][] = $job_id;
					} else {
						$this->job_repository->update(
							$job_id,
							[
								'status'        => 'failed',
								'error_message' => sprintf( 'Job %s; retries exhausted (%d)', $state_desc, $max_retries ),
								'completed_at'  => time(),
							]
						);
						$this->result_processor->mark_job_files_as_error( $job_id, 'awaiting_upload_timeout', 'Cloud did not confirm job status; retries exhausted' );
						$summary['failed_job_ids'][] = $job_id;
					}
				} else {
					$this->record_pending_job( $summary, $job_id, $job, $now );
				}
				continue;
			}

			if ( $this->is_job_timed_out( $job ) ) {
				$retry_count = (int) ( $job['retry_count'] ?? 0 );
				$max_retries = (int) ( $job['max_retries'] ?? 3 );

				if ( $retry_count < $max_retries ) {
					$this->job_repository->update(
						$job_id,
						[
							'status'       => 'submitted',
							'retry_count'  => $retry_count + 1,
							'timeout_at'   => time() + 900,
							'last_poll_at' => null,
						]
					);
					$this->record_pending_job( $summary, $job_id, $job, $now );
				} else {
					$this->job_repository->update(
						$job_id,
						[
							'status'        => 'failed',
							'error_message' => sprintf( 'Job failed after %d retries', $max_retries ),
							'completed_at'  => time(),
						]
					);
					$this->result_processor->mark_job_files_as_error( $job_id, 'max_retries_exhausted', 'Scan job failed after maximum retries' );
					$summary['failed_job_ids'][] = $job_id;
				}
				continue;
			}

			$this->record_pending_job( $summary, $job_id, $job, $now );
		}

		return $summary;
	}

	/**
	 * Track pending jobs and the youngest pending age for scan-level poll cadence.
	 *
	 * @param array<string,mixed> $summary
	 * @param string              $job_id
	 * @param array<string,mixed> $job
	 * @param int                 $now
	 * @return void
	 */
	private function record_pending_job( array &$summary, string $job_id, array $job, int $now ): void {
		$summary['pending_job_ids'][] = $job_id;

		$submitted_at = (int) ( $job['submitted_at'] ?? 0 );
		if ( $submitted_at <= 0 ) {
			return;
		}

		$age = max( 0, $now - $submitted_at );
		if ( null === $summary['youngest_pending_age'] || $age < (int) $summary['youngest_pending_age'] ) {
			$summary['youngest_pending_age'] = $age;
		}
	}

	/**
	 * Handle cloud API errors.
	 *
	 * stable client errors (4xx except 408/429) fail the scan immediately
	 * since they will never self-resolve on retry. Transient errors (5xx, network)
	 * accumulate toward MAX_CLOUD_ERRORS before failing.
	 *
	 * @param \WP_Error $error
	 * @param string $context
	 * @return int Counted transient error total; 0 when not counted.
	 */
	private function handle_cloud_error( \WP_Error $error, string $context ): int {
		$error_data = $this->normalize_cloud_error_data( $error );
		$status_code = (int) ( $error_data['status'] ?? 0 );
		$message = $error->get_error_message();
		$error_code = $error->get_error_code();
		$normalized_error_code = $this->extract_cloud_api_error_code( $error, $error_data );
		$is_rate_limit = ( 429 === $status_code || $error_code === 'rate_limited' || false !== strpos( $message, '429' ) );
		if ( $is_rate_limit ) {
			return 0;
		}

		if ( $normalized_error_code === 'credits_exhausted' ) {
			$this->apply_deep_analysis_credit_exhaustion( $this->extract_cloud_credit_context( $error_data ) );
			return 0;
		}

		// 409 hash_registration_busy is a transient contention signal: the file's upload slot is
		// briefly row-locked by another in-flight cloud job server-side. It carries retry_after
		// and clears on its own, so treat it like a rate limit - never a fatal client error, and
		// do not burn the cloud-error budget. Callers defer/re-queue the affected files.
		if ( 409 === $status_code || $normalized_error_code === 'hash_registration_busy' ) {
			return 0;
		}

		// Map internal context to user-friendly labels for activity log.
		$context_labels = [
			'hash_check'    => 'file analysis',
			'upload'        => 'file upload',
			'poll_results'  => 'results retrieval',
		];
		$label = $context_labels[ $context ] ?? $context;

		// Determine if this is a stable client error that will never resolve.
		// 4xx (except 408 timeout, 425 too early, 429 rate limit) are client-side
		// validation failures. Retrying the same request will produce the same error.
		$is_client_error = ( $status_code >= 400 && $status_code < 500
			&& ! in_array( $status_code, [ 408, 425, 429 ], true ) );

		// Use a professional message for server/network errors; keep API error details as-is.
		if ( in_array( $error_code, [ 'api_server_error', 'api_parse_error', 'http_exception', 'rate_limited' ], true ) ) {
			$summary = sprintf( 'Cloud service unavailable during %s. Will retry automatically.', $label );
		} else {
			$summary = sprintf( 'Cloud %s error: %s%s', $label, $message, $status_code ? " (HTTP $status_code)" : '' );
		}

		$count = \SiteFort\Modules\Scanner\Queue\ScanQueue::record_cloud_error( $summary );

		// Fail immediately on stable client errors, or after threshold on transient errors.
		$should_fail = $is_client_error || ( $count >= self::MAX_CLOUD_ERRORS );

		if ( $should_fail ) {
			$request_id = '';
			if ( isset( $error_data['request_id'] ) ) {
				$request_id = sanitize_text_field( (string) $error_data['request_id'] );
			} elseif ( isset( $detail['request_id'] ) ) {
				$request_id = sanitize_text_field( (string) $detail['request_id'] );
			}
			$host_id = '';
			if ( isset( $error_data['host_id'] ) ) {
				$host_id = sanitize_text_field( (string) $error_data['host_id'] );
			} elseif ( isset( $detail['host_id'] ) ) {
				$host_id = sanitize_text_field( (string) $detail['host_id'] );
			}
			$retryable = ! $is_client_error;

			if ( $is_client_error ) {
				$fail_message = sprintf( 'Cloud %s failed with a non-recoverable error (HTTP %d). Please retry the scan.', $label, $status_code );
			} else {
				$fail_message = 'Cloud service is temporarily unavailable. The scan could not complete after multiple retries. Please try again later.';
			}

			\SiteFort\Modules\Scanner\Queue\ScanQueue::fail(
				$fail_message,
				[
					'error_code'        => $normalized_error_code,
					'stage'             => sanitize_key( $context ),
					'failure_origin'    => 'cloud',
					'failure_type'      => sanitize_key( 'cloud_' . $context . '_failed' ),
					'status_code'       => $status_code,
					'request_id'        => $request_id,
					'host_id'           => $host_id,
					'cloud_error_count' => $count,
					'retryable'         => $retryable,
					'technical_message' => sanitize_text_field( $message ),
				]
			);
		}

		return $count;
	}

	/**
	 * Re-queue all files belonging to a cloud job back into pending_upload.
	 *
	 * This is used when the cloud job stays in awaiting_upload/uploading too long, which would otherwise
	 * cause the scan to hang forever.
	 */
	private function requeue_job_files_for_upload( string $job_id ): void {
		global $wpdb;
		$table = $this->table_identifier( $wpdb->prefix . 'sitefort_file_states' );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Uses a validated plugin-owned table identifier and prepared values.
		$files = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, state, upload_attempt, file_path FROM {$table}
				WHERE scan_id = %s
				AND cloud_job_id = %s
				AND state IN ('uploaded','scan_requested','uploading')",
				$this->scan_id,
				$job_id
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( empty( $files ) ) {
			return;
		}

		foreach ( $files as $file ) {
			$attempts = (int) ( $file['upload_attempt'] ?? 0 );

			if ( $attempts >= 3 ) {
				// File has cycled through upload too many times - mark as error to prevent infinite loop.
				$this->fsm->transition(
					(int) $file['id'],
					(string) $file['state'],
					'error',
					[
						'error_code'    => 'upload_cycle_exhausted',
						'error_message' => 'Cloud never acknowledged file receipt after multiple re-uploads',
						'cloud_job_id'  => null,
					]
				);

				CloudResultProcessor::log_skipped_file(
					(string) ( $file['file_path'] ?? '' ),
					'Upload failed: cloud never acknowledged file receipt after multiple attempts',
					[ 'error_code' => 'upload_cycle_exhausted', 'upload_attempts' => $attempts ],
					$this->scan_id
				);
				continue;
			}

			$this->fsm->transition(
				(int) $file['id'],
				(string) $file['state'],
				'pending_upload',
				[
					'cloud_job_id' => null,
				]
			);
		}
	}

	/**
	 * File-centric reconciliation: find files stuck in cloud states and resolve them.
	 *
	 * Instead of starting from the cloud_jobs table (which misses orphaned files),
	 * this starts from file_states. Every file in a cloud-related non-terminal state
	 * is checked against its cloud job record and resolved.
	 *
	 * Handles:
	 *  - Files whose cloud job completed/failed but results were not applied
	 *  - Files whose cloud_job_id has no matching job record (orphans)
	 *  - Files with NULL/empty cloud_job_id stuck in uploaded/uploading states
	 *
	 * @return bool True if any remediation was performed.
	 */
	private function reconcile_completed_jobs(): bool {
		$last_reconcile = (int) get_transient( 'sitefort_last_completed_reconcile_' . $this->scan_id );
		if ( ( time() - $last_reconcile ) < 60 ) {
			return false;
		}
		set_transient( 'sitefort_last_completed_reconcile_' . $this->scan_id, time(), 120 );

		global $wpdb;
		$file_table = $this->table_identifier( $wpdb->prefix . 'sitefort_file_states' );
		$jobs_table = $this->table_identifier( $wpdb->prefix . 'sitefort_cloud_jobs' );

		// Step 1: Find ALL files stuck in cloud-related non-terminal states.
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Joined table identifiers are plugin-owned and validated before interpolation; scan_id remains prepared.
		$stuck_files = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT fs.id, fs.file_path, fs.state, fs.cloud_job_id, fs.upload_attempt,
				        cj.status AS job_status, cj.completed_at AS job_completed_at,
				        cj.submitted_at AS job_submitted_at
				FROM {$file_table} fs
				LEFT JOIN {$jobs_table} cj ON fs.cloud_job_id = cj.job_id
				WHERE fs.scan_id = %s
				AND fs.state IN ('uploading','uploaded','scan_requested','scanning')
				LIMIT 500",
				$this->scan_id
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( empty( $stuck_files ) ) {
			return false;
		}

		$did_work = false;

		// Step 2: Group files by their cloud_job_id for batch resolution.
		$by_job = [];
		$orphans = [];
		foreach ( $stuck_files as $file ) {
			$job_id = $file['cloud_job_id'] ?? '';
			if ( empty( $job_id ) || $file['job_status'] === null ) {
				// No job record exists (orphan): job_status is NULL from the LEFT JOIN.
				$orphans[] = $file;
			} else {
				$by_job[ $job_id ][] = $file;
			}
		}

		// Step 3: Handle orphaned files (no matching cloud job record).
		// These files can never be resolved via polling. Re-queue or error immediately.
		foreach ( $orphans as $file ) {
			$attempts = (int) ( $file['upload_attempt'] ?? 0 );
			if ( $attempts < 3 ) {
				$this->fsm->transition(
					(int) $file['id'],
					(string) $file['state'],
					'pending_upload',
					[ 'cloud_job_id' => null ]
				);
			} else {
				$this->fsm->transition(
					(int) $file['id'],
					(string) $file['state'],
					'error',
					[
						'error_code'    => 'orphan_no_job',
						'error_message' => 'File in cloud state but no matching job record exists',
						'cloud_job_id'  => null,
					]
				);
				CloudResultProcessor::log_skipped_file(
					$file['file_path'] ?? '(unknown)',
					'Cloud upload failed: no job record found after multiple attempts',
					[ 'error_code' => 'orphan_no_job' ],
					$this->scan_id
				);
			}
			$did_work = true;
		}

		// Step 4: For files with job records, resolve based on job status.
		// Collect job IDs that are terminal locally but still have stuck files.
		$terminal_job_ids = [];
		$active_job_ids = [];
		foreach ( $by_job as $job_id => $files ) {
			$job_status = $files[0]['job_status'] ?? '';
			if ( in_array( $job_status, [ 'completed', 'failed', 'timeout' ], true ) ) {
				$terminal_job_ids[ $job_id ] = $files;
			} else {
				// Job is still active (submitted/processing/queued). Leave for the poll_cloud task.
				// But track for timeout detection below.
				$active_job_ids[ $job_id ] = $files;
			}
		}

		// Step 5: Terminal jobs with stuck files. Re-poll cloud for results.
		if ( ! empty( $terminal_job_ids ) ) {
			$job_ids_to_poll = array_keys( $terminal_job_ids );
			$batch_results = $this->cloud_service->poll_batch_results( $job_ids_to_poll );

			foreach ( $terminal_job_ids as $job_id => $files ) {
				$result_data = is_array( $batch_results ) ? ( $batch_results[ $job_id ] ?? null ) : null;
				$cloud_status = is_array( $result_data ) ? ( $result_data['status'] ?? null ) : null;
				$job_status = $files[0]['job_status'];

				if ( 'completed' === $cloud_status ) {
					// Cloud still has results. Apply them.
					$this->result_processor->process_scan_results( $job_id, $result_data['results'] ?? [] );
					$did_work = true;
				} elseif ( in_array( $job_status, [ 'failed', 'timeout' ], true ) ) {
					// Job failed/timed out and cloud has no results. Terminal error.
					$this->result_processor->mark_job_files_as_error( $job_id, 'scan_failed', 'Cloud scan job failed or timed out' );
					$did_work = true;
				} else {
					// Job locally 'completed' but cloud no longer returns results.
					$completed_at = (int) ( $files[0]['job_completed_at'] ?? 0 );
					if ( $completed_at > 0 && ( time() - $completed_at ) > 600 ) {
						$this->result_processor->mark_job_files_as_error( $job_id, 'results_missing', 'Cloud job completed but results could not be retrieved' );
						$did_work = true;
					}
				}
			}
		}

		// Step 6: Active jobs with stuck files. Detect stale active jobs.
		// If a job has been 'submitted'/'processing' for longer than timeout_at allows,
		// the poll_cloud task should handle it. But if polling missed it (no timeout_at
		// set, or status is unexpected like 'queued'), catch it here.
		foreach ( $active_job_ids as $job_id => $files ) {
			$submitted_at = (int) ( $files[0]['job_submitted_at'] ?? 0 );
			$age = $submitted_at > 0 ? ( time() - $submitted_at ) : 0;

			// Jobs older than 15 minutes that are still "active" are stale.
			if ( $submitted_at > 0 && $age > 900 ) {
				$this->result_processor->mark_job_files_as_error( $job_id, 'job_stale', 'Cloud job did not complete within expected timeframe' );
				$this->job_repository->update( $job_id, [
					'status'        => 'failed',
					'error_message' => sprintf( 'Job stale after %ds with no completion', $age ),
					'completed_at'  => time(),
				] );
				$did_work = true;
			}
		}

		return $did_work;
	}
	/**
	 * Check if job has timed out.
	 *
	 * @param array $job Job record.
	 * @return bool True if timed out.
	 */
	private function is_job_timed_out( array $job ): bool {
		$timeout_at = $job['timeout_at'] ?? 0;
		return $timeout_at > 0 && time() > $timeout_at;
	}
	/**
	 * Get active job ID for this scan.
	 *
	 * @return string|null Job ID or null.
	 */
	private function get_active_job_id(): ?string {
		// Check transient first
		$job_id = get_transient( 'sitefort_cloud_job_' . $this->scan_id );
		if ( $job_id ) {
			return $job_id;
		}

		// Check database for active job
		$jobs = $this->job_repository->get_by_scan_id( $this->scan_id );
		foreach ( $jobs as $job ) {
			if ( in_array( $job['status'], [ 'awaiting_upload', 'uploading', 'submitted', 'processing' ], true ) ) {
				return $job['job_id'];
			}
		}

		return null;
	}

	/**
	 * Log pressure state transitions to the scan log.
	 * Only logs when the pressure level changes, not on every cycle.
	 *
	 * @param array $pressure Pressure state from AIMD.
	 * @param array $metrics  Raw resource metrics.
	 */
	private function log_pressure_state( array $pressure, array $metrics ): void {
		if ( ! class_exists( '\SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
			return;
		}

		$level = $pressure['level'] ?? 'normal';

		// Skip if level has not changed since last log.
		if ( $level === self::$last_logged_pressure_level ) {
			return;
		}

		$prev_level = self::$last_logged_pressure_level;
		self::$last_logged_pressure_level = $level;

		$memory = round( (float) ( $metrics['memory'] ?? 0 ), 1 );
		$queue  = (int) ( $metrics['cloud_queue'] ?? 0 );
		$cause  = $this->describe_pressure_reasons( $pressure, $metrics );

		if ( $level === 'critical' ) {
			$window = $this->aimd->get_hash_window_for_phase( $pressure, $metrics );
			\SiteFort\Modules\Scanner\Service\ScanLogger::log(
				sprintf(
					'[Resource] CRITICAL pressure (%s) - scan throttled to %d files/batch. Memory: %.1f%%, Cloud queue: %d',
					$cause, $window, $memory, $queue
				),
				2, 'warning'
			);
		} elseif ( $level === 'moderate' ) {
			$window = $this->aimd->get_hash_window_for_phase( $pressure, $metrics );
			\SiteFort\Modules\Scanner\Service\ScanLogger::log(
				sprintf(
					'[Resource] Moderate pressure (%s) - hash batches capped at %d files. Memory: %.1f%%, Cloud queue: %d',
					$cause, $window, $memory, $queue
				),
				2, 'warning'
			);
		} elseif ( in_array( $prev_level, [ 'critical', 'moderate' ], true ) ) {
			// Only log recovery if we were previously in a constrained state.
			\SiteFort\Modules\Scanner\Service\ScanLogger::log(
				sprintf(
					'[Resource] Pressure cleared - resuming normal scan speed. Memory: %.1f%%',
					$memory
				),
				1, 'info'
			);
		}
	}

	/**
	 * Human-readable cause list for a pressure state.
	 *
	 * @param array $pressure Pressure state (with reasons).
	 * @param array $metrics  Raw metrics for detail values.
	 * @return string
	 */
	private function describe_pressure_reasons( array $pressure, array $metrics ): string {
		$reasons = is_array( $pressure['reasons'] ?? null ) ? $pressure['reasons'] : [];
		$labels  = [];

		foreach ( array_unique( $reasons ) as $reason ) {
			switch ( $reason ) {
				case 'memory_at_cap':
				case 'memory_high':
					$ema = (float) ( $pressure['memory_ema'] ?? 0 );
					$labels[] = sprintf( 'memory usage high (%.1f%% avg)', $ema );
					break;
				case 'upload_backlog':
					$buffer_mb = ( (int) ( $metrics['buffer_size'] ?? 0 ) ) / ( 1024 * 1024 );
					$labels[] = sprintf( '%.1fMB of files awaiting cloud analysis', $buffer_mb );
					break;
				case 'cloud_queue_deep':
					$labels[] = 'cloud analysis queue saturated';
					break;
				case 'low_free_memory':
					$labels[] = sprintf( 'only %dMB PHP memory free', (int) ( $metrics['memory_free_mb'] ?? 0 ) );
					break;
				case 'upload_failures':
					$labels[] = 'recent upload failures';
					break;
				case 'sustained_from_previous_readings':
					$labels[] = 'sustained from earlier readings';
					break;
			}
		}

		$labels = array_values( array_unique( $labels ) );

		return ! empty( $labels ) ? implode( ', ', $labels ) : 'resource pressure';
	}

	private function log_scan_progress(): void {
		if ( ! class_exists( '\SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
			return;
		}

		if ( ! \SiteFort\Modules\Scanner\Service\ScanLogger::should_log_throttled_for_scan( $this->scan_id, 'scan_progress', self::PROGRESS_LOG_INTERVAL ) ) {
			return;
		}

		$progress = $this->fsm->get_scan_progress( $this->scan_id );
		$by_state = $progress['by_state'] ?? [];

		// Terminal states = processed
		$processed = (
			( $by_state['completed'] ?? 0 ) +
			( $by_state['flagged'] ?? 0 ) +
			( $by_state['error'] ?? 0 ) +
			( $by_state['verified'] ?? 0 ) +
			( $by_state['safe'] ?? 0 ) +
			( $by_state['scan_skipped'] ?? 0 )
		);

		$total = $progress['total'] ?? 0;

		$cloud_pending = (
			( $by_state['pending_upload'] ?? 0 ) +
			( $by_state['uploading'] ?? 0 ) +
			( $by_state['uploaded'] ?? 0 ) +
			( $by_state['scan_requested'] ?? 0 ) +
			( $by_state['scanning'] ?? 0 )
		);
		$processed_label = function_exists( 'number_format_i18n' ) ? number_format_i18n( $processed ) : (string) $processed;
		$total_label     = function_exists( 'number_format_i18n' ) ? number_format_i18n( $total ) : (string) $total;
		$remaining       = max( 0, $total - $processed );
		$remaining_label = function_exists( 'number_format_i18n' ) ? number_format_i18n( $remaining ) : (string) $remaining;
		$cloud_label     = function_exists( 'number_format_i18n' ) ? number_format_i18n( $cloud_pending ) : (string) $cloud_pending;

		$msg = sprintf(
			'Scan Progress: %s/%s files reached a final state.',
			$processed_label,
			$total_label
		);

		if ( $remaining > 0 ) {
			if ( $cloud_pending > 0 ) {
				$msg .= sprintf(
					' %s file(s) still in progress, including %s awaiting cloud processing.',
					$remaining_label,
					$cloud_label
				);
			} else {
				$msg .= sprintf(
					' %s file(s) still in progress.',
					$remaining_label
				);
			}
		} else {
			$msg .= ' Finalizing scan results.';
		}

		\SiteFort\Modules\Scanner\Service\ScanLogger::log_throttled_for_scan(
			$this->scan_id,
			'scan_progress',
			$msg,
			self::PROGRESS_LOG_INTERVAL,
			1,
			'info',
			$remaining <= 0
		);
	}

	/**
	 * Quote a table identifier after validating its shape.
	 */
	private function table_identifier( string $table ): string {
		if ( $table === '' || strlen( $table ) > 64 || ! preg_match( '/^[A-Za-z0-9_]+$/', $table ) ) {
			throw new \InvalidArgumentException( 'Invalid table identifier.' );
		}

		return '`' . $table . '`';
	}

}
