<?php

declare(strict_types=1);

namespace SiteFort\Modules\Firewall;

use SiteFort\Includes\IpDetector;
use SiteFort\Includes\IpProbe;
use SiteFort\Includes\ProxyPresets;
use SiteFort\Includes\RequestProvenance;
use SiteFort\Includes\SecretManager;
use SiteFort\Includes\SettingsStore;
use SiteFort\Modules\Cloudflare\Manager as CloudflareManager;
use SiteFort\Modules\Firewall\GeoIP\GeoIPService;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

final class FirewallSettings {
	private const GEOIP_ENABLE_ERROR = 'Country blocking requires Cloudflare edge enforcement, verified Cloudflare origin headers, or a local MaxMind GeoIP database.';
	private const GEOIP_LICENSE_SECRET = 'geoip_license_key';
	private const SANITIZE_CACHE_LIMIT = 16;
	private const MULTILINE_TEXT_KEYS = [
		'banned_ips',
		'banned_user_agents',
		'trusted_user_agents',
		'allowlist',
		'trusted_proxies',
		'geoip_countries',
	];

	/** @var array<string, array<string, mixed>> */
	private static array $sanitize_cache = [];

	/** @var string|null Memoized decrypted GeoIP license key for this request. */
	private static ?string $geoip_secret_cache = null;

	/** @var bool Whether the GeoIP secret has been read this request. */
	private static bool $geoip_secret_loaded = false;

	/** @var string|null Table-prefix context for the GeoIP secret cache (multisite). */
	private static ?string $geoip_secret_prefix = null;

	public static function defaults(): array {
		return [
			'enabled' => true,
			'origin_enforcement_mode' => FirewallConfigService::MODE_PENDING_VALIDATION,
			'banned_ips' => '',
			'banned_user_agents' => '',
			'trusted_user_agents' => '',
			'allowlist' => '',
			'bot_policy_enabled' => true,
			'bot_policy_profile' => RuleEngine::PROFILE_BALANCED,
			'block_ai_training_crawlers' => false,
			'proxy_detection' => 'automatic',
			'proxy_header' => '',
			'trusted_proxy_preset' => 'none',
			'trusted_proxies' => '',
			'lockout_message' => 'Your IP address has been blocked due to suspicious activity.',
			'ban_duration' => 7,
			'rate_limit_enabled' => false,
			'rate_limit_requests' => 60,
			'rate_limit_404' => 10,
			'blocklist_enabled' => true,
			'sensitive_path_enabled' => true,
			'sensitive_path_threshold' => 3,
			'sensitive_path_window' => 600,
			'geoip_enabled' => false,
			'geoip_mode' => 'block_list',
			'geoip_countries' => '',
			'geoip_account_id' => '',
			'geoip_license_key' => '',
		];
	}

	public static function get(): array {
		return self::sanitize( self::with_runtime_secrets( SettingsStore::get( SettingsStore::COL_FIREWALL, self::defaults() ) ) );
	}

	public static function merge_defaults( array $settings ): array {
		return array_replace( self::defaults(), $settings );
	}

	/**
	 * Add encrypted runtime-only secrets to firewall settings loaded from storage.
	 *
	 * @param array<string,mixed> $settings
	 * @return array<string,mixed>
	 */
	public static function with_runtime_secrets( array $settings ): array {
		$secret = self::read_geoip_license_key();
		if ( '' !== $secret ) {
			$settings['geoip_license_key'] = $secret;
		}

		return $settings;
	}

	/**
	 * Persist runtime-only secrets before the non-secret settings payload is saved.
	 *
	 * @param array<string,mixed> $settings
	 */
	public static function persist_runtime_secrets( array $settings ): bool {
		if ( ! array_key_exists( 'geoip_license_key', $settings ) ) {
			return true;
		}

		$license_key = self::sanitize_text_patch_value( 'geoip_license_key', $settings['geoip_license_key'] );
		if ( '' === trim( $license_key ) || self::is_masked_secret_value( $license_key ) ) {
			return true;
		}

		return self::store_geoip_license_key( $license_key );
	}

	/**
	 * Remove runtime-only secret material before writing the firewall settings row.
	 *
	 * @param array<string,mixed> $settings
	 * @return array<string,mixed>
	 */
	public static function prepare_for_storage( array $settings ): array {
		unset( $settings['geoip_license_key'] );
		return $settings;
	}

	public static function read_geoip_license_key(): string {
		// Memoize per request; re-validate on a blog-prefix change so multisite
		// never bleeds secrets.
		global $wpdb;
		$prefix = isset( $wpdb->prefix ) ? (string) $wpdb->prefix : '';
		if ( self::$geoip_secret_prefix !== $prefix ) {
			self::$geoip_secret_prefix = $prefix;
			self::$geoip_secret_loaded = false;
			self::$geoip_secret_cache  = null;
		}

		if ( self::$geoip_secret_loaded ) {
			return self::$geoip_secret_cache ?? '';
		}

		$secret = SecretManager::get( self::GEOIP_LICENSE_SECRET );
		self::$geoip_secret_cache  = is_string( $secret ) ? $secret : '';
		self::$geoip_secret_loaded = true;

		return self::$geoip_secret_cache;
	}

	public static function store_geoip_license_key( string $license_key ): bool {
		$license_key = self::sanitize_text_patch_value( 'geoip_license_key', $license_key );
		if ( '' === trim( $license_key ) ) {
			SecretManager::delete( self::GEOIP_LICENSE_SECRET );
			SettingsStore::delete_field( SettingsStore::COL_FIREWALL, 'geoip_license_key' );
			self::prime_geoip_secret_cache( '' );
			return true;
		}

		if ( ! SecretManager::set( self::GEOIP_LICENSE_SECRET, $license_key ) ) {
			return false;
		}

		SettingsStore::delete_field( SettingsStore::COL_FIREWALL, 'geoip_license_key' );
		self::prime_geoip_secret_cache( $license_key );
		return true;
	}

	/**
	 * Keep the request-local GeoIP secret cache in sync after a write, so a save
	 * in the same request does not return a stale value on the next read.
	 *
	 * @param string $value Plaintext secret just persisted (or '' when cleared).
	 * @return void
	 */
	private static function prime_geoip_secret_cache( string $value ): void {
		global $wpdb;
		self::$geoip_secret_prefix = isset( $wpdb->prefix ) ? (string) $wpdb->prefix : '';
		self::$geoip_secret_cache  = $value;
		self::$geoip_secret_loaded = true;
	}

	public static function apply_patch( array $current, array $patch, array $locked_keys = [] ): array {
		$current = self::merge_defaults( $current );
		$defaults = self::defaults();
		$patch = self::preserve_credential_fields( $current, $patch );

		foreach ( $defaults as $key => $default ) {
			if ( in_array( $key, $locked_keys, true ) || ! array_key_exists( $key, $patch ) ) {
				continue;
			}

			$value = $patch[ $key ];
			if ( is_bool( $default ) ) {
				$current[ $key ] = (bool) $value;
				continue;
			}

			if ( is_int( $default ) ) {
				$current[ $key ] = (int) $value;
				continue;
			}

			if ( is_float( $default ) ) {
				$current[ $key ] = (float) $value;
				continue;
			}

			$current[ $key ] = self::sanitize_text_patch_value( $key, $value );
		}

		return self::sanitize( $current );
	}

	/**
	 * Preserve stored GeoIP credentials when a generic settings writer sends
	 * back blank or masked placeholders.
	 *
	 * @param array<string,mixed> $current
	 * @param array<string,mixed> $patch
	 * @return array<string,mixed>
	 */
	private static function preserve_credential_fields( array $current, array $patch ): array {
		if ( array_key_exists( 'geoip_account_id', $patch ) ) {
			$incoming_account_id = self::sanitize_text_patch_value( 'geoip_account_id', $patch['geoip_account_id'] );
			if ( '' === trim( $incoming_account_id ) && ! empty( $current['geoip_account_id'] ) ) {
				unset( $patch['geoip_account_id'] );
			} else {
				$patch['geoip_account_id'] = $incoming_account_id;
			}
		}

		if ( array_key_exists( 'geoip_license_key', $patch ) ) {
			$incoming_license_key = self::sanitize_text_patch_value( 'geoip_license_key', $patch['geoip_license_key'] );
			if ( self::should_preserve_geoip_license_key( $incoming_license_key, $current ) ) {
				unset( $patch['geoip_license_key'] );
			} else {
				$patch['geoip_license_key'] = $incoming_license_key;
			}
		}

		return $patch;
	}

	private static function should_preserve_geoip_license_key( string $incoming, array $current ): bool {
		$incoming = trim( $incoming );

		if ( self::is_masked_secret_value( $incoming ) ) {
			return true;
		}

		return '' === $incoming && ! empty( $current['geoip_license_key'] );
	}

	private static function is_masked_secret_value( string $value ): bool {
		$value = trim( $value );
		if ( '' === $value ) {
			return false;
		}

		return preg_match( '/^\*{3,}[A-Za-z0-9]{2,}$/', $value ) === 1;
	}

	private static function sanitize_text_patch_value( string $key, $value ): string {
		if ( is_array( $value ) ) {
			if ( in_array( $key, self::MULTILINE_TEXT_KEYS, true ) ) {
				$items = [];
				foreach ( $value as $entry ) {
					if ( is_array( $entry ) || is_object( $entry ) ) {
						continue;
					}

					$clean = sanitize_text_field( (string) $entry );
					if ( '' !== trim( $clean ) ) {
						$items[] = $clean;
					}
				}

				return implode( "\n", $items );
			}

			return '';
		}

		if ( is_object( $value ) ) {
			return '';
		}

		return sanitize_textarea_field( (string) $value );
	}

	public static function sanitize( array $settings ): array {
		$defaults = self::defaults();
		$settings = self::merge_defaults( $settings );
		$cache_key = self::build_sanitize_cache_key( $settings );
		if ( isset( self::$sanitize_cache[ $cache_key ] ) ) {
			return self::$sanitize_cache[ $cache_key ];
		}

		$sanitized = [];

		foreach ( $defaults as $key => $default ) {
			$value = $settings[ $key ] ?? $default;
			if ( is_bool( $default ) ) {
				$sanitized[ $key ] = (bool) $value;
				continue;
			}

			if ( is_int( $default ) ) {
				$sanitized[ $key ] = (int) $value;
				continue;
			}

			if ( is_float( $default ) ) {
				$sanitized[ $key ] = (float) $value;
				continue;
			}

			$sanitized[ $key ] = sanitize_textarea_field( (string) $value );
		}

		$allowed_proxy_modes = [ 'automatic', 'manual', 'disabled' ];
		if ( ! in_array( $sanitized['proxy_detection'], $allowed_proxy_modes, true ) ) {
			$sanitized['proxy_detection'] = 'automatic';
		}

		$allowed_headers = IpDetector::get_proxy_headers();
		if ( ! in_array( $sanitized['proxy_header'], $allowed_headers, true ) ) {
			$sanitized['proxy_header'] = '';
		}
		if ( $sanitized['proxy_detection'] !== 'manual' ) {
			$sanitized['proxy_header'] = '';
		}

		if ( ! ProxyPresets::is_valid( $sanitized['trusted_proxy_preset'] ) ) {
			$sanitized['trusted_proxy_preset'] = 'none';
		}

		$trusted_proxies = RuleEngine::normalize_ip_entries( $sanitized['trusted_proxies'], false );
		$trusted_proxies = array_values( array_filter( $trusted_proxies, [ self::class, 'is_safe_trusted_proxy' ] ) );
		$blocked_ips = RuleEngine::normalize_ip_entries( $sanitized['banned_ips'], true );
		$allowlisted_ips = RuleEngine::normalize_ip_entries( $sanitized['allowlist'], true );
		$blocked_user_agents = RuleEngine::normalize_user_agent_patterns( $sanitized['banned_user_agents'] );
		$trusted_user_agents = RuleEngine::normalize_user_agent_patterns( $sanitized['trusted_user_agents'] );
		$country_codes = RuleEngine::normalize_country_codes( $sanitized['geoip_countries'] );

		$sanitized['trusted_proxies'] = implode( "\n", $trusted_proxies );
		$sanitized['banned_ips'] = implode( "\n", $blocked_ips );
		$sanitized['allowlist'] = implode( "\n", $allowlisted_ips );
		$sanitized['banned_user_agents'] = implode( "\n", $blocked_user_agents );
		$sanitized['trusted_user_agents'] = implode( "\n", $trusted_user_agents );
		$sanitized['geoip_countries'] = implode( "\n", $country_codes );

		$sanitized['rate_limit_requests'] = max( 10, min( 300, $sanitized['rate_limit_requests'] ) );
		$sanitized['rate_limit_404'] = max( 0, min( 100, $sanitized['rate_limit_404'] ) );
		$sanitized['ban_duration'] = max( 1, min( 365, $sanitized['ban_duration'] ) );
		$sanitized['sensitive_path_threshold'] = max( 1, min( 20, $sanitized['sensitive_path_threshold'] ) );
		$sanitized['sensitive_path_window'] = max( 60, min( 3600, $sanitized['sensitive_path_window'] ) );
		$sanitized['bot_policy_profile'] = RuleEngine::sanitize_profile( $sanitized['bot_policy_profile'] );

		if ( ! in_array( $sanitized['geoip_mode'], [ 'block_list', 'allow_list' ], true ) ) {
			$sanitized['geoip_mode'] = 'block_list';
		}

		$sanitized['origin_enforcement_mode'] = FirewallConfigService::normalize_origin_enforcement_mode(
			$sanitized['origin_enforcement_mode'] ?? '',
			! empty( $sanitized['enabled'] )
		);

		self::assert_no_conflicts( $allowlisted_ips, $blocked_ips, $trusted_user_agents, $blocked_user_agents );
		self::remember_sanitized_settings( $cache_key, $sanitized );

		return $sanitized;
	}

	/**
	 * Reject catch-all proxy ranges (0.0.0.0/0, ::/0) that would let any client
	 * spoof its address through a forwarded header.
	 */
	private static function is_safe_trusted_proxy( string $entry ): bool {
		$slash = strpos( $entry, '/' );
		if ( false === $slash ) {
			return true;
		}

		return '0' !== trim( substr( $entry, $slash + 1 ) );
	}

	public static function compile_policy_from_sanitized( array $settings, array $overrides = [] ): array {
		$allowlisted_ips = $overrides['allowlisted_ips'] ?? RuleEngine::normalize_ip_entries( $settings['allowlist'], true );
		$blocked_ips = $overrides['blocked_ips'] ?? RuleEngine::normalize_ip_entries( $settings['banned_ips'], true );
		$safe_service_ranges = $overrides['safe_service_ranges'] ?? \SiteFort\Modules\Firewall\SafeNetwork\SafeNetworkRanges::get_custom_safe_ranges();

		return [
			'allowlisted_ips' => array_values( array_unique( array_map( 'strval', $allowlisted_ips ) ) ),
			'safe_service_ranges' => array_values( array_unique( array_map( 'strval', $safe_service_ranges ) ) ),
			'trusted_user_agents' => RuleEngine::normalize_user_agent_patterns( $settings['trusted_user_agents'] ),
			'blocked_ips' => array_values( array_unique( array_map( 'strval', $blocked_ips ) ) ),
			'blocked_user_agents' => RuleEngine::normalize_user_agent_patterns( $settings['banned_user_agents'] ),
			'bot_policy_enabled' => ! empty( $settings['bot_policy_enabled'] ),
			'bot_policy_profile' => RuleEngine::sanitize_profile( (string) $settings['bot_policy_profile'] ),
			'block_ai_training_crawlers' => ! empty( $settings['block_ai_training_crawlers'] ),
			'rate_limit_enabled' => ! empty( $settings['rate_limit_enabled'] ),
			'country_mode' => ! empty( $settings['geoip_enabled'] )
				? ( $settings['geoip_mode'] === 'allow_list' ? 'allow' : 'block' )
				: 'off',
			'country_codes' => RuleEngine::normalize_country_codes( $settings['geoip_countries'] ),
		];
	}

	public static function assert_supported_configuration( array $settings ): void {
		$settings = self::sanitize( $settings );
		if ( ! empty( $settings['geoip_enabled'] ) && ! self::can_enable_geoip_enforcement() ) {
			throw new \InvalidArgumentException( esc_html( self::GEOIP_ENABLE_ERROR ) );
		}
	}

	public static function get_geoip_provider_status(): array {
		$service = new GeoIPService();
		$local_provider_ready = $service->has_usable_mmdb();
		$cloudflare_settings = ( new CloudflareManager() )->get_admin_settings();
		$cloudflare_edge_rule_ready = ! empty( $cloudflare_settings['enabled'] )
			&& ! empty( $cloudflare_settings['configured'] )
			&& ! empty( $cloudflare_settings['connected'] );
		$provenance = RequestProvenance::resolve_current_request( null, [
			'cloudflare_ranges' => ProxyPresets::get_ranges( ProxyPresets::PRESET_CLOUDFLARE ),
		] );
		$cloudflare_origin_header_ready = ! empty( $provenance['cloudflare_peer_verified'] )
			&& ! empty( $provenance['cloudflare_country_valid'] );
		$origin_fallback_ready = $local_provider_ready || $cloudflare_origin_header_ready;
		$can_enable_country_blocking = $origin_fallback_ready || $cloudflare_edge_rule_ready;
		$origin_restored_diagnostic = false;

		$probe = IpProbe::get_recommendation();
		if (
			! $cloudflare_origin_header_ready
			&& ! empty( $provenance['cloudflare_header_present'] )
			&& is_array( $probe )
			&& IpProbe::is_fresh()
			&& ( $probe['status'] ?? '' ) === 'proxy'
			&& ! empty( $probe['origin_restored'] )
			&& ( $probe['header'] ?? '' ) === 'HTTP_CF_CONNECTING_IP'
		) {
			$origin_restored_diagnostic = true;
		}

		return [
			'local_provider_ready' => $local_provider_ready,
			'cloudflare_edge_rule_ready' => $cloudflare_edge_rule_ready,
			'cloudflare_origin_header_ready' => $cloudflare_origin_header_ready,
			'origin_fallback_ready' => $origin_fallback_ready,
			'cloudflare_detected' => ! empty( $provenance['cloudflare_detected'] ),
			'server_restored_remote_addr' => ! empty( $provenance['server_restored_remote_addr'] ),
			'origin_restored_diagnostic' => $origin_restored_diagnostic,
			'provenance_failure_reason' => sanitize_key( (string) ( $provenance['failure_reason'] ?? '' ) ),
			'can_enable_country_blocking' => $can_enable_country_blocking,
		];
	}

	public static function can_enable_geoip_enforcement(): bool {
		$status = self::get_geoip_provider_status();

		return ! empty( $status['can_enable_country_blocking'] );
	}

	public static function redact_sensitive_keys( array $settings, bool $mask = true ): array {
		if ( ! empty( $settings['geoip_license_key'] ) ) {
			if ( $mask ) {
				$key_len = strlen( (string) $settings['geoip_license_key'] );
				$settings['geoip_license_key'] = $key_len > 4 ? '***' . substr( (string) $settings['geoip_license_key'], -4 ) : '***';
			} else {
				unset( $settings['geoip_license_key'] );
			}
		}

		return $settings;
	}

	/**
	 * @param array<int, string> $allowlisted_ips
	 * @param array<int, string> $blocked_ips
	 * @param array<int, string> $trusted_user_agents
	 * @param array<int, string> $blocked_user_agents
	 */
	private static function assert_no_conflicts(
		array $allowlisted_ips,
		array $blocked_ips,
		array $trusted_user_agents,
		array $blocked_user_agents
	): void {
		$ip_conflicts = [];
		foreach ( $allowlisted_ips as $allowlisted_ip ) {
			foreach ( $blocked_ips as $blocked_ip ) {
				if ( RuleEngine::ip_entries_overlap( (string) $allowlisted_ip, (string) $blocked_ip ) ) {
					$ip_conflicts[] = sprintf( '%s <-> %s', $allowlisted_ip, $blocked_ip );
				}
			}
		}
		if ( ! empty( $ip_conflicts ) ) {
			throw new \InvalidArgumentException(
				'The same IP or network cannot exist in both the allowlist and the block list.'
			);
		}

		$ua_conflicts = [];
		foreach ( $trusted_user_agents as $trusted_user_agent ) {
			foreach ( $blocked_user_agents as $blocked_user_agent ) {
				if ( RuleEngine::user_agent_patterns_overlap( (string) $trusted_user_agent, (string) $blocked_user_agent ) ) {
					$ua_conflicts[] = sprintf( '%s <-> %s', $trusted_user_agent, $blocked_user_agent );
				}
			}
		}
		if ( ! empty( $ua_conflicts ) ) {
			throw new \InvalidArgumentException(
				'The same user-agent pattern cannot exist in both trusted and blocked rules.'
			);
		}
	}

	/**
	 * @param array<string, mixed> $settings
	 */
	private static function build_sanitize_cache_key( array $settings ): string {
		$encoded = wp_json_encode( $settings, JSON_UNESCAPED_SLASHES );
		if ( ! is_string( $encoded ) ) {
			$encoded = serialize( $settings );
		}

		return hash( 'sha256', $encoded );
	}

	/**
	 * @param array<string, mixed> $sanitized
	 */
	private static function remember_sanitized_settings( string $cache_key, array $sanitized ): void {
		if ( count( self::$sanitize_cache ) >= self::SANITIZE_CACHE_LIMIT ) {
			array_shift( self::$sanitize_cache );
		}

		self::$sanitize_cache[ $cache_key ] = $sanitized;
	}
}
