<?php
/**
 * FirewallModule — World-class WAF with deep request inspection.
 *
 * Detection layers (fastest-first):
 *   1.  IP whitelist bypass
 *   2.  IP blacklist (exact + CIDR)
 *   3.  Sliding-window rate limiting
 *   4.  Geo-blocking (blacklist / whitelist mode) via IP-API.com
 *   5.  Bot / bad-UA detection
 *   6.  Proxy / VPN / datacenter detection
 *   7.  WAF: SQLi, XSS, RFI, RCE, Path Traversal, XXE, SSRF, CMDi, LFI
 *   8.  User-defined custom rules
 *
 * Additional:
 *   • Hotlink protection for media assets
 *   • wp-login.php brute-force throttling
 *   • Auto-ban IPs that exceed attack thresholds
 *   • Reputation feed integration (scheduled sync)
 *   • OWASP-aligned attack pattern seeding
 *   • Hourly cleanup cron (expire blacklist, purge old logs)
 *
 * @package UltraguardPro\modules\firewall
 */

namespace UltraguardPro\modules\firewall;

use UltraguardPro\modules\core\AbstractModule;
use UltraguardPro\support\LicenseInfo;
use UltraguardPro\support\SecureRemoteTransport;

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

class FirewallModule extends AbstractModule {

    public function get_id(): string { return 'firewall'; }

    /**
     * Firewall is a free-tier module — available to all users.
     */
    protected function requires_pro(): bool { return false; }

    /**
     * Gate on license first, then the WAF enabled option.
     */
    public function is_enabled(): bool {
        if ( $this->requires_pro() && ! LicenseInfo::instance()->isPro() ) {
            return false;
        }
        return (bool) $this->option( 'firewall_waf_enabled', true );
    }

    public function boot(): void {
        // 1. Register DB schema
        $_db_tables = $this->plugin->modules->get_db_tables();
        if ( $_db_tables ) {
            $_db_tables->register_schema( new schema\FirewallSchema() );
        }

        // 2. AJAX handlers
        ( new ajax\FirewallAjax( $this->plugin ) )->register();

        // 3. Dashboard contributor
        ( new FirewallDashboardContributor( $this->plugin ) )->register();

        // 4. Admin pages
        add_action( 'ultraguard_register_admin_pages', [ $this, 'register_pages' ], 20, 1 );

        // 5. Runtime protection — priority 2 fires before most plugins
        add_action( 'init', [ $this, 'run_firewall' ], 2 );

        // 6. Hotlink protection (before headers sent)
        add_action( 'template_redirect', [ $this, 'check_hotlinking' ], 1 );

        // 7. wp-login.php extra throttle
        add_action( 'login_init', [ $this, 'throttle_login_page' ], 1 );

        // 8. Scheduled jobs
        add_action( 'ultraguard_firewall_cleanup',    [ $this, 'cleanup_expired'      ] );
        add_action( 'ultraguard_firewall_reputation', [ $this, 'sync_reputation_feed' ] );
        add_action( 'wp_loaded',                      [ $this, 'maybe_schedule_crons' ] );

        // 9. Seed attack patterns + default firewall rules on first boot.
        //    Activation already performs the initial seed, so runtime requests
        //    should only queue recovery or scheduled background refreshes.
        add_action( 'ultraguard_firewall_seed_patterns', [ $this, 'seed_firewall_data' ] );
        add_action( 'ultraguard_firewall_sync_seed_data', [ $this, 'seed_firewall_data' ] );
        add_action( 'init', [ $this, 'maybe_seed_firewall_data' ], 6 );
        add_action( 'ultraguard_pro/firewall/block_ip', [ $this, 'handle_external_block_ip' ], 10, 3 );
        add_action( 'ultraguard_pro/firewall/whitelist_ip', [ $this, 'handle_external_whitelist_ip' ], 10, 3 );
        add_filter( 'ultraguard_pro/compliance/available_sections', [ $this, 'add_compliance_sections' ] );
        add_filter( 'ultraguard_pro/compliance/collect_data', [ $this, 'add_compliance_data' ], 10, 4 );

        do_action( 'ultraguard_pro/firewall/booted', $this );
    }

    // ── Admin pages ────────────────────────────────────────────────────────────

    /**
     * Register all firewall admin pages with the UltraGuard page manager.
     *
     * @param mixed $admin  Admin page manager.
     */
    public function register_pages( $admin ): void {
        $admin->register_page( [
            'id'              => 'firewall',
            'page_title'      => __( 'Firewall', 'ultraguard-security-suite' ),
            'menu_title'      => __( 'Firewall', 'ultraguard-security-suite' ),
            'capability'      => 'manage_options',
            'scheme_callback' => [ pages\FirewallDashboard::class, $this->plugin ],
            'is_parent'       => true,
            'priority'        => 6,
            'group'           => 'Protection',
            'icon'            => 'dashicons-shield',
        ] );

        $subpages = [
            [ 'id' => 'firewall-rules',           'title' => __( 'Firewall Rules',    'ultraguard-security-suite' ), 'menu' => __( 'Rules',            'ultraguard-security-suite' ), 'class' => pages\FirewallRules::class,           'p' => 20 ],
            [ 'id' => 'firewall-logs',             'title' => __( 'Firewall Logs',     'ultraguard-security-suite' ), 'menu' => __( 'Logs',             'ultraguard-security-suite' ), 'class' => pages\FirewallLogs::class,            'p' => 30 ],
            [ 'id' => 'firewall-ip-management',    'title' => __( 'IP Management',     'ultraguard-security-suite' ), 'menu' => __( 'IP Management',    'ultraguard-security-suite' ), 'class' => pages\FirewallIpManagement::class,    'p' => 35 ],
            [ 'id' => 'firewall-country-blocking', 'title' => __( 'Country Blocking',  'ultraguard-security-suite' ), 'menu' => __( 'Country Blocking', 'ultraguard-security-suite' ), 'class' => pages\FirewallCountryBlocking::class, 'p' => 40 ],
            [ 'id' => 'firewall-add-rules',        'title' => __( 'Add Rule',          'ultraguard-security-suite' ), 'menu' => __( 'Add Rule',         'ultraguard-security-suite' ), 'class' => pages\FirewallRulesAdd::class,        'p' => 45 ],
            [ 'id' => 'firewall-settings',         'title' => __( 'Firewall Settings', 'ultraguard-security-suite' ), 'menu' => __( 'Settings',         'ultraguard-security-suite' ), 'class' => pages\FirewallSettings::class,        'p' => 50 ],
        ];

        foreach ( $subpages as $p ) {
            $admin->register_page( [
                'id'              => $p['id'],
                'page_title'      => $p['title'],
                'menu_title'      => $p['menu'],
                'capability'      => 'manage_options',
                'scheme_callback' => [ $p['class'], $this->plugin ],
                'parent_id'       => 'firewall',
                'priority'        => $p['p'],
            'group'           => 'Protection',
            ] );
        }
    }

    public function handle_external_block_ip( string $ip, string $source = 'integration', string $reason = '' ): void {
        if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
            return;
        }

        $blocklist = $this->get_table( 'ip_blacklist' );
        if ( ! $blocklist || ! method_exists( $blocklist, 'block_ip' ) ) {
            return;
        }

        $saved = $blocklist->block_ip(
            $ip,
            $reason ?: 'Blocked by firewall integration',
            sanitize_key( $source ) ?: 'integration',
            'medium',
            null
        );

        if ( false !== $saved ) {
            do_action( 'ultraguard_pro/firewall/ip_blocked', $ip, $source, $reason );
            do_action( 'ultraguard_ip_list_updated' );
        }
    }

    public function handle_external_whitelist_ip( string $ip, string $source = 'integration', string $reason = '' ): void {
        if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
            return;
        }

        $allowlist = $this->get_table( 'ip_whitelist' );
        $blocklist = $this->get_table( 'ip_blacklist' );

        if ( $blocklist && method_exists( $blocklist, 'unblock_ip' ) ) {
            $blocklist->unblock_ip( $ip );
        }

        if ( $allowlist && method_exists( $allowlist, 'add_ip' ) ) {
            if ( ! method_exists( $allowlist, 'is_whitelisted' ) || ! $allowlist->is_whitelisted( $ip ) ) {
                $allowlist->add_ip( $ip, $reason ?: 'Added by ' . sanitize_key( $source ) );
            }
        }

        do_action( 'ultraguard_pro/firewall/ip_whitelisted', $ip, $source, $reason );
        do_action( 'ultraguard_ip_list_updated' );
    }

    public function add_compliance_sections( array $sections ): array {
        $sections['firewall_rules'] = __( 'Firewall Rules', 'ultraguard-security-suite' );
        return $sections;
    }

    public function add_compliance_data( array $sections, string $type, array $options, array $meta ): array {
        $requested = isset( $options['sections'] ) && is_array( $options['sections'] ) ? $options['sections'] : [];
        if ( $requested && ! in_array( 'firewall_rules', $requested, true ) ) {
            return $sections;
        }

        $rules_table   = $this->get_table( 'firewall_rules' );
        $blocked_table = $this->get_table( 'blocked_requests' );

        if ( ! $rules_table || ! $blocked_table ) {
            return $sections;
        }

        $active_rules   = method_exists( $rules_table, 'get_active_rules' ) ? $rules_table->get_active_rules() : $rules_table->get_by( [ 'is_active' => 1 ], 50, 'priority', 'ASC' );
        $recent_blocks  = method_exists( $blocked_table, 'get_recent' ) ? $blocked_table->get_recent( 10 ) : $blocked_table->get_by( [], 10, 'created_at', 'DESC' );
        $critical_rules = array_filter( $active_rules, static fn( array $row ): bool => ( $row['action'] ?? '' ) === 'block' );

        $sections['firewall_rules'] = [
            'title'   => __( 'Firewall Rules', 'ultraguard-security-suite' ),
            'content' => [
                'summary'       => [
                    'active_rules'      => (int) $rules_table->count( [ 'is_active' => 1 ] ),
                    'blocking_rules'    => count( $critical_rules ),
                    'blocked_requests'  => (int) $blocked_table->count(),
                ],
                'rules'         => array_map( static function( array $row ): array {
                    return [
                        'name'      => $row['name'] ?? '',
                        'action'    => $row['action'] ?? '',
                        'match'     => $row['match_field'] ?? '',
                        'priority'  => (int) ( $row['priority'] ?? 0 ),
                        'hits'      => (int) ( $row['hits_count'] ?? 0 ),
                    ];
                }, array_slice( $active_rules, 0, 10 ) ),
                'recent_blocks' => array_map( static function( array $row ): array {
                    return [
                        'ip_address'   => $row['ip_address'] ?? '',
                        'reason'       => $row['block_reason'] ?? '',
                        'threat_level' => $row['threat_level'] ?? '',
                        'created_at'   => $row['created_at'] ?? '',
                    ];
                }, $recent_blocks ),
            ],
        ];

        return $sections;
    }

    // ── Runtime firewall ───────────────────────────────────────────────────────

    /**
     * Main firewall entry — runs on every request at init priority 2.
     * Checks ordered fastest-first to minimise latency on clean requests.
     *
     * CRITICAL bypass rules (checked before any IP or WAF logic):
     *   • WP-Cron internal calls
     *   • WordPress REST API requests
     *   • wp-admin requests from logged-in users with manage_options
     *   • wp-admin AJAX requests (admin-ajax.php) from logged-in admins
     *   • The plugin's own AJAX actions
     */

    /**
     * Public alias called by Plugin::early_security_init() via method_exists() check.
     *
     * Plugin boots at plugins_loaded priority 10, which schedules run_firewall() on
     * the 'init' hook at priority 2. early_security_init() also runs on 'init' at
     * priority 2 — after boot() has already registered the hook — so calling
     * start_protection() here is a no-op for the normal request path. Its purpose is
     * to allow external callers (e.g. mu-plugins or integration code) to trigger an
     * immediate inline firewall check on the current request outside the normal hook flow.
     */
    public function start_protection(): void {
        $this->run_firewall();
    }

    public function run_firewall(): void {
        if ( ! $this->is_enabled() ) { return; }
        if ( $this->is_internal_cron() )       { return; }
        if ( $this->is_signed_internal_request() ) { return; }
        if ( $this->is_safe_admin_request() )  { return; }
        if ( $this->is_rest_api_request() )    { return; }

        $ip = \UltraguardPro\core\Utils::get_client_ip();
        if ( ! $ip ) { return; }

        // 0. Cloud threat intelligence — check against globally crowd-sourced blacklist
        $db_tables = $this->plugin->modules->get_db_tables();
        $threat_t  = $db_tables ? $db_tables->get_table( 'threat_ips' ) : null;
        if ( $threat_t && $threat_t->is_threat( $ip ) ) {
            $this->block( $ip, 'Cloud threat intelligence blacklist', 'high' );
        }

        // 1. Whitelist bypass — trusted IPs skip all checks
        if ( $this->is_whitelisted( $ip ) ) { return; }

        // 2. Blacklist — exact + CIDR match
        if ( $this->is_blacklisted( $ip ) ) {
            $this->block( $ip, 'IP blacklisted', 'high' );
        }

        // 3. Rate limiting — sliding window per IP per endpoint
        if ( $this->opt( 'firewall_rate_limiting', false ) ) {
            $this->check_rate_limit( $ip );
        }

        // 4. Geo-blocking
        if ( $this->opt( 'firewall_geo_blocking', false ) ) {
            $this->check_geo_block( $ip );
        }

        // 5. Bot / bad UA detection
        if ( $this->opt( 'firewall_bot_protection', true ) ) {
            $this->check_bad_bot( $ip );
        }

        // 6. Proxy / VPN / datacenter blocking
        if ( $this->opt( 'firewall_block_proxies', false ) ) {
            $this->check_proxy( $ip );
        }

        // 7. WAF — SQLi, XSS, RFI, RCE, traversal, XXE, SSRF, CMDi, LFI
        if ( $this->opt( 'firewall_waf_enabled', true ) ) {
            $this->run_waf( $ip );
        }

        // 8. User-defined custom rules
        $this->run_custom_rules( $ip );
    }

    // ── Whitelist / blacklist ──────────────────────────────────────────────────

    private function is_whitelisted( string $ip ): bool {
        $wl = $this->get_table( 'ip_whitelist' );
        if ( ! $wl ) { return false; }
        if ( $wl->is_whitelisted( $ip ) ) { return true; }
        foreach ( $wl->get_by( [ 'is_active' => 1 ], 500 ) as $r ) {
            if ( ! empty( $r['ip_range'] ) && $this->ip_in_cidr( $ip, $r['ip_range'] ) ) {
                return true;
            }
        }
        return false;
    }

    private function is_blacklisted( string $ip ): bool {
        $bl = $this->get_table( 'ip_blacklist' );
        if ( ! $bl ) { return false; }
        if ( $bl->is_blocked( $ip ) ) { return true; }
        foreach ( $bl->get_by( [ 'is_active' => 1 ], 1000 ) as $r ) {
            if ( ! empty( $r['ip_range'] ) && $this->ip_in_cidr( $ip, $r['ip_range'] ) ) {
                $bl->increment_field( 'hits_count', [ 'id' => $r['id'] ] );
                return true;
            }
        }
        return false;
    }

    // ── Rate limiting ──────────────────────────────────────────────────────────

    private function check_rate_limit( string $ip ): void {
        $rl_table   = $this->get_table( 'rate_limiting' );
        if ( ! $rl_table ) { return; }

        $max_req    = (int) $this->opt( 'firewall_rate_limit_requests', 200 );
        $window_sec = (int) $this->opt( 'firewall_rate_limit_window',   60  );
        $endpoint   = strtok( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '/' ) ), '?' ) ?: '/';
        $count      = $rl_table->hit( $ip, $endpoint, $window_sec );

        if ( $count >= $max_req ) {
            $action = (string) $this->opt( 'firewall_rate_limit_action', 'block' );
            $this->log_request( $ip, 'blocked', 'medium', null, 'Rate limit exceeded' );

            if ( $action === 'block' ) {
                $bl = $this->get_table( 'ip_blacklist' );
                if ( $bl ) {
                    $bl->block_ip( $ip, 'Rate limit exceeded', 'auto', 'medium',
                        gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ) );
                }
                $this->block( $ip, 'Rate limit exceeded', 'medium' );
            } elseif ( $action === 'throttle' ) {
                sleep( 1 );
            }
        }
    }

    // ── Geo-blocking ───────────────────────────────────────────────────────────

    private function check_geo_block( string $ip ): void {
        $country = $this->get_country_code( $ip );
        if ( ! $country ) { return; }

        $mode = (string) \UltraguardPro\ultraguard_pro()->get_option( 'country_blocking_mode', 'none' );
        $list = (array)  \UltraguardPro\ultraguard_pro()->get_option( 'blocked_countries', [] );

        $should_block = match ( $mode ) {
            'blacklist' => in_array( $country, $list, true ),
            'whitelist' => ! in_array( $country, $list, true ),
            default     => false,
        };

        if ( $should_block ) {
            $this->log_request( $ip, 'blocked', 'medium', null, 'Geo-blocked: ' . $country );
            $this->block( $ip, 'Geo-blocked: ' . $country, 'medium' );
        }
    }

    private function get_country_code( string $ip ): ?string {
        $geo = $this->get_table( 'geolocation_data' );
        if ( $geo ) {
            $cached = $geo->get_for_ip( $ip );
            if ( $cached ) { return $cached['country_code'] ?? null; }
        }

        $country = strtoupper( (string) Utils::get_country_from_ip( $ip ) );
        if ( 'XX' === $country || ! preg_match( '/^[A-Z]{2}$/', $country ) ) {
            return null;
        }

        if ( $geo ) {
            $geo->upsert( $ip, [
                'country_code'  => $country,
                'country_name'  => Utils::get_country_name_from_code( $country ),
                'is_proxy'      => 0,
                'is_datacenter' => 0,
            ] );
        }

        return $country;
    }

    // ── Bot detection ──────────────────────────────────────────────────────────

    private function check_bad_bot( string $ip ): void {
        $ua = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ) );

        if ( ! $ua ) {
            if ( $this->opt( 'firewall_block_empty_ua', false ) ) {
                $this->block( $ip, 'Empty User-Agent', 'medium' );
            }
            return;
        }

        $bad_patterns = [
            'sqlmap', 'nikto', 'nmap', 'masscan', 'zgrab', 'nuclei',
            'acunetix', 'netsparker', 'burpsuite', 'havij', 'pangolin',
            'dirbuster', 'gobuster', 'wfuzz', 'ffuf', 'metasploit',
            'zmeu', 'morfeus', 'libwww-perl', 'python-requests',
            'go-http-client', 'scrapy', 'curl/7',
        ];

        foreach ( $bad_patterns as $pattern ) {
            if ( str_contains( $ua, $pattern ) ) {
                $this->log_request( $ip, 'blocked', 'high', null, 'Bad bot: ' . $pattern );
                $this->block( $ip, 'Bad bot detected: ' . $pattern, 'high' );
            }
        }
    }

    // ── Proxy / VPN detection ─────────────────────────────────────────────────

    private function check_proxy( string $ip ): void {
        $geo = $this->get_table( 'geolocation_data' );
        if ( ! $geo ) { return; }
        $cached = $geo->get_for_ip( $ip );
        if ( $cached && ! empty( $cached['is_proxy'] ) ) {
            $this->log_request( $ip, 'blocked', 'medium', null, 'Proxy/VPN detected' );
            $this->block( $ip, 'Proxy/VPN detected', 'medium' );
        }
    }

    // ── WAF ────────────────────────────────────────────────────────────────────

    private function run_waf( string $ip ): void {
        $method = strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) );
        $uri    = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
        $ua     = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) );

        $inputs = [
            'uri' => urldecode( $uri ),
            'ua'  => $ua,
        ];

        // Same safe-field skip list applied to GET — these WP params contain
        // URLs and nonces that produce false positives in WAF detectors.
        $wp_safe_get_fields = [
            'redirect_to', 'action', '_wpnonce', 'page',
            'post', 'paged', 's', 'return',
        ];
        foreach ( $_GET as $k => $v ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Firewall intentionally inspects raw request payload during runtime protection.
            if ( in_array( $k, $wp_safe_get_fields, true ) ) { continue; }
            $inputs[ 'get_' . $k ] = is_array( $v ) ? implode( ',', $v ) : (string) $v;
        }

        if ( in_array( $method, [ 'POST', 'PUT', 'PATCH' ], true ) ) {
            // These core WordPress POST fields are always safe and contain values
            // (redirect URLs, nonces, passwords) that would cause false positives.
            $wp_safe_post_fields = [
                'redirect_to', 'log', 'pwd', 'user_login', 'user_pass',
                'pass1', 'pass2', 'user_email', 'testcookie', 'wp-submit',
                'submit', '_wpnonce', '_wp_http_referer', 'action',
                'nonce', '_ultraguard_nonce', 'ultraguard_admin_nonce',
                'content', 'post_content', 'post_excerpt', 'comment_content',
                'excerpt', 'wp_customize', 'customized',
            ];
            foreach ( $_POST as $k => $v ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Firewall intentionally inspects raw request payload during runtime protection.
                if ( in_array( $k, $wp_safe_post_fields, true ) ) { continue; }
                // on every Gutenberg save, ACF update, and page-builder request.
                $content_fields = [ 'content', 'post_content', 'description',
                                    'excerpt', 'comment', 'message', 'text',
                                    'body', 'data', 'payload', 'widget_content' ];
                $k_lower = strtolower( $k );
                $is_content = false;
                foreach ( $content_fields as $cf ) {
                    if ( str_contains( $k_lower, $cf ) ) { $is_content = true; break; }
                }
                if ( $is_content ) { continue; }

                $inputs[ 'post_' . $k ] = is_array( $v ) ? implode( ',', $v ) : (string) $v;
            }
        }

        foreach ( [
            'HTTP_X_FORWARDED_FOR' => 'xff',
            'HTTP_X_REAL_IP'       => 'x_real_ip',
        ] as $srv => $label ) {
            if ( isset( $_SERVER[ $srv ] ) ) {
                $inputs[ 'hdr_' . $label ] = sanitize_text_field( wp_unslash( $_SERVER[ $srv ] ) );
            }
        }

        $checks = [
            'firewall_sql_injection_scan'  => [ $this, 'detect_sqli'     ],
            'firewall_xss_scan'            => [ $this, 'detect_xss'      ],
            'firewall_directory_traversal' => [ $this, 'detect_traversal' ],
            'firewall_rfi_protection'      => [ $this, 'detect_rfi'      ],
            'firewall_rce_protection'      => [ $this, 'detect_rce'      ],
            'firewall_xxe_protection'      => [ $this, 'detect_xxe'      ],
            'firewall_ssrf_protection'     => [ $this, 'detect_ssrf'     ],
            'firewall_cmdi_protection'     => [ $this, 'detect_cmdi'     ],
            'firewall_lfi_protection'      => [ $this, 'detect_lfi'      ],
        ];

        foreach ( $checks as $opt_key => $callback ) {
            if ( ! $this->opt( $opt_key, true ) ) { continue; }
            foreach ( $inputs as $field => $value ) {
                $result = call_user_func( $callback, $value );
                if ( $result ) {
                    $threat = $this->threat_level_for_check( $opt_key );
                    $this->auto_ban_if_threshold( $ip );
                    $this->log_request( $ip, 'blocked', $threat, null,
                        strtoupper( str_replace( [ 'firewall_', '_scan', '_protection' ], '', $opt_key ) )
                        . ' in ' . $field );
                    $this->block( $ip, $result . ' in ' . $field, $threat );
                }
            }
        }
    }

    // ── Attack detectors ───────────────────────────────────────────────────────

    private function detect_sqli( string $v ): ?string {
        $p = [
            '/(\\bUNION\\b.+\\bSELECT\\b)/i',
            '/(\\bSELECT\\b.+\\bFROM\\b)/i',
            '/(\\bINSERT\\b.+\\bINTO\\b)/i',
            '/(\\bDELETE\\b.+\\bFROM\\b)/i',
            '/(\\bDROP\\b.+\\bTABLE\\b)/i',
            '/(\\bEXEC\\b.+\\()/i',
            '/(\\bOR\\b\\s+\\d+\\s*=\\s*\\d+)/i',
            '/--\\s*$|#\\s*$|\\/\\*[\\s\\S]*?\\*\\//i',
            '/\\b(SLEEP|BENCHMARK|WAITFOR\\s+DELAY)\\s*\\(/i',
            '/\\bLOAD_FILE\\s*\\(/i',
            '/\\bINTO\\s+(OUT|DUMP)FILE\\b/i',
            '/\\bINFORMATION_SCHEMA\\b/i',
            '/\\b0x[0-9a-fA-F]{4,}\\b/',
            '/char\\s*\\(\\s*\\d+/i',
        ];
        foreach ( $p as $pat ) { if ( @preg_match( $pat, $v ) ) { return 'SQL Injection detected'; } }
        return null;
    }

    private function detect_xss( string $v ): ?string {
        $d = html_entity_decode( urldecode( $v ), ENT_QUOTES );

        $p = [
            // <script> tag — must have whitespace or > immediately after "script"
            // so "post_content" containing the word "script" as part of a word is safe
            '/<script[\s>]/i',

            // javascript: protocol — only dangerous as a URI scheme.
            // The pattern requires it to appear at the start of a value or after = " '
            // to avoid blocking text like "use javascript: to do X" in post content.
            '/(?:^|[=\'"(\s])javascript\s*:/i',

            // Event handlers with = sign — require the attribute to look like HTML:
            // it must be preceded by whitespace or < to exclude text like
            // "the onclick event handler description" or "onload=false in CSS"
            '/[\s<]on(?:click|load|error|mouseover|submit|focus|blur|change|keyup|keydown)\s*=/i',

            // Dangerous tags
            '/<iframe[\s>]/i',
            '/<object[\s>]/i',
            '/<embed[\s>]/i',

            // CSS expression() injection (IE legacy)
            '/\bexpression\s*\(/i',

            // VBScript protocol
            '/vbscript\s*:/i',

            // data: URI with HTML payload
            '/data:text\/html/i',

            // SVG with event handler OR javascript — require the SVG tag itself
            '/<svg[\s>][^>]*(?:on\w+\s*=|javascript\s*:)/is',

            // document.cookie, document.write, document.location
            // Only flag when combined with ( or = to avoid blocking text references
            // like "document.write() is a method" in blog post content
            '/document\.(?:cookie|location)\s*[=(]/i',
            '/document\.write\s*\(/i',

            // window.location and window.open with assignment or call
            '/window\.(?:location\s*[=.(]|open\s*\()/i',
        ];

        foreach ( $p as $pat ) {
            if ( @preg_match( $pat, $d ) ) {
                return 'XSS attempt detected';
            }
        }
        return null;
    }

    private function detect_traversal( string $v ): ?string {
        $d = urldecode( $v );
        $p = [
            // Actual directory traversal sequences only — not bare %2f (= normal URL /)
            '/\\.\\.[\\/ \\\\]/',    // ../  ..\ or ..space
            '/%2e%2e[%2f%5c]/i',              // URL-encoded ../
            '/\\.\\.\\.\\./i',          // ....
            '/etc\\/passwd/i',
            '/etc\\/shadow/i',
            '/proc\\/self/i',
            '/windows\\/system32/i',
            '/boot\\.ini/i',
        ];
        foreach ( $p as $pat ) { if ( @preg_match( $pat, $d ) ) { return 'Path Traversal detected'; } }
        return null;
    }

    private function detect_rfi( string $v ): ?string {
        // Only flag external URLs that point to executable script files.
        // The overly broad /=\s*https?:\/\// pattern was removed because it
        // fires on WordPress's own redirect_to=https://... login parameter.
        $host = preg_quote( wp_parse_url( home_url(), PHP_URL_HOST ) ?? '', '/' );
        $p = [
            '/https?:\\/\\/(?!' . $host . ')[^\\s]+\\.(php|phtml|asp|aspx|jsp)/i',
            '/=\\s*ftp:\\/\\/[^\\s]+\\.(php|phtml|asp|aspx|jsp)/i',
        ];
        foreach ( $p as $pat ) { if ( @preg_match( $pat, $v ) ) { return 'Remote File Inclusion detected'; } }
        return null;
    }

    private function detect_rce( string $v ): ?string {
        $p = [
            '/\\b(system|exec|shell_exec|passthru|popen|proc_open)\\s*\\(/i',
            '/\\beval\\s*\\(\\s*base64_decode/i',
            '/\\beval\\s*\\(\\s*\\$/i',
            '/`[^`]+`/',
            '/;\\s*(cat|ls|id|whoami|uname|wget|curl|nc|bash|sh)\\s/i',
            '/\\|\\s*(cat|bash|sh|nc|id|whoami)/i',
        ];
        foreach ( $p as $pat ) { if ( @preg_match( $pat, $v ) ) { return 'RCE attempt detected'; } }
        return null;
    }

    private function detect_xxe( string $v ): ?string {
        $p = [
            '/<!ENTITY\\s+\\w+\\s+SYSTEM/i',
            '/<!DOCTYPE[^>]+\\[/i',
//            '/\\bSYSTEM\\s+[\\'"]file:\\/\\//i',
        ];
        foreach ( $p as $pat ) { if ( @preg_match( $pat, $v ) ) { return 'XXE attack detected'; } }
        return null;
    }

    private function detect_ssrf( string $v ): ?string {
        // Always allow the site's own configured URL — on localhost dev environments
        // home_url() is http://localhost which would otherwise match the SSRF patterns
        // and block every wp-admin request, redirect, and navigation action.
        $site_host = preg_quote( wp_parse_url( home_url(), PHP_URL_HOST ) ?? '', '/' );
        if ( $site_host && @preg_match( '/https?:\/\/' . $site_host . '/i', $v ) ) {
            return null;
        }

        $p = [
            '/https?:\/\/(?:localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0|::1|169\.254\.\d+\.\d+)/i',
            '/https?:\/\/(?:10\.|172\.1[6-9]\.|172\.2\d\.|172\.3[01]\.|192\.168\.)/i',
            '/https?:\/\/metadata\.google\.internal/i',
            '/https?:\/\/169\.254\.169\.254/i',
            '/file:\/\//i',
        ];
        foreach ( $p as $pat ) { if ( @preg_match( $pat, $v ) ) { return 'SSRF attempt detected'; } }
        return null;
    }

    private function detect_cmdi( string $v ): ?string {
        $p = [
            '/[;&|`]\\s*(cat|ls|id|whoami|uname|hostname|ifconfig|netstat|ps\\s)/i',
            '/\\$\\(.*?\\)/',
            '/\\$\\{.*?\\}/',
            '/%0[aA].*?(cat|ls|id|bash)/i',
        ];
        foreach ( $p as $pat ) { if ( @preg_match( $pat, $v ) ) { return 'Command Injection detected'; } }
        return null;
    }

    private function detect_lfi( string $v ): ?string {
        $p = [
            '/\\bphp:\\/\\/(?:input|filter|data)/i',
            '/\\bexpect:\\/\\//i',
            '/\\bzip:\\/\\//i',
            '/\\bdata:\\/\\//i',
        ];
        foreach ( $p as $pat ) { if ( @preg_match( $pat, $v ) ) { return 'LFI attempt detected'; } }
        return null;
    }

    // ── User-defined custom rules ──────────────────────────────────────────────

    private function run_custom_rules( string $ip ): void {
        $rules_table = $this->get_table( 'firewall_rules' );
        if ( ! $rules_table ) { return; }

        $uri    = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
        $method = strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) );
        $ua     = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) );
        $host   = sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) );

        foreach ( $rules_table->get_active_rules() as $rule ) {
            $field   = $rule['match_field'] ?? 'ip';
            $subject = match ( $field ) {
                'ip'     => $ip,
                'uri'    => $uri,
                'method' => $method,
                'ua'     => $ua,
                'host'   => $host,
                default  => '',
            };
            if ( ! $subject ) { continue; }

            if ( $this->compare( $subject, $rule['match_value'] ?? '', $rule['match_operator'] ?? 'equals' ) ) {
                $rules_table->increment_hits( (int) $rule['id'] );
                $action = $rule['action'] ?? 'block';

                if ( $action === 'block' ) {
                    $this->log_request( $ip, 'blocked', $rule['threat_level'] ?? 'medium', (int) $rule['id'] );
                    $this->block( $ip, 'Rule: ' . ( $rule['name'] ?? 'custom' ), $rule['threat_level'] ?? 'medium', (int) $rule['id'] );
                } elseif ( $action === 'allow' ) {
                    return; // Explicit allow — stop further processing
                } elseif ( $action === 'log' ) {
                    $this->log_request( $ip, 'logged', $rule['threat_level'] ?? 'none', (int) $rule['id'] );
                }
            }
        }
    }

    // ── Hotlink protection ─────────────────────────────────────────────────────

    /**
     * Block hotlinking of media assets from external domains.
     */
    public function check_hotlinking(): void {
        if ( ! $this->opt( 'firewall_hotlink_protection', false ) ) { return; }

        $referer = sanitize_text_field( wp_unslash( $_SERVER['HTTP_REFERER'] ?? '' ) );
        if ( ! $referer ) { return; }

        $uri = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
        if ( ! preg_match( '/\\.(jpg|jpeg|png|gif|webp|svg|mp4|mp3|pdf)(\\?|$)/i', $uri ) ) { return; }

        $site_host    = wp_parse_url( home_url(), PHP_URL_HOST );
        $referer_host = wp_parse_url( $referer, PHP_URL_HOST );

        if ( $referer_host && $referer_host !== $site_host ) {
            status_header( 403 );
            exit;
        }
    }

    // ── Login throttle ─────────────────────────────────────────────────────────

    /**
     * Extra rate-limiting for wp-login.php to prevent brute force.
     */
    public function throttle_login_page(): void {
        if ( ! $this->opt( 'firewall_login_throttle', true ) ) { return; }

        $ip = \UltraguardPro\core\Utils::get_client_ip();

        // 0.0.0.0 is the fallback when the real IP could not be determined
        // (e.g. local/CLI requests where REMOTE_ADDR is a private range that
        // validate_private rejects). Skip rate-limiting for these — tracking
        // them all under the same key causes spurious duplicate-entry errors
        // and would block every localhost login after just a few attempts.
        if ( ! $ip || $ip === '0.0.0.0' ) { return; }

        if ( $this->is_whitelisted( $ip ) ) { return; }

        $rl_table = $this->get_table( 'rate_limiting' );
        if ( ! $rl_table ) { return; }

        $max    = (int) $this->opt( 'firewall_login_max_attempts', 10 );
        $window = (int) $this->opt( 'firewall_login_window_seconds', 300 );
        $count  = $rl_table->hit( $ip, '__login__', $window );

        if ( $count >= $max ) {
            $bl = $this->get_table( 'ip_blacklist' );
            if ( $bl ) {
                $bl->block_ip( $ip, 'Login brute-force', 'auto', 'high',
                    gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ) );
            }
            $this->log_request( $ip, 'blocked', 'high', null, 'Login brute-force' );
            wp_die(
                esc_html__( 'Too many login attempts. Please wait before trying again.', 'ultraguard-security-suite' ),
                esc_html__( 'Access Denied', 'ultraguard-security-suite' ),
                [ 'response' => 429 ]
            );
        }
    }

    // ── Auto-ban ───────────────────────────────────────────────────────────────

    private function auto_ban_if_threshold( string $ip ): void {
        if ( ! $this->opt( 'firewall_auto_ban', true ) ) { return; }

        $threshold = (int) $this->opt( 'firewall_auto_ban_threshold', 5 );
        $br_table  = $this->get_table( 'blocked_requests' );
        if ( ! $br_table ) { return; }

        $recent_hits = (int) $br_table->count( [
            'ip_address'    => $ip,
            'created_at >=' => gmdate( 'Y-m-d H:i:s', strtotime( '-10 minutes' ) ),
        ] );

        if ( $recent_hits >= $threshold ) {
            $bl = $this->get_table( 'ip_blacklist' );
            if ( $bl ) {
                $duration = (int) $this->opt( 'firewall_auto_ban_duration', 3600 );
                $bl->block_ip( $ip, 'Auto-banned: ' . $recent_hits . ' attacks in 10 min',
                    'auto', 'high',
                    gmdate( 'Y-m-d H:i:s', time() + $duration ) );
            }
        }
    }

    // ── Reputation feed sync ───────────────────────────────────────────────────

    /**
     * Download and import a public IP reputation blocklist (FireHOL Level 1).
     * Runs on the twicedaily cron schedule.
     */
    public function sync_reputation_feed(): void {
        if ( ! $this->opt( 'firewall_reputation_sync', false ) ) { return; }
        if ( get_transient( 'ultraguard_reputation_sync' ) ) { return; }

        $feed_url = apply_filters(
            'ultraguard_firewall_reputation_feed_url',
            'https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset'
        );

        // Validate the URL before fetching — a filter hook could supply a bad value.
        if ( ! filter_var( $feed_url, FILTER_VALIDATE_URL )
             || ! in_array( wp_parse_url( $feed_url, PHP_URL_SCHEME ), [ 'https', 'http' ], true ) ) {
            return;
        }

        $resp = SecureRemoteTransport::request( 'GET', esc_url_raw( $feed_url ), [ 'timeout' => 15, 'sslverify' => true ] );
        if ( is_wp_error( $resp ) ) { return; }

        $bl_table = $this->get_table( 'ip_blacklist' );
        if ( ! $bl_table ) { return; }

        $lines = array_filter(
            explode( "\n", wp_remote_retrieve_body( $resp ) ),
            fn( $l ) => $l && $l[0] !== '#'
        );

        $added = 0;
        foreach ( array_slice( $lines, 0, 5000 ) as $entry ) {
            $entry = trim( $entry );
            if ( ! $entry ) { continue; }

            if ( str_contains( $entry, '/' ) ) {
                if ( ! $bl_table->exists( [ 'ip_range' => $entry ] ) ) {
                    $bl_table->insert( [ 'ip_address' => '0.0.0.0', 'ip_range' => $entry, 'reason' => 'Reputation feed', 'source' => 'reputation', 'severity' => 'medium' ] );
                    $added++;
                }
            } elseif ( filter_var( $entry, FILTER_VALIDATE_IP ) ) {
                $bl_table->block_ip( $entry, 'Reputation feed', 'reputation', 'medium', null );
                $added++;
            }
        }

        set_transient( 'ultraguard_reputation_sync', $added, 12 * HOUR_IN_SECONDS );
        do_action( 'ultraguard_firewall_reputation_synced', $added );
    }

    // ── Attack pattern seeding ─────────────────────────────────────────────────

    /**
    // ── Firewall data seeding ─────────────────────────────────────────────────

    /**
     * Lightweight init hook: if the firewall datasets are missing their initial
     * seed markers, queue a one-time recovery seed instead of checking remote
     * updates inline on the current request.
     */
    public function maybe_seed_firewall_data(): void {
        if ( get_option( FirewallSeeder::PATTERNS_SEEDED_OPTION ) && get_option( FirewallSeeder::RULES_SEEDED_OPTION ) ) {
            return;
        }

        if ( ! wp_next_scheduled( 'ultraguard_firewall_seed_patterns' ) ) {
            wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'ultraguard_firewall_seed_patterns' );
        }
    }

    /**
     * Public seed entrypoint — used by the WP-Cron fallback hook, activation,
     * and the "Update Firewall Data" AJAX button.
     *
     * @param  bool $force  When true, forces a full re-seed.
     * @return array  FirewallSeeder::seed() result.
     */
    public function seed_firewall_data( bool $force = false ): array {
        $seeder = $this->get_seeder();
        return $force ? $seeder->force_reseed() : $seeder->seed();
    }

    /**
     * Compatibility shim — called by any code that still references
     * the old seed_attack_patterns() method name.
     *
     * @deprecated  Use seed_firewall_data() instead.
     */
    public function seed_attack_patterns(): void {
        $this->seed_firewall_data();
    }

    /**
     * Lazily build and return the FirewallSeeder instance.
     *
     * @return FirewallSeeder|null
     */
    private function get_seeder(): FirewallSeeder {
        return new FirewallSeeder();
    }

    // ── Cleanup ────────────────────────────────────────────────────────────────

    /**
     * Expire old blacklist entries, rate-limit windows, and stale logs.
     */
    public function cleanup_expired(): void {
        $bl = $this->get_table( 'ip_blacklist' );
        if ( $bl ) {
            $bl->update_where( [ 'is_active' => 0 ], [ 'expires_at <' => current_time( 'mysql', true ), 'is_active' => 1 ] );
        }

        $rl = $this->get_table( 'rate_limiting' );
        if ( $rl ) { $rl->cleanup_expired(); }

        $retention = (int) $this->opt( 'firewall_log_retention_days', 30 );
        $logs      = $this->get_table( 'firewall_logs' );
        if ( $logs && $retention > 0 ) {
            $logs->delete_where( [ 'created_at <' => gmdate( 'Y-m-d H:i:s', strtotime( "-{$retention} days" ) ) ] );
        }

        $geo = $this->get_table( 'geolocation_data' );
        if ( $geo ) {
            $geo->delete_where( [ 'cached_at <' => gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) ) ] );
        }
    }

    // ── Cron scheduling ────────────────────────────────────────────────────────

    /**
     * Register cron jobs if not already scheduled.
     */
    public function maybe_schedule_crons(): void {
        if ( ! wp_next_scheduled( 'ultraguard_firewall_cleanup' ) ) {
            wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', 'ultraguard_firewall_cleanup' );
        }
        if ( ! wp_next_scheduled( 'ultraguard_firewall_reputation' ) ) {
            wp_schedule_event( time() + HOUR_IN_SECONDS, 'twicedaily', 'ultraguard_firewall_reputation' );
        }
        if ( ! wp_next_scheduled( 'ultraguard_firewall_sync_seed_data' ) ) {
            wp_schedule_event( time() + ( 2 * HOUR_IN_SECONDS ), 'twicedaily', 'ultraguard_firewall_sync_seed_data' );
        }
    }

    // ── Private helpers ────────────────────────────────────────────────────────

    /**
     * Read a firewall option, falling back to the default.
     *
     * @param  string $key      Option key.
     * @param  mixed  $default  Default value.
     * @return mixed
     */
    private function opt( string $key, $default = '' ) {
        return $this->option( $key, $default );
    }

    /**
     * Block the current request: record it, fire the action hook, and die.
     *
     * @param string $ip      Client IP.
     * @param string $reason  Human-readable reason.
     * @param string $threat  Threat level (low/medium/high/critical).
     * @param int    $rule_id Optional rule ID.
     */
    private function block( string $ip, string $reason, string $threat = 'medium', int $rule_id = 0 ): never {
        $br = $this->get_table( 'blocked_requests' );
        if ( $br ) {
            $br->record_block(
                $ip,
                sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) ),
                $reason,
                $threat,
                $rule_id ?: null
            );
        }

        do_action( 'ultraguard_request_blocked', $ip, $reason, $threat );

        $code    = str_contains( $reason, 'Rate limit' ) ? 429 : 403;
        $message = apply_filters(
            'ultraguard_firewall_block_message',
            esc_html__( 'Access denied. Your request has been blocked by the security firewall.', 'ultraguard-security-suite' ),
            $reason, $threat
        );

        status_header( $code );
        nocache_headers();
        wp_die( esc_html( $message ), esc_html__( 'Access Denied', 'ultraguard-security-suite' ), [ 'response' => absint( $code ) ] );
    }

    /**
     * Log a firewall event without blocking.
     *
     * @param string   $ip      Client IP.
     * @param string   $action  'blocked' | 'allowed' | 'logged'.
     * @param string   $threat  Threat level.
     * @param int|null $rule_id Matched rule ID.
     * @param string   $reason  Optional reason.
     */
    private function log_request(
        string $ip,
        string $action  = 'blocked',
        string $threat  = 'medium',
        ?int   $rule_id = null,
        string $reason  = ''
    ): void {
        $logs = $this->get_table( 'firewall_logs' );
        if ( ! $logs ) { return; }
        $logs->insert( [
            'ip_address'     => $ip,
            'request_method' => strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) ),
            'request_uri'    => sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) ),
            'user_agent'     => sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ),
            'action'         => $action,
            'threat_level'   => $threat,
            'rule_id'        => $rule_id,
            'user_id'        => get_current_user_id() ?: null,
        ] );
    }

    /**
     * Map a WAF check option key to its threat level.
     *
     * @param  string $opt_key  Check option key.
     * @return string           Threat level string.
     */
    private function threat_level_for_check( string $opt_key ): string {
        return match ( $opt_key ) {
            'firewall_sql_injection_scan', 'firewall_rce_protection',
            'firewall_xxe_protection', 'firewall_ssrf_protection' => 'critical',
            'firewall_xss_scan', 'firewall_rfi_protection',
            'firewall_cmdi_protection', 'firewall_lfi_protection' => 'high',
            default => 'medium',
        };
    }

    /**
     * Compare a subject string against a pattern.
     *
     * @param  string $subject   Value to test.
     * @param  string $pattern   Pattern to match.
     * @param  string $operator  equals|contains|starts_with|ends_with|regex.
     * @return bool
     */
    private function compare( string $subject, string $pattern, string $operator ): bool {
        return match ( $operator ) {
            'equals'       => $subject === $pattern,
            'not_equals'   => $subject !== $pattern,
            'contains'     => str_contains( $subject, $pattern ),
            'not_contains' => ! str_contains( $subject, $pattern ),
            'starts_with'  => str_starts_with( $subject, $pattern ),
            'ends_with'    => str_ends_with( $subject, $pattern ),
            'regex'        => (bool) @preg_match( $pattern, $subject ),
            default        => false,
        };
    }

    /**
     * Test whether an IPv4/IPv6 address falls within a CIDR range.
     *
     * @param  string $ip    IP address.
     * @param  string $cidr  CIDR notation (e.g. 192.168.1.0/24).
     * @return bool
     */
    private function ip_in_cidr( string $ip, string $cidr ): bool {
        if ( ! str_contains( $cidr, '/' ) ) { return $ip === $cidr; }
        [ $range, $prefix ] = explode( '/', $cidr, 2 );
        $prefix = (int) $prefix;

        if ( str_contains( $ip, ':' ) ) { // IPv6
            $ip_bin    = inet_pton( $ip );
            $range_bin = inet_pton( $range );
            if ( $ip_bin === false || $range_bin === false ) { return false; }
            $bytes = (int) ceil( $prefix / 8 );
            return substr( $ip_bin, 0, $bytes ) === substr( $range_bin, 0, $bytes );
        }

        // IPv4
        $ip_long    = ip2long( $ip );
        $range_long = ip2long( $range );
        if ( $ip_long === false || $range_long === false ) { return false; }
        $mask = $prefix === 0 ? 0 : ( ~0 << ( 32 - $prefix ) );
        return ( $ip_long & $mask ) === ( $range_long & $mask );
    }

    /**
     * Detect whether the current request is an internal WP-Cron call.
     *
     * @return bool
     */
    private function is_internal_cron(): bool {
        return defined( 'DOING_CRON' ) && DOING_CRON;
    }

    /**
     * Allow UltraGuard's own signed uptime self-check requests to bypass the WAF.
     *
     * The request must carry both the expected internal action marker and a
     * host-bound token derived from WordPress salts, so external traffic cannot
     * spoof this bypass just by sending a custom header.
     *
     * @return bool
     */
    private function is_signed_internal_request(): bool {
        $action = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_ULTRAGUARD_INTERNAL'] ?? '' ) );
        $allowed_actions = apply_filters( 'ultraguard_firewall_signed_internal_actions', [
            'uptime-monitor',
            'security-headers-test',
        ] );

        if ( ! in_array( $action, $allowed_actions, true ) ) {
            return false;
        }

        $token          = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_ULTRAGUARD_INTERNAL_TOKEN'] ?? '' ) );
        $expected_token = wp_hash( $action . '|' . untrailingslashit( home_url() ), 'auth' );

        if ( '' === $token || ! hash_equals( $expected_token, $token ) ) {
            return false;
        }

        $method = strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) );
        if ( ! in_array( $method, [ 'GET', 'HEAD' ], true ) ) {
            return false;
        }

        $request_host = wp_parse_url( ( is_ssl() ? 'https://' : 'http://' ) . wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ), PHP_URL_HOST ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Host is unslashed and parsed to a hostname immediately.
        $site_host    = wp_parse_url( home_url(), PHP_URL_HOST );

        return ! empty( $request_host ) && ! empty( $site_host ) && strtolower( (string) $request_host ) === strtolower( (string) $site_host );
    }

    /**
     * Returns true for any request that should unconditionally bypass the WAF.
     *
     * Two tiers of bypass:
     *
     * Tier 1 — unconditional (no login check needed):
     *   • Any request whose SCRIPT_FILENAME resolves inside wp-admin/
     *     WordPress enforces its own authentication on these pages; blocking
     *     them in the WAF only hurts admins and never hurts attackers (who
     *     don't have valid auth cookies anyway).
     *   • admin-ajax.php — same reasoning.
     *
     * Tier 2 — logged-in admin:
     *   • Any request where is_admin() is true AND the user has manage_options.
     *     Belt-and-suspenders for edge cases where SCRIPT_FILENAME is unusual.
     *
     * At init priority 2 the current user IS loaded (set_current_user fires
     * at init priority 1), so is_user_logged_in() and current_user_can() are
     * both reliable here.
     *
     * @return bool  True = skip all firewall checks.
     */
    private function is_safe_admin_request(): bool {
        $script = wp_normalize_path( wp_unslash( $_SERVER['SCRIPT_FILENAME'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Script filename is unslashed and normalized before comparison.
        $admin  = wp_normalize_path( ABSPATH . 'wp-admin/' );

        // Tier 1: script lives inside wp-admin (unconditional)
        if ( $script && str_starts_with( $script, $admin ) ) {
            return true;
        }

        // Tier 1: admin-ajax.php (unconditional — WP auth handles it)
        if ( wp_doing_ajax() ) {
            return true;
        }

        // Tier 1: wp-login.php — users cannot be authenticated yet when they
        // visit the login page, so Tier 2 never fires for them.  WordPress
        // enforces brute-force protection via throttle_login_page() on login_init;
        // the WAF must not block the page itself or legitimate logins will fail.
        $login = wp_normalize_path( ABSPATH . 'wp-login.php' );
        if ( $script && $script === $login ) {
            return true;
        }
        // Also catch when REQUEST_URI starts with /wp-login.php (e.g. Nginx configs
        // that route through index.php instead of calling wp-login.php directly).
        $uri = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
        if ( $uri && str_contains( $uri, '/wp-login.php' ) ) {
            return true;
        }

        // Tier 2: is_admin() + logged-in manage_options user (belt-and-suspenders)
        if ( is_admin() && is_user_logged_in() && \UltraguardPro\ultraguard_current_user_can_manage() ) {
            return true;
        }

        return false;
    }

    /**
     * Returns true for WordPress REST API requests.
     * REST endpoints authenticate themselves; the WAF must not block them.
     *
     * @return bool
     */
    private function is_rest_api_request(): bool {
        if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return true; }

        $prefix = trailingslashit( rest_get_url_prefix() );
        $uri    = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
        return $uri && (
            str_contains( $uri, '/' . $prefix ) ||
            str_contains( $uri, '/index.php/' . $prefix )
        );
    }
}
