<?php

declare(strict_types=1);

namespace SecurWP\Modules\Firewall;

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

use SecurWP\Includes\IpDetector;
use SecurWP\Includes\SecurWPKeyManager;
use SecurWP\Modules\Hardening\Hardening;

/**
 * Centralized firewall bypass logic.
 *
 * Determines whether the current request should skip firewall checks entirely.
 * This prevents admin lockouts, false positives on internal WordPress requests,
 * and provides emergency recovery mechanisms.
 *
 * Bypass reasons (checked in priority order):
 *   1. SECURWP_DISABLE_FIREWALL constant (emergency kill switch).
 *   2. WP-CLI requests (command line administration).
 *   3. WordPress cron jobs (scheduled tasks).
 *   4. Server loopback IPs (internal health checks, REST callbacks).
 *   5. Infrastructure IPs (IntelEngine, Console backend).
 *   6. Temporarily whitelisted admin IPs for recovery/admin control paths only.
 *
 */
class FirewallBypass {

	/**
	 * Infrastructure IPs that always bypass the firewall.
	 * Override via SECURWP_INFRA_IPS constant in wp-config.php.
	 */
	private const DEFAULT_INFRA_IPS = [
		'3.225.249.159', // IntelEngine
		'3.213.191.47',  // Console backend
	];

	/**
	 * WordPress option key for storing temporarily whitelisted admin IPs.
	 *
	 * Stored in wp_options (not transients) for persistence across
	 * external object cache evictions.
	 */
	private const ADMIN_WHITELIST_OPTION = 'securwp_admin_ip_whitelist';

	/**
	 * How long admin IPs stay whitelisted (24 hours in seconds).
	 */
	private const ADMIN_WHITELIST_TTL = DAY_IN_SECONDS;

	/**
	 * Refresh the whitelist when less than 1 hour remains.
	 * Prevents unnecessary DB writes on every admin page load.
	 */
	private const ADMIN_WHITELIST_REFRESH_THRESHOLD = HOUR_IN_SECONDS;
	private const SERVER_VERIFY_NONCE_PREFIX = 'securwp_server_verify_nonce_';
	private const SERVER_VERIFY_WINDOW = 120;

	/**
	 * Per-request cache of the bypass decision.
	 *
	 * @var bool|null
	 */
	private static ?bool $bypass_cache = null;
	private static ?bool $server_verify_cache = null;

	/**
	 * Check whether the current request should bypass all firewall checks.
	 *
	 * @return bool True if the firewall should be skipped entirely.
	 */
	public static function should_bypass(): bool {
		if ( self::$bypass_cache !== null ) {
			return self::$bypass_cache;
		}

		// 1. Emergency kill switch via wp-config.php constant.
		if ( defined( 'SECURWP_DISABLE_FIREWALL' ) && SECURWP_DISABLE_FIREWALL ) {
			return self::cache_result( true );
		}

		// 2. WP-CLI requests should never be blocked.
		if ( defined( 'WP_CLI' ) && WP_CLI ) {
			return self::cache_result( true );
		}

		// 3. WordPress cron jobs should not be blocked.
		if ( self::is_cron_request() ) {
			return self::cache_result( true );
		}

		// 4. Signed internal verification requests should not be blocked by the firewall.
		if ( self::is_signed_server_verification_request() ) {
			return self::cache_result( true );
		}

		// 5. Server loopback and local IPs.
		$ip = IpDetector::get_ip();
		if ( self::is_server_ip( $ip ) ) {
			return self::cache_result( true );
		}

		// 6. Infrastructure IPs (IntelEngine, Console backend).
		if ( self::is_infrastructure_ip( $ip ) ) {
			return self::cache_result( true );
		}

		// 7. Temporarily whitelisted admin IPs for admin recovery requests only.
		if ( self::is_admin_whitelisted( $ip ) && self::is_admin_recovery_request() ) {
			return self::cache_result( true );
		}

		return self::cache_result( false );
	}

	/**
	 * Check whether the current request is unsuitable for origin-validation writes.
	 *
	 * This is stricter than the normal bypass check: admin recovery requests are
	 * still allowed to validate and heal the effective firewall mode, but loopback,
	 * cron, CLI, and infrastructure traffic must never be allowed to poison the
	 * persisted validation state.
	 */
	public static function should_skip_origin_validation(): bool {
		if ( defined( 'SECURWP_DISABLE_FIREWALL' ) && SECURWP_DISABLE_FIREWALL ) {
			return true;
		}

		if ( defined( 'WP_CLI' ) && WP_CLI ) {
			return true;
		}

		if ( self::is_cron_request() ) {
			return true;
		}

		if ( self::is_signed_server_verification_request() ) {
			return true;
		}

		$ip = IpDetector::get_server_value( 'REMOTE_ADDR' );
		if ( self::is_server_ip( $ip ) ) {
			return true;
		}

		return self::is_infrastructure_ip( $ip );
	}

	/**
	 * Refresh the admin IP whitelist.
	 *
	 * Called on admin page loads (admin_init hook) to keep the current
	 * admin's IP in the whitelist. Uses a smart refresh: only writes to
	 * the database when the existing entry is about to expire (< 1 hour).
	 *
	 * This is the primary admin lockout prevention mechanism. Even if
	 * firewall rules are misconfigured, the admin can still access the site
	 * from recently-used IPs.
	 */
	public static function refresh_admin_whitelist(): void {
		// Only for admin users on admin pages.
		if ( ! \SecurWP\Includes\Capabilities::can_manage_plugin() ) {
			return;
		}

		$ip = IpDetector::get_ip();
		if ( '' === $ip ) {
			return;
		}

		$whitelist = self::get_admin_whitelist();
		$now = time();

		// Check if this IP already has a fresh entry.
		if ( isset( $whitelist[ $ip ] ) ) {
			$remaining = ( $whitelist[ $ip ] + self::ADMIN_WHITELIST_TTL ) - $now;
			if ( $remaining > self::ADMIN_WHITELIST_REFRESH_THRESHOLD ) {
				// Still fresh, no DB write needed.
				return;
			}
		}

		// Add or refresh this IP.
		$whitelist[ $ip ] = $now;

		// Purge expired entries while we are here.
		$whitelist = self::purge_expired( $whitelist, $now );

		update_option( self::ADMIN_WHITELIST_OPTION, $whitelist, false );
	}

	/**
	 * Return the infrastructure IP whitelist.
	 *
	 * @return string[]
	 */
	public static function get_infrastructure_ips(): array {
		return defined( 'SECURWP_INFRA_IPS' )
			? (array) SECURWP_INFRA_IPS
			: self::DEFAULT_INFRA_IPS;
	}

	/**
	 * Reset per-request caches. Used in testing.
	 */
	public static function reset_cache(): void {
		self::$bypass_cache = null;
		self::$server_verify_cache = null;
	}

	// ------------------------------------------------------------------
	// Internal checks
	// ------------------------------------------------------------------

	/**
	 * Check if the current request is a WordPress cron job.
	 */
	private static function is_cron_request(): bool {
		if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
			return true;
		}
		if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
			return true;
		}
		return false;
	}

	/**
	 * Verify a signed internal server-verification probe request.
	 *
	 * The signature binds the method, path, query string, nonce, and timestamp to
	 * a secret derived from the site master key. A short-lived nonce transient
	 * prevents replay within the validity window.
	 */
	private static function is_signed_server_verification_request(): bool {
		if ( self::$server_verify_cache !== null ) {
			return self::$server_verify_cache;
		}

		$timestamp = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_SECURWP_VERIFY_TIMESTAMP'] ?? '' ) );
		$nonce     = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_SECURWP_VERIFY_NONCE'] ?? '' ) );
		$signature = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_SECURWP_VERIFY_SIGNATURE'] ?? '' ) );
		$version   = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_SECURWP_VERIFY_VERSION'] ?? '' ) );

		if ( $timestamp === '' || $nonce === '' || $signature === '' || $version !== '1' ) {
			return self::cache_server_verification_result( false );
		}

		if ( ! ctype_digit( $timestamp ) || strlen( $nonce ) > 128 || strlen( $signature ) > 128 ) {
			return self::cache_server_verification_result( false );
		}

		$ts = (int) $timestamp;
		if ( abs( time() - $ts ) > self::SERVER_VERIFY_WINDOW ) {
			return self::cache_server_verification_result( false );
		}

		$request_uri = IpDetector::get_server_value( 'REQUEST_URI' );
		$path        = (string) ( wp_parse_url( $request_uri, PHP_URL_PATH ) ?: '/' );
		$query       = (string) ( wp_parse_url( $request_uri, PHP_URL_QUERY ) ?: '' );
		$method      = strtoupper( sanitize_text_field( wp_unslash( (string) ( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) ) );
		$expected    = SecurWPKeyManager::build_server_verification_signature( $method, $path, $query, $nonce, $ts );

		if ( $expected === '' || ! hash_equals( $expected, $signature ) ) {
			return self::cache_server_verification_result( false );
		}

		if ( ! self::mark_server_verification_nonce_used( $nonce, $signature, $ts ) ) {
			return self::cache_server_verification_result( false );
		}

		return self::cache_server_verification_result( true );
	}

	/**
	 * Mark a signed server-verification nonce as used for the validity window.
	 */
	private static function mark_server_verification_nonce_used( string $nonce, string $signature, int $timestamp ): bool {
		$key = self::SERVER_VERIFY_NONCE_PREFIX . hash( 'sha256', $nonce . '|' . $signature . '|' . $timestamp );
		if ( get_transient( $key ) ) {
			return false;
		}

		set_transient( $key, 1, self::SERVER_VERIFY_WINDOW );
		return true;
	}

	/**
	 * Check if an IP is a server/loopback address.
	 *
	 * Includes 127.0.0.1, ::1, and the server's own IP addresses.
	 * This prevents the server from blocking itself during internal
	 * HTTP requests (WP-Cron loopback, REST API health checks, etc.).
	 */
	private static function is_server_ip( string $ip ): bool {
		if ( '' === $ip ) {
			return false;
		}

		// Standard loopback addresses.
		$loopback = [ '127.0.0.1', '::1' ];
		if ( in_array( $ip, $loopback, true ) ) {
			return true;
		}

		// Server's own IP addresses.
		$server_ips = [];
		$server_addr = IpDetector::get_server_value( 'SERVER_ADDR' );
		if ( '' !== $server_addr ) {
			$server_ips[] = $server_addr;
		}
		$local_addr = IpDetector::get_server_value( 'LOCAL_ADDR' );
		if ( '' !== $local_addr ) {
			$server_ips[] = $local_addr;
		}

		return in_array( $ip, $server_ips, true );
	}

	/**
	 * Check if an IP is in the admin auto-whitelist.
	 */
	private static function is_admin_whitelisted( string $ip ): bool {
		if ( '' === $ip ) {
			return false;
		}

		$whitelist = self::get_admin_whitelist();
		if ( ! isset( $whitelist[ $ip ] ) ) {
			return false;
		}

		$added_at = (int) $whitelist[ $ip ];
		$now = time();

		// Check if the entry has expired.
		return ( $now - $added_at ) < self::ADMIN_WHITELIST_TTL;
	}

	/**
	 * Restrict admin-IP bypass to real recovery/control-plane requests.
	 *
	 * This prevents a recently used admin IP from skipping the firewall on
	 * unrelated public traffic for the next 24 hours.
	 */
	private static function is_admin_recovery_request(): bool {
		$path = self::get_request_path();
		if ( $path === '' ) {
			return false;
		}

		$login_paths = [
			'wp-login.php',
			'wp-signup.php',
			'wp-register.php',
			'wp-activate.php',
		];

		if ( in_array( $path, $login_paths, true ) ) {
			return true;
		}

		$settings      = Hardening::get_saved_settings();
		$login_slug    = sanitize_title( (string) ( $settings['rename_login_url_slug'] ?? '' ) );
		$register_slug = self::normalize_register_slug_setting( (string) ( $settings['rename_login_url_register_slug'] ?? '' ) );

		if ( $login_slug !== '' && $path === $login_slug ) {
			return true;
		}

		if ( $register_slug !== '' && $register_slug !== 'wp-signup.php' && $path === $register_slug ) {
			return true;
		}

		if ( $path === 'wp-admin' || str_starts_with( $path, 'wp-admin/' ) ) {
			if ( $path === 'wp-admin/admin-ajax.php' || $path === 'wp-admin/admin-post.php' ) {
				return self::is_authenticated_admin_request();
			}

			return true;
		}

		$rest_prefix = function_exists( 'rest_get_url_prefix' ) ? trim( (string) rest_get_url_prefix(), '/' ) : 'wp-json';
		if ( $rest_prefix !== '' ) {
			$securwp_rest_prefix = $rest_prefix . '/securwp/v1';
			if ( $path === $securwp_rest_prefix || str_starts_with( $path, $securwp_rest_prefix . '/' ) ) {
				return self::is_authenticated_admin_request();
			}
		}

		return false;
	}

	/**
	 * Normalize the current request path relative to the site base path.
	 */
	private static function get_request_path(): string {
		$request_uri = IpDetector::get_server_value( 'REQUEST_URI' );
		if ( $request_uri === '' ) {
			return '';
		}

		$path = wp_parse_url( $request_uri, PHP_URL_PATH );
		if ( ! is_string( $path ) || $path === '' ) {
			return '';
		}

		$normalized = trim( rawurldecode( $path ), '/' );
		if ( $normalized === '' ) {
			return '';
		}

		$site_base = trim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' );
		if ( $site_base !== '' ) {
			if ( $normalized === $site_base ) {
				return '';
			}

			$prefix = $site_base . '/';
			if ( str_starts_with( $normalized, $prefix ) ) {
				$normalized = substr( $normalized, strlen( $prefix ) );
			}
		}

		return trim( $normalized, '/' );
	}

	/**
	 * Restrict AJAX/REST admin bypasses to authenticated administrators.
	 */
	private static function is_authenticated_admin_request(): bool {
		return function_exists( 'is_user_logged_in' )
			&& is_user_logged_in()
			&& \SecurWP\Includes\Capabilities::can_manage_plugin();
	}

	private static function normalize_register_slug_setting( string $value ): string {
		$slug = trim( $value );
		if ( $slug === '' || strtolower( $slug ) === 'wp-signup.php' ) {
			return 'wp-signup.php';
		}

		return sanitize_title( $slug );
	}

	/**
	 * Load the admin IP whitelist from wp_options.
	 *
	 * @return array<string, int> Map of IP => timestamp.
	 */
	private static function get_admin_whitelist(): array {
		$whitelist = get_option( self::ADMIN_WHITELIST_OPTION, [] );
		if ( ! is_array( $whitelist ) ) {
			return [];
		}
		return $whitelist;
	}

	/**
	 * Remove expired entries from the whitelist.
	 *
	 * @param array<string, int> $whitelist Current whitelist.
	 * @param int                $now       Current timestamp.
	 * @return array<string, int> Cleaned whitelist.
	 */
	private static function purge_expired( array $whitelist, int $now ): array {
		return array_filter( $whitelist, function ( int $added_at ) use ( $now ): bool {
			return ( $now - $added_at ) < self::ADMIN_WHITELIST_TTL;
		} );
	}

	/**
	 * Check if an IP belongs to SecurWP infrastructure.
	 */
	private static function is_infrastructure_ip( string $ip ): bool {
		if ( '' === $ip ) {
			return false;
		}
		return in_array( $ip, self::get_infrastructure_ips(), true );
	}

	/**
	 * Cache and return a bypass result.
	 */
	private static function cache_result( bool $bypass ): bool {
		self::$bypass_cache = $bypass;
		return $bypass;
	}

	/**
	 * Cache and return the signed verification bypass result separately so it can
	 * be reused by origin-validation decisions in the same request.
	 */
	private static function cache_server_verification_result( bool $result ): bool {
		self::$server_verify_cache = $result;
		return $result;
	}
}
