<?php

declare(strict_types=1);

namespace SecurWP\Modules\Scanner\FileIntegrity;

use SecurWP\Modules\Scanner\Cloud\CloudScanService;
use SecurWP\Modules\Scanner\Repository\HashCacheRepository;
use SecurWP\Modules\Scanner\Repository\IssueRepository;
use SecurWP\Modules\Scanner\ScanTypes;
use SecurWP\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;

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

	/**
	 * 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;
	}

	/**
	 * 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    = \SecurWP\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
		$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'];
			$abs_path = ABSPATH . $file['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->stage_skipped_issue(
					$scan_id,
					$file['file_path'] ?? '(unknown)',
					'Scan skipped: ' . $message,
					[ 'error_code' => 'file_too_large' ]
				);
				continue;
			}

			// wp-config.php is sent to the cloud in redacted form (credentials stripped).
			// Hash and size are derived from the REDACTED content so every stage
			// (check-hash, get-secure-link, zip upload) uses the same values.
			$computed_size = null;
			if ( basename( $abs_path ) === 'wp-config.php' ) {
				$raw = @file_get_contents( $abs_path );
				if ( $raw !== false ) {
					$sanitized     = FileCollector::sanitize_wp_config_content( $raw );
					$hash          = hash( 'sha256', $sanitized );
					$computed_size = strlen( $sanitized );
				} else {
					$hash = false;
				}
			} else {
				$hash = $this->compute_hash( $abs_path );
			}

			$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->stage_skipped_issue(
					$scan_id,
					$file['file_path'] ?? '(unknown)',
					'Scan skipped: file unreadable or hash failed',
					[ 'error_code' => 'hash_failed' ]
				);
				continue;
			}

			$hashes[ $id ]       = $hash;
			$sizes[ $id ]        = $computed_size ?? ( file_exists( $abs_path ) ? (int) filesize( $abs_path ) : 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 );

		// --- 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     = [];
		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;
			}
			// Deep scan: 'safe' hits are ignored; file stays in hash_complete for cloud check.
		}

		// --- 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;
	}

	/**
	 * Compute SHA-256 hash for a file.
	 *
	 * @param string $file_path Absolute file path.
	 * @return string|false Hash or false on failure.
	 */
	private function compute_hash( string $file_path ) {
		if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) {
			return false;
		}

		$hash = hash_file( 'sha256', $file_path );

		return $hash !== false ? $hash : false;
	}

	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 = ( new FileCollector() )->get_file_info_for_hash_check( $file_path );
		$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 ) ) {
			securwp_log( '[SecurWP] Failed to mark fake image for cloud upload: ' . $result->get_error_message() );
		}
	}

	/**
	 * Log a skipped file to the scan activity log.
	 *
	 * Previously staged as an issue; now routed to ScanLogger so skipped
	 * items appear in the scan log instead of the findings list.
	 *
	 * @param string $scan_id   Scan ID (unused, kept for call-site compat).
	 * @param string $file_path File path.
	 * @param string $message   Human-readable description.
	 * @param array  $data      Extra context.
	 */
	private function stage_skipped_issue( string $scan_id, string $file_path, string $message, array $data = [] ): void {
		if ( ! class_exists( 'SecurWP\\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 . ')';
		}

		\SecurWP\Modules\Scanner\Service\ScanLogger::log( $log_msg, 2, 'warning' );
	}
}
