<?php

declare(strict_types=1);

namespace SecurWP\Modules\Scanner\FileIntegrity;

use SecurWP\Includes\Capabilities;
use SecurWP\Modules\Scanner\Repository\FileStateRepository;
use SecurWP\Modules\Scanner\Repository\FileIndexRepository;
use SecurWP\Modules\Scanner\ScanTypes;
use SecurWP\Modules\Scanner\Service\ScanLogger;

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

					$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 );
			$this->detect_hardlinks( $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 ) {
			$content = preg_replace_callback(
				'/(\bdefine\s*\(\s*[\'"]' . preg_quote( $key, '/' ) . '[\'"]\s*,\s*)[\'"][^\'"]*[\'"]/i',
				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( 'securwp_file_index_complete_' . $scan_id, true, HOUR_IN_SECONDS );
	}

	private function clear_legacy_discovery_transients( string $scan_id ): void {
		delete_transient( 'securwp_discovery_complete_' . $scan_id );
		delete_transient( 'securwp_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( 'SecurWP\\Modules\\Scanner\\Repository\\IssueRepository' ) ) {
			require_once dirname( __DIR__ ) . '/Repository/IssueRepository.php';
		}
		if ( ! class_exists( 'SecurWP\\Modules\\Scanner\\Repository\\IssueRepository' ) ) {
			return;
		}
		\SecurWP\Modules\Scanner\Repository\IssueRepository::stage( [
			'scan_id'  => $scan_id,
			'hash'     => \SecurWP\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' ],
		] );
	}

	private function detect_hardlinks( string $scan_id ): void {
		$file_states = $this->repository->get_by_scan_id( $scan_id );
		if ( empty( $file_states ) ) {
			return;
		}

		$inode_map = [];
		$abs_root  = defined( 'ABSPATH' ) ? ABSPATH : '';

		foreach ( $file_states as $file_state ) {
			$relative_path = is_object( $file_state )
				? ( $file_state->file_path ?? '' )
				: ( $file_state['file_path'] ?? '' );
			if ( empty( $relative_path ) ) {
				continue;
			}

			$full_path = $abs_root . $relative_path;
			if ( ! @is_file( $full_path ) ) {
				continue;
			}

			$inode = @fileinode( $full_path );
			if ( $inode === false ) {
				continue;
			}

			if ( isset( $inode_map[ $inode ] ) ) {
				if ( class_exists( ScanLogger::class ) ) {
					ScanLogger::log(
						'Hardlink detected: "' . $relative_path . '" shares inode ' . $inode . ' with "' . $inode_map[ $inode ] . '"',
						2,
						'warning'
					);
				}
			} else {
				$inode_map[ $inode ] = $relative_path;
			}
		}
	}

	// =========================================================================
	// Metadata helpers used by CloudScanService
	// =========================================================================

	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 === '' ) {
				continue;
			}
			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' );
			}
		}
	}

	/**
	 * Return file metadata used by CloudScanService for hash checks.
	 *
	 * @param string $relative_path File path relative to ABSPATH.
	 * @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 ): 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' ) {
			if ( preg_match( '#^wp-content/plugins/([^/]+)/#', $normalized_relative_path, $m ) ) {
				$info['slug'] = $m[1];
			}
			if ( ! empty( $info['slug'] ) ) {
				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'] ) ) {
				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, '/' ) );
		}
		if ( @is_file( $absolute_path ) ) {
			$mtime = @filemtime( $absolute_path );
			if ( false !== $mtime && $mtime > 0 ) {
				$info['last_modified'] = (int) $mtime;
			}
		}

		return $info;
	}
}
