<?php

declare(strict_types=1);

namespace SiteFort\Modules\Scanner\FileIntegrity;

use SiteFort\Includes\Capabilities;
use SiteFort\Includes\DataDirectoryProtection;
use SiteFort\Modules\Scanner\Repository\FileStateRepository;
use SiteFort\Modules\Scanner\Repository\FileIndexRepository;
use SiteFort\Modules\Scanner\ScanTypes;

/**
 * FileCollector - discovers files via stack-based DFS and registers them
 * in the FileStateRepository for subsequent hashing and cloud analysis.
 *
 * Standard scan:
 *   Scans everything under ABSPATH. At the root level, only the three standard
 *   WP directories (wp-admin, wp-includes, wp-content) and flat root files are
 *   included. Non-WP root subdirectories (e.g. phpmyadmin/, backup/) are skipped.
 *
 * Deep scan:
 *   Same as standard plus non-WP ABSPATH root directories. Images and documents
 *   are collected for local binary pre-filtering before cloud escalation.
 *
 * wp-config.php is indexed and hashed normally. Before the file content is sent
 * to the cloud the upload service calls sanitize_wp_config_content() to strip
 * database credentials and secret keys from the payload.
 *
 */
class FileCollector {

	/**
	 * Standard WordPress directories that live directly under ABSPATH.
	 * In standard scan, every other root-level directory is excluded.
	 */
	private const WP_ROOT_DIRS = [ 'wp-admin', 'wp-includes', 'wp-content' ];

	/**
	 * Built-in exclusions remain backend-only and are not surfaced in the UI.
	 * They avoid scanning repository/dependency noise.
	 */
	private const DEFAULT_EXCLUSIONS = [
		'.git',
		'.svn',
		'node_modules',
	];

	/**
	 * wp-config.php define() keys whose values are redacted before cloud upload.
	 */
	private const WP_CONFIG_SENSITIVE_KEYS = [
		'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_HOST',
		'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY',
		'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT',
	];

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

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

	/** @var array<string, array{file:string,version:string}> */
	private static $fresh_plugin_versions_by_slug = [];

	/** @var array<string, array{file:string,version:string}> */
	private static $fresh_theme_versions_by_slug = [];

	/** @var FileStateRepository */
	private $repository;

	/** @var FileIndexRepository */
	private $file_index_repository;

	public function __construct() {
		$this->repository            = new FileStateRepository();
		$this->file_index_repository = new FileIndexRepository();
	}

	// =========================================================================
	// Public API
	// =========================================================================

	/**
	 * Run a discovery batch.
	 *
	 * @param string $scan_id   Scan UUID.
	 * @param string $scan_type ScanTypes constant.
	 * @param array  $options   Optional overrides: paths, exclusions, extensions, batch_limit.
	 * @return int Number of files discovered in this batch.
	 */
	public function collect( string $scan_id, string $scan_type, array $options = [] ): int {
		$batch_limit      = $options['batch_limit'] ?? 500;
		$extensions       = $options['extensions'] ?? $this->get_scan_extensions( $scan_type );
		$scan_config      = ScanTypes::get_config( $scan_type );
		$scan_root_extras = ! empty( $scan_config['scan_root_extras'] );
		$exclusions       = array_merge( self::DEFAULT_EXCLUSIONS, $options['exclusions'] ?? [] );

		$stack_state    = $this->file_index_repository->get_state( $scan_id );
		$stack          = $stack_state['stack'];
		$is_first_batch = ! is_array( $stack ) || empty( $stack );

		if ( $stack_state['is_complete'] ) {
			$this->set_file_index_complete_transient( $scan_id );
			return 0;
		}

		if ( $is_first_batch ) {
			$roots = $options['paths'] ?? $this->get_scan_root();
			$stack = [];
			foreach ( $roots as $root ) {
				if ( file_exists( $root ) && is_dir( $root ) ) {
					$stack[] = rtrim( $root, '/\\' );
				}
			}
			if ( empty( $stack ) ) {
				$this->file_index_repository->mark_complete( $scan_id );
				$this->set_file_index_complete_transient( $scan_id );
				$this->clear_legacy_discovery_transients( $scan_id );
				return 0;
			}
		}

		$abspath          = defined( 'ABSPATH' ) ? rtrim( ABSPATH, '/\\' ) : '';
		$start_time       = time();
		$time_limit       = 15;
		$files_discovered = 0;
		$inserted_total   = 0;
		$batch_files      = [];

		while ( ! empty( $stack ) ) {
			if ( ( time() - $start_time ) > $time_limit ) {
				break;
			}
			if ( $files_discovered >= $batch_limit ) {
				break;
			}

			$current_dir = array_pop( $stack );

			if ( ! is_dir( $current_dir ) || ! is_readable( $current_dir ) ) {
				continue;
			}

			$items = @scandir( $current_dir );
			if ( $items === false ) {
				continue;
			}

			foreach ( $items as $item ) {
				if ( $item === '.' || $item === '..' ) {
					continue;
				}

				$path = $current_dir . DIRECTORY_SEPARATOR . $item;

				if ( is_link( $path ) ) {
					continue;
				}

				if ( is_dir( $path ) ) {
					// Standard mode: at ABSPATH root only descend into WP directories.
					if ( ! $scan_root_extras && $abspath !== '' && $current_dir === $abspath ) {
						if ( ! in_array( $item, self::WP_ROOT_DIRS, true ) ) {
							continue;
						}
					}

					$relative = $this->get_relative_path( $path );

					if ( $this->should_exclude( $relative, $exclusions ) ) {
						continue;
					}

					if ( $this->is_nested_wp_install( $path ) ) {
						$this->stage_nested_wp_issue( $scan_id, $relative );
						continue;
					}

					$stack[] = $path;

				} elseif ( is_file( $path ) ) {
					$relative = $this->get_relative_path( $path );

					if ( $this->should_exclude( $relative, $exclusions ) ) {
						continue;
					}

					if ( ! $this->has_allowed_extension( $path, $extensions ) ) {
						continue;
					}

					// Skip SiteFort's own runtime artifacts so their churning hashes never
					// hit the cloud; unexpected files in sitefort-data are still indexed.
					if ( class_exists( DataDirectoryProtection::class )
						&& DataDirectoryProtection::is_runtime_artifact_path( $path ) ) {
						continue;
					}

					$file_type = $this->classify_file( $relative );
					$batch_files[]  = [
						'scan_id'       => $scan_id,
						'file_path'     => $relative,
						'file_type'     => $file_type,
						'file_size'     => @filesize( $path ) ?: 0,
					];
					$files_discovered++;

					if ( count( $batch_files ) >= 100 ) {
						$inserted_total += $this->repository->batch_create( $batch_files );
						$batch_files    = [];
					}
				}
			}
		}

		if ( ! empty( $batch_files ) ) {
			$inserted_total += $this->repository->batch_create( $batch_files );
		}

		if ( $inserted_total > 0 ) {
			$this->file_index_repository->increment_total( $scan_id, $inserted_total );
		}

		if ( empty( $stack ) ) {
			$this->file_index_repository->mark_complete( $scan_id );
			$this->set_file_index_complete_transient( $scan_id );
			$this->clear_legacy_discovery_transients( $scan_id );
		} else {
			$this->file_index_repository->save_stack( $scan_id, $stack );
		}

		return $files_discovered;
	}

	/**
	 * Sanitize wp-config.php content before transmission to the cloud.
	 *
	 * Replaces the values of sensitive define() calls with '[REDACTED]' so the
	 * cloud scanner can analyse code structure and malware patterns without
	 * receiving real database credentials or secret keys.
	 *
	 * Called by CloudScanService immediately before uploading wp-config.php.
	 *
	 * @param string $content Raw file content.
	 * @return string Sanitized content safe for cloud transmission.
	 */
	public static function sanitize_wp_config_content( string $content ): string {
		foreach ( self::WP_CONFIG_SENSITIVE_KEYS as $key ) {
			// Match to the closing quote of the same type (consuming escaped
			// quotes) so an opposite quote in the value can't cut redaction short.
			$content = preg_replace_callback(
				'/(\bdefine\s*\(\s*([\'"])' . preg_quote( $key, '/' ) . '\2\s*,\s*)([\'"])(?:\\\\.|(?!\3).)*\3/is',
				static function ( array $matches ): string {
					return (string) ( $matches[1] ?? '' ) . "'[REDACTED]'";
				},
				$content
			) ?? $content;
		}

		// Also cover const-style definitions.
		foreach ( self::WP_CONFIG_SENSITIVE_KEYS as $key ) {
			$content = preg_replace_callback(
				'/(\bconst\s+' . preg_quote( $key, '/' ) . '\s*=\s*)([\'"])(?:\\\\.|(?!\2).)*\2/is',
				static function ( array $matches ): string {
					return (string) ( $matches[1] ?? '' ) . "'[REDACTED]'";
				},
				$content
			) ?? $content;
		}

		return $content;
	}

	// =========================================================================
	// Private helpers
	// =========================================================================

	/**
	 * Returns the single DFS scan root: the WordPress installation directory.
	 *
	 * @return string[]
	 */
	private function get_scan_root(): array {
		if ( ! defined( 'ABSPATH' ) ) {
			return [ dirname( __DIR__, 5 ) ]; // fallback for unit tests
		}
		return [ rtrim( ABSPATH, '/\\' ) ];
	}

	/**
	 * Return the file extensions to collect for a given scan type.
	 *
	 * @param string $type ScanTypes constant.
	 * @return string[]
	 */
	private function get_scan_extensions( string $type ): array {
		$config = ScanTypes::get_config( $type );
		return ! empty( $config['extensions'] ) ? $config['extensions'] : ScanTypes::CODE_EXTENSIONS;
	}

	private function get_relative_path( string $full_path ): string {
		if ( defined( 'ABSPATH' ) ) {
			return str_replace( ABSPATH, '', $full_path );
		}
		return $full_path;
	}

	private function should_exclude( string $path, array $exclusions ): bool {
		$normalized = '/' . ltrim( str_replace( '\\', '/', $path ), '/' );
		foreach ( $exclusions as $exclude ) {
			$exclude = rtrim( $exclude, '/' );
			if ( $exclude === '' ) {
				continue;
			}
			// Wildcard pattern: fnmatch against full normalized path.
			if ( strpos( $exclude, '*' ) !== false || strpos( $exclude, '?' ) !== false ) {
				if ( fnmatch( $exclude, $normalized, FNM_CASEFOLD ) ) {
					return true;
				}
				continue;
			}
			// Absolute path (starts with /): match as prefix.
			if ( $exclude[0] === '/' ) {
				if ( $normalized === $exclude || strpos( $normalized, $exclude . '/' ) === 0 ) {
					return true;
				}
				continue;
			}
			// Bare name: match against path components.
			if ( strpos( $normalized, '/' . $exclude . '/' ) !== false ) {
				return true;
			}
			if ( substr( $normalized, -( strlen( $exclude ) + 1 ) ) === '/' . $exclude ) {
				return true;
			}
		}
		return false;
	}

	private function has_allowed_extension( string $path, array $extensions ): bool {
		$basename = basename( $path );
		if ( strpos( $basename, '.' ) === false ) {
			// Extensionless files: include in uploads and wp-content root (dropper hiding spots).
			$relative = $this->get_relative_path( $path );
			if ( strpos( $relative, 'wp-content/uploads' ) !== false ) {
				return true;
			}
			$lower      = strtolower( $basename );
			$safe_names = [
				'license', 'readme', 'changelog', 'makefile', 'dockerfile',
				'gemfile', 'rakefile', 'procfile', 'guardfile', 'vagrantfile',
				'thumbs', 'authors', 'contributors', 'copying', 'install', 'todo', 'version',
			];
			if ( in_array( $lower, $safe_names, true ) ) {
				return false;
			}
			return true;
		}
		$ext = pathinfo( $path, PATHINFO_EXTENSION );
		return in_array( strtolower( $ext ), $extensions, true );
	}

	private function classify_file( string $path ): string {
		$normalized_path = ltrim( wp_normalize_path( $path ), '/' );
		$component_type  = Capabilities::infer_scanner_file_component_type( $normalized_path );

		if ( in_array( $component_type, [ 'plugin', 'theme', 'core', 'uploads' ], true ) ) {
			return $component_type;
		}

		return 'other';
	}

	private function set_file_index_complete_transient( string $scan_id ): void {
		set_transient( 'sitefort_file_index_complete_' . $scan_id, true, HOUR_IN_SECONDS );
	}

	private function clear_legacy_discovery_transients( string $scan_id ): void {
		delete_transient( 'sitefort_discovery_complete_' . $scan_id );
		delete_transient( 'sitefort_discovery_stack_' . $scan_id );
	}

	private function is_nested_wp_install( string $dir_path ): bool {
		if ( ! defined( 'ABSPATH' ) ) {
			return false;
		}
		if ( rtrim( $dir_path, '/\\' ) === rtrim( ABSPATH, '/\\' ) ) {
			return false;
		}
		return file_exists( $dir_path . DIRECTORY_SEPARATOR . 'wp-config.php' )
			&& is_dir( $dir_path . DIRECTORY_SEPARATOR . 'wp-includes' )
			&& is_dir( $dir_path . DIRECTORY_SEPARATOR . 'wp-admin' );
	}

	private function stage_nested_wp_issue( string $scan_id, string $relative_dir ): void {
		if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Repository\\IssueRepository' ) ) {
			require_once dirname( __DIR__ ) . '/Repository/IssueRepository.php';
		}
		if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Repository\\IssueRepository' ) ) {
			return;
		}
		\SiteFort\Modules\Scanner\Repository\IssueRepository::stage( [
			'scan_id'  => $scan_id,
			'hash'     => \SiteFort\Modules\Scanner\Repository\IssueRepository::generateHash( 'nested_wp', $relative_dir ),
			'type'     => 'other',
			'status'   => 'warning',
			'severity' => 'medium',
			'message'  => 'Nested WordPress installation detected and excluded from scan. Verify this installation is intentional and kept up to date.',
			'file'     => $relative_dir,
			'data'     => [ 'reason' => 'nested_wp_install' ],
		] );
	}

	// =========================================================================
	// Metadata helpers used by scanner hash and cloud checks.
	// =========================================================================

	private static function ensure_plugin_versions_loaded(): void {
		if ( is_array( self::$plugin_versions_by_slug ) ) {
			return;
		}
		self::$plugin_versions_by_slug = [];
		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}
		foreach ( get_plugins() as $plugin_file => $data ) {
			$dir = dirname( (string) $plugin_file );
			if ( $dir === '.' || $dir === '' ) {
				$dir = basename( (string) $plugin_file, '.php' );
			}
			if ( ! isset( self::$plugin_versions_by_slug[ $dir ] ) ) {
				self::$plugin_versions_by_slug[ $dir ] = (string) ( $data['Version'] ?? '' );
			}
		}
	}

	private static function ensure_theme_versions_loaded(): void {
		if ( is_array( self::$theme_versions_by_slug ) ) {
			return;
		}
		self::$theme_versions_by_slug = [];
		if ( function_exists( 'wp_get_themes' ) ) {
			foreach ( wp_get_themes() as $slug => $theme ) {
				self::$theme_versions_by_slug[ (string) $slug ] = (string) $theme->get( 'Version' );
			}
		}
	}

	private static function resolve_plugin_version_from_disk( string $slug, bool $single_file_plugin = false ): string {
		if ( $slug === '' || strpbrk( $slug, "\\/\0" ) !== false || ! defined( 'WP_PLUGIN_DIR' ) ) {
			return '';
		}

		if ( $single_file_plugin ) {
			$single_file = trailingslashit( WP_PLUGIN_DIR ) . $slug . '.php';
			$version     = self::read_cached_plugin_header_version( $slug, $single_file );
			if ( null !== $version ) {
				return $version;
			}
			return '';
		}

		$cached = self::$fresh_plugin_versions_by_slug[ $slug ]['file'] ?? '';
		if ( $cached !== '' ) {
			$version = self::read_cached_plugin_header_version( $slug, $cached );
			if ( null !== $version ) {
				return $version;
			}
		}

		$plugin_dir = trailingslashit( WP_PLUGIN_DIR ) . $slug;
		if ( ! is_dir( $plugin_dir ) ) {
			return '';
		}

		$handle = @opendir( $plugin_dir );
		if ( ! $handle ) {
			return '';
		}
		$files = [];
		while ( ( $entry = readdir( $handle ) ) !== false ) {
			if ( $entry === '.' || $entry === '..' || strpos( $entry, '.' ) === 0 ) {
				continue;
			}

			$entry_path = trailingslashit( $plugin_dir ) . $entry;
			if ( is_file( $entry_path ) && substr( $entry, -4 ) === '.php' ) {
				$files[] = $entry_path;
				continue;
			}

			if ( is_dir( $entry_path ) ) {
				$subhandle = @opendir( $entry_path );
				if ( ! $subhandle ) {
					continue;
				}
				while ( ( $subentry = readdir( $subhandle ) ) !== false ) {
					if ( $subentry === '.' || $subentry === '..' || strpos( $subentry, '.' ) === 0 || substr( $subentry, -4 ) !== '.php' ) {
						continue;
					}
					$files[] = trailingslashit( $entry_path ) . $subentry;
				}
				closedir( $subhandle );
			}
		}
		closedir( $handle );
		sort( $files, SORT_STRING );

		foreach ( $files as $file ) {
			$version = self::read_cached_plugin_header_version( $slug, $file );
			if ( null === $version ) {
				continue;
			}

			return $version;
		}

		return '';
	}

	/**
	 * @param array<string,array{file:string,version:string}> $cache
	 * @param callable(string):?string $reader
	 */
	private static function read_cached_header_version( array &$cache, string $slug, string $file, callable $reader ): ?string {
		$cached = $cache[ $slug ] ?? null;
		if ( is_array( $cached ) && (string) ( $cached['file'] ?? '' ) === $file ) {
			return (string) ( $cached['version'] ?? '' );
		}
		if ( ! is_readable( $file ) ) {
			unset( $cache[ $slug ] );
			return null;
		}

		$version = $reader( $file );
		if ( null === $version ) {
			unset( $cache[ $slug ] );
			return null;
		}

		$cache[ $slug ] = [
			'file'    => $file,
			'version' => $version,
		];

		return $version;
	}

	private static function read_cached_plugin_header_version( string $slug, string $file ): ?string {
		return self::read_cached_header_version(
			self::$fresh_plugin_versions_by_slug,
			$slug,
			$file,
			static function ( string $candidate ): ?string {
				return self::read_plugin_header_version( $candidate );
			}
		);
	}

	private static function read_plugin_header_version( string $file ): ?string {
		if ( ! is_readable( $file ) ) {
			return null;
		}

		$data = get_file_data(
			$file,
			[
				'Name'    => 'Plugin Name',
				'Version' => 'Version',
			],
			'plugin'
		);
		if ( empty( $data['Name'] ) ) {
			return null;
		}

		return (string) ( $data['Version'] ?? '' );
	}

	private static function resolve_theme_version_from_disk( string $slug ): string {
		if ( $slug === '' || strpbrk( $slug, "\\/\0" ) !== false ) {
			return '';
		}

		$theme_root = function_exists( 'get_theme_root' ) ? get_theme_root( $slug ) : WP_CONTENT_DIR . '/themes';
		$style_file = trailingslashit( $theme_root ) . $slug . '/style.css';
		$version    = self::read_cached_header_version(
			self::$fresh_theme_versions_by_slug,
			$slug,
			$style_file,
			static function ( string $file ): ?string {
				$data = get_file_data(
					$file,
					[
						'Version' => 'Version',
					],
					'theme'
				);

				return (string) ( $data['Version'] ?? '' );
			}
		);

		return null !== $version ? $version : '';
	}

	/**
	 * Return file metadata used by CloudScanService for hash checks.
	 *
	 * @param string $relative_path             File path relative to ABSPATH.
	 * @param bool   $fresh_component_versions Read plugin/theme versions from disk instead of request-local caches.
	 * @return array{type: string, slug: string, version: string, locale: string, last_modified: int|null}
	 */
	public function get_file_info_for_hash_check( string $relative_path, bool $fresh_component_versions = false ): array {
		$info = [
			'type'          => 'other',
			'slug'          => '',
			'version'       => '',
			'locale'        => '',
			'last_modified' => null,
		];

		$normalized_relative_path = ltrim( wp_normalize_path( $relative_path ), '/' );
		$info['type']            = $this->classify_file( $normalized_relative_path );

		if ( $info['type'] === 'plugin' ) {
			$single_file_plugin = false;
			if ( preg_match( '#^wp-content/plugins/([^/]+)/#', $normalized_relative_path, $m ) ) {
				$info['slug'] = $m[1];
			} elseif ( preg_match( '#^wp-content/plugins/([^/]+)\.php$#', $normalized_relative_path, $m ) ) {
				$info['slug'] = $m[1];
				$single_file_plugin = true;
			}
			if ( ! empty( $info['slug'] ) ) {
				if ( $fresh_component_versions ) {
					$info['version'] = self::resolve_plugin_version_from_disk( $info['slug'], $single_file_plugin );
				} else {
					self::ensure_plugin_versions_loaded();
					$info['version'] = self::$plugin_versions_by_slug[ $info['slug'] ] ?? '';
				}
			}
		} elseif ( $info['type'] === 'theme' ) {
			if ( preg_match( '#^wp-content/themes/([^/]+)/#', $normalized_relative_path, $m ) ) {
				$info['slug'] = $m[1];
			}
			if ( ! empty( $info['slug'] ) ) {
				if ( $fresh_component_versions ) {
					$info['version'] = self::resolve_theme_version_from_disk( $info['slug'] );
				} else {
					self::ensure_theme_versions_loaded();
					$info['version'] = self::$theme_versions_by_slug[ $info['slug'] ] ?? '';
				}
			}
		} elseif ( $info['type'] === 'core' ) {
			$info['slug']    = 'wordpress';
			global $wp_version;
			$info['version'] = $wp_version ?? '';
			$info['locale']  = function_exists( 'get_locale' ) ? (string) get_locale() : 'en_US';
		}

		$normalized_path = wp_normalize_path( $relative_path );
		if ( strpos( $normalized_path, wp_normalize_path( ABSPATH ) ) === 0 ) {
			$absolute_path = $normalized_path;
		} else {
			$absolute_path = wp_normalize_path( ABSPATH . ltrim( $normalized_path, '/' ) );
		}
		clearstatcache( true, $absolute_path );
		if ( @is_file( $absolute_path ) ) {
			$mtime = @filemtime( $absolute_path );
			if ( false !== $mtime && $mtime > 0 ) {
				$info['last_modified'] = (int) $mtime;
			}
		}

		return $info;
	}

	/**
	 * Compute hash, size, mtime, and component metadata with before/after stat validation.
	 *
	 * @return array{hash:string|false,size:int,mtime:int,stable:bool,info:array<string,mixed>}
	 */
	public function build_hash_snapshot( string $relative_path, ?string $absolute_path = null ): array {
		$last = [
			'hash'   => false,
			'size'   => 0,
			'mtime'  => 0,
			'stable' => false,
			'info'   => [],
		];
		$absolute_path = $absolute_path ?? ABSPATH . ltrim( wp_normalize_path( $relative_path ), '/' );

		for ( $attempt = 0; $attempt < 2; $attempt++ ) {
			clearstatcache( true, $absolute_path );
			if ( ! is_file( $absolute_path ) || ! is_readable( $absolute_path ) ) {
				return $last;
			}

			$mtime_before = @filemtime( $absolute_path );
			$size_before  = @filesize( $absolute_path );
			$info         = $this->get_file_info_for_hash_check( $relative_path, true );
			$hash_data    = self::hash_file_for_scan( $absolute_path );
			if ( null === $hash_data ) {
				return $last;
			}

			clearstatcache( true, $absolute_path );
			$mtime_after = @filemtime( $absolute_path );
			$size_after  = @filesize( $absolute_path );
			$last = [
				'hash'   => $hash_data['hash'],
				'size'   => $hash_data['size'],
				'mtime'  => false !== $mtime_after ? (int) $mtime_after : 0,
				'stable' => false !== $mtime_before
					&& false !== $mtime_after
					&& false !== $size_before
					&& false !== $size_after
					&& (int) $mtime_before === (int) $mtime_after
					&& (int) $size_before === (int) $size_after,
				'info'   => $info,
			];

			if ( ! empty( $last['stable'] ) ) {
				return $last;
			}
		}

		return $last;
	}

	/**
	 * @return array{hash:string,size:int}|null
	 */
	private static function hash_file_for_scan( string $absolute_path ): ?array {
		if ( basename( $absolute_path ) === 'wp-config.php' ) {
			$raw = @file_get_contents( $absolute_path );
			if ( false === $raw ) {
				return null;
			}
			$sanitized = self::sanitize_wp_config_content( $raw );
			return [
				'hash' => hash( 'sha256', $sanitized ),
				'size' => strlen( $sanitized ),
			];
		}

		$hash = hash_file( 'sha256', $absolute_path );
		if ( false === $hash ) {
			return null;
		}

		clearstatcache( true, $absolute_path );
		$size = @filesize( $absolute_path );
		return [
			'hash' => $hash,
			'size' => false !== $size ? (int) $size : 0,
		];
	}

	/**
	 * Return component metadata from a file-state row, falling back to live
	 * resolution only for rows created before snapshot fields existed.
	 *
	 * @param array<string,mixed> $file_state
	 * @return array{type: string, slug: string, version: string, locale: string, last_modified: int|null}
	 */
	public function get_file_info_from_state( array $file_state ): array {
		$relative_path = (string) ( $file_state['file_path'] ?? '' );
		$snapshot_mtime = isset( $file_state['file_mtime_at_hash'] ) && is_numeric( $file_state['file_mtime_at_hash'] )
			? (int) $file_state['file_mtime_at_hash']
			: 0;
		$has_hash_snapshot = $snapshot_mtime > 0;
		$info = [
			'type'          => 'other',
			'slug'          => '',
			'version'       => '',
			'locale'        => '',
			'last_modified' => null,
		];
		if ( ! $has_hash_snapshot && $relative_path !== '' ) {
			$info = $this->get_file_info_for_hash_check( $relative_path, true );
		}

		if ( ! empty( $file_state['file_type'] ) ) {
			$info['type'] = (string) $file_state['file_type'];
		}
		if ( array_key_exists( 'component_slug', $file_state ) && $file_state['component_slug'] !== null ) {
			$info['slug'] = (string) $file_state['component_slug'];
		}
		if ( array_key_exists( 'component_version', $file_state ) && $file_state['component_version'] !== null ) {
			$info['version'] = (string) $file_state['component_version'];
		}
		if ( array_key_exists( 'component_locale', $file_state ) && $file_state['component_locale'] !== null ) {
			$info['locale'] = (string) $file_state['component_locale'];
		}
		if ( $snapshot_mtime > 0 ) {
			$info['last_modified'] = $snapshot_mtime;
		}

		return $info;
	}

	/**
	 * @param array<string,mixed> $file_state
	 */
	public function file_state_snapshot_matches_current( array $file_state ): bool {
		$file_path = (string) ( $file_state['file_path'] ?? '' );
		if ( $file_path === '' ) {
			return false;
		}

		$current = $this->get_file_info_for_hash_check( $file_path, true );
		$snapshot_mtime = isset( $file_state['file_mtime_at_hash'] ) ? (int) $file_state['file_mtime_at_hash'] : 0;
		$current_mtime = isset( $current['last_modified'] ) && is_numeric( $current['last_modified'] ) ? (int) $current['last_modified'] : 0;
		if ( $snapshot_mtime <= 0 || $current_mtime <= 0 || $snapshot_mtime !== $current_mtime ) {
			return false;
		}

		return (string) ( $file_state['component_slug'] ?? '' ) === (string) ( $current['slug'] ?? '' )
			&& (string) ( $file_state['component_version'] ?? '' ) === (string) ( $current['version'] ?? '' )
			&& (string) ( $file_state['component_locale'] ?? '' ) === (string) ( $current['locale'] ?? '' );
	}
}
