<?php
/**
 * @file qtrBotProtection.php
 * @brief Layered bot protection with risk scoring and token-bucket rate limiting
 *
 * WHAT IT DOES (high-level)
 *  - Protects REST API, XML-RPC, WooCommerce checkout/cart, and general endpoints
 *  - Applies multi-stage risk evaluation: heuristics, rate limiting, challenge mechanisms
 *  - Recognizes good bots (Googlebot, Bingbot, etc.) and grants elevated rate limits
 *  - Does NOT interfere with wp-login.php (delegated to CQtrBruteForce)
 *  - Does NOT implement login failure counters or brute-force logic
 *
 * @author Quttera
 */

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

require_once('qtrIPManager.php');
require_once('qtrRateLimitCache.php');
require_once('qtrRateLimiter.php');
require_once('qtrLogger.php');
require_once('qtrAlarmsManager.php');

final class CQtrBotProtection
{
    /* =======================================================================
     * Configuration Constants
     * ======================================================================= */

    /** @var string Operation modes */
    const MODE_OBSERVE    = 'observe';    // Log only, never block
    const MODE_BALANCED   = 'balanced';   // Default: soft enforcement, fail-open for checkout
    const MODE_AGGRESSIVE = 'aggressive'; // Hard enforcement

    /** @var array Token bucket configurations per lane */
    private const LANES = [
        'global'   => ['refill_tokens' => 120, 'refill_window_sec' => 60,  'burst' => 60],
        'rest'     => ['refill_tokens' => 60,  'refill_window_sec' => 300, 'burst' => 15],
        'xmlrpc'   => ['refill_tokens' => 5,   'refill_window_sec' => 600, 'burst' => 2],
        'checkout' => ['refill_tokens' => 60,  'refill_window_sec' => 300, 'burst' => 20],
        'cart'     => ['refill_tokens' => 120, 'refill_window_sec' => 300, 'burst' => 30],
    ];

    /** @var int Good bot elevated rate limits (multiplier) */
    private const GOOD_BOT_RATE_MULTIPLIER = 3;

    /** @var array Endpoint base risk scores (lower = safer) */
    private const ENDPOINT_BASE = [
        'WP_LOGIN'    => 0,  // Always bypass - handled by brute-force
        'REST_ENUM'   => 6,  // Enumeration-prone REST routes
        'REST_AUTH'   => 5,  // Auth-related REST routes
        'REST_PUBLIC' => 2,  // Public REST endpoints
        'XMLRPC'      => 8,  // Legacy, easily abused
        'WC_CHECKOUT' => 4,  // Payment flow - fail-open in balanced
        'WC_CART'     => 3,  // Add to cart
        'ADMIN_GUEST' => 5,  // Admin access without auth
        'PROBE_PATH'  => 10, // Vulnerability probe - immediate high risk
        'GENERIC'     => 1,  // Normal page requests
    ];

    /** @var array Risk score thresholds */
    private const THRESH_ALLOW     = 3;
    private const THRESH_OBSERVE   = 6;
    private const THRESH_CHALLENGE = 9;
    private const THRESH_SOFT      = 12;
    // Above THRESH_SOFT = HARD_BLOCK

    /** @var array Exponential backoff for hard blocks (minutes) */
    private const BLOCK_BACKOFF_MIN = [10, 30, 120, 720, 1440];

    /** @var int Challenge token validity (seconds) */
    private const CHALLENGE_TTL = 300;

    /** @var int Bad UA counter cache TTL (seconds) - 10 minutes */
    private const BAD_UA_COUNTER_TTL = 600;

    /** @var int Bad UA request count threshold for soft escalation (requests 1-3 = +5 risk) */
    private const BAD_UA_ESCALATION_THRESHOLD_1 = 3;

    /** @var int Bad UA request count threshold for hard escalation (requests 4-6 = +10 risk, 7+ = +15) */
    private const BAD_UA_ESCALATION_THRESHOLD_2 = 6;
    private const AUDIT_RETENTION = 604800;

    /** @var array Known vulnerability probe paths */
    private const PROBE_PATHS = [
        '/.env', '/.git', '/.svn', '/.htaccess', '/.htpasswd',
        '/wp-config.php~', '/wp-config.php.bak', '/wp-config.php.old', '/wp-config.php.save',
        '/wp-config.php.swp', '/wp-config.php.txt', '/wp-config.bak', '/wp-config.txt',
        '/vendor/phpunit', '/vendor/autoload.php', '/composer.json', '/composer.lock',
        '/phpmyadmin', '/pma', '/myadmin', '/mysql', '/adminer', '/dbadmin',
        '/setup.php', '/install.php', '/config.php', '/configuration.php',
        '/debug.php', '/phpinfo.php', '/info.php', '/test.php', '/backup.zip', '/backup.tar.gz',
        '/xmlrpc.php?rsd',
        '/readme.html', '/license.txt', '/wp-includes/version.php',
    ];

    /** @var array Known bad User-Agent signatures (vulnerability scanners) */
    private const BAD_UA_SIGNATURES = [
        'masscan', 'sqlmap', 'nikto', 'acunetix', 'zgrab', 'gobuster',
        'dirbuster', 'wpscan', 'nuclei', 'httpx', 'feroxbuster',
        'nmap', 'zmap', 'shodan', 'censys', 'internetmeasurement',
        'python-requests/', 'python-urllib/', 'python/', 'curl/',
        'wget/', 'go-http-client/', 'aiohttp/', 'httpie/', 'scrapy',
        'phantomjs', 'headless', 'puppeteer',
    ];

    /** @var array Good bot User-Agent patterns (case-insensitive) */
    private const GOOD_BOT_PATTERNS = [
        'googlebot', 'bingbot', 'slurp', 'duckduckbot', 'baiduspider',
        'yandexbot', 'facebookexternalhit', 'facebot', 'twitterbot',
        'linkedinbot', 'pinterest', 'applebot', 'ia_archiver',
        'msnbot', 'teoma', 'alexa', 'archive.org_bot',
    ];

    /** @var array Enumeration-prone REST route patterns */
    private const REST_ENUM_ROUTES = [
        '/wp-json/wp/v2/users',
        '/wp-json/wp/v2/posts',
        '/wp-json/wp/v2/pages',
        '/wp-json/wp/v2/media',
        '/wp-json/wp/v2/comments',
        '/wp-json/wp/v2/taxonomies',
        '/wp-json/wp/v2/categories',
        '/wp-json/wp/v2/tags',
        '/wp-json/oembed/',
    ];

    /** @var array REST auth route patterns */
    private const REST_AUTH_ROUTES = [
        '/wp-json/jwt-auth/',
        '/wp-json/wc/',
        '/wp-json/woocommerce/',
        '/wp-json/wp/v2/users/me',
    ];

    /* =======================================================================
     * Public API (bootstrap)
     * ======================================================================= */

    /**
     * init()
     * -----
     * Register WordPress hooks used by bot protection.
     *
     * Where to call:
     *  - Call once during plugin load (e.g., in main plugin file or loader).
     *
     * Hooks:
     *  - rest_authentication_errors (filter): Pre-auth gate for REST API (priority 0)
     *  - xmlrpc_call (action): Gate XML-RPC method calls (priority 0)
     *  - wp_loaded (action): Detect WooCommerce checkout/cart AJAX (priority 0)
     *  - template_redirect (action): Detect path sweeps and probes (priority 0)
     *
     * @return void
     */
    public static function init(): void
    {
        // Emergency bypass check
        if (self::is_emergency_bypass()) {
            return;
        }

        // Load settings and check if bot protection is enabled
        $settings = QutteraSettings::LoadFromOptions();
        if (!$settings || !$settings->GetBotProtectionEnabled()) {
            // Bot protection is disabled - skip hook registration
            $logger = new CQtrLogger();
            $logger->Info('Bot protection is disabled in settings, skipping hook registration');
            return;
        }

        // REST API pre-auth gate
        add_filter('rest_authentication_errors', [__CLASS__, 'on_rest_auth_errors'], 0);

        // XML-RPC method gate
        add_action('xmlrpc_call', [__CLASS__, 'on_xmlrpc_call'], 0);

        // WooCommerce and general request detection
        add_action('wp_loaded', [__CLASS__, 'on_wp_loaded'], 0);

        // General page request detection (for path sweep and probe)
        add_action('template_redirect', [__CLASS__, 'on_template_redirect'], 0);
    }

    /* =======================================================================
     * Hook callbacks
     * ======================================================================= */

    /**
     * on_rest_auth_errors()
     * --------------------
     * REST API authentication errors filter.
     *
     * Called before REST API authentication. Here we:
     *  - Respect existing authentication errors
     *  - Skip checks for logged-in users
     *  - Classify REST endpoint (enumeration, auth, public)
     *  - Evaluate request for bot signals
     *  - Block, challenge, or allow based on risk and mode
     *
     * IMPORTANT:
     *  - Runs at priority 0, earliest possible
     *  - Does not modify core WP auth logic
     *  - Fails open in observe mode
     *
     * @param WP_Error|null|true $result Existing auth result
     * @return WP_Error|null|true Updated auth result
     */
    public static function on_rest_auth_errors($result)
    {
        $logger = new CQtrLogger();
        // DIAGNOSTIC: Log hook entry
        $logger->Debug('[BOT_PROTECTION] on_rest_auth_errors HOOK FIRED - REQUEST_URI=' . ($_SERVER['REQUEST_URI'] ?? 'NOT SET'));
        
        // Respect existing auth errors
        if (!empty($result)) {
            $logger->Debug('[BOT_PROTECTION] Existing auth error found, returning');
            return $result;
        }

        // Skip for logged-in users
        if (is_user_logged_in()) {
            $logger->Debug('[BOT_PROTECTION] User logged in, returning');
            return $result;
        }

        $logger->Debug('[BOT_PROTECTION] About to classify REST endpoint');
        $endpoint_class = self::classify_rest_endpoint();
        $decision = self::evaluate_request($endpoint_class);

        if ($decision['action'] === 'ALLOW' || $decision['action'] === 'OBSERVE') {
            return $result;
        }

        if ($decision['action'] === 'CHALLENGE') {
            if (self::validate_challenge()) {
                return $result;
            }
            return new \WP_Error('qtr_bot_challenge', 'Challenge required.', ['status' => 429]);
        }

        if ($decision['action'] === 'SOFT_BLOCK') {
            return new \WP_Error('qtr_bot_rate_limit', 'Too many requests. Retry later.', ['status' => 429]);
        }

        return new \WP_Error('qtr_bot_blocked', 'Access denied.', ['status' => 403]);
    }

    /**
     * on_xmlrpc_call()
     * ----------------
     * XML-RPC method gate.
     *
     * Called for each XML-RPC method. Here we:
     *  - Skip checks for logged-in users
     *  - Evaluate request for bot signals (XMLRPC endpoint has high base risk)
     *  - Block if decision is not ALLOW or OBSERVE
     *
     * @param string $method XML-RPC method name
     * @return void
     */
    public static function on_xmlrpc_call(string $method): void
    {
        if (is_user_logged_in()) {
            return;
        }

        $decision = self::evaluate_request('XMLRPC');

        if ($decision['action'] === 'ALLOW' || $decision['action'] === 'OBSERVE') {
            return;
        }

        self::respond_block(403, 'Access denied.', $decision);
    }

    /**
     * on_wp_loaded()
     * --------------
     * WooCommerce checkout and cart detection.
     *
     * Called after WordPress is fully loaded. Here we:
     *  - Detect WooCommerce AJAX actions (wc-ajax=checkout, add_to_cart)
     *  - Evaluate request with endpoint-specific rules
     *  - Enforce decisions with fail-open behavior for checkout in balanced mode
     *
     * @return void
     */
    public static function on_wp_loaded(): void
    {
        //$uri = strtolower($_SERVER['REQUEST_URI'] ?? '');
        //$logger = new CQtrLogger();
        //$logger->Debug('CQtrBotProtection::on_wp_loaded() uri=' . $uri);

        if (is_user_logged_in()) {
            return;
        }

        // Detect WooCommerce AJAX actions
        $wc_ajax = isset($_GET['wc-ajax']) ? sanitize_text_field($_GET['wc-ajax']) : '';

        if ($wc_ajax === 'checkout') {
            $decision = self::evaluate_request('WC_CHECKOUT');
            self::enforce_woocommerce_decision($decision, 'checkout');
            return;
        }

        if ($wc_ajax === 'add_to_cart' || $wc_ajax === 'apply_coupon') {
            $decision = self::evaluate_request('WC_CART');
            self::enforce_woocommerce_decision($decision, 'cart');
            return;
        }
    }

    /**
     * on_template_redirect()
     * ----------------------
     * General page request handler (path sweeps and vulnerability probes).
     *
     * Called just before WordPress determines which template to load. Here we:
     *  - Skip login requests entirely (delegated to brute-force)
     *  - Detect vulnerability probe paths (/.env, /wp-config.php~, etc.)
     *  - Detect path sweep behavior (many distinct paths in time window)
     *  - Block hard blocks; allow soft actions (let WordPress handle 404)
     *
     * @return void
     */
    public static function on_template_redirect(): void
    {
        // Skip for logged-in users
        if (is_user_logged_in()) {
            return;
        }

        // Skip wp-login.php entirely - delegated to brute-force
        if (self::is_login_request()) {
            return;
        }

        // Check for probe paths (vulnerability scanners)
        $path = $_SERVER['REQUEST_URI'] ?? '';
        if (self::is_probe_path($path)) {
            $decision = self::evaluate_request('PROBE_PATH');
            if ($decision['action'] === 'HARD_BLOCK') {
                self::respond_block(403, 'Access denied.', $decision);
            }
            // Observe mode or soft actions: let WordPress handle 404
            return;
        }

        // General heuristic check for path sweeps
        $decision = self::evaluate_request('GENERIC');
        if ($decision['action'] === 'HARD_BLOCK') {
            self::respond_block(403, 'Access denied.', $decision);
        }
    }

    /* =======================================================================
     * Core Evaluation Engine
     * ======================================================================= */

    /**
     * evaluate_request()
     * ------------------
     * Evaluate a request and determine enforcement action.
     *
     * Process:
     *  1. Check login bypass (never reach here, safety net)
     *  2. Check allowlists (IP/CIDR, path regex, User-Agent)
     *  3. Check active block (from previous hard blocks)
     *  4. Check good bot status (grant elevated limits and challenge bypass)
     *  5. Compute heuristic risk signals
     *  6. Apply rate limit penalties
     *  7. Decide action based on total risk
     *  8. Apply side effects (set blocks, raise alarms, audit log)
     *
     * @param string $endpoint_class Endpoint classification
     * @return array Decision with action, risk, reasons, metadata
     */
    private static function evaluate_request(string $endpoint_class): array
    {
        $logger = new CQtrLogger();
        $ip = CQtrIPManager::ResolveClientIP();
        $ua = self::get_user_agent();
        $client_key = self::compute_client_key($ip, $ua);
        $ip_hash = self::hash_for_privacy($ip);
        $path = $_SERVER['REQUEST_URI'] ?? '';
        $mode = self::get_mode();
        $now = time();

        $logger->Info('CQtrBotProtection::evaluate_request() endpoint=' . $endpoint_class . ' ip=' . $ip . ' ua_len=' . strlen($ua));

        // 1) Check login bypass (should never reach here, but safety)
        if ($endpoint_class === 'WP_LOGIN') {
            $logger->Info('Login request detected, applying bypass');
            return self::build_decision('ALLOW', 0, ['login_bypass'], $endpoint_class, $ip, $client_key);
        }

        // 2) Check allowlists
        $allowlist_result = self::check_allowlists($ip, $path, $ua);
        if ($allowlist_result !== false) {
            $logger->Info('Allowlist match: ' . $allowlist_result);
            return self::build_decision('ALLOW', 0, [$allowlist_result], $endpoint_class, $ip, $client_key);
        }

        // 3) Check active block
        $block = CQtrRateLimitCache::get("botblock:{$client_key}");
        if (is_array($block) && isset($block['until']) && $now < (int)$block['until']) {
            $logger->Warning('Active block found, client_key=' . $client_key);
            $decision = self::build_decision('HARD_BLOCK', 999, ['active_block'], $endpoint_class, $ip, $client_key);
            self::audit($decision);
            return $decision;
        }

        // 4) Check good bot status
        $is_good_bot = self::is_good_bot($ua);
        if ($is_good_bot) {
            $logger->Info('Good bot detected, granting elevated limits');
            // Good bots bypass challenges and get higher rate limits
            $decision = self::evaluate_good_bot($endpoint_class, $client_key, $ip);
            return $decision;
        }

        // 5) Compute risk score
        $base = self::ENDPOINT_BASE[$endpoint_class] ?? self::ENDPOINT_BASE['GENERIC'];
        $reasons = [];

        // Get bad UA count for audit purposes
        $bad_ua_count_cache_key = "botprotection:bad_ua_count:{$client_key}";
        $bad_ua_count = (int)CQtrRateLimitCache::get($bad_ua_count_cache_key);

        // Heuristic signals
        [$heur_score, $heur_reasons] = self::compute_heuristics($endpoint_class, $ua, $path);
        $reasons = array_merge($reasons, $heur_reasons);

        // Rate limit penalties
        [$rate_score, $rate_reasons] = self::apply_rate_limits($endpoint_class, $client_key, false);
        $reasons = array_merge($reasons, $rate_reasons);

        $total_risk = $base + $heur_score + $rate_score;

        $logger->Debug('Risk computation: base=' . $base . ' heur=' . $heur_score . ' rate=' . $rate_score . ' total=' . $total_risk);

        // 6) Decide action based on risk and mode
        $action = self::decide_action($total_risk, $endpoint_class, $mode);

        // 7) Apply side effects
        $decision = self::build_decision($action, $total_risk, $reasons, $endpoint_class, $ip, $client_key, $bad_ua_count);
        self::apply_side_effects($decision, $client_key);

        $logger->Info('Decision: action=' . $action . ' risk=' . $total_risk . ' reasons=' . implode(',', $reasons));

        return $decision;
    }

    /**
     * evaluate_good_bot()
     * -------------------
     * Evaluate good bot with elevated rate limits.
     *
     * Good bots (Googlebot, Bingbot, etc.) are granted:
     *  - 3x elevated rate limits
     *  - Challenge bypass
     *  - But still subject to hard blocking for extreme abuse
     *
     * @param string $endpoint_class
     * @param string $client_key
     * @param string $ip
     * @return array Decision
     */
    private static function evaluate_good_bot(string $endpoint_class, string $client_key, string $ip): array
    {
        // Good bots get higher rate limits and bypass challenges
        [$rate_score, $rate_reasons] = self::apply_rate_limits($endpoint_class, $client_key, true);

        if ($rate_score > 0) {
            // Even good bots can be rate limited if abusing
            $action = 'SOFT_BLOCK';
            $reasons = array_merge(['good_bot'], $rate_reasons);
        } else {
            $action = 'ALLOW';
            $reasons = ['good_bot_allowed'];
        }

        return self::build_decision($action, $rate_score, $reasons, $endpoint_class, $ip, $client_key);
    }

    /* =======================================================================
     * Heuristic Detection
     * ======================================================================= */

    /**
     * compute_heuristics()
     * --------------------
     * Compute heuristic risk signals.
     *
     * Heuristics detect bot-like behavior patterns:
     *  - H1: Missing User-Agent (scripts often omit this)
     *  - H2: Missing Accept header on browser-like endpoints
     *  - H3: No cookies on checkout/cart (non-human browsing)
     *  - H4: Vulnerability probe path (/.env, /wp-config.php~, etc.)
     *  - H5: Path sweep (25+ distinct paths in 1 minute)
     *  - H6: Bad UA signature (sqlmap, nikto, wpscan, etc.)
     *  - H7: Endpoint mapping (50+ requests in 5m without cookies/referrer)
     *
     * @param string $endpoint_class
     * @param string $ua User-Agent
     * @param string $path Request path
     * @return array [score, reasons[]]
     */
    private static function compute_heuristics(string $endpoint_class, string $ua, string $path): array
    {
        $score = 0;
        $reasons = [];
        $client_key = self::compute_client_key(CQtrIPManager::ResolveClientIP(), $ua);

        // H1: Missing User-Agent
        if (trim($ua) === '') {
            $score += 3;
            $reasons[] = 'H1_MISSING_USER_AGENT';
        }

        // H2: Missing Accept header on browser-like endpoints
        $accept = $_SERVER['HTTP_ACCEPT'] ?? '';
        $browser_endpoints = ['REST_ENUM', 'REST_AUTH', 'WC_CHECKOUT', 'WC_CART'];
        if (trim($accept) === '' && in_array($endpoint_class, $browser_endpoints, true)) {
            $score += 1;
            $reasons[] = 'H2_MISSING_ACCEPT';
        }

        // H3: Missing cookies where expected (checkout)
        if (in_array($endpoint_class, ['WC_CHECKOUT', 'WC_CART'], true) && empty($_COOKIE)) {
            $score += 2;
            $reasons[] = 'H3_MISSING_COOKIES';
        }

        // H4: Probe path access (already classified, but add extra signal)
        if ($endpoint_class === 'PROBE_PATH' || self::is_probe_path($path)) {
            $score += 5;
            $reasons[] = 'H4_PROBE_PATH';
        }

        // H5: Path sweep detection (many distinct paths in time window)
        if (self::detect_path_sweep($client_key, $path)) {
            $score += 3;
            $reasons[] = 'H5_PATH_SWEEP';
        }

        // H6: Bad User-Agent signature (vulnerability scanner)
        // Progressive enforcement: +5 for requests 1-3, +10 for requests 4-6, +15 for requests 7+
        $bad_ua_info = self::is_bad_ua_signature($ua, $client_key);
        if ($bad_ua_info['detected']) {
            $bad_ua_count = $bad_ua_info['count'];
            
            if ($bad_ua_count <= self::BAD_UA_ESCALATION_THRESHOLD_1) {
                $score += 5;  // Requests 1-3: baseline risk
            } elseif ($bad_ua_count <= self::BAD_UA_ESCALATION_THRESHOLD_2) {
                $score += 10; // Requests 4-6: escalated risk
            } else {
                $score += 15; // Requests 7+: severe risk
            }
            
            $reasons[] = 'H6_BAD_UA_SIGNATURE';
        }

        // H7: Endpoint mapping behavior (sustained non-human navigation)
        if (self::detect_endpoint_mapping($client_key)) {
            $score += 2;
            $reasons[] = 'H7_ENDPOINT_MAPPING';
        }

        return [$score, $reasons];
    }

    /* =======================================================================
     * Rate Limiting
     * ======================================================================= */

    /**
     * apply_rate_limits()
     * -------------------
     * Apply token bucket rate limits.
     *
     * Each request consumes tokens from two buckets:
     *  1. Global lane: applies to all endpoints
     *  2. Endpoint-specific lane: applies only to the requested endpoint type
     *
     * If any bucket exhausts, penalty is added to risk score.
     * Good bots get 3x multiplier on all limits.
     *
     * @param string $endpoint_class
     * @param string $client_key
     * @param bool   $is_good_bot Apply elevated limits for good bots
     * @return array [penalty, reasons[]]
     */
    private static function apply_rate_limits(string $endpoint_class, string $client_key, bool $is_good_bot): array
    {
        $penalty = 0;
        $reasons = [];
        $multiplier = $is_good_bot ? self::GOOD_BOT_RATE_MULTIPLIER : 1;

        // Global lane
        $global_config = self::LANES['global'];
        $global_config['refill_tokens'] *= $multiplier;
        $global_config['burst'] *= $multiplier;

        if (!CQtrRateLimiter::consume('global', $client_key, $global_config)) {
            $penalty += 3;
            $reasons[] = 'RL_GLOBAL_EXHAUSTED';
        }

        // Endpoint-specific lane
        $lane = self::get_lane_for_endpoint($endpoint_class);
        if ($lane !== null && isset(self::LANES[$lane])) {
            $lane_config = self::LANES[$lane];
            $lane_config['refill_tokens'] *= $multiplier;
            $lane_config['burst'] *= $multiplier;

            if (!CQtrRateLimiter::consume($lane, $client_key, $lane_config)) {
                $penalty += 4;
                $reasons[] = 'RL_' . strtoupper($lane) . '_EXHAUSTED';
            }
        }

        return [$penalty, $reasons];
    }

    /**
     * get_lane_for_endpoint()
     * -----------------------
     * Map endpoint class to rate limit lane.
     *
     * @param string $endpoint_class
     * @return string|null Lane name or null if no specific lane
     */
    private static function get_lane_for_endpoint(string $endpoint_class): ?string
    {
        $map = [
            'REST_ENUM'   => 'rest',
            'REST_AUTH'   => 'rest',
            'REST_PUBLIC' => 'rest',
            'XMLRPC'      => 'xmlrpc',
            'WC_CHECKOUT' => 'checkout',
            'WC_CART'     => 'cart',
        ];
        return $map[$endpoint_class] ?? null;
    }

    /* =======================================================================
     * Decision Engine
     * ======================================================================= */

    /**
     * decide_action()
     * ---------------
     * Decide enforcement action based on risk score and mode.
     *
     * Risk thresholds:
     *  - ALLOW (risk <= 3): Pass through
     *  - OBSERVE (3 < risk <= 6): Log but allow
     *  - CHALLENGE (6 < risk <= 9): Require proof-of-work
     *  - SOFT_BLOCK (9 < risk <= 12): 429 Retry-After
     *  - HARD_BLOCK (risk > 12): 403 Forbidden
     *
     * Mode overrides:
     *  - observe: Never block, only log
     *  - balanced: Soft enforcement, fail-open for checkout
     *  - aggressive: Hard enforcement
     *
     * @param int    $risk Risk score
     * @param string $endpoint_class
     * @param string $mode Operation mode
     * @return string Action name
     */
    private static function decide_action(int $risk, string $endpoint_class, string $mode): string
    {
        // Observe mode: never enforce
        if ($mode === self::MODE_OBSERVE) {
            return $risk > self::THRESH_ALLOW ? 'OBSERVE' : 'ALLOW';
        }

        // Risk thresholds
        if ($risk <= self::THRESH_ALLOW) {
            return 'ALLOW';
        }
        if ($risk <= self::THRESH_OBSERVE) {
            return 'OBSERVE';
        }
        if ($risk <= self::THRESH_CHALLENGE) {
            // Challenge for endpoints with UI
            if (in_array($endpoint_class, ['WC_CHECKOUT', 'WC_CART'], true)) {
                return 'CHALLENGE';
            }
            return 'SOFT_BLOCK';
        }
        if ($risk <= self::THRESH_SOFT) {
            return 'SOFT_BLOCK';
        }

        // High risk: hard block
        // But in balanced mode, fail-open for checkout
        if ($mode === self::MODE_BALANCED && $endpoint_class === 'WC_CHECKOUT') {
            return 'SOFT_BLOCK';
        }

        return 'HARD_BLOCK';
    }

    /**
     * build_decision()
     * ----------------
     * Build decision array with all required fields.
     *
     * @param string $action Action name
     * @param int    $risk Risk score
     * @param array  $reasons Reason codes
     * @param string $endpoint Endpoint classification
     * @param string $ip Client IP
     * @param string $client_key Client key hash
     * @param int    $bad_ua_count Bad UA request count (optional)
     * @return array Decision array
     */
    private static function build_decision(string $action, int $risk, array $reasons, string $endpoint, string $ip, string $client_key, int $bad_ua_count = 0): array
    {
        $minutes = null;
        if ($action === 'HARD_BLOCK') {
            $minutes = self::compute_backoff_minutes($client_key);
        } elseif ($action === 'SOFT_BLOCK') {
            $minutes = 2; // 2 minute soft block
        }

        return [
            'action'     => $action,
            'risk'       => $risk,
            'reasons'    => $reasons,
            'endpoint'   => $endpoint,
            'ip'         => $ip,
            'ip_hash'    => self::hash_for_privacy($ip),
            'client_key' => $client_key,
            'minutes'    => $minutes,
            'timestamp'  => time(),
            'mode'       => self::get_mode(),
            'bad_ua_count' => $bad_ua_count,
        ];
    }

    /**
     * apply_side_effects()
     * --------------------
     * Apply side effects: set blocks, raise alarms, audit log.
     *
     * @param array  $decision Decision array
     * @param string $client_key Client key hash
     * @return void
     */
    private static function apply_side_effects(array $decision, string $client_key): void
    {
        // Audit all non-ALLOW decisions
        if ($decision['action'] !== 'ALLOW') {
            self::audit($decision);
        }

        // Set blocks
        if ($decision['action'] === 'SOFT_BLOCK') {
            self::set_block($client_key, (int)$decision['minutes'], 429, $decision['reasons'], $decision['risk'], $decision['ip']);
        } elseif ($decision['action'] === 'HARD_BLOCK') {
            self::set_block($client_key, (int)$decision['minutes'], 403, $decision['reasons'], $decision['risk'], $decision['ip']);
            CQtrRateLimitCache::incr("botblock_count:{$client_key}", 2 * 24 * 3600, 1);
            self::raise_alarm('Bot protection hard block', $decision);
        } elseif ($decision['action'] === 'CHALLENGE') {
            // Store challenge requirement
            CQtrRateLimitCache::set("botchallenge:{$client_key}", ['issued' => time()], self::CHALLENGE_TTL);
        }
    }

    /* =======================================================================
     * Challenge Mechanism
     * ======================================================================= */

    /**
     * validate_challenge()
     * --------------------
     * Validate server-verifiable challenge.
     *
     * Challenge mechanism: timestamp + HMAC token
     * Client must include header X-Qtr-Challenge: timestamp:hmac
     *
     * Token is valid for CHALLENGE_TTL (300 seconds).
     * Prevents challenge replay via timestamp verification.
     *
     * @return bool True if challenge is valid
     */
    private static function validate_challenge(): bool
    {
        $header = $_SERVER['HTTP_X_QTR_CHALLENGE'] ?? '';
        if (empty($header)) {
            return false;
        }

        $parts = explode(':', $header, 2);
        if (count($parts) !== 2) {
            return false;
        }

        [$timestamp, $provided_hmac] = $parts;
        $timestamp = (int)$timestamp;
        $now = time();

        // Check timestamp is recent (within 5 minutes)
        if (abs($now - $timestamp) > self::CHALLENGE_TTL) {
            return false;
        }

        // Verify HMAC
        $secret = self::get_challenge_secret();
        $ip = CQtrIPManager::ResolveClientIP();
        $expected_hmac = hash_hmac('sha256', $timestamp . ':' . $ip, $secret);

        return hash_equals($expected_hmac, $provided_hmac);
    }

    /**
     * generate_challenge_token()
     * ---------------------------
     * Generate challenge token for response header.
     *
     * @return string Token in format "timestamp:hmac"
     */
    public static function generate_challenge_token(): string
    {
        $timestamp = time();
        $ip = CQtrIPManager::ResolveClientIP();
        $secret = self::get_challenge_secret();
        $hmac = hash_hmac('sha256', $timestamp . ':' . $ip, $secret);
        return $timestamp . ':' . $hmac;
    }

    /**
     * get_challenge_secret()
     * ----------------------
     * Get challenge secret (from WordPress auth keys).
     *
     * Uses AUTH_KEY if defined, otherwise uses site URL.
     *
     * @return string Secret key
     */
    private static function get_challenge_secret(): string
    {
        if (defined('AUTH_KEY') && AUTH_KEY !== '') {
            return AUTH_KEY;
        }
        return 'qtr_default_challenge_secret_' . get_site_url();
    }

    /* =======================================================================
     * Classification Helpers
     * ======================================================================= */

    /**
     * is_login_request()
     * ------------------
     * Check if request is to wp-login.php.
     *
     * @return bool True if login request
     */
    private static function is_login_request(): bool
    {
        $script = $_SERVER['SCRIPT_NAME'] ?? '';
        $uri = $_SERVER['REQUEST_URI'] ?? '';

        return (strpos($script, 'wp-login.php') !== false ||
                strpos($uri, 'wp-login.php') !== false ||
                strpos($uri, '/login') !== false);
    }

    /**
     * classify_rest_endpoint()
     * ------------------------
     * Classify REST endpoint as enumeration, auth, or public.
     *
     * @return string Endpoint class
     */
    private static function classify_rest_endpoint(): string
    {
        $uri = strtolower($_SERVER['REQUEST_URI'] ?? '');
        
        // Check enumeration-prone routes
        foreach (self::REST_ENUM_ROUTES as $pattern) {
            if (strpos($uri, $pattern) !== false) {
                return 'REST_ENUM';
            }
        }

        // Check auth routes
        foreach (self::REST_AUTH_ROUTES as $pattern) {
            if (strpos($uri, $pattern) !== false) {
                return 'REST_AUTH';
            }
        }

        return 'REST_PUBLIC';
    }

    /**
     * is_probe_path()
     * ---------------
     * Check if path is a known vulnerability probe.
     *
     * @param string $path Request path
     * @return bool True if probe path
     */
    private static function is_probe_path(string $path): bool
    {
        $path_lower = strtolower($path);
        foreach (self::PROBE_PATHS as $probe) {
            if (strpos($path_lower, $probe) !== false) {
                return true;
            }
        }
        return false;
    }

    /**
     * is_bad_ua_signature()
     * ----------------------
     * Check if User-Agent matches bad signature and track request count.
     *
     * Returns array with 'detected' (bool) and 'count' (int) keys.
     * Increments counter in cache with TTL for progressive enforcement.
     *
     * @param string $ua User-Agent string
     * @param string $client_key Client key hash for cache tracking
     * @return array ['detected' => bool, 'count' => int]
     */
    private static function is_bad_ua_signature(string $ua, string $client_key): array
    {
        $ua_lower = strtolower($ua);
        $detected = false;

        // Check if UA matches any bad signature
        foreach (self::BAD_UA_SIGNATURES as $sig) {
            if ($sig !== '' && strpos($ua_lower, $sig) !== false) {
                $detected = true;
                break;
            }
        }

        // If no bad signature detected, return early
        if (!$detected) {
            return ['detected' => false, 'count' => 0];
        }

        // Increment counter for bad UA requests
        $cache_key = "botprotection:bad_ua_count:{$client_key}";
        $count = (int)CQtrRateLimitCache::get($cache_key);
        $count++;

        // Store updated count with TTL
        CQtrRateLimitCache::set($cache_key, $count, self::BAD_UA_COUNTER_TTL);

        return ['detected' => true, 'count' => $count];
    }

    /**
     * is_good_bot()
     * --------------
     * Check if User-Agent is a known good bot.
     *
     * Recognizes major search engines and social media crawlers.
     * Extensible via qtr_bot_is_good_bot filter.
     *
     * @param string $ua User-Agent string
     * @return bool True if good bot
     */
    private static function is_good_bot(string $ua): bool
    {
        $ua_lower = strtolower($ua);
        foreach (self::GOOD_BOT_PATTERNS as $pattern) {
            if (strpos($ua_lower, $pattern) !== false) {
                return true;
            }
        }

        // Custom filter for additional good bots
        return (bool)apply_filters('qtr_bot_is_good_bot', false, $ua);
    }

    /**
     * detect_path_sweep()
     * --------------------
     * Detect path sweep behavior (many distinct paths in time window).
     *
     * Tracks distinct paths per minute. If 25+ paths in 1 minute, flags as sweep.
     * Useful for detecting automated scanners crawling the site.
     *
     * @param string $client_key Client key hash
     * @param string $path Request path
     * @return bool True if sweep detected
     */
    private static function detect_path_sweep(string $client_key, string $path): bool
    {
        $bucket = (int)floor(time() / 60);
        $key = "botpaths:{$client_key}:{$bucket}";

        $paths = CQtrRateLimitCache::get($key);
        if (!is_array($paths)) {
            $paths = [];
        }

        $path_hash = substr(hash('sha256', substr($path, 0, 200)), 0, 16);
        $paths[$path_hash] = 1;

        // Limit storage
        if (count($paths) > 100) {
            $paths = array_slice($paths, -100, null, true);
        }

        CQtrRateLimitCache::set($key, $paths, 180);

        // Threshold: 25+ distinct paths in 1 minute = sweep
        return count($paths) >= 25;
    }

    /**
     * detect_endpoint_mapping()
     * --------------------------
     * Detect endpoint mapping behavior (sustained non-human navigation).
     *
     * Tracks rapid requests without cookies/referrer. If 50+ requests in 5 minutes
     * with no cookies and no referrer, flags as automated endpoint enumeration.
     *
     * @param string $client_key Client key hash
     * @return bool True if mapping detected
     */
    private static function detect_endpoint_mapping(string $client_key): bool
    {
        $key = "botmap:{$client_key}";
        $count = CQtrRateLimitCache::get($key);
        $count = is_numeric($count) ? (int)$count + 1 : 1;

        CQtrRateLimitCache::set($key, $count, 300);

        // 50+ requests in 5 minutes without cookies/referrer = mapping
        $has_cookies = !empty($_COOKIE);
        $has_referer = !empty($_SERVER['HTTP_REFERER']);

        if (!$has_cookies && !$has_referer && $count >= 50) {
            return true;
        }

        return false;
    }

    /* =======================================================================
     * Allowlist Checking
     * ======================================================================= */

    /**
     * check_allowlists()
     * ------------------
     * Check all allowlists (IP/CIDR, path, User-Agent).
     *
     * Checks in order:
     *  1. IP/CIDR allowlist via CQtrIPManager
     *  2. Path regex allowlist via filter qtr_bot_allowlist_paths
     *  3. User-Agent regex allowlist via filter qtr_bot_allowlist_user_agents
     *
     * @param string $ip Client IP
     * @param string $path Request path
     * @param string $ua User-Agent
     * @return string|false Allowlist match reason or false
     */
    private static function check_allowlists(string $ip, string $path, string $ua)
    {
        // IP/CIDR allowlist
        if (CQtrIPManager::IsAllowlisted($ip, 'qtr_bot')) {
            return 'ALLOWLIST_IP';
        }

        // Path allowlist (filter-based)
        $allowed_paths = apply_filters('qtr_bot_allowlist_paths', []);
        if (is_array($allowed_paths)) {
            foreach ($allowed_paths as $pattern) {
                if (@preg_match($pattern, $path)) {
                    return 'ALLOWLIST_PATH';
                }
            }
        }

        // User-Agent allowlist (filter-based)
        $allowed_uas = apply_filters('qtr_bot_allowlist_user_agents', []);
        if (is_array($allowed_uas)) {
            foreach ($allowed_uas as $pattern) {
                if (@preg_match($pattern, $ua)) {
                    return 'ALLOWLIST_UA';
                }
            }
        }

        return false;
    }

    /* =======================================================================
     * Blocking and Response
     * ======================================================================= */

    /**
     * set_block()
     * -----------
     * Set a block for a client.
     *
     * Stores block record in cache with TTL. Multiple blocks increase backoff.
     * Also updates the active blocks index for efficient retrieval via GetActiveLockedIPs().
     *
     * @param string $client_key Client key hash
     * @param int    $minutes Block duration in minutes
     * @param int    $http_code HTTP status code (429 or 403)
     * @param array  $reasons Reason codes for this block
     * @param int    $risk Risk score (optional, for index)
     * @return void
     */
    private static function set_block(string $client_key, int $minutes, int $http_code, array $reasons, int $risk = 0, string $ip = ''): void
    {
        $ttl = max(60, $minutes * 60 + 60);
        CQtrRateLimitCache::set("botblock:{$client_key}", [
            'until'   => time() + ($minutes * 60),
            'code'    => $http_code,
            'reasons' => array_slice($reasons, 0, 10),
            'ip'      => $ip,
        ], $ttl);

        // Update active blocks index for dashboard retrieval
        self::update_active_blocks_index($client_key, $minutes, $reasons, $risk, $ip);
    }

    /**
     * compute_backoff_minutes()
     * --------------------------
     * Compute backoff minutes based on block history.
     *
     * Implements exponential backoff: [10, 30, 120, 720, 1440] minutes.
     * Each hard block increments counter; backoff increases with repeat blocks.
     *
     * @param string $client_key Client key hash
     * @return int Backoff duration in minutes
     */
    private static function compute_backoff_minutes(string $client_key): int
    {
        $count = CQtrRateLimitCache::get("botblock_count:{$client_key}");
        $count = is_numeric($count) ? (int)$count : 0;
        $idx = min($count, count(self::BLOCK_BACKOFF_MIN) - 1);
        return (int)self::BLOCK_BACKOFF_MIN[$idx];
    }

    /**
     * update_active_blocks_index()
     * ----------------------------
     * Update the master index of active blocks.
     *
     * Maintains a cache-based index for efficient retrieval of all currently
     * blocked IPs/clients. Used by GetActiveLockedIPs() for dashboard display.
     *
     * @param string $client_key Client key hash
     * @param int    $minutes Block duration in minutes
     * @param array  $reasons Reason codes
     * @param int    $risk Risk score
     * @return void
     */
    private static function update_active_blocks_index(string $client_key, int $minutes, array $reasons, int $risk = 0, string $ip = ''): void
    {
        $now = time();
        $expires_at = $now + ($minutes * 60);
        $ttl = ($minutes * 60) + 300; // TTL = block duration + 5m buffer

        // Get current index
        $index = CQtrRateLimitCache::get('botactive_blocks_index');
        if (!is_array($index)) {
            $index = [];
        }

        // Add/update entry in index
        $index[$client_key] = [
            'blocked_at'    => $now,
            'expires_at'    => $expires_at,
            'ttl'           => $minutes * 60,
            'reasons'       => array_slice($reasons, 0, 10),
            'risk'          => $risk,
            'ip'            => $ip,
        ];

        // Keep index size reasonable (max 10,000 entries)
        if (count($index) > 10000) {
            // Remove oldest 1000 entries
            $index = array_slice($index, 1000, null, true);
        }

        // Store updated index
        CQtrRateLimitCache::set('botactive_blocks_index', $index, $ttl);
    }

    /**
     * remove_from_active_blocks_index()
     * -----------------------------------
     * Remove IP/client from active blocks index.
     *
     * Called when manually unlocking an IP via admin dashboard.
     *
     * @param string $client_key Client key hash
     * @return void
     */
    private static function remove_from_active_blocks_index(string $client_key): void
    {
        $index = CQtrRateLimitCache::get('botactive_blocks_index');
        if (!is_array($index)) {
            return;
        }

        unset($index[$client_key]);

        // Re-store updated index
        if (!empty($index)) {
            CQtrRateLimitCache::set('botactive_blocks_index', $index, 24 * 3600);
        } else {
            // Clear empty index
            CQtrRateLimitCache::delete('botactive_blocks_index');
        }
    }

    /**
     * GetActiveLockedIPs()
     * ---------------------
     * PUBLIC API: Get all currently active blocks.
     *
     * Returns array of all blocked client_keys with:
     *  - blocked_at: Unix timestamp when block was set
     *  - expires_at: Unix timestamp when block expires
     *  - expiry_relative: Human-readable time until expiry
     *  - ttl: Original TTL in seconds
     *  - reasons: Array of reason codes
     *  - risk: Risk score
     *  - status: 'active' or 'expired'
     *
     * Performance: O(1) cache lookup, returns in 1-5ms.
     *
     * Usage (admin dashboard):
     *  $blocked = CQtrBotProtection::GetActiveLockedIPs();
     *  foreach ($blocked as $client_key => $info) {
     *      echo "Blocked: " . $info['expiry_relative'] . " ago\n";
     *  }
     *
     * @return array Array of [client_key => [blocked_at, expires_at, expiry_relative, ttl, reasons, risk, status]]
     */
    public static function GetActiveLockedIPs(): array
    {
        $index = CQtrRateLimitCache::get('botactive_blocks_index');
        if (!is_array($index) || empty($index)) {
            return [];
        }

        $now = time();
        $result = [];

        foreach ($index as $client_key => $entry) {
            $expires_at = (int)($entry['expires_at'] ?? $now);
            $status = $now < $expires_at ? 'active' : 'expired';
            $seconds_remaining = max(0, $expires_at - $now);

            $result[$client_key] = [
                'blocked_at'       => (int)($entry['blocked_at'] ?? $now),
                'expires_at'       => $expires_at,
                'expiry_relative'  => self::format_seconds_human($seconds_remaining),
                'ttl'              => (int)($entry['ttl'] ?? 0),
                'reasons'          => (array)($entry['reasons'] ?? []),
                'risk'             => (int)($entry['risk'] ?? 0),
                'ip'               => (string)($entry['ip'] ?? ''),
                'status'           => $status,
            ];
        }

        return $result;
    }

    /**
     * ClearBotBlock()
     * ---------------
     * Manually clears a bot protection block for a specific client.
     * Called by admin to manually unlock a client.
     *
     * @param string $client_key The client key (IP or fingerprint)
     * @return bool true if block was cleared, false if not found
     */
    public static function ClearBotBlock(string $client_key): bool
    {
        $logger = new CQtrLogger();

        if (empty($client_key)) {
            $logger->Warning('ClearBotBlock called with empty client_key');
            return false;
        }

        // Check if block exists in the index (more reliable than individual cache)
        $index = CQtrRateLimitCache::get('botactive_blocks_index');
        if (!is_array($index) || !isset($index[$client_key])) {
            $logger->Info('No active bot protection block found in index for client_key=' . $client_key);
            return false;
        }

        // Delete from individual cache (if exists)
        CQtrRateLimitCache::delete("botblock:{$client_key}");
        
        // Delete from index
        self::remove_from_active_blocks_index($client_key);

        $logger->Info('Bot protection block cleared for client_key=' . $client_key);

        return true;
    }

    /**
     * CleanupExpiredBlocks()
     * ----------------------
     * Remove expired blocks from the active blocks index.
     * Called periodically via WordPress cron (every 15 minutes).
     * Also removes individual block cache entries.
     *
     * @return int Number of expired blocks removed
     */
    public static function CleanupExpiredBlocks(): int
    {
        $logger = new CQtrLogger();
        
        $index = CQtrRateLimitCache::get('botactive_blocks_index');
        if (!is_array($index) || empty($index)) {
            return 0;
        }

        $now = time();
        $removed = 0;

        foreach ($index as $client_key => $entry) {
            $expires_at = (int)($entry['expires_at'] ?? $now);
            
            // If expired, remove from index and delete individual block record
            if ($now >= $expires_at) {
                unset($index[$client_key]);
                CQtrRateLimitCache::delete("botblock:{$client_key}");
                $removed++;
            }
        }

        // Update index if any were removed
        if ($removed > 0) {
            if (empty($index)) {
                CQtrRateLimitCache::delete('botactive_blocks_index');
            } else {
                $ttl = 24 * 3600;
                CQtrRateLimitCache::set('botactive_blocks_index', $index, $ttl);
            }
            
            $logger->Info("Cleaned up {$removed} expired bot protection blocks");
        }

        return $removed;
    }


    /**
     * CleanupDatabaseTransients()
     * ---------------------------
     * Remove expired transients from WordPress database.
     * 
     * Called periodically to clean up accumulated expired transients.
     * Runs directly on database for efficiency.
     * 
     * Note: Only checks bot protection transients (qtrbp_* prefix)
     *
     * @return int Number of transient pairs deleted
     */
    public static function CleanupDatabaseTransients(): int
    {
        global $wpdb;
        $logger = new CQtrLogger();
        
        $now = time();
        
        // DELETE expired transients (check timeout values)
        // For each _transient_timeout_* that's in the past, 
        // delete both the timeout and the actual transient
        
        $deleted = $wpdb->query($wpdb->prepare(
            "DELETE o1 FROM {$wpdb->options} o1 
            WHERE o1.option_name LIKE %s
            AND CAST(o1.option_value AS UNSIGNED) < %d",
            '_transient_timeout_qtrbp_%',
            $now
        ));
        
        // Also delete orphaned transient values (without timeout entries)
        $deleted += $wpdb->query($wpdb->prepare(
            "DELETE o1 FROM {$wpdb->options} o1
            WHERE o1.option_name LIKE %s
            AND o1.option_name NOT LIKE %s
            AND NOT EXISTS (
                SELECT 1 FROM {$wpdb->options} o2
                WHERE o2.option_name = CONCAT('_transient_timeout_', 
                    SUBSTRING(o1.option_name, 12))
            )",
            '_transient_qtrbp_%',
            '_transient_timeout_%'
        ));
        
        if ($deleted > 0) {
            $logger->Info("Cleaned up {$deleted} expired bot protection transients from database");
        }
        
        return $deleted;
    }

    /**
     * format_seconds_human()
     * ----------------------
     * Convert seconds to human-readable duration string.
     *
     * Examples:
     *  - 45 seconds => "45 seconds"
     *  - 65 seconds => "1 minute, 5 seconds"
     *  - 3661 seconds => "1 hour, 1 minute, 1 second"
     *  - 86400 seconds => "1 day"
     *
     * @param int $seconds Duration in seconds
     * @return string Human-readable duration (e.g. "1 hour, 30 minutes")
     */
    private static function format_seconds_human(int $seconds): string
    {
        if ($seconds <= 0) {
            return 'just now';
        }

        $units = [
            ['day', 86400],
            ['hour', 3600],
            ['minute', 60],
            ['second', 1],
        ];

        $parts = [];
        $remaining = $seconds;

        foreach ($units as [$unit, $divisor]) {
            if ($remaining >= $divisor) {
                $count = (int)floor($remaining / $divisor);
                $remaining %= $divisor;
                $parts[] = $count . ' ' . $unit . ($count !== 1 ? 's' : '');
            }
        }

        // Limit to 2 parts for brevity
        $parts = array_slice($parts, 0, 2);
        return implode(', ', $parts);
    }

    /**
     * enforce_woocommerce_decision()
     * --------------------------------
     * Enforce WooCommerce decision (special handling for fail-open).
     *
     * In balanced mode, checkout flow is downgraded to soft block (429).
     * This prevents payment gateway timeouts while still protecting cart.
     *
     * @param array  $decision Decision array
     * @param string $type 'checkout' or 'cart'
     * @return void
     */
    private static function enforce_woocommerce_decision(array $decision, string $type): void
    {
        $mode = self::get_mode();

        if ($decision['action'] === 'ALLOW' || $decision['action'] === 'OBSERVE') {
            return;
        }

        if ($decision['action'] === 'CHALLENGE') {
            if (self::validate_challenge()) {
                return;
            }
            // In balanced mode, fail-open for checkout
            if ($mode === self::MODE_BALANCED && $type === 'checkout') {
                return;
            }
            self::respond_block(429, 'Challenge required.', $decision);
        }

        if ($decision['action'] === 'SOFT_BLOCK') {
            // In balanced mode, fail-open for checkout
            if ($mode === self::MODE_BALANCED && $type === 'checkout') {
                return;
            }
            self::respond_block(429, 'Too many requests. Retry later.', $decision);
        }

        if ($decision['action'] === 'HARD_BLOCK') {
            // In balanced mode, downgrade checkout to 429
            if ($mode === self::MODE_BALANCED && $type === 'checkout') {
                self::respond_block(429, 'Too many requests. Retry later.', $decision);
            }
            self::respond_block(403, 'Access denied.', $decision);
        }
    }

    /**
     * respond_block()
     * ---------------
     * Send block response and exit.
     *
     * Sets appropriate HTTP status and headers, then exits with message.
     * For 429 responses, includes challenge token in header.
     *
     * @param int    $status HTTP status code
     * @param string $message User-facing message
     * @param array  $decision Decision array for logging
     * @return void (exits)
     */
    private static function respond_block(int $status, string $message, array $decision): void
    {
        if ($status === 429) {
            header('Retry-After: 120');
        }

        // Include challenge token in response for 429
        if ($status === 429) {
            header('X-Qtr-Challenge-Token: ' . self::generate_challenge_token());
        }

        wp_die(
            esc_html($message),
            esc_html__('Security Check', 'quttera'),
            [
                'response'  => $status,
                'back_link' => true,
            ]
        );
    }

    /* =======================================================================
     * Utility Methods
     * ======================================================================= */

    /**
     * get_user_agent()
     * ----------------
     * Get current User-Agent header.
     *
     * @return string User-Agent string (empty if not present)
     */
    private static function get_user_agent(): string
    {
        $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
        return is_string($ua) ? trim($ua) : '';
    }

    /**
     * compute_client_key()
     * --------------------
     * Compute client key (IP + UA hash).
     *
     * Combines IP and User-Agent into a single identifier for rate limiting.
     * Different UAs from same IP are tracked separately.
     *
     * @param string $ip Client IP
     * @param string $ua User-Agent
     * @return string Client key hash (32 chars)
     */
    private static function compute_client_key(string $ip, string $ua): string
    {
        $ua_hash = substr(hash('sha256', $ua), 0, 16);
        return substr(hash('sha256', $ip . ':' . $ua_hash), 0, 32);
    }

    /**
     * hash_for_privacy()
     * ------------------
     * Hash IP for privacy.
     *
     * Creates one-way hash of IP for storage. Cannot be reversed to IP.
     * Useful for audit logs where storing raw IPs is undesired.
     *
     * @param string $ip Client IP
     * @return string IP hash (16 chars)
     */
    private static function hash_for_privacy(string $ip): string
    {
        return substr(hash('sha256', $ip . ':' . self::get_challenge_secret()), 0, 16);
    }

    /**
     * get_mode()
     * -----------
     * Get current operation mode.
     *
     * Checks in order:
     *  1. Constant QTR_BOT_MODE
     *  2. Filter qtr_bot_mode
     *  3. Default to MODE_BALANCED
     *
     * @return string Mode name
     */
    private static function get_mode(): string
    {
        // Check constant override
        if (defined('QTR_BOT_MODE')) {
            $mode = QTR_BOT_MODE;
            if (in_array($mode, [self::MODE_OBSERVE, self::MODE_BALANCED, self::MODE_AGGRESSIVE], true)) {
                return $mode;
            }
        }

        // Check filter
        $mode = apply_filters('qtr_bot_mode', self::MODE_BALANCED);
        if (in_array($mode, [self::MODE_OBSERVE, self::MODE_BALANCED, self::MODE_AGGRESSIVE], true)) {
            return $mode;
        }

        return self::MODE_BALANCED;
    }

    /**
     * is_emergency_bypass()
     * ----------------------
     * Check emergency bypass.
     *
     * Allows complete disable of bot protection for emergencies:
     *  - Constant: QTR_BOT_BYPASS = true
     *  - Filter: qtr_bot_emergency_bypass = true
     *
     * @return bool True if bypass enabled
     */
    private static function is_emergency_bypass(): bool
    {
        // Constant bypass
        if (defined('QTR_BOT_BYPASS') && QTR_BOT_BYPASS === true) {
            return true;
        }

        // Filter bypass
        return (bool)apply_filters('qtr_bot_emergency_bypass', false);
    }

    /* =======================================================================
     * Audit and Logging
     * ======================================================================= */

    /**
     * audit()
     * -------
     * Audit a decision.
     *
     * Stores audit event in cache with TTL (7 days default).
     * Updates rolling stats counter for each action.
     * Logs to CQtrLogger if available.
     *
     * @param array $decision Decision array
     * @return void
     */
    private static function audit(array $decision): void
    {
        // Store audit event
        $audit_key = 'botaudit:' . time() . ':' . $decision['client_key'];
        $audit_data = [
            'timestamp' => $decision['timestamp'],
            'action'    => $decision['action'],
            'risk'      => $decision['risk'],
            'reasons'   => $decision['reasons'],
            'endpoint'  => $decision['endpoint'],
            'ip_hash'   => $decision['ip_hash'],
            'mode'      => $decision['mode'],
        ];

        // Include bad UA count if present
        if (isset($decision['bad_ua_count']) && $decision['bad_ua_count'] > 0) {
            $audit_data['bad_ua_count'] = $decision['bad_ua_count'];
        }

        CQtrRateLimitCache::set($audit_key, $audit_data, self::AUDIT_RETENTION);

        // Rolling stats
        CQtrRateLimitCache::incr('botstats:' . strtolower($decision['action']), 6 * 3600, 1);

        // Log via CQtrLogger if available
        $logger = new CQtrLogger();
        $log_msg = sprintf(
            '[BotProtect] %s %s risk=%d reasons=%s ip=%s ip_hash=%s',
            $decision['endpoint'],
            $decision['action'],
            $decision['risk'],
            implode(',', $decision['reasons']),
            $decision['ip'],
            $decision['ip_hash']
        );

        // Append bad UA count if present
        if (isset($decision['bad_ua_count']) && $decision['bad_ua_count'] > 0) {
            $log_msg .= ' bad_ua_count=' . $decision['bad_ua_count'];
        }

        $logger->Warning($log_msg);
    }

    /**
     * get_reason_explanations()
     * -------------------------
     * Convert reason codes to human-readable explanation text.
     *
     * Maps technical reason codes (H1_MISSING_USER_AGENT, RL_GLOBAL_EXHAUSTED, etc.)
     * to user-friendly descriptions.
     *
     * @param array $reasons Array of reason codes
     * @return string Human-readable explanation
     */
    private static function get_reason_explanations(array $reasons): string
    {
        $explanations = [];
        
        $reason_map = [
            // Heuristic reasons
            'H1_MISSING_USER_AGENT'     => 'Missing User-Agent header',
            'H2_MISSING_ACCEPT'         => 'Missing Accept header',
            'H3_MISSING_COOKIES'        => 'Missing cookies',
            'H4_PROBE_PATH'             => 'Vulnerability probe detected',
            'H5_PATH_SWEEP'             => 'Path enumeration sweep',
            'H6_BAD_UA_SIGNATURE'       => 'Suspicious User-Agent signature',
            'H7_ENDPOINT_MAPPING'       => 'Endpoint mapping probe',
            
            // Rate limiting reasons
            'RL_GLOBAL_EXHAUSTED'       => 'Global rate limit exceeded',
            'RL_REST_EXHAUSTED'         => 'REST API rate limit exceeded',
            'RL_XMLRPC_EXHAUSTED'       => 'XML-RPC rate limit exceeded',
            'RL_CHECKOUT_EXHAUSTED'     => 'Checkout rate limit exceeded',
            'RL_CART_EXHAUSTED'         => 'Cart rate limit exceeded',
            
            // Block status
            'blocked_active'            => 'Previously blocked IP',
            'good_bot'                  => 'Recognized good bot',
            'good_bot_allowed'          => 'Good bot with elevated limits',
        ];
        
        foreach ($reasons as $reason) {
            $explanations[] = isset($reason_map[$reason]) 
                ? $reason_map[$reason] 
                : $reason;
        }
        
        return implode(' + ', $explanations);
    }

    /**
     * get_risk_level_text()
     * ---------------------
     * Convert numeric risk score to text level.
     *
     * Maps risk thresholds to human-readable severity levels.
     *
     * @param int $risk Numeric risk score
     * @return string Risk level text (info|warning|critical|emergency)
     */
    private static function get_risk_level_text(int $risk): string
    {
        if ($risk <= self::THRESH_ALLOW) {
            return 'info';
        } elseif ($risk <= self::THRESH_OBSERVE) {
            return 'warning';
        } elseif ($risk <= self::THRESH_CHALLENGE) {
            return 'critical';
        } else {
            return 'emergency';
        }
    }

    /**
     * raise_alarm()
     * --------------
     * Raise alarm for severe activity.
     *
     * Called for hard blocks. Integrates with CQtrAlarmsManager if available.
     * Provides structured alert for security dashboard/email notifications.
     * Severity is automatically determined based on risk classification.
     *
     * @param string $message Alert message
     * @param array  $decision Decision array for context
     * @return void
     */
    private static function raise_alarm(string $message, array $decision): void
    {
        $risk_level = self::get_risk_level_text($decision['risk']);
        $reason = self::get_reason_explanations($decision['reasons']);
        
        CQtrAlarmsManager::raise(
            CQtrAlarmTypes::TYPE_BOT_BLOCK,
            [
                'ip' => $decision['ip'],
                'risk_level' => $risk_level,
                'reason' => $reason,
                'endpoint' => $decision['endpoint'],
                'timestamp' => current_time('mysql')
            ],
            false
        );
    }
}
