<?php

declare(strict_types=1);

namespace SecurWP\Modules\Scanner\Cloud;

use SecurWP\Modules\Cloud\SecureApi;
use SecurWP\Modules\Cloud\Service\CloudSettings;
use SecurWP\Modules\Scanner\FileIntegrity\FSMEngine;
use SecurWP\Modules\Scanner\FileIntegrity\FileCollector;
use SecurWP\Modules\Scanner\Repository\CloudJobRepository;
use SecurWP\Modules\Scanner\Repository\IssueRepository;
use SecurWP\Modules\Scanner\Service\IssueType;
use SecurWP\Modules\Scanner\Repository\HashCacheRepository;
use SecurWP\Modules\Cloud\Services\CloudScanner;
use SecurWP\Modules\Scanner\Service\MalwareFindingFormatter;

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

/**
 * Cloud Scan Service - Zip-Batch Architecture
 *
 * Handles hash checking, job creation, Zip batch uploads, and result polling.
 * SQS/S3 events trigger scan processing automatically after uploads.
 *
 * Flow:
 * 1. check_hash() - Verdict-only hash check (no job creation)
 * 2. get_secure_link() - Create upload job + batch URLs
 * 3. poll_results() - Retrieve results via polling (SQS triggers processing)
 *
 * Size Limits:
 * - Max single file: 8MB (files larger are rejected by server)
 * - Batch grouping: 2MB (files >2MB get dedicated batch)
 *
 */
class CloudScanService {

	/**
	 * Size Limits (must match server-side values)
	 */
	const MAX_SINGLE_FILE_SIZE = 8 * 1024 * 1024;   // 8MB
	const BATCH_SIZE_LIMIT = 2 * 1024 * 1024;       // 2MB
	const MAX_FILES_PER_BATCH = 50;
	private const EXECUTABLE_EXTENSIONS = [ 'php', 'js' ];

	/**
	 * Documentation/metadata-only files suppressed from integrity findings entirely.
	 * These files have no executable content and are routinely edited on WordPress.org
	 * (e.g. "Tested up to:" bumps) without a version number change, producing constant
	 * false-positive noise with zero security value.
	 */
	private const DOC_FILES_SKIP_INTEGRITY = [
		'readme.txt',
		'readme.md',
		'license.txt',
		'changelog.txt',
		'changes.txt',
		'history.txt',
	];



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

	/**
	 * Shield API client.
	 *
	 * @var SecureApi
	 */
	private $api;

	/**
	 * Cloud scanner (for bulk uploads).
	 *
	 * @var CloudScanner
	 */
	private $cloud_scanner;

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

	/**
	 * Signed hash cache repository.
	 *
	 * @var HashCacheRepository
	 */
	private $hash_cache_repo;

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

	/**
	 * WordPress core manifest slug+version pairs that Intel reported as not yet
	 * indexed in the most recent hash-check response for this scan worker.
	 *
	 * @var array<int,array{slug:string,version:string}>
	 */
	private $last_unindexed_wp_versions = [];

	/** @var array<string,string>|null */
	private static $plugin_name_map = null;

	/** @var array<string,string>|null */
	private static $theme_name_map = null;

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

	/**
	 * Constructor.
	 *
	 * @param string $scan_id Scan UUID.
	 */
	public function __construct( string $scan_id ) {
		$this->scan_id         = $scan_id;
		$this->fsm             = new FSMEngine();
		$this->job_repository  = new CloudJobRepository();
		$this->hash_cache_repo = new HashCacheRepository();
		$this->file_collector  = new FileCollector();
		$this->cloud_scanner   = new CloudScanner();

		// Initialize API client
		$settings = CloudSettings::get_all();
		$license  = $settings['license_key'] ?? '';
		$token    = $settings['site_token'] ?? '';
		$secret   = $settings['site_secret'] ?? '';

		$this->api = new SecureApi( $license, $token, $secret );
	}

	/**
	 * Check file hashes (Verdict Only - Decoupled Architecture).
	 *
	 * Sends hashes to /check-hash endpoint. Does NOT create jobs or upload URLs.
	 * Client buffers files needing upload and calls get_secure_link() when buffer is full.
	 *
	 * @param array $files File records with hashes.
	 * @return array{hash_results: array, needs_upload: int, signature_version: string}|\WP_Error
	 */
	public function check_hash( array $files ) {
		try {
			if ( empty( $files ) ) {
				$this->last_unindexed_wp_versions = [];
				return [
					'hash_results'         => [],
					'needs_upload'         => 0,
					'skipped'              => 0,
					'signature_version'    => 'unknown',
					'credits_remaining'    => -1,
					'credits_total'        => -1,
					'credit_reset_at'      => null,
					'credit_limit_reached' => false,
				];
			}

			// Build payload
			$payload_files = [];
			foreach ( $files as $file ) {
				try {
					$file_size = $file['file_size'] ?? 0;

					// Oversized files are an intentional policy skip, not a pipeline failure.
					if ( $file_size > self::MAX_SINGLE_FILE_SIZE ) {
						$max_size_mb = (int) ( self::MAX_SINGLE_FILE_SIZE / ( 1024 * 1024 ) );
						$message     = sprintf( 'File exceeds maximum size of %dMB', $max_size_mb );
						if ( isset( $file['id'] ) ) {
							$this->fsm->transition(
								(int) $file['id'],
								$file['state'] ?? 'hash_complete',
								'scan_skipped',
								[
									'skip_reason'   => 'file_too_large',
									'error_code'    => 'file_too_large',
									'error_message' => $message,
								]
							);
						}
						if ( class_exists( '\SecurWP\Modules\Scanner\Service\ScanLogger' ) ) {
							\SecurWP\Modules\Scanner\Service\ScanLogger::log(
								sprintf(
									'[Skipped] %s - %s',
									(string) ( $file['file_path'] ?? '(unknown)' ),
									$message
								),
								2,
								'warning'
							);
						}
						continue;
					}

					$file_info       = $this->file_collector->get_file_info_for_hash_check( $file['file_path'] );
					$payload_entry = [
						'path'    => $file['file_path'],
						'hash'    => $file['file_hash'],
						'size'    => $file_size,
						'type'    => $file_info['type'] ?? 'other',
						'slug'    => $file_info['slug'] ?? '',
						'version' => $file_info['version'] ?? '',
					];
					if ( ! empty( $file_info['locale'] ) ) {
						$payload_entry['locale'] = (string) $file_info['locale'];
					}
					$payload_files[] = $payload_entry;
				} catch ( \RuntimeException $e ) {
					securwp_log( sprintf( '[SecurWP] Error processing file %s: %s', $file['file_path'] ?? 'unknown', $e->getMessage() ) );
					continue;
				}
			}

			// Call verdict-only endpoint
			$response = $this->api->post(
				'services/intelligence/check-hash',
				[
					'files'  => $payload_files,
					'locale' => function_exists( 'get_locale' ) ? get_locale() : 'en_US',
				]
			);

			if ( is_wp_error( $response ) ) {
				securwp_log( '[SecurWP] check_hash failed: ' . $response->get_error_message() );
				return $response;
			}

			$hash_results            = $response['hash_results'] ?? [];
			$needs_upload            = $response['needs_upload'] ?? 0;
			$skipped                 = $response['skipped'] ?? 0;
			$signature_version       = $response['signature_version'] ?? 'unknown';
			$unindexed_wp_versions   = self::normalize_unindexed_wp_versions( (array) ( $response['unindexed_wp_versions'] ?? [] ) );
			$this->last_unindexed_wp_versions = $unindexed_wp_versions;

			// Cloud credit fields (free plan rolling window). -1 = unlimited (paid plans).
			$credits_remaining    = isset( $response['credits_remaining'] ) ? (int) $response['credits_remaining'] : -1;
			$credits_total        = isset( $response['credits_total'] )     ? (int) $response['credits_total']     : -1;
			$credit_reset_at      = $response['credit_reset_at'] ?? null;
			$credit_limit_reached = (bool) ( $response['credit_limit_reached'] ?? false );

			// Persist credit state for the admin UI (non-blocking, best effort).
			CloudSettings::update_stored_fields( [
				'scan_credits_remaining'    => $credits_remaining,
				'scan_credits_total'        => $credits_total,
				'scan_credit_reset_at'      => $credit_reset_at,
				'scan_credit_limit_reached' => $credit_limit_reached,
			] );

			// Store signature version
			$this->update_scan_signature_version( $signature_version );

			return [
				'hash_results'           => $hash_results,
				'needs_upload'           => $needs_upload,
				'skipped'                => $skipped,
				'signature_version'      => $signature_version,
				'credits_remaining'      => $credits_remaining,
				'credits_total'          => $credits_total,
				'credit_reset_at'        => $credit_reset_at,
				'credit_limit_reached'   => $credit_limit_reached,
				'unindexed_wp_versions'  => $unindexed_wp_versions,
			];

		} catch ( \RuntimeException $e ) {
			securwp_log( '[SecurWP] Exception in check_hash: ' . $e->getMessage() );
			return new \WP_Error( 'system_error', $e->getMessage() );
		}
	}

	/**
	 * Get secure upload link (Just-in-Time Job Creation).
	 *
	 * Called when upload buffer exceeds 2MB or scan completes.
	 * Creates CloudScanJob on server and returns presigned S3 URLs.
	 *
	 * @param array $files Files to upload (must have hash, size, path).
	 * @return array{job_id: string, batches: array, upload_deadline: string}|\WP_Error
	 */
	public function get_secure_link( array $files ) {
		try {
			if ( empty( $files ) ) {
				return new \WP_Error( 'no_files', 'No files provided for upload' );
			}

			// Build payload (hash and size come from FSM, already using redacted
			// values for wp-config.php set during the hashing phase).
			$payload_files = [];
			foreach ( $files as $file ) {
				$payload_files[] = [
					'path' => $file['file_path'] ?? $file['path'] ?? '',
					'hash' => $file['file_hash'] ?? $file['hash'] ?? '',
					'size' => $file['file_size'] ?? $file['size'] ?? 0,
				];
			}

			// Request secure upload URLs
			$response = $this->api->post(
				'services/intelligence/get-secure-link',
				[
					'files'  => $payload_files,
					'locale' => function_exists( 'get_locale' ) ? get_locale() : 'en_US',
				]
			);

			if ( is_wp_error( $response ) ) {
				securwp_log( '[SecurWP] get_secure_link failed: ' . $response->get_error_message() );
				return $response;
			}

			$job_id          = $response['job_id'] ?? null;
			$batches         = $response['batches'] ?? [];
			$upload_deadline = $response['upload_deadline'] ?? null;

			if ( ! $job_id ) {
				return new \WP_Error( 'no_job_id', 'Server did not return job_id' );
			}

			// Upload batches immediately
			$uploaded_hashes = [];
			if ( ! empty( $batches ) ) {
				$uploaded_hashes = $this->upload_zip_batches( $batches, $payload_files );
                
				// Compare unique hash count (server batches are hash-based).
				$expected_hashes = [];
				foreach ( $files as $file ) {
					$hash = $file['file_hash'] ?? $file['hash'] ?? '';
					if ( ! empty( $hash ) ) {
						$expected_hashes[ $hash ] = true;
					}
				}
				$expected_count = count( $expected_hashes );
				$uploaded_unique = array_unique( $uploaded_hashes );
				$uploaded_hashes = $uploaded_unique;

				// If upload failed (0 hashes) OR Partial Upload, do not create job.
				// Partial uploads leave the server job waiting forever for missing batches.
				if ( empty( $uploaded_unique ) || count( $uploaded_unique ) !== $expected_count ) {
					securwp_log( sprintf(
						'[SecurWP] Job Aborted: Partial Upload. Expected %d unique hashes, Uploaded %d',
						$expected_count,
						count( $uploaded_unique )
					) );
					return new \WP_Error( 'upload_failed', 'Failed to upload all files to S3. Job aborted.' );
				}
			}

			// Store job in local database
			$this->job_repository->create(
				[
					'job_id'       => $job_id,
					'scan_id'      => $this->scan_id,
					'status'       => 'submitted',
					'submitted_at' => time(),
					'timeout_at'   => $upload_deadline ? strtotime( $upload_deadline ) : time() + 900,
				]
			);

			return [
				'job_id'          => $job_id,
				'batches'         => $batches,
				'upload_deadline' => $upload_deadline,
				'uploaded_hashes' => $uploaded_hashes,
			];

		} catch ( \RuntimeException $e ) {
			securwp_log( '[SecurWP] Exception in get_secure_link: ' . $e->getMessage() );
			return new \WP_Error( 'system_error', $e->getMessage() );
		}
	}

	/**
	 * Upload batches of files using PclZip (Store-Only).
	 *
	 * @param array $batches Batch metadata from Intel Engine.
	 * @param array $files   List of local files (for path lookup).
	 * @return array List of hashes that were successfully uploaded to S3.
	 */
	private function upload_zip_batches( array $batches, array $files ): array {
		try {
			if ( empty( $batches ) ) {
				securwp_log( '[SecurWP] upload_zip_batches: No batches provided' );
				return [];
			}

			// Map Hash -> [Paths] for resilient lookup (avoid losing duplicates)
			$hash_to_paths = [];
			foreach ( $files as $file ) {
				// Support both 'hash'/'path' (payload_files) and 'file_hash'/'file_path' (fsm files)
				$hash = $file['hash'] ?? $file['file_hash'] ?? null;
				$path = $file['path'] ?? $file['file_path'] ?? null;
				if ( ! empty( $hash ) && ! empty( $path ) ) {
					if ( ! isset( $hash_to_paths[ $hash ] ) ) {
						$hash_to_paths[ $hash ] = [];
					}
					$hash_to_paths[ $hash ][] = $path;
				}
			}

				// Load PclZip
			if ( ! class_exists( 'PclZip' ) ) {
				if ( defined( 'ABSPATH' ) ) {
					require_once ABSPATH . 'wp-admin/includes/class-pclzip.php';
				}
				if ( ! class_exists( 'PclZip' ) ) {
					securwp_log( '[SecurWP] PclZip class not found. Upload failed.' );
					return [];
				}
			}

			$uploaded_hashes = [];  // Track actually uploaded hashes
			$temp_dir = get_temp_dir();
				foreach ( $batches as $batch ) {
				$zip_file = $temp_dir . 'sp_batch_' . uniqid() . '.zip';
				$batch_hashes = [];  // Hashes included in this batch

				// register cleanup
				$cleanup = function() use ($zip_file) {
					if ( file_exists( $zip_file ) ) {
						wp_delete_file( $zip_file );
					}
				};
				register_shutdown_function( $cleanup );

				try {
					$archive = new \PclZip( $zip_file );
					$content_list = [];
					$temp_files   = []; // Temp files to clean up after zip is built

					// Add files to zip, renamed to their hash
					foreach ( $batch['hashes'] as $hash ) {
						$paths = $hash_to_paths[ $hash ] ?? [];
						if ( empty( $paths ) ) {
							continue;
						}

						$full_path = null;
						foreach ( $paths as $path ) {
							$candidate = ABSPATH . $path;
							if ( file_exists( $candidate ) ) {
								$full_path = $candidate;
								break;
							}
						}

						if ( ! $full_path ) {
							securwp_log( '[SecurWP] Upload skip - file not found for hash: ' . $hash );
							continue;
						}

						// Sanitize wp-config.php before cloud upload to strip DB
						// credentials and secret keys.
						// PCLZIP_ATT_FILE_CONTENT without PCLZIP_ATT_FILE_NAME causes
						// PclZip to silently skip the entry (empty filename check in
						// privAddFileList). Write to a temp file instead so PclZip
						// adds the entry reliably.
						if ( basename( $full_path ) === 'wp-config.php' ) {
							$raw = file_get_contents( $full_path );
							if ( $raw !== false ) {
								$sanitized = \SecurWP\Modules\Scanner\FileIntegrity\FileCollector::sanitize_wp_config_content( $raw );
								$tmp = @tempnam( get_temp_dir(), 'sp_wpcfg_' );
								if ( $tmp && file_put_contents( $tmp, $sanitized ) !== false ) {
									$content_list[] = [
										PCLZIP_ATT_FILE_NAME          => $tmp,
										PCLZIP_ATT_FILE_NEW_FULL_NAME => $hash,
									];
									$temp_files[]   = $tmp;
									$batch_hashes[] = $hash;
								}
							}
						} else {
							$content_list[] = [
								PCLZIP_ATT_FILE_NAME          => $full_path,
								PCLZIP_ATT_FILE_NEW_FULL_NAME => $hash,
							];
							$batch_hashes[] = $hash;
						}
					}

					if ( empty( $content_list ) ) {
						$cleanup();
						continue;
					}

					// Create Zip (Store-Only, no compression for speed/CPU)
					$v_list = $archive->create( $content_list, PCLZIP_OPT_NO_COMPRESSION );

					// Clean up any temp files written for redacted wp-config content
					foreach ( $temp_files as $tmp ) {
						wp_delete_file( $tmp );
					}

					if ( $v_list == 0 ) {
						securwp_log( '[SecurWP] PclZip Error: ' . $archive->errorInfo(true) );
						$cleanup();
						continue;
					}

					// Upload Zip
					$result = $this->upload_to_s3( $batch['url'], $zip_file );

					if ( true === $result ) {
						// Only mark hashes as uploaded if S3 upload succeeded
						$uploaded_hashes = array_merge( $uploaded_hashes, $batch_hashes );
					} else {
						securwp_log( '[SecurWP] Batch upload failed: ' . ( is_wp_error($result) ? $result->get_error_message() : 'Unknown error' ) );
					}

				} catch ( \RuntimeException $e ) {
					securwp_log( '[SecurWP] Zip Batch Error: ' . $e->getMessage() );
				}

				// Explicit cleanup
				$cleanup();
			}

			return $uploaded_hashes;
		} catch ( \RuntimeException $e ) {
			securwp_log( '[SecurWP] Critical Error in upload_zip_batches: ' . $e->getMessage() );
			return [];
		}
	}

	/**
	 * Helper: Upload file to S3 using pre-signed URL.
	 * 
	 * @param string $url  Pre-signed URL.
	 * @param string $path Local file path.
	 * @return bool|\WP_Error True on success.
	 */
	public function upload_to_s3( string $url, string $path ) {
		// Validate presigned URL points to a trusted S3 domain.
		$parsed_host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
		if ( $parsed_host === '' || (
			! str_ends_with( $parsed_host, '.amazonaws.com' )
			&& ! str_ends_with( $parsed_host, '.r2.cloudflarestorage.com' )
		) ) {
			return new \WP_Error( 'invalid_upload_host', 'Presigned URL points to an untrusted host: ' . $parsed_host );
		}

		if ( ! file_exists( $path ) ) {
			return new \WP_Error( 'file_not_found', 'Upload file not found' );
		}
		
		$file_size = filesize( $path );
		if ( $file_size === false || $file_size === 0 ) {
			return new \WP_Error( 'empty_file', 'Upload file is empty or unreadable' );
		}

		// Small files: Use standard wp_remote_request.
		$body = file_get_contents( $path );
		if ( $body === false ) {
			return new \WP_Error( 'file_read_error', 'Failed to read upload file' );
		}

		$args = [
			'method'    => 'PUT',
			'body'      => $body,
			'timeout'   => 120,
			'sslverify' => true,
			'headers'   => [
				'Content-Type'   => 'application/zip',
				'Content-Length' => $file_size,
			],
		];
		
		$response = wp_remote_request( $url, $args );

		if ( is_wp_error( $response ) ) {
			securwp_log( '[SecurWP] S3 upload error: ' . $response->get_error_message() );
			return $response;
		}

		$code = wp_remote_retrieve_response_code( $response );
		if ( $code !== 200 ) {
			$body = (string) wp_remote_retrieve_body( $response );
			$headers_obj = wp_remote_retrieve_headers( $response );
			$headers = [];
			if ( $headers_obj instanceof \WP_Http_Headers ) {
				$headers = $headers_obj->getAll();
			} elseif ( is_array( $headers_obj ) ) {
				$headers = $headers_obj;
			}
			$headers = array_change_key_case( $headers, CASE_LOWER );

			$this->log_s3_failure( 'standard', $code, $body, $headers );

			return new \WP_Error(
				's3_error',
				"S3 upload failed with code $code",
				[
					'status'     => $code,
					'request_id' => $headers['x-amz-request-id'] ?? null,
					'host_id'    => $headers['x-amz-id-2'] ?? null,
				]
			);
		}

		return true;
	}

		/**
		 * Log S3 upload failure details with safe truncation.
		 *
	 * @param string $context standard|streaming
	 * @param int    $code
	 * @param string $body
	 * @param array  $headers
	 * @return void
	 */
	private function log_s3_failure( string $context, int $code, string $body = '', array $headers = [] ): void {
		$headers = array_change_key_case( $headers, CASE_LOWER );
		$req_id  = $headers['x-amz-request-id'] ?? '';
		$host_id = $headers['x-amz-id-2'] ?? '';
		$region  = $headers['x-amz-bucket-region'] ?? '';

		$body = trim( (string) $body );
		if ( strlen( $body ) > 600 ) {
			$body = substr( $body, 0, 600 ) . '...';
		}

		securwp_log( sprintf(
			'[SecurWP] S3 %s upload failed HTTP %d (request_id=%s host_id=%s region=%s) body=%s',
			$context,
			$code,
			$req_id ?: 'n/a',
			$host_id ?: 'n/a',
			$region ?: 'n/a',
			$body !== '' ? $body : 'n/a'
		) );

		// Also surface in scan activity log for UI visibility.
		if ( ! class_exists( '\SecurWP\Modules\Scanner\Service\ScanLogger' ) ) {
			require_once dirname( __DIR__ ) . '/../Service/ScanLogger.php';
		}
		if ( class_exists( '\SecurWP\Modules\Scanner\Service\ScanLogger' ) ) {
			$reason = '';
			$message = '';
			if ( $body !== '' ) {
				if ( preg_match( '/<Code>([^<]+)<\/Code>/', $body, $m ) ) {
					$reason = $m[1];
				}
				if ( preg_match( '/<Message>([^<]+)<\/Message>/', $body, $m ) ) {
					$message = $m[1];
				}
				if ( empty( $req_id ) && preg_match( '/<RequestId>([^<]+)<\/RequestId>/', $body, $m ) ) {
					$req_id = $m[1];
				}
			}
			$msg = sprintf(
				'S3 upload failed (HTTP %d%s%s) request_id=%s',
				$code,
				$reason ? ' ' . $reason : '',
				$message ? ' - ' . $message : '',
				$req_id ?: 'n/a'
			);
			\SecurWP\Modules\Scanner\Service\ScanLogger::log( $msg, 3, 'error' );
		}
	}

		/**
		 * Check whether the cloud scan credit limit is currently reached.
	 *
	 * Reads the value stored by the most recent check_hash() call.
	 * Returns false for unlimited plans (credits_remaining === -1).
	 *
	 * @return bool
	 */
	public static function is_credit_limit_reached(): bool {
		$settings = CloudSettings::get_all();
		return (bool) ( $settings['scan_credit_limit_reached'] ?? false );
	}

	// Processing is triggered automatically via S3 → SQS → Lambda
	// No confirmation endpoint needed - cloud detects uploads from S3 events

	/**
	 * Backward-compatible single-job polling wrapper.
	 *
	 * Legacy callers (e.g. WatchdogService) expect:
	 * - array results when the job is completed
	 * - null while still pending/processing or on failure
	 *
	 * @param string $job_id Cloud job ID.
	 * @return array|null
	 */
	public function poll_results( string $job_id ): ?array {
		$job_id = trim( $job_id );
		if ( $job_id === '' ) {
			return null;
		}

		$batch = $this->poll_batch_results( [ $job_id ] );
		if ( is_wp_error( $batch ) ) {
			if ( class_exists( '\SecurWP\Modules\Scanner\Service\ScanLogger' ) ) {
				\SecurWP\Modules\Scanner\Service\ScanLogger::log(
					'Poll results error for job ' . $job_id . ': ' . $batch->get_error_message(),
					2,
					'warning'
				);
			}
			return null;
		}

		$job_data = $batch[ $job_id ] ?? null;
		if ( ! is_array( $job_data ) ) {
			return null;
		}

		$status = strtolower( (string) ( $job_data['status'] ?? '' ) );
		if ( $status !== 'completed' ) {
			return null;
		}

		$results = $job_data['results'] ?? [];
		return is_array( $results ) ? $results : [];
	}

	/**
	 * Step 4: Poll for results (PRIMARY method - Retrieval via Bulk Polling).
	 *
	 * Retrieves scan results from Intel Engine via vectorized polling.
	 *
	 * @param array $job_ids List of Cloud job IDs.
	 * @return array|\WP_Error Map of [job_id => result|processing_status] or error.
	 */
	public function poll_batch_results( array $job_ids ) {
		if ( empty( $job_ids ) ) {
			return [];
		}

		$response = $this->api->post( 'jobs/results', [
			'job_ids' => $job_ids
		] );

		if ( is_wp_error( $response ) ) {
			if ( class_exists( '\SecurWP\Modules\Scanner\Service\ScanLogger' ) ) {
				$error_data = $response->get_error_data( 'api_error' );
				if ( empty( $error_data ) ) {
					$error_data = $response->get_error_data();
				}
				$status_code = is_array( $error_data ) ? (int) ( $error_data['status'] ?? 0 ) : 0;
				$is_rate_limited = $response->get_error_code() === 'rate_limited'
					|| 429 === $status_code
					|| false !== strpos( $response->get_error_message(), '429' );
				\SecurWP\Modules\Scanner\Service\ScanLogger::log(
					( $is_rate_limited ? 'Cloud result polling deferred: ' : 'Batch Polling Error: ' ) . $response->get_error_message(),
					$is_rate_limited ? 1 : 2,
					$is_rate_limited ? 'info' : 'warning'
				);
			}
			return $response;
		}

		$raw_results = $response['results'] ?? [];
		$mapped_results = [];

		foreach ( $raw_results as $job_data ) {
			$job_id = $job_data['job_id'] ?? '';
			if ( ! $job_id ) continue;

			$status = $job_data['status'] ?? 'pending';

			if ( 'completed' === $status ) {
				$mapped_results[ $job_id ] = [
					'status'  => 'completed',
					'results' => $job_data['results'] ?? []
				];
			} elseif ( 'failed' === $status ) {
				$mapped_results[ $job_id ] = [
					'status' => 'failed',
					'error'  => $job_data['error'] ?? 'Unknown error'
				];
			} else {
				$mapped_results[ $job_id ] = [
					'status' => $status
				];
			}
		}

		return $mapped_results;
	}


	/**
	 * Handle verified file (repository-verified, signature-immune).
	 *
	 * @param array $file   File record.
	 * @param array $result Hash check result.
	 */
	public function handle_verified_file( array $file, array $result ): void {
		$proof_b64 = $result['proof'] ?? '';
		$proof     = is_string( $proof_b64 ) ? base64_decode( $proof_b64, true ) : false;

		if ( ! $proof ) {
			// Do not trust unverifiable verdicts. Fall back to deep scan.
			$this->fsm->transition( (int) $file['id'], 'cloud_hash_check', 'pending_upload' );
			securwp_log( '[SecurWP] Missing/invalid proof for verified hash cache. Falling back to upload: ' . ( $file['file_path'] ?? '' ) );
			return;
		}

		$file_path = (string) ( $file['file_path'] ?? '' );
		if ( ! $this->is_core_system_directory_file( $file_path ) ) {
			$ok = $this->hash_cache_repo->upsert( $file['file_hash'], 'verified', $proof, null );
			if ( ! $ok ) {
				$this->fsm->transition( (int) $file['id'], 'cloud_hash_check', 'pending_upload' );
				securwp_log( '[SecurWP] Failed to store verified hash cache (signature verify failed). Falling back to upload: ' . $file_path );
				return;
			}
		}

		$this->fsm->transition(
			(int) $file['id'],
			'cloud_hash_check',
			'verified',
			[
				'error_code'    => null,
				'error_message' => null,
			]
		);
		$this->clear_integrity_findings_for_file( $file_path );

		do_action( 'securwp_verified_file_cached', $file['file_path'], $file['id'] );
	}

	/**
	 * Handle safe file (signature-based validation only).
	 *
	 * @param array  $file              File record.
	 * @param array  $result            Hash check result.
	 * @param string $current_signature Current signature version.
	 */
	public function handle_safe_file( array $file, array $result, string $current_signature ): void {
		$proof_b64 = $result['proof'] ?? '';
		$proof     = is_string( $proof_b64 ) ? base64_decode( $proof_b64, true ) : false;

		if ( ! $proof ) {
			$this->fsm->transition( (int) $file['id'], 'cloud_hash_check', 'pending_upload' );
			securwp_log( '[SecurWP] Missing/invalid proof for safe hash cache. Falling back to upload: ' . ( $file['file_path'] ?? '' ) );
			return;
		}

		$file_path = (string) ( $file['file_path'] ?? '' );
		if ( ! $this->is_core_system_directory_file( $file_path ) ) {
			$ok = $this->hash_cache_repo->upsert( $file['file_hash'], 'safe', $proof, $current_signature );
			if ( ! $ok ) {
				$this->fsm->transition( (int) $file['id'], 'cloud_hash_check', 'pending_upload' );
				securwp_log( '[SecurWP] Failed to store safe hash cache (signature verify failed). Falling back to upload: ' . $file_path );
				return;
			}
		}

		$this->fsm->transition(
			(int) $file['id'],
			'cloud_hash_check',
			'safe',
			[
				'error_code'    => null,
				'error_message' => null,
			]
		);
		$this->clear_integrity_findings_for_file( $file_path );
	}

	/**
	 * Handle malicious file (blacklist hit).
	 *
	 * @param array $file   File record.
	 * @param array $result Hash check result.
	 */
	public function handle_malicious_file( array $file, array $result ): void {
		$data = [
			'hash_status'   => 'malicious',
			'error_message' => 'Blacklist hit: malicious hash',
		];

		$this->fsm->transition( (int) $file['id'], 'cloud_hash_check', 'malicious_detected', $data );

		// Create malware finding
		$this->create_malware_finding( $file, $result );

		$this->fsm->transition( (int) $file['id'], 'malicious_detected', 'flagged' );
	}

	/**
	 * Create a malware finding from cloud blacklist hit.
	 *
	 * @param array $file   File record.
	 * @param array $result Hash check result.
	 */
	private function create_malware_finding( array $file, array $result ): void {
		$file_info = $this->file_collector->get_file_info_for_hash_check( $file['file_path'] );
		$repair_flag = array_key_exists( 'repair_available', $result )
			? wp_validate_boolean( $result['repair_available'] )
			: false;
		$threat_type = MalwareFindingFormatter::resolveThreatLabel( $result );
		$description = MalwareFindingFormatter::buildDescription( $result, (string) ( $file['file_path'] ?? '' ) );

		$finding = [
			'scan_id'      => $this->scan_id,
			'file_path'    => $file['file_path'],
			'type'         => 'malware',
			'severity'     => 'critical',
			'title'        => 'Malware Detected: ' . $threat_type,
			'message'      => $description,
			'description'  => $description,
			'data'         => [
				'threat_type'      => $threat_type,
				'description'      => $description,
				'threat_description' => $description,
				'summary'          => $description,
				'match'            => isset( $result['match'] ) && is_string( $result['match'] ) ? $result['match'] : '',
				'match_snippet'    => isset( $result['match'] ) && is_string( $result['match'] ) ? $result['match'] : '',
				'threat_severity'  => $result['threat_severity'] ?? 'critical',
				'file_hash'        => $file['file_hash'],
				'expected_hash'    => $result['expected_hash'] ?? null,
				'signature_id'     => isset( $result['signature_id'] ) && is_string( $result['signature_id'] ) ? $result['signature_id'] : '',
				'detection_type'   => 'cloud_blacklist',
				'hash_status'      => sanitize_key( (string) ( $result['status'] ?? 'malicious' ) ),
				'threat_type_raw'  => (string) ( $result['threat_type'] ?? '' ),
				'repair_available' => $repair_flag,
				'component_type'   => $file_info['type'] ?? '',
				'slug'             => $file_info['slug'] ?? '',
				'version'          => $file_info['version'] ?? '',
				'locale'           => $file_info['locale'] ?? '',
				'last_modified'    => $file_info['last_modified'] ?? null,
			],
			'status'       => 'open',
			'detected_at'  => time(),
			'file_hash'    => $file['file_hash'],
			'is_reputable' => false,
			'hash'         => IssueRepository::generateHash( 'malware', $file['file_path'] ),
		];

		IssueRepository::stage( $finding );
		$this->clear_integrity_findings_for_file( (string) ( $file['file_path'] ?? '' ) );
		$this->clear_fake_image_findings_for_file( (string) ( $file['file_path'] ?? '' ) );
	}

	/**
	 * Handle modified file (Integrity Mismatch).
	 *
	 * @param array $file   File record.
	 * @param array $result Hash check result.
	 */
	public function handle_modified_file( array $file, array $result ): void {
		$this->create_integrity_finding( $file, $result );
		// No state transition here - allowing fall-through to upload
	}

	/**
	 * Handle unknown hash verdicts.
	 *
	 * Current Intel hash verdicts no longer guarantee the historical "orphaned"
	 * flag for WordPress core extras. For files in protected core directories we
	 * therefore stage the finding whenever the file hashes as unknown, unless the
	 * current WordPress version was explicitly reported as not yet indexed. Plugin
	 * orphaned-file findings retain the legacy explicit gate until Intel exposes a
	 * similarly reliable indexed/manifest-miss signal for plugins.
	 *
	 * Rules: WP core = any file type; plugins = PHP and JS only; themes = never.
	 *
	 * @param array $file   File record.
	 * @param array $result Hash check result.
	 */
	public function handle_unknown_file( array $file, array $result ): void {
		$file_path = (string) ( $file['file_path'] ?? '' );
		if ( $file_path === '' ) {
			return;
		}

		$file_info = $this->file_collector->get_file_info_for_hash_check( $file_path );
		if ( $this->is_core_system_directory_file( $file_path ) ) {
			if ( $this->is_core_version_unindexed( $file_info ) ) {
				$this->clear_integrity_findings_for_file( $file_path );
				return;
			}
			$this->create_unknown_core_file_finding( $file, $result );
			return;
		}

		if ( empty( $result['orphaned'] ) ) {
			return;
		}

		if ( ( $file_info['type'] ?? '' ) === 'plugin' ) {
			$this->create_unknown_plugin_file_finding( $file, $result, $file_info );
		}
	}

	/**
	 * Create an integrity finding (Core Modified).
	 *
	 * @param array $file   File record.
	 * @param array $result Hash check result.
	 */
	private function create_integrity_finding( array $file, array $result ): void {
		$file_path = (string) ( $file['file_path'] ?? '' );
		if ( $file_path === '' ) {
			return;
		}

		// Documentation files (readme, changelog, history, license) are excluded from
		// integrity findings entirely. They carry no executable content and are edited
		// on WordPress.org without version bumps, producing only false-positive noise.
		if ( $this->is_doc_file( $file_path ) ) {
			return;
		}

		// Theme stylesheets whose only change is within the file header metadata block
		// (e.g. "Tested up to:" compatibility bumps) are suppressed. The file is still
		// uploaded and scanned for malware; we just do not raise an integrity alert.
		if ( $this->is_theme_stylesheet_metadata_change( $file_path ) ) {
			return;
		}

		$file_info = $this->file_collector->get_file_info_for_hash_check( $file['file_path'] );
		$repair_flag = array_key_exists( 'repair_available', $result )
			? wp_validate_boolean( $result['repair_available'] )
			: false;
		$file_type = strtolower( (string) ( $file_info['type'] ?? 'other' ) );
		$severity = $this->resolve_integrity_severity( $file_path, $file_type );
		$policy_reason = $this->resolve_integrity_policy_reason( $file_path, $file_type );
		$component_label = $this->resolve_integrity_component_label( $file_type );
		$asset_slug = (string) ( $file_info['slug'] ?? '' );
		$asset_version = (string) ( $file_info['version'] ?? '' );
		$asset_name = $this->resolve_integrity_asset_name( $file_type, $asset_slug );
		$description = $this->build_integrity_mismatch_description( $file_type, $component_label, $asset_name, $asset_version );

		$finding = [
			'scan_id'     => $this->scan_id,
			'file_path'   => $file_path,
			'type'        => IssueType::INTEGRITY,
			'severity'    => $severity,
			'message'     => sprintf( '%s File Modified: %s', $component_label, $description ),
			'data'        => [
				'title'            => sprintf( 'Modified %s File Detected', $component_label ),
				'description'      => $description,
				'repair_available' => $repair_flag,
				'expected_hash'    => $result['expected_hash'] ?? null,
				'file_hash'        => $file['file_hash'],
				'detection_type'   => 'checksum_mismatch',
				'policy_reason'    => $policy_reason,
				'component_type'   => $file_type,
				'component_name'   => $asset_name,
				'asset_name'       => $asset_name,
				'slug'             => $asset_slug,
				'version'          => $asset_version,
				'locale'           => $file_info['locale'] ?? '',
				'last_modified'    => $file_info['last_modified'] ?? null,
			],
			'status'      => 'open',
			'detected_at' => time(),
			'file_hash'   => $file['file_hash'],
			'hash'        => IssueRepository::generateHash( IssueType::INTEGRITY, $file_path ),
		];

		IssueRepository::stage( $finding );
	}

	/**
	 * Resolve the installed asset display name for plugin/theme/core integrity findings.
	 *
	 * @param string $file_type
	 * @param string $asset_slug
	 * @return string
	 */
	private function resolve_integrity_asset_name( string $file_type, string $asset_slug ): string {
		$asset_slug = sanitize_key( $asset_slug );
		if ( $file_type === 'core' ) {
			return 'WordPress Core';
		}

		if ( $file_type === 'plugin' && $asset_slug !== '' ) {
			if ( self::$plugin_name_map === null ) {
				self::$plugin_name_map = [];
				if ( ! function_exists( 'get_plugins' ) ) {
					require_once ABSPATH . 'wp-admin/includes/plugin.php';
				}
				if ( function_exists( 'get_plugins' ) ) {
					foreach ( get_plugins() as $plugin_file => $plugin_data ) {
						$slug = dirname( (string) $plugin_file );
						if ( $slug === '.' || $slug === '' ) {
							$slug = basename( (string) $plugin_file, '.php' );
						}
						if ( ! isset( self::$plugin_name_map[ $slug ] ) && ! empty( $plugin_data['Name'] ) ) {
							self::$plugin_name_map[ $slug ] = (string) $plugin_data['Name'];
						}
					}
				}
			}
			if ( isset( self::$plugin_name_map[ $asset_slug ] ) ) {
				return self::$plugin_name_map[ $asset_slug ];
			}
		}

		if ( $file_type === 'theme' && $asset_slug !== '' ) {
			if ( self::$theme_name_map === null ) {
				self::$theme_name_map = [];
				if ( function_exists( 'wp_get_themes' ) ) {
					foreach ( wp_get_themes() as $theme_slug => $theme_obj ) {
						if ( $theme_obj instanceof \WP_Theme ) {
							$theme_name = trim( (string) $theme_obj->get( 'Name' ) );
							if ( $theme_name !== '' ) {
								self::$theme_name_map[ (string) $theme_slug ] = $theme_name;
							}
						}
					}
				}
			}
			if ( isset( self::$theme_name_map[ $asset_slug ] ) ) {
				return self::$theme_name_map[ $asset_slug ];
			}
		}

		return '';
	}

	/**
	 * Build user-facing summary copy for integrity mismatch findings.
	 *
	 * @param string $file_type
	 * @param string $component_label
	 * @param string $asset_name
	 * @param string $asset_version
	 * @return string
	 */
	private function build_integrity_mismatch_description( string $file_type, string $component_label, string $asset_name, string $asset_version ): string {
		$version_clause = $asset_version !== ''
			? sprintf( ' for the installed version (%s)', $asset_version )
			: '';

		if ( $file_type === 'plugin' ) {
			$subject = $asset_name !== '' ? sprintf( 'the official %s plugin files', $asset_name ) : 'the official plugin files';
			return sprintf(
				'This file differs from %s%s. This can result from an approved customization, an incomplete update, or unauthorized tampering. Validate the recent change history and compare the file with a verified clean copy before marking it safe.',
				$subject,
				$version_clause
			);
		}

		if ( $file_type === 'theme' ) {
			$subject = $asset_name !== '' ? sprintf( 'the official %s theme files', $asset_name ) : 'the official theme files';
			return sprintf(
				'This file differs from %s%s. This can result from an approved customization, an incomplete update, or unauthorized tampering. Validate the recent change history and compare the file with a verified clean copy before marking it safe.',
				$subject,
				$version_clause
			);
		}

		if ( $file_type === 'core' ) {
			return sprintf(
				'This file differs from the official WordPress core files%s. Core file changes should be treated as high risk because they can affect platform integrity, update reliability, and incident response. Validate the recent change history and compare the file with a verified clean copy before marking it safe.',
				$version_clause
			);
		}

		return sprintf(
			'This %s file differs from the trusted reference expected for this installation. Review the file history or compare it with a clean copy before marking it safe.',
			strtolower( $component_label )
		);
	}

	/**
	 * Create finding for unknown files in core system directories.
	 *
	 * @param array $file   File record.
	 * @param array $result Hash check result.
	 */
	private function create_unknown_core_file_finding( array $file, array $result ): void {
		$file_path = (string) ( $file['file_path'] ?? '' );
		if ( $file_path === '' ) {
			return;
		}

		$class = $this->classify_unknown_core_verdict( $result );
		if ( $class['action'] === 'suppress' ) {
			return;
		}

		$file_info = $this->file_collector->get_file_info_for_hash_check( $file_path );
		$detection_type = $class['action'] === 'legacy' ? 'core_legacy_file' : 'core_unknown_file';
		$policy_reason = $class['action'] === 'legacy' ? 'legacy_core_system_file' : 'unknown_core_system_file';
		$title = $class['action'] === 'legacy' ? 'Legacy Core File Detected' : 'Unknown Core File Detected';
		$description = $class['action'] === 'legacy'
			? 'This file appears to belong to an older WordPress core release and remains in a protected core directory.'
			: 'A file not present in the official core baseline was found in a protected WordPress system directory.';
		$severity = $class['action'] === 'legacy' ? 'low' : 'high';

		$finding = [
			'scan_id'     => $this->scan_id,
			'file_path'   => $file_path,
			'type'        => IssueType::INTEGRITY,
			'severity'    => $severity,
			'message'     => $title . ': ' . $description,
			'data'        => [
				'title'            => $title,
				'description'      => $description,
				'repair_available' => false,
				'expected_hash'    => $result['expected_hash'] ?? null,
				'file_hash'        => $file['file_hash'],
				'detection_type'   => $detection_type,
				'policy_reason'    => $policy_reason,
				'legacy_versions'  => $class['legacy_versions'],
				'core_origin'      => $class['origin'],
				'slug'             => $file_info['slug'] ?? '',
				'last_modified'    => $file_info['last_modified'] ?? null,
			],
			'status'      => 'open',
			'detected_at' => time(),
			'file_hash'   => $file['file_hash'],
			'hash'        => IssueRepository::generateHash( IssueType::INTEGRITY, $file_path ),
		];

		IssueRepository::stage( $finding );
	}

	/**
	 * Create an orphaned-file finding for a plugin (PHP or JS file not in the indexed manifest).
	 *
	 * @param array $file      File record.
	 * @param array $result    Hash check result.
	 * @param array $file_info File info from collector.
	 */
	private function create_unknown_plugin_file_finding( array $file, array $result, array $file_info ): void {
		$file_path = (string) ( $file['file_path'] ?? '' );
		if ( $file_path === '' ) {
			return;
		}

		$slug        = (string) ( $file_info['slug'] ?? '' );
		$plugin_name = $slug !== '' ? $slug : basename( dirname( $file_path ) );
		$title       = 'Unknown File in Plugin: ' . $plugin_name;
		$description = 'A file not present in the official plugin release was found inside ' . $plugin_name . '. This may indicate an injected or leftover file.';

		$finding = [
			'scan_id'     => $this->scan_id,
			'file_path'   => $file_path,
			'type'        => IssueType::INTEGRITY,
			'severity'    => 'high',
			'message'     => $title . ': ' . $description,
			'data'        => [
				'title'          => $title,
				'description'    => $description,
				'repair_available' => false,
				'file_hash'      => $file['file_hash'],
				'detection_type' => 'plugin_orphaned_file',
				'policy_reason'  => 'unknown_plugin_file',
				'slug'           => $slug,
				'last_modified'  => $file_info['last_modified'] ?? null,
			],
			'status'      => 'open',
			'detected_at' => time(),
			'file_hash'   => $file['file_hash'],
			'hash'        => IssueRepository::generateHash( IssueType::INTEGRITY, $file_path ),
		];

		IssueRepository::stage( $finding );
	}

	/**
	 * Classify unknown core verdict into unknown/legacy/current buckets.
	 *
	 * Current Intel schema returns only {status, needs_upload}. This parser is
	 * forward-compatible for lineage fields when Intel starts returning them.
	 *
	 * @param array $result Hash check verdict.
	 * @return array{action:string,origin:string,legacy_versions:array}
	 */
	private function classify_unknown_core_verdict( array $result ): array {
		$origin = strtolower( (string) ( $result['core_origin'] ?? $result['origin'] ?? '' ) );
		$legacy_versions = [];

		$candidates = [
			$result['legacy_versions'] ?? null,
			$result['matched_versions'] ?? null,
			$result['known_versions'] ?? null,
		];

		foreach ( $candidates as $candidate ) {
			if ( is_array( $candidate ) ) {
				foreach ( $candidate as $version ) {
					if ( is_scalar( $version ) ) {
						$legacy_versions[] = (string) $version;
					}
				}
			}
		}

		$legacy_versions = array_values( array_unique( array_filter( $legacy_versions, static function ( $value ) {
			return $value !== '';
		} ) ) );

		if ( $origin === 'current' || $origin === 'current_core' ) {
			return [
				'action'          => 'suppress',
				'origin'          => 'current',
				'legacy_versions' => [],
			];
		}

		if ( $origin === 'legacy' || $origin === 'legacy_core' || ! empty( $legacy_versions ) ) {
			return [
				'action'          => 'legacy',
				'origin'          => $origin !== '' ? $origin : 'legacy',
				'legacy_versions' => $legacy_versions,
			];
		}

		return [
			'action'          => 'unknown',
			'origin'          => $origin !== '' ? $origin : 'unknown',
			'legacy_versions' => [],
		];
	}

	/**
	 * Resolve severity for integrity mismatch findings by file type + location.
	 *
	 * @param string $file_path Relative file path.
	 * @param string $file_type File type from collector.
	 * @return string
	 */
	private function resolve_integrity_severity( string $file_path, string $file_type ): string {
		$is_executable = $this->is_executable_file( $file_path );

		if ( $file_type === 'core' ) {
			return $is_executable ? 'critical' : 'high';
		}

		if ( $file_type === 'plugin' || $file_type === 'theme' ) {
			return $is_executable ? 'high' : 'medium';
		}

		if ( $this->is_core_system_directory_file( $file_path ) ) {
			return 'high';
		}

		return 'medium';
	}

	/**
	 * Resolve policy reason used for stable classification auditing.
	 *
	 * @param string $file_path Relative file path.
	 * @param string $file_type File type from collector.
	 * @return string
	 */
	private function resolve_integrity_policy_reason( string $file_path, string $file_type ): string {
		$is_executable = $this->is_executable_file( $file_path );

		if ( $file_type === 'core' ) {
			return $is_executable ? 'core_executable_modified' : 'core_non_executable_modified';
		}

		if ( $file_type === 'plugin' || $file_type === 'theme' ) {
			return $is_executable ? 'asset_executable_modified' : 'asset_non_executable_modified';
		}

		if ( $this->is_core_system_directory_file( $file_path ) ) {
			return 'core_system_directory_modified';
		}

		return 'non_core_modified';
	}

	/**
	 * Resolve component label for finding title/message copy.
	 *
	 * @param string $file_type File type from collector.
	 * @return string
	 */
	private function resolve_integrity_component_label( string $file_type ): string {
		if ( $file_type === 'plugin' ) {
			return 'Plugin';
		}
		if ( $file_type === 'theme' ) {
			return 'Theme';
		}
		if ( $file_type === 'core' ) {
			return 'Core';
		}
		return 'File';
	}

	/**
	 * Check if a file is a documentation/metadata-only file that should be excluded
	 * from integrity findings entirely.
	 *
	 * @param string $file_path Relative file path.
	 * @return bool
	 */
	private function is_doc_file( string $file_path ): bool {
		$basename = strtolower( basename( $file_path ) );
		return in_array( $basename, self::DOC_FILES_SKIP_INTEGRITY, true );
	}

	/**
	 * Check if a theme stylesheet modification is metadata-only and safe to suppress.
	 *
	 * Returns true when ALL of the following hold:
	 *   1. The path is the root style.css of a theme (wp-content/themes/{slug}/style.css).
	 *   2. The file content contains a WordPress theme file header comment block.
	 *   3. The file contains no PHP or inline script content (no executable risk).
	 *
	 * Theme style.css files are routinely updated on WordPress.org to bump compatibility
	 * declarations ("Tested up to:", "Requires PHP:", tag changes) without a version
	 * increment. These are pure metadata edits with no security relevance.
	 *
	 * We cannot diff against the reference file without fetching it remotely, so we use
	 * a content-inspection heuristic: if the file is a pure CSS file with a recognisable
	 * WP theme header, treat any mismatch as a metadata-only change.
	 *
	 * @param string $file_path Relative file path.
	 * @return bool
	 */
	private function is_theme_stylesheet_metadata_change( string $file_path ): bool {
		// Must be the root style.css of a theme directory, not a nested CSS file.
		$normalized = ltrim( str_replace( '\\', '/', $file_path ), '/' );
		if ( ! preg_match( '#^wp-content/themes/[^/]+/style\.css$#i', $normalized ) ) {
			return false;
		}

		$abs_path = rtrim( ABSPATH, '/\\' ) . '/' . $normalized;
		if ( ! is_readable( $abs_path ) ) {
			return false;
		}

		// Read only the first 8 KB - enough to cover the theme header and detect PHP.
		$content = file_get_contents( $abs_path, false, null, 0, 8192 );
		if ( $content === false || $content === '' ) {
			return false;
		}

		// Reject immediately if any executable content is present.
		if (
			stripos( $content, '<?php' ) !== false ||
			stripos( $content, '<?=' ) !== false ||
			stripos( $content, '<script' ) !== false ||
			stripos( $content, 'javascript:' ) !== false
		) {
			return false;
		}

		// Require a WordPress theme file header comment (must start with "/*" and
		// contain "Theme Name:" - the only mandatory WP theme header field).
		if (
			strpos( $content, '/*' ) === false ||
			stripos( $content, 'Theme Name:' ) === false
		) {
			return false;
		}

		return true;
	}

	/**
	 * Check if file extension is executable in this policy.
	 *
	 * @param string $file_path Relative file path.
	 * @return bool
	 */
	private function is_executable_file( string $file_path ): bool {
		$extension = strtolower( (string) pathinfo( $file_path, PATHINFO_EXTENSION ) );
		return in_array( $extension, self::EXECUTABLE_EXTENSIONS, true );
	}

	/**
	 * Check if file belongs to protected WordPress core system directories.
	 *
	 * @param string $file_path Relative file path.
	 * @return bool
	 */
	private function is_core_system_directory_file( string $file_path ): bool {
		$normalized = strtolower( ltrim( wp_normalize_path( $file_path ), '/' ) );
		return strpos( $normalized, 'wp-admin/' ) === 0 || strpos( $normalized, 'wp-includes/' ) === 0;
	}

	/**
	 * Normalize the Intel unindexed-core response into slug+version pairs.
	 *
	 * Accepts both the current structured response shape ([[slug, version], ...])
	 * and the legacy string-only form (["6.9.1", ...]) for compatibility.
	 *
	 * @param array $entries
	 * @return array<int,array{slug:string,version:string}>
	 */
	public static function normalize_unindexed_wp_versions( array $entries ): array {
		$normalized = [];

		foreach ( $entries as $entry ) {
			$normalized_entry = self::normalize_unindexed_wp_version_entry( $entry );
			if ( null === $normalized_entry ) {
				continue;
			}
			$key = $normalized_entry['slug'] . '@' . $normalized_entry['version'];
			$normalized[ $key ] = $normalized_entry;
		}

		return array_values( $normalized );
	}

	/**
	 * Normalize a single Intel unindexed-core response entry.
	 *
	 * @param mixed $entry
	 * @return array{slug:string,version:string}|null
	 */
	private static function normalize_unindexed_wp_version_entry( $entry ): ?array {
		$slug    = '';
		$version = '';

		if ( is_array( $entry ) ) {
			if ( array_key_exists( 'slug', $entry ) || array_key_exists( 'version', $entry ) ) {
				$slug    = trim( (string) ( $entry['slug'] ?? '' ) );
				$version = trim( (string) ( $entry['version'] ?? '' ) );
			} else {
				$slug    = trim( (string) ( $entry[0] ?? '' ) );
				$version = trim( (string) ( $entry[1] ?? '' ) );
			}
		} elseif ( is_scalar( $entry ) ) {
			$version = trim( (string) $entry );
		}

		if ( '' === $version ) {
			return null;
		}

		if ( '' === $slug ) {
			$slug = 'wordpress';
		}

		return [
			'slug'    => $slug,
			'version' => $version,
		];
	}

	/**
	 * Normalize a WordPress locale value.
	 *
	 * @param string $locale
	 * @return string
	 */
	private static function normalize_wp_locale( string $locale = '' ): string {
		$normalized = trim( $locale );
		return '' !== $normalized ? $normalized : 'en_US';
	}

	/**
	 * Determine whether a slug refers to WordPress core.
	 *
	 * @param string $slug
	 * @return bool
	 */
	private static function is_core_slug( string $slug ): bool {
		$normalized = trim( $slug );
		return 'wordpress' === $normalized || 0 === strpos( $normalized, 'wordpress-' );
	}

	/**
	 * Build the locale-specific manifest slug for a core file.
	 *
	 * @param string $slug
	 * @param string $locale
	 * @return string
	 */
	private static function resolve_core_manifest_slug( string $slug = '', string $locale = '' ): string {
		$normalized_slug = trim( $slug );
		if ( self::is_core_slug( $normalized_slug ) && 'wordpress' !== $normalized_slug ) {
			return $normalized_slug;
		}

		$normalized_locale = self::normalize_wp_locale( $locale );
		return 'en_US' === $normalized_locale ? 'wordpress' : 'wordpress-' . $normalized_locale;
	}

	/**
	 * Whether the current WordPress core version was reported as not yet indexed
	 * in the latest hash-check response.
	 *
	 * @param array $file_info
	 * @return bool
	 */
	private function is_core_version_unindexed( array $file_info ): bool {
		$version = trim( (string) ( $file_info['version'] ?? '' ) );
		if ( '' === $version || empty( $this->last_unindexed_wp_versions ) ) {
			return false;
		}

		$core_slug = self::resolve_core_manifest_slug(
			(string) ( $file_info['slug'] ?? 'wordpress' ),
			(string) ( $file_info['locale'] ?? '' )
		);

		foreach ( $this->last_unindexed_wp_versions as $candidate ) {
			$candidate_slug    = trim( (string) ( $candidate['slug'] ?? '' ) );
			$candidate_version = trim( (string) ( $candidate['version'] ?? '' ) );

			if ( $candidate_version !== $version ) {
				continue;
			}

			if ( '' === $candidate_slug || 'wordpress' === $candidate_slug || $candidate_slug === $core_slug ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Clear legacy/current integrity findings for a path.
	 *
	 * @param string $file_path Relative file path.
	 * @return void
	 */
	private function clear_integrity_findings_for_file( 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 );
	}

	/**
	 * Clear fake-image findings when a stronger malware finding supersedes them.
	 *
	 * @param string $file_path Relative file path.
	 * @return void
	 */
	private function clear_fake_image_findings_for_file( string $file_path ): void {
		if ( $file_path === '' ) {
			return;
		}

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

	/**
	 * Update scan signature version in scan queue.
	 *
	 * @param string $signature_version Signature version.
	 */
	private function update_scan_signature_version( string $signature_version ): void {
		global $wpdb;
		$table_name       = $wpdb->prefix . 'securwp_scan_queue';
		$table_identifier = $this->table_identifier( $table_name );

		// 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.
		$current = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT malware_signature_version FROM {$table_identifier} 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

		// Do not override pinned signature during an active scan
		if ( ! empty( $current ) ) {
			return;
		}

		if ( class_exists( '\SecurWP\Modules\Scanner\Service\ScannerSettings' ) ) {
			$settings = \SecurWP\Modules\Scanner\Service\ScannerSettings::get_all();
			$prev_sig = (string) ( $settings['malware_sig_version'] ?? '' );
			if ( ! empty( $prev_sig ) && $prev_sig !== '0.0.0' && $prev_sig !== $signature_version ) {
				$deleted = $this->hash_cache_repo->invalidate_safe();
				if ( class_exists( '\SecurWP\Modules\Scanner\Service\ScanLogger' ) ) {
					\SecurWP\Modules\Scanner\Service\ScanLogger::log(
						sprintf( 'Signature updated (%s -> %s). Invalidated %d SAFE cache entries.', $prev_sig, $signature_version, $deleted )
					);
				}
			}
			\SecurWP\Modules\Scanner\Service\ScannerSettings::set_internal_setting( 'malware_sig_version', $signature_version );
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Updates one row in the plugin-owned scan queue table.
		$wpdb->update(
			$table_name,
			[ 'malware_signature_version' => $signature_version ],
			[ 'job_id' => $this->scan_id ],
			[ '%s' ],
			[ '%s' ]
		);
	}

	/**
	 * Quote a SQL 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 . '`';
	}

}
