<?php

declare(strict_types=1);

namespace SiteFort\Modules\Scanner\FileIntegrity;

use SiteFort\Modules\Scanner\Cloud\CloudScanService;
use SiteFort\Modules\Scanner\Repository\HashCacheRepository;
use SiteFort\Modules\Scanner\Repository\IssueRepository;
use SiteFort\Modules\Scanner\ScanTypes;
use SiteFort\Modules\Scanner\Service\IssueType;

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

/**
 * Computes file hashes and applies local fast-path verdicts before cloud stages.
 */
class HashService {

	/** @var FSMEngine */
	private $fsm;

	/** @var HashCacheRepository */
	private $hash_cache_repo;

	/** @var FileCollector */
	private $file_collector;

	public function __construct() {
		$this->fsm             = new FSMEngine();
		$this->hash_cache_repo = new HashCacheRepository();
		$this->file_collector  = new FileCollector();
	}

	/**
	 * Protected WordPress core directories are manifest-bound. A hash-only local
	 * cache cannot safely distinguish a legitimate core file from an injected file
	 * placed at the wrong core path, so these paths must always be revalidated by
	 * the cloud hash-check stage.
	 *
	 * @param string $file_path Relative file path.
	 * @return bool
	 */
	private function is_protected_core_system_path( string $file_path ): bool {
		$normalized = strtolower( ltrim( wp_normalize_path( $file_path ), '/' ) );
		return strpos( $normalized, 'wp-admin/' ) === 0 || strpos( $normalized, 'wp-includes/' ) === 0;
	}

	/**
	 * Component types that can carry an integrity finding (modified/unknown/orphaned).
	 */
	private function is_integrity_tracked_component( string $file_type ): bool {
		return in_array( strtolower( $file_type ), [ 'core', 'plugin', 'theme' ], true );
	}

	/**
	 * Process a batch of files in 'discovered' state: hash them and apply fast-path logic.
	 *
	 * Cloud hash checking is handled separately by ScanOrchestrator
	 * via CloudScanService.check_hash() and get_secure_link().
	 *
	 * @param string $scan_id    Scan UUID.
	 * @param int    $batch_size Number of files to process.
	 * @param string $scan_type  Scan type constant (standard|deep). Controls cache and binary-check behaviour.
	 * @return int Number of files hashed.
	 */
	public function process_hashing_batch( string $scan_id, int $batch_size = 50, string $scan_type = ScanTypes::STANDARD ): int {
		$files = $this->fsm->get_files_by_state( $scan_id, 'discovered', $batch_size );

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

		$config            = ScanTypes::get_config( $scan_type );
		$bypass_safe_cache = ! empty( $config['bypass_safe_cache'] );
		$binary_check_imgs = ! empty( $config['binary_check_images'] );
		$max_size_bytes    = CloudScanService::MAX_SINGLE_FILE_SIZE;
		$max_size_mb       = (int) ( $max_size_bytes / ( 1024 * 1024 ) );
		$pinned_version    = \SiteFort\Modules\Scanner\Queue\ScanQueue::get_pinned_signature_version( $scan_id );
		$pinned_version    = is_string( $pinned_version ) ? $pinned_version : null;

		// --- Phase 1: Hash all files in memory (no DB) ---
		$hashes      = []; // id => hash
		$sizes       = []; // id => size
		$snapshots   = []; // id => component snapshot
		$file_map    = []; // id => file record
		$hash_to_id  = []; // hash => id (for verdict lookup)
		$ok_ids      = []; // ids that hashed successfully

		foreach ( $files as $file ) {
			$id       = (int) $file['id'];
			$file_path = (string) ( $file['file_path'] ?? '' );
			$abs_path = ABSPATH . $file_path;
			$file_size = (int) ( $file['file_size'] ?? 0 );

			if ( $file_size <= 0 && @is_file( $abs_path ) ) {
				$stat_size = @filesize( $abs_path );
				if ( $stat_size !== false ) {
					$file_size = (int) $stat_size;
				}
			}

			// Enforce the upload ceiling before hashing so oversized artifacts are
			// skipped as policy exclusions instead of surfacing as failed files later.
			if ( $file_size > $max_size_bytes ) {
				$message = sprintf( 'File exceeds maximum size of %dMB', $max_size_mb );
				$this->fsm->transition(
					$id,
					'discovered',
					'scan_skipped',
					[
						'skip_reason'   => 'file_too_large',
						'error_code'    => 'file_too_large',
						'error_message' => $message,
					]
				);
				$this->log_skipped_file(
					$scan_id,
					$file['file_path'] ?? '(unknown)',
					'Scan skipped: ' . $message,
					[ 'error_code' => 'file_too_large' ]
				);
				$this->stage_oversized_skip_finding( $scan_id, $file_path, $file_size, $max_size_mb );
				continue;
			}

			$snapshot = $this->file_collector->build_hash_snapshot( $file_path, $abs_path );
			$hash     = $snapshot['hash'] ?? false;

			$file_map[ $id ] = $file;

			if ( false === $hash ) {
				// Error path: handle individually (rare).
				$this->fsm->transition( $id, 'discovered', 'hashing' );
				$this->fsm->transition( $id, 'hashing', 'error', [
					'error_code'    => 'hash_failed',
					'error_message' => 'Failed to compute file hash',
				] );
				$this->log_skipped_file(
					$scan_id,
					$file['file_path'] ?? '(unknown)',
					'Scan skipped: file unreadable or hash failed',
					[ 'error_code' => 'hash_failed' ]
				);
				continue;
			}

			if ( empty( $snapshot['stable'] ) ) {
				$this->fsm->transition( $id, 'discovered', 'scan_skipped', [
					'skip_reason'   => 'changed_during_hash',
					'error_code'    => 'changed_during_hash',
					'error_message' => 'File changed while it was being hashed; rerun the scan after updates finish',
				] );
				$this->log_skipped_file(
					$scan_id,
					$file_path !== '' ? $file_path : '(unknown)',
					'Scan skipped: file changed while it was being hashed',
					[ 'error_code' => 'changed_during_hash' ]
				);
				continue;
			}

			$info = is_array( $snapshot['info'] ?? null ) ? $snapshot['info'] : [];
			$snapshots[ $id ] = [
				'component_slug'      => (string) ( $info['slug'] ?? '' ),
				'component_version'   => (string) ( $info['version'] ?? '' ),
				'component_locale'    => (string) ( $info['locale'] ?? '' ),
				'file_mtime_at_hash'  => (int) ( $snapshot['mtime'] ?? 0 ),
			];
			$file_map[ $id ] = array_merge( $file, $snapshots[ $id ] );

			$hashes[ $id ]       = $hash;
			$sizes[ $id ]        = (int) ( $snapshot['size'] ?? 0 );
			$ok_ids[]            = $id;
			$hash_to_id[ $hash ] = $id;
		}

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

		// --- Phase 2: Bulk UPDATE discovered -> hash_complete (1 query for entire batch) ---
		$this->fsm->bulk_transition_to_hash_complete( $hashes, $sizes, $ok_ids, $snapshots );

		// --- Phase 3: Binary/image content check (deep scan only) ---
		$binary_safe_ids = [];
		$fake_image_ids  = [];
		if ( $binary_check_imgs ) {
			foreach ( $ok_ids as $id ) {
				$file      = $file_map[ $id ];
				$file_path = (string) ( $file['file_path'] ?? '' );
				$abs_path  = ABSPATH . $file_path;
				$ext       = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
				if ( ScanTypes::is_binary_check_extension( $ext ) ) {
					$image_verdict = null;
					if ( ImageTypeVerifier::is_supported_extension( $ext ) ) {
						$image_verdict = ImageTypeVerifier::verify( $abs_path, $ext );
						if ( empty( $image_verdict['valid'] ) ) {
							if ( ImageTypeVerifier::is_security_finding( $image_verdict ) ) {
								$this->stage_fake_image_issue( $scan_id, $file, (string) ( $hashes[ $id ] ?? '' ), $image_verdict );
								$this->mark_fake_image_for_cloud_upload( $id, $image_verdict );
								$fake_image_ids[] = $id;
								continue;
							}
							// Keep ambiguous image containers in the cloud path.
							continue;
						}
					}

					if ( ! BinaryContentChecker::check( $abs_path ) ) {
						$binary_safe_ids[] = $id;
					} elseif ( is_array( $image_verdict ) && ! empty( $image_verdict['valid'] ) ) {
						$embedded_verdict = $this->build_embedded_payload_verdict( $image_verdict );
						$this->stage_fake_image_issue( $scan_id, $file, (string) ( $hashes[ $id ] ?? '' ), $embedded_verdict );
						$this->mark_fake_image_for_cloud_upload( $id, $embedded_verdict );
						$fake_image_ids[] = $id;
						continue;
					}
					// Suspicious: fall through to cloud pipeline.
				}
			}
		}

		// --- Phase 4: Bulk hash cache lookup (1 query for entire batch) ---
		// Signed hash cache fast-path.
		// 'verified': cryptographic proof the file matches WP.org canonical version - always trusted.
		// 'safe':     cloud signature-verified hash - trusted in standard scan, bypassed in deep scan.
		$binary_safe_set = array_flip( $binary_safe_ids );
		$fake_image_set  = array_flip( $fake_image_ids );
		$hashes_to_check = [];
		foreach ( $ok_ids as $id ) {
			$file_path = (string) ( $file_map[ $id ]['file_path'] ?? '' );
			if ( isset( $binary_safe_set[ $id ] ) || isset( $fake_image_set[ $id ] ) || $this->is_protected_core_system_path( $file_path ) ) {
				continue;
			}
			$hashes_to_check[] = $hashes[ $id ];
		}

		$verdicts    = ! empty( $hashes_to_check )
			? $this->hash_cache_repo->get_bulk_verdicts( $hashes_to_check, $pinned_version )
			: [];
		$verified_ids        = [];
		$safe_ids            = [];
		$safe_component_path = []; // file_path => id, component-file 'safe' hits to integrity-gate.
		foreach ( $hashes_to_check as $hash ) {
			$id      = $hash_to_id[ $hash ] ?? null;
			$verdict = $verdicts[ $hash ] ?? null;
			if ( null === $id ) {
				continue;
			}
			if ( $verdict === 'verified' ) {
				$verified_ids[] = $id;
			} elseif ( $verdict === 'safe' && ! $bypass_safe_cache ) {
				$safe_ids[] = $id;
				$cache_path = (string) ( $file_map[ $id ]['file_path'] ?? '' );
				$cache_type = (string) ( $file_map[ $id ]['file_type'] ?? '' );
				if ( $cache_path !== '' && $this->is_integrity_tracked_component( $cache_type ) ) {
					$safe_component_path[ $cache_path ] = $id;
				}
			}
			// Deep scan: 'safe' hits are ignored; file stays in hash_complete for cloud check.
		}

		// 'safe' means malware-clean, not that the file belongs here. Component files
		// with an open integrity finding go back to the live cloud hash-check instead of
		// fast-pathing, so standard scans keep the finding deep scans raised.
		if ( ! empty( $safe_component_path ) ) {
			$flagged  = IssueRepository::getActiveIntegrityPaths( array_keys( $safe_component_path ) );
			$drop_ids = [];
			foreach ( $flagged as $flagged_path ) {
				if ( isset( $safe_component_path[ $flagged_path ] ) ) {
					$drop_ids[ $safe_component_path[ $flagged_path ] ] = true;
				}
			}
			if ( ! empty( $drop_ids ) ) {
				$safe_ids = array_values( array_filter( $safe_ids, static function ( $id ) use ( $drop_ids ) {
					return ! isset( $drop_ids[ $id ] );
				} ) );
			}
		}

		// --- Phase 5: Bulk apply verdicts (1-3 queries total) ---
		if ( ! empty( $binary_safe_ids ) ) {
			$this->fsm->bulk_transition( $binary_safe_ids, 'hash_complete', 'safe' );
		}
		if ( ! empty( $verified_ids ) ) {
			$this->fsm->bulk_transition( $verified_ids, 'hash_complete', 'verified' );
		}
		if ( ! empty( $safe_ids ) ) {
			$this->fsm->bulk_transition( $safe_ids, 'hash_complete', 'safe' );
		}
		// Cloud hash check handled by ScanOrchestrator via CloudScanService.
		return count( $ok_ids );
	}

	/**
	 * @param array<string,mixed> $image_verdict
	 * @return array<string,mixed>
	 */
	private function build_embedded_payload_verdict( array $image_verdict ): array {
		$verdict = $image_verdict;
		$verdict['valid'] = false;
		$verdict['reason'] = 'embedded_code_payload';
		$verdict['detected_type'] = 'embedded_code';
		return $verdict;
	}

	private function stage_fake_image_issue( string $scan_id, array $file, string $file_hash, array $image_verdict ): void {
		$file_path = (string) ( $file['file_path'] ?? '' );
		if ( '' === $file_path ) {
			return;
		}

		$file_info = $this->file_collector->get_file_info_from_state( $file );
		$reason    = sanitize_key( (string) ( $image_verdict['reason'] ?? 'image_type_mismatch' ) );
		$expected  = sanitize_key( (string) ( $image_verdict['expected_extension'] ?? strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ) ) );
		$detected  = sanitize_key( (string) ( $image_verdict['detected_type'] ?? '' ) );
		$mime      = sanitize_text_field( (string) ( $image_verdict['detected_mime'] ?? '' ) );

		$description = 'This file uses an image extension but contains content that is unexpected for an image. This can indicate a compromised upload or hidden payload. If the file is not expected, delete it and restore a clean copy from a trusted source.';

		$finding = [
			'scan_id'     => $scan_id,
			'file_path'   => $file_path,
			'type'        => IssueType::FAKE_IMAGE,
			'severity'    => 'high',
			'message'     => 'Suspicious Image File Detected',
			'data'        => [
				'title'              => 'Suspicious Image File Detected',
				'description'        => $description,
				'detection_type'     => 'image_type_mismatch',
				'validation_reason'  => $reason,
				'expected_extension' => $expected,
				'expected_mime'      => $image_verdict['expected_mime'] ?? [],
				'detected_type'      => $detected,
				'detected_mime'      => $mime,
				'parser_type'        => sanitize_key( (string) ( $image_verdict['parser_type'] ?? '' ) ),
				'bytes_checked'      => (int) ( $image_verdict['bytes_checked'] ?? 0 ),
				'file_size'          => (int) ( $image_verdict['file_size'] ?? 0 ),
				'file_hash'          => $file_hash,
				'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_hash,
			'hash'        => IssueRepository::generateHash( IssueType::FAKE_IMAGE, $file_path ),
		];

		IssueRepository::stage( $finding );
	}

	private function mark_fake_image_for_cloud_upload( int $file_id, array $image_verdict ): void {
		$payload = [
			'local_detection' => 'fake_image',
			'image_verdict'   => $image_verdict,
		];

		$result = $this->fsm->transition(
			$file_id,
			'hash_complete',
			'hash_complete',
			[
				'hash_status'   => IssueType::FAKE_IMAGE,
				'error_code'    => 'image_type_mismatch',
				'error_message' => 'Image extension/content validation failed; queued for cloud analysis',
				'scan_result'   => wp_json_encode( $payload ),
			]
		);

		if ( is_wp_error( $result ) ) {
			sitefort_log( '[SiteFort] Failed to mark fake image for cloud upload: ' . $result->get_error_message() );
		}
	}

	private function log_skipped_file( string $scan_id, string $file_path, string $message, array $data = [] ): void {
		if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Service\\ScanLogger' ) ) {
			require_once dirname( __DIR__ ) . '/Service/ScanLogger.php';
		}

		$log_msg = '[Skipped] ' . $file_path . ' - ' . $message;
		$code = $data['error_code'] ?? null;
		if ( $code ) {
			$log_msg .= ' (' . $code . ')';
		}

		$key = 'skip_' . sanitize_key( (string) ( $code ?: 'file' ) );
		\SiteFort\Modules\Scanner\Service\ScanLogger::log_throttled_for_scan( $scan_id, $key, $log_msg, 30, 2, 'warning' );
	}

	/**
	 * Record an oversized, unscannable file as a low-severity finding so the user can review or delete it.
	 */
	private function stage_oversized_skip_finding( string $scan_id, string $file_path, int $file_size, int $max_size_mb ): void {
		if ( $file_path === '' ) {
			return;
		}

		$size_label  = function_exists( 'size_format' ) ? size_format( $file_size ) : $file_size . ' bytes';
		$limit_label = $max_size_mb . ' MB';

		IssueRepository::stage( [
			'scan_id'  => $scan_id,
			'hash'     => IssueRepository::generateHash( IssueType::SKIPPED, 'too_large:' . $file_path ),
			'type'     => IssueType::SKIPPED,
			'status'   => 'too_large',
			'severity' => 'low',
			'message'  => sprintf( 'File exceeds the %s scan limit and was not analyzed.', $limit_label ),
			'file'     => $file_path,
			'data'     => [
				'reason'      => 'file_too_large',
				'file_size'   => $file_size,
				'size_label'  => $size_label,
				'limit_label' => $limit_label,
			],
		] );
	}
}
