<?php
/**
 * CQtrBruteForce — Brute-force login protection for WordPress
 *
 * WHAT IT DOES (high-level)
 *  - Watches login attempts and applies progressive protection:
 *      1) Delay (throttle) after a few failures
 *      2) Soft lock (short TTL)
 *      3) Hard lock (longer TTL)
 *      4) Spray attack detection (many usernames from one IP) => day lock
 *  - Uses "lock keys" (IP + IP|username combo) with TTL to block quickly.
 *  - Avoids username-only hard locks to prevent attacker-induced admin lockouts.
 *  - Never blocks password reset flows (lostpassword/resetpass/rp).
 *
 * INSTALLATION
 *  - Save as: includes/security/class-cqtr-bruteforce.php
 *  - In plugin bootstrap:
 *      require_once plugin_dir_path(__FILE__) . 'includes/security/class-cqtr-bruteforce.php';
 *      CQtrBruteForce::init();
 */

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

require_once('qtrLogger.php');
require_once('qtrBruteForcePolicy.php');
require_once('qtrSettings.php');
require_once('qtrIPManager.php');
require_once('qtrAlarmsManager.php');
require_once('qtrAlarmTypes.php');


final class CQtrBruteForce
{
    /** Cache group for wp_cache_* calls */
    private const CACHE_GROUP   = 'qtr_bf';

    /** Prefix for all transient/cache keys used by this module */
    private const KEY_PREFIX    = 'qtr_bf_';

    /** Query flag used for unlock links */
    private const UNLOCK_ACTION = 'qtr_bf_unlock';

    /** Cache for resolved client IP within current request */
    private static $cached_client_ip = null;

    /** Cache for hosting type from settings */
    private static $cached_hosting_type = QutteraSettings::SHARED_HOSTING;

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

    /**
     * GetClientIP()
     * -----------
     * Public API to resolve the client's real IP address.
     *
     * This method respects the configured trusted proxy IP list:
     *  - If REMOTE_ADDR is in the trusted proxy list, accepts CF-Connecting-IP / X-Forwarded-For headers
     *  - Otherwise uses REMOTE_ADDR directly (ignores proxy headers)
     *
     * Results are cached per-request to avoid redundant settings lookups.
     *
     * Use cases:
     *  - Getting real client IP for logging/analytics
     *  - Identifying client location/reputation
     *  - Custom rate-limiting outside brute-force context
     *
     * @return string IP address (IPv4 or IPv6) or "0.0.0.0" if unable to determine
     *
     * Example:
     *  $client_ip = CQtrBruteForce::GetClientIP();
     *  error_log('Login attempt from: ' . $client_ip);
     */
    public static function GetClientIP(): string
    {
        // Return cached result if already resolved in this request
        if (self::$cached_client_ip !== null) {
            return self::$cached_client_ip;
        }

        // Resolve and cache for this request
        return self::$cached_client_ip = self::client_ip();
    }

    /**
     * init()
     * ------
     * Registers WordPress hooks used by this module.
     *
     * Where to call:
     *  - Call once during plugin load (e.g., in main plugin file or loader).
     *
     * Hooks:
     *  - authenticate (filter): pre-auth gate (block/delay before WP validates password)
     *  - wp_login_failed (action): increment counters and potentially lock
     *  - wp_login (action): clear counters on success
     *  - xmlrpc_call (action): optional protection for XML-RPC login attempts
     *  - init (action): unlock link handler
     */
    public static function init(): void
    {
        // Emergency bypass: allow complete disable via constant or filter
        if (self::is_emergency_bypass()) {
            return;
        }

        // Load settings to check if brute-force is enabled
        $settings = QutteraSettings::LoadFromOptions();
        
        if($settings) 
        {
            $hostingType = $settings->GetHostingType();
            if (!empty($hostingType) && in_array($hostingType, [QutteraSettings::SHARED_HOSTING, QutteraSettings::DEDICATED_SERVER], true)) 
            {
                self::$cached_hosting_type = $hostingType;
            }
        }

        // Only register protection hooks if brute-force is enabled
        if (!$settings || !$settings->GetBruteForceEnabled()) {
            // Always register unlock handler even if protection is disabled
            add_action('init', [__CLASS__, 'maybe_handle_unlock_link']);
            return;
        }

        add_filter('authenticate', [__CLASS__, 'on_authenticate_gate'], 1, 3);
        add_action('wp_login_failed', [__CLASS__, 'on_login_failed'], 10, 1);
        add_action('wp_login',        [__CLASS__, 'on_login_success'], 10, 2);

        // Optional: cover XML-RPC brute force by blocking if locked (minimal approach).
        add_action('xmlrpc_call', [__CLASS__, 'on_xmlrpc_call'], 0, 1);

        // Unlock link handler: executed on every front request; exits early if not used.
        add_action('init', [__CLASS__, 'maybe_handle_unlock_link']);
    }

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

    /**
     * on_authenticate_gate()
     * ---------------------
     * Pre-authentication gate.
     *
     * Called for wp-login.php username/password attempts BEFORE WP completes authentication.
     * Here we:
     *  - Skip CLI/cron
     *  - Allow password reset flows (owner recovery)
     *  - Resolve client IP (proxy-aware only if explicitly enabled)
     *  - Allow allowlisted IPs/CIDRs
     *  - Block if locked (IP or IP+username)
     *  - Apply progressive delays (throttling) when failures accumulate
     *
     * IMPORTANT:
     *  - Must be fast. Uses O(1) lock keys first. Only reads a few counters.
     *
     * @param mixed  $user     WP_User or WP_Error or null
     * @param string $username Submitted username
     * @param string $password Submitted password
     * @return mixed WP_User|WP_Error|null
     */
    public static function on_authenticate_gate($user, $username, $password)
    {
        $logger = new CQtrLogger();
        $logger->Info('CQtrBruteForce::on_authenticate_gate() called with username=' . (is_string($username) ? $username : 'unknown'));

        // Do not break CLI / cron.
        if ((defined('WP_CLI') && WP_CLI) || (defined('DOING_CRON') && DOING_CRON)) {
            $logger->Info('CLI or CRON detected, skipping brute-force check');
            return $user;
        }

        // Never block password reset flows (owner recovery path).
        if (self::is_password_reset_flow()) {
            $logger->Info('Password reset flow detected, skipping brute-force check');
            // Optional: throttle reset flows separately if desired.
            return $user;
        }

        // Identify the client.
        $ip = self::client_ip();
        $logger->Info('Client IP resolved to: ' . $ip);

        // Blacklist check: block known malicious IPs immediately.
        if (self::is_blacklisted($ip)) {
            $logger->Warning('IP is blacklisted, blocking access');
            self::maybe_raise_alarm(
                CQtrAlarmTypes::TYPE_BRUTE_FORCE_BLACKLIST,
                ['ip' => $ip, 'username' => $username ?: 'unknown', 'timestamp' => current_time('mysql'), 'site_name' => get_bloginfo('name')]
            );
            status_header(403);
            header('Content-Type: text/plain; charset=UTF-8');
            echo 'Access denied.';
            exit;
        }

        // Allowlist bypass: office IPs, VPNs, ThreatSign IPs, etc.
        if (self::is_allowlisted($ip)) {
            $logger->Info('IP is allowlisted, bypassing brute-force protection');
            return $user;
        }

        // Normalize username into stable lowercase key component.
        $uname = self::norm_username($username);
        $logger->Info('Normalized username: ' . ($uname ?: '[empty]'));

        // Fast lock check (O(1) keys). If locked, block early.
        $is_ip_locked = self::is_locked_ip($ip);
        $logger->Debug('is_locked_ip(' . $ip . ') = ' . ($is_ip_locked ? 'TRUE' : 'FALSE'));
        
        $is_combo_locked = !empty($uname) && self::is_locked_combo($ip, $uname);
        $logger->Debug('is_locked_combo(' . $ip . '|' . $uname . ') = ' . ($is_combo_locked ? 'TRUE' : 'FALSE'));
        
        if ($is_ip_locked) {
            $logger->Warning('IP lock is active for IP=' . $ip);
            self::maybe_raise_alarm(
                CQtrAlarmTypes::TYPE_BRUTE_FORCE_IP_LOCK,
                ['ip' => $ip, 'username' => $username ?: 'unknown', 'timestamp' => current_time('mysql'), 'site_name' => get_bloginfo('name')]
            );
            status_header(403);
            header('Content-Type: text/plain; charset=UTF-8');
            echo 'Access denied. Too many login attempts.';
            exit;
        }
        
        if ($is_combo_locked) {
            $logger->Warning('Combo lock is active for IP=' . $ip . ' user=' . $uname);
            self::maybe_raise_alarm(
                CQtrAlarmTypes::TYPE_COMBO_LOCK,
                ['ip' => $ip, 'username' => $username ?: 'unknown', 'timestamp' => current_time('mysql'), 'site_name' => get_bloginfo('name')]
            );
            status_header(403);
            header('Content-Type: text/plain; charset=UTF-8');
            echo 'Access denied. Too many login attempts.';
            exit;
        }
        
        $logger->Info('No active locks found for IP=' . $ip . ' user=' . ($uname ?: '[empty]'));

        // Progressive delay: slows bots even before locks activate.
        // We use 10m counter because it reflects "recent rapid attempts".
        $fails10m = 0;
        if (!empty($uname)) {
            $fails10m = (int)(self::get_counter('combo', self::combo_key($ip, $uname))['fails10m'] ?? 0);
            $logger->Info('Combo failures in 10m: ' . $fails10m . ' (IP=' . $ip . ' user=' . $uname . ')');
        } else {
            $fails10m = (int)(self::get_counter('ip', $ip)['fails10m'] ?? 0);
            $logger->Info('IP failures in 10m: ' . $fails10m . ' (IP=' . $ip . ')');
        }

        $policy = self::policy();

        // Start delaying after N failures, with exponential backoff capped by delay_cap_seconds.
        if ($fails10m >= $policy['delay_threshold']) {
            $delay = (int) min(
                $policy['delay_cap_seconds'],
                pow(2, max(0, $fails10m - $policy['delay_threshold']))
            );

            if ($delay > 0) {
                $logger->Warning('Applying exponential delay: ' . $delay . 's (fails10m=' . $fails10m . ', threshold=' . $policy['delay_threshold'] . ')');
                // NOTE: sleep() is simple and effective for login path (low concurrency).
                // If you prefer non-blocking delay, you can use usleep() or a microtime loop.
                sleep($delay);
                $logger->Info('Delay completed');
            }
        } else {
            $logger->Info('No delay needed (fails10m=' . $fails10m . ' < threshold=' . $policy['delay_threshold'] . ')');
        }

        $logger->Info('on_authenticate_gate() completed, allowing authentication to proceed');
        return $user;
    }

    /**
     * on_login_failed()
     * ----------------
     * Called when WordPress authentication fails.
     *
     * Here we:
     *  - Identify client IP and normalize username
     *  - Increment counters for rolling windows (10m/1h/24h) using TTL-based decay
     *  - Track spray behavior (many usernames from one IP)
     *  - Apply lock policy (soft/hard/day)
     *  - If lock triggered for an admin/shop_manager account, optionally send unlock email
     *
     * @param string $username The username that was attempted (may be non-existent)
     * @return void
     */
    public static function on_login_failed(string $username): void
    {
        $logger = new CQtrLogger();
        $logger->Info('CQtrBruteForce::on_login_failed() called with username=' . $username);

        $ip = self::client_ip();
        $logger->Info('Client IP: ' . $ip);

        // Blacklist check: count all failures from blacklisted IPs (helps track attacks).
        if (self::is_blacklisted($ip)) {
            $logger->Info('IP is blacklisted, but still tracking failure counter for analytics');
            // Continue to increment counters for blacklisted IPs for tracking purposes
        } elseif (self::is_allowlisted($ip)) {
            // Allowlist bypass: don't count or lock allowlisted clients.
            $logger->Info('IP is allowlisted, skipping counter increment');
            return;
        }

        // Do not treat password reset requests as brute-force login attempts.
        if (self::is_password_reset_flow()) {
            $logger->Info('Password reset flow detected, skipping counter increment');
            return;
        }

        $uname = self::norm_username($username);
        $logger->Info('Normalized username: ' . ($uname ?: '[empty]'));

        // Increment rolling counters (we store counters as separate TTL keys per window).
        self::inc_fail('ip', $ip);
        $ipCounter = self::get_counter('ip', $ip);
        $logger->Info('Incremented IP counter for: ' . $ip . ' | 10m=' . $ipCounter['fails10m'] . ' 1h=' . $ipCounter['fails1h'] . ' 24h=' . $ipCounter['fails24h']);

        if (!empty($uname)) {
            // Combo counter is the most precise brute-force indicator.
            self::inc_fail('combo', self::combo_key($ip, $uname));
            $comboCounter = self::get_counter('combo', self::combo_key($ip, $uname));
            $logger->Info('Incremented combo counter for: ' . $ip . '|' . $uname . ' | 10m=' . $comboCounter['fails10m'] . ' 1h=' . $comboCounter['fails1h'] . ' 24h=' . $comboCounter['fails24h']);

            // Username-only counter is useful for scoring/challenge, but we do NOT hard-lock by user alone.
            self::inc_fail('user', $uname);
            $userCounter = self::get_counter('user', $uname);
            $logger->Info('Incremented user counter for: ' . $uname . ' | 10m=' . $userCounter['fails10m'] . ' 1h=' . $userCounter['fails1h'] . ' 24h=' . $userCounter['fails24h']);
        }

        // Spray detection: many distinct usernames from one IP in a short window.
        if (!empty($uname)) {
            self::spray_track_username_for_ip($ip, $uname);
            $sprayCount = self::spray_distinct_count_10m($ip);
            $logger->Info('Spray tracking: ' . $sprayCount . ' distinct usernames from IP=' . $ip . ' in 10m');
        }

        // Apply lock policy based on counters and spray detection.
        $logger->Info('Applying lock policy...');
        $lockInfo = self::apply_lock_policy($ip, $uname);
        
        if ($lockInfo['locked']) {
            $logger->Warning('Lock applied: IP=' . $ip . ' user=' . ($uname ?: '[empty]') . ' TTL=' . $lockInfo['ttl'] . 's');
        } else {
            $logger->Info('No lock applied (counters below threshold)');
        }

        // If a lock was triggered and the targeted username is high-privilege, send unlock link email.
        // (This helps prevent "admin locked out" support incidents.)
        if ($lockInfo['locked'] && !empty($uname)) {
            if (self::is_high_privilege_username($uname)) {
                $logger->Info('High-privilege user detected (' . $uname . '), sending unlock email');
                self::maybe_send_unlock_email($ip, $uname, (int)$lockInfo['ttl']);
            } else {
                $logger->Info('User ' . $uname . ' is not high-privilege, skipping unlock email');
            }
        }
        
        $logger->Info('on_login_failed() completed');
    }

    /**
     * on_login_success()
     * -----------------
     * Called when a user successfully logs in.
     *
     * Here we:
     *  - Clear combo and username counters (forgiveness)
     *  - Optionally clear IP counters (off by default)
     *  - Clear combo lock so the successful user isn't blocked further
     *
     * @param string  $user_login WP username used to log in
     * @param WP_User $user      WP user object
     * @return void
     */
    public static function on_login_success(string $user_login, $user): void
    {
        $logger = new CQtrLogger();
        $logger->Info('CQtrBruteForce::on_login_success() called with username=' . $user_login);

        $ip = self::client_ip();
        $logger->Info('Client IP: ' . $ip);
        
        if (self::is_allowlisted($ip)) {
            $logger->Info('IP is allowlisted, skipping counter cleanup');
            return;
        }

        $uname = self::norm_username($user_login);
        $logger->Info('Normalized username: ' . $uname);

        // Forgiveness: clear most specific counters.
        $logger->Info('Clearing combo counter for: ' . $ip . '|' . $uname);
        self::clear_counter('combo', self::combo_key($ip, $uname));
        
        $logger->Info('Clearing user counter for: ' . $uname);
        self::clear_counter('user', $uname);

        // Keep IP counters by default (useful if same IP attacks multiple accounts).
        // If you want to be more forgiving, uncomment:
        // self::clear_counter('ip', $ip);
        $logger->Info('Keeping IP counter active for: ' . $ip);

        // Clear lock for this IP+user combo.
        $logger->Info('Clearing combo lock for: ' . $ip . '|' . $uname);
        self::clear_lock_combo($ip, $uname);

        $logger->Info('Login forgiveness applied - counters and locks cleared for user');
        $logger->Info('Brute-force protection counters cleared for IP=' . $ip . ' user=' . ($uname ?: 'n/a'));
    }

    /**
     * on_xmlrpc_call()
     * ---------------
     * Minimal XML-RPC protection.
     *
     * If the IP is locked, block XML-RPC calls with HTTP 429.
     * XML-RPC is commonly abused for brute-force attacks (especially system.multicall).
     *
     * @param string $method XML-RPC method name
     * @return void
     */
    public static function on_xmlrpc_call(string $method): void
    {
        $logger = new CQtrLogger();
        $logger->Info('CQtrBruteForce::on_xmlrpc_call() called with method=' . $method);

        $ip = self::client_ip();
        $logger->Info('Client IP: ' . $ip);
        
        if (self::is_allowlisted($ip)) {
            $logger->Info('IP is allowlisted, allowing XML-RPC call');
            return;
        }

        if (self::is_locked_ip($ip)) {
            $logger->Warning('IP lock is active, blocking XML-RPC call from IP=' . $ip);
            // For XML-RPC, it's simplest to stop early.
            status_header(429);
            header('Content-Type: text/plain; charset=UTF-8');
            echo 'Too many attempts. Try later.';
            exit;
        }
        
        $logger->Info('No IP lock active, allowing XML-RPC call');
    }

    /**
     * maybe_handle_unlock_link()
     * -------------------------
     * Handles a one-time unlock link sent by email.
     *
     * URL format:
     *  /?qtr_unlock=1&t=<token>&sig=<hmac>
     *
     * Flow:
     *  - Look up token payload from cache/transient
     *  - Verify expiry time
     *  - Verify HMAC signature (prevents guessing tokens)
     *  - Clear combo lock (and optionally IP lock)
     *  - Render a small HTML result page and exit
     *
     * Security:
     *  - Short TTL (e.g., 10 minutes)
     *  - HMAC uses WP salt
     *
     * @return void
     */
    public static function maybe_handle_unlock_link(): void
    {
        $logger = new CQtrLogger();
        //$logger->Info('CQtrBruteForce::maybe_handle_unlock_link() called');

        if (empty($_GET['qtr_unlock']) || empty($_GET['t']) || empty($_GET['sig'])) {
            //$logger->Debug('No unlock parameters present, exiting early');
            return;
        }

        $logger->Info('Unlock link parameters detected, processing...');
        $t   = sanitize_text_field((string)$_GET['t']);
        $sig = sanitize_text_field((string)$_GET['sig']);
        $logger->Info('Token: ' . substr($t, 0, 8) . '... Signature: ' . substr($sig, 0, 8) . '...');

        $payload = self::cache_get(self::key('unlock', $t));
        if (!is_array($payload) || empty($payload['ip']) || empty($payload['user']) || empty($payload['exp'])) {
            $logger->Warning('Invalid or missing unlock token payload');
            self::render_unlock_result(false, 'Invalid or expired unlock token.');
        }

        if (time() > (int)$payload['exp']) {
            $logger->Warning('Unlock token expired for IP=' . $payload['ip'] . ' user=' . $payload['user']);
            self::cache_del(self::key('unlock', $t));
            self::render_unlock_result(false, 'Unlock token expired.');
        }

        $logger->Info('Token payload valid, verifying HMAC signature...');
        // Verify signature (constant-time compare).
        $expected = hash_hmac('sha256', $t, self::unlock_secret());
        if (!hash_equals($expected, $sig)) {
            $logger->Warning('Unlock signature verification failed');
            self::render_unlock_result(false, 'Unlock signature invalid.');
        }
        
        $logger->Info('Signature verification passed');

        // Unlock: clear combo lock for the specified IP+user.
        $ip   = (string)$payload['ip'];
        $user = (string)$payload['user'];
        
        $logger->Info('Clearing combo lock for IP=' . $ip . ' user=' . $user);
        self::clear_lock_combo($ip, $user);

        // Optional: also clear IP lock for this IP (comment out if you want IP lock to stay)
        // self::clear_lock_ip($ip);

        // Invalidate token after use.
        $logger->Info('Invalidating unlock token');
        self::cache_del(self::key('unlock', $t));

        $logger->Info('Unlock successful for IP=' . $ip . ' user=' . $user);
        self::maybe_raise_alarm(
            CQtrAlarmTypes::TYPE_UNLOCK_SENT,
            ['ip' => $ip, 'username' => $user, 'timestamp' => current_time('mysql'), 'site_name' => get_bloginfo('name')]
        );
        self::render_unlock_result(true, 'Unlock successful. You can now try logging in again.');
    }

    /**
     * render_unlock_result()
     * ---------------------
     * Outputs a small HTML page for unlock results and terminates the request.
     *
     * @param bool   $ok  Success/failure
     * @param string $msg User-visible message
     * @return void
     */
    private static function render_unlock_result(bool $ok, string $msg): void
    {
        status_header($ok ? 200 : 400);
        header('Content-Type: text/html; charset=UTF-8');

        echo '<!doctype html><html><head><meta charset="utf-8"><title>Unlock</title></head>';
        echo '<body style="font-family: sans-serif; padding: 24px;">';
        echo $ok ? '<h2>✅ Unlock</h2>' : '<h2>❌ Unlock</h2>';
        echo '<p>' . esc_html($msg) . '</p>';
        echo '<p><a href="' . esc_url(wp_login_url()) . '">Go to login</a></p>';
        echo '</body></html>';
        exit;
    }

    /* =======================================================================
     * Protection policy / lock logic
     * ======================================================================= */

    /**
     * policy()
     * --------
     * Delegates to CQtrBruteForcePolicy for centralized policy management.
     *
     * The policy is determined by the 'qtr_hosting_type' WordPress option:
     *  - 'shared': Optimized for shared hosting with limited workers
     *  - 'dedicated': Optimized for dedicated servers with many workers
     *
     * To change the hosting type, use:
     *  CQtrBruteForcePolicy::SetHostingType('shared' or 'dedicated')
     *
     * @return array<string,mixed> Policy configuration
     */
    private static function policy(): array
    {
        return CQtrBruteForcePolicy::GetPolicy(self::$cached_hosting_type);
    }

    /**
     * apply_lock_policy()
     * ------------------
     * Decides whether to lock and for how long, based on:
     *  - IP failures in 10m
     *  - IP+username failures in 10m
     *  - Spray signature (distinct usernames in 10m + total failures)
     *
     * Lock keys created:
     *  - lock_ip: blocks all logins from that IP for TTL
     *  - lock_combo: blocks attempts for that username from that IP for TTL
     *
     * Username-only hard locks are NOT used by default (prevents attacker-induced lockout).
     *
     * @param string $ip    Client IP
     * @param string $uname Normalized username (may be empty)
     * @return array{locked:bool, ttl:int}
     */
    private static function apply_lock_policy(string $ip, string $uname): array
    {
        $logger = new CQtrLogger();
        $logger->Info('apply_lock_policy() called for IP=' . $ip . ' user=' . ($uname ?: '[empty]'));
        
        $p = self::policy();

        // Read recent fail counters.
        $ip10    = (int)(self::get_counter('ip', $ip)['fails10m'] ?? 0);
        $combo10 = (!empty($uname)) ? (int)(self::get_counter('combo', self::combo_key($ip, $uname))['fails10m'] ?? 0) : 0;
        
        $logger->Info('Counter values read: ip10m=' . $ip10 . ' combo10m=' . $combo10 . ' soft_threshold=' . $p['soft_threshold_10m'] . ' hard_threshold=' . $p['hard_threshold_10m']);

        // SPRAY SIGNATURE:
        // One IP tries many usernames quickly => day lock.
        $sprayDistinct = (int) self::spray_distinct_count_10m($ip);
        $logger->Debug('Spray check: distinct_users=' . $sprayDistinct . ' (threshold=' . $p['spray_distinct_users_10m'] . ') total_fails=' . $ip10 . ' (threshold=' . $p['spray_total_fails_10m'] . ')');
        
        if ($sprayDistinct >= $p['spray_distinct_users_10m'] && $ip10 >= $p['spray_total_fails_10m']) {
            $logger->Warning('SPRAY LOCK TRIGGERED: Setting day lock');
            self::set_lock_ip($ip, $p['day_lock']);
            self::maybe_raise_alarm(
                CQtrAlarmTypes::TYPE_SPRAY,
                ['ip' => $ip, 'distinct_users' => $sprayDistinct, 'fails_10m' => $ip10, 'timestamp' => current_time('mysql'), 'site_name' => get_bloginfo('name')]
            );
            return ['locked' => true, 'ttl' => $p['day_lock']];
        }

        // Use the strongest signal: max of IP and combo failures.
        $fails10 = max($ip10, $combo10);
        $logger->Info('Using max(ip10m, combo10m) = ' . $fails10);

        $locked = false;
        $ttl    = 0;

        // SOFT LOCK:
        // After enough rapid failures, temporarily lock.
        $logger->Debug('Checking soft lock: fails10=' . $fails10 . ' >= soft_threshold=' . $p['soft_threshold_10m'] . ' ? ' . ($fails10 >= $p['soft_threshold_10m'] ? 'YES' : 'NO'));
        if ($fails10 >= $p['soft_threshold_10m']) {
            $ttl = $p['soft_lock'];
            $logger->Warning('SOFT LOCK TRIGGERED: Setting lock with TTL=' . $ttl);
            self::set_lock_ip($ip, $ttl);
            if (!empty($uname)) self::set_lock_combo($ip, $uname, $ttl);
            $locked = true;

            $send_email = !empty($uname) && self::is_high_privilege_username($uname);
            self::maybe_raise_alarm(
                CQtrAlarmTypes::TYPE_BRUTE_FORCE_SOFT_LOCK,
                ['ip' => $ip, 'username' => $uname ?: 'unknown', 'fails10m' => $fails10, 'lock_duration' => self::format_seconds_human($ttl), 'timestamp' => current_time('mysql'), 'site_name' => get_bloginfo('name')],
                $send_email
            );
        }

        // HARD LOCK:
        // More failures => longer lock. Optionally escalates further on repeat.
        $logger->Debug('Checking hard lock: fails10=' . $fails10 . ' >= hard_threshold=' . $p['hard_threshold_10m'] . ' ? ' . ($fails10 >= $p['hard_threshold_10m'] ? 'YES' : 'NO'));
        if ($fails10 >= $p['hard_threshold_10m']) {
            $ttl = $p['hard_lock'];
            $logger->Warning('HARD LOCK TRIGGERED: Setting lock with TTL=' . $ttl);
            self::set_lock_ip($ip, $ttl);
            if (!empty($uname)) self::set_lock_combo($ip, $uname, $ttl);
            $locked = true;

            // Escalate if hard lock repeats within 24h.
            $hardKey = self::key('meta', 'hard_' . md5($ip));
            $meta = self::cache_get($hardKey);
            $now  = time();

            if (is_array($meta) && !empty($meta['ts']) && ($now - (int)$meta['ts']) <= $p['repeat_hard_within']) {
                $ttl = $p['day_lock'];
                $logger->Warning('HARD LOCK ESCALATION: Repeat detected within 24h, setting day lock with TTL=' . $ttl);
                self::set_lock_ip($ip, $ttl);
                if (!empty($uname)) self::set_lock_combo($ip, $uname, $ttl);

                $send_email = !empty($uname) && self::is_high_privilege_username($uname);
                self::maybe_raise_alarm(
                    CQtrAlarmTypes::TYPE_BRUTE_FORCE_HARD_LOCK,
                    ['ip' => $ip, 'username' => $uname ?: 'unknown', 'fails10m' => $fails10, 'lock_duration' => self::format_seconds_human($ttl), 'timestamp' => current_time('mysql'), 'site_name' => get_bloginfo('name')],
                    $send_email
                );
            }

            // Store timestamp of last hard lock.
            self::cache_set($hardKey, ['ts' => $now], $p['ttl_24h']);
        }
        
        $logger->Info('apply_lock_policy() result: locked=' . ($locked ? 'YES' : 'NO') . ' ttl=' . $ttl);
        return ['locked' => $locked, 'ttl' => $ttl];
    }

    /* =======================================================================
     * Unlock email (break-glass recovery)
     * ======================================================================= */

    /**
     * maybe_send_unlock_email()
     * ------------------------
     * Sends an unlock link to the site's admin email (get_option('admin_email')).
     *
     * Why:
     *  - Prevents support incidents where legitimate store owner is locked out.
     *  - Allows safe recovery without disabling plugin or editing DB.
     *
     * Anti-spam:
     *  - Rate-limits emails per (IP,user) for a short time.
     *
     * @param string $ip      Client IP that got locked
     * @param string $uname   Normalized username (login)
     * @param int    $lockTtl Lock duration in seconds
     * @return void
     */
    private static function maybe_send_unlock_email(string $ip, string $uname, int $lockTtl): void
    {
        $p = self::policy();
        if (empty($p['send_unlock_email'])) return;

        $logger = new CQtrLogger();

        // Check if this is an admin user
        if (!self::is_high_privilege_username($uname)) {
            $logger->Info('User ' . $uname . ' is not a high-privilege user, skipping admin lock notification');
            return;
        }

        // Get user object to retrieve email
        $user = get_user_by('login', $uname);
        if (!$user || !isset($user->user_email)) {
            $logger->Warning('Could not find user object or email for username: ' . $uname);
            return;
        }

        // Rate-limit unlock emails to avoid email floods.
        $rl = self::key('meta', 'unlock_mail_rl_' . md5($ip . '|' . $uname));
        if (self::cache_get($rl)) {
            $logger->Info('Admin lock notification rate-limited for user=' . $uname);
            return;
        }

        // Raise alarm with admin account locked event
        // This will send to both the blocked user AND the main admin email
        self::maybe_raise_alarm(
            CQtrAlarmTypes::TYPE_ADMIN_ACCOUNT_LOCKED,
            [
                'username' => $uname,
                'email' => $user->user_email,
                'ip' => $ip,
                'timestamp' => current_time('mysql'),
                'site_name' => get_bloginfo('name'),
            ],
            true  // send_email
        );

        // Set rate-limit key for 10 minutes to prevent email floods
        self::cache_set($rl, 1, 10 * MINUTE_IN_SECONDS);

        $logger->Info('Admin lock notification raised for user=' . $uname . ' (email=' . $user->user_email . ') from IP=' . $ip);
    }

    /**
     * unlock_secret()
     * --------------
     * Returns a stable secret used to sign unlock tokens.
     *
     * Uses WP salts (AUTH_SALT/wp_salt('auth')) so the signature changes if salts rotate.
     *
     * @return string
     */
    private static function unlock_secret(): string
    {
        return (string) (defined('AUTH_SALT') ? AUTH_SALT : wp_salt('auth'));
    }

    /**
     * is_high_privilege_username()
     * ---------------------------
     * Determines if a username maps to a high-privilege role.
     *
     * Used to decide whether to send unlock emails.
     *
     * Roles covered:
     *  - administrator
     *  - shop_manager (WooCommerce)
     *
     * @param string $uname Normalized username
     * @return bool
     */
    private static function is_high_privilege_username(string $uname): bool
    {
        $user = get_user_by('login', $uname);
        if (!$user || empty($user->roles)) return false;

        foreach ($user->roles as $r) {
            if ($r === 'administrator' || $r === 'shop_manager') return true;
        }
        return false;
    }

    /* =======================================================================
     * Spray tracking (distinct usernames per IP)
     * ======================================================================= */

    /**
     * spray_track_username_for_ip()
     * ----------------------------
     * Tracks how many distinct usernames a given IP has attempted in the last 10 minutes.
     *
     * We store a set of username hashes in cache/transient with TTL=10m.
     * This is used to detect "spray" attacks (one IP trying many usernames).
     *
     * @param string $ip
     * @param string $uname Normalized username
     * @return void
     */
    private static function spray_track_username_for_ip(string $ip, string $uname): void
    {
        $p = self::policy();
        $k = self::key('spray', $ip . ':users10m');

        $data = self::cache_get($k);
        if (!is_array($data)) $data = [];

        // Store only hash to keep size small and avoid keeping raw usernames.
        $data[md5($uname)] = 1;

        // Cap set size to prevent memory abuse.
        if (count($data) > 200) {
            $data = array_slice($data, -200, null, true);
        }

        self::cache_set($k, $data, (int)$p['ttl_10m']);
    }

    /**
     * spray_distinct_count_10m()
     * -------------------------
     * Returns distinct username count for IP over ~10 minutes.
     *
     * @param string $ip
     * @return int
     */
    private static function spray_distinct_count_10m(string $ip): int
    {
        $k = self::key('spray', $ip . ':users10m');
        $data = self::cache_get($k);
        return is_array($data) ? count($data) : 0;
    }

    /* =======================================================================
     * Counter storage (TTL-based decay)
     * ======================================================================= */

    /**
     * inc_fail()
     * ----------
     * Increments rolling window counters for a bucket+key.
     *
     * Approach:
     *  - Keep separate counters as separate keys with different TTLs:
     *      * key:10m => expires in 10 minutes
     *      * key:1h  => expires in 1 hour
     *      * key:24h => expires in 24 hours
     *  - This gives time-based decay without background jobs.
     *
     * @param string $bucket 'ip'|'combo'|'user'
     * @param string $key    IP, username, or "IP|username"
     * @return void
     */
    private static function inc_fail(string $bucket, string $key): void
    {
        $p = self::policy();

        foreach (['10m' => $p['ttl_10m'], '1h' => $p['ttl_1h'], '24h' => $p['ttl_24h']] as $w => $ttl) {
            $k = self::key($bucket, $key . ':' . $w);
            $v = self::cache_get($k);

            $n = is_numeric($v) ? ((int)$v + 1) : 1;

            // Cap to avoid runaway integers in extreme attacks.
            if ($n > 1000000) $n = 1000000;

            self::cache_set($k, $n, (int)$ttl);
        }

        // last_seen is useful for reporting.
        self::cache_set(self::key($bucket, $key . ':last'), time(), (int)$p['ttl_24h']);
    }

    /**
     * get_counter()
     * -------------
     * Reads rolling counters for a bucket+key (10m/1h/24h + last_seen).
     *
     * @param string $bucket
     * @param string $key
     * @return array{fails10m:int,fails1h:int,fails24h:int,last:int}
     */
    private static function get_counter(string $bucket, string $key): array
    {
        return [
            'fails10m' => (int) self::cache_get(self::key($bucket, $key . ':10m')),
            'fails1h'  => (int) self::cache_get(self::key($bucket, $key . ':1h')),
            'fails24h' => (int) self::cache_get(self::key($bucket, $key . ':24h')),
            'last'     => (int) self::cache_get(self::key($bucket, $key . ':last')),
        ];
    }

    /**
     * clear_counter()
     * ---------------
     * Clears rolling counters for a bucket+key.
     *
     * @param string $bucket
     * @param string $key
     * @return void
     */
    private static function clear_counter(string $bucket, string $key): void
    {
        foreach (['10m', '1h', '24h', 'last'] as $w) {
            self::cache_del(self::key($bucket, $key . ':' . $w));
        }
    }

    /* =======================================================================
     * Lock storage (TTL keys)
     * ======================================================================= */

    /**
     * set_lock_ip()
     * -------------
     * Creates/extends an IP lock. While active, all logins from this IP are blocked.
     *
     * @param string $ip
     * @param int    $ttl Seconds until lock expires
     * @return void
     */
    private static function set_lock_ip(string $ip, int $ttl): void
    {
        $lockKey = self::key('lock_ip', $ip);
        $logger = new CQtrLogger();
        $logger->Debug('set_lock_ip() called: IP=' . $ip . ' TTL=' . $ttl . ' lockKey=' . $lockKey);
        self::cache_set($lockKey, 1, $ttl);
        
        // Update active locks index for display in settings
        self::update_active_locks_index($ip, $ttl);
    }

    /**
     * update_active_locks_index()
     * --------------------------
     * Maintains an index of all currently active IP locks.
     * This index is used to display blocked IPs in the admin settings page.
     *
     * The index tracks:
     *  - Each locked IP with its expiry timestamp
     *  - TTL for each lock
     *  - Lock timestamp
     *
     * The index itself uses the longest TTL among all locks to stay alive.
     *
     * @param string $ip  IP address that was locked
     * @param int    $ttl Lock duration in seconds
     * @return void
     */
    private static function update_active_locks_index(string $ip, int $ttl): void
    {
        $indexKey = self::key('meta', 'active_locked_ips');
        $lockedIPs = self::cache_get($indexKey);
        
        if (!is_array($lockedIPs)) {
            $lockedIPs = [];
        }
        
        $now = time();
        
        // Add or update IP in the index
        $lockedIPs[$ip] = [
            'locked_at' => $now,
            'expires_at' => $now + $ttl,
            'ttl' => $ttl
        ];
        
        // Use the longest TTL among all locked IPs to keep index alive
        $maxTTL = (int) max(array_column($lockedIPs, 'ttl'));
        $logger = new CQtrLogger();
        $logger->Debug('update_active_locks_index() updated indexKey=' . $indexKey . ' maxTTL=' . $maxTTL . ' total_locked_ips=' . count($lockedIPs)); 
        self::cache_set($indexKey, $lockedIPs, $maxTTL);
    }

    /**
     * remove_from_active_locks_index()
     * --------------------------------
     * Removes an IP from the active locks index when the lock is manually cleared.
     *
     * If the index becomes empty after removal, the entire index is deleted.
     * Otherwise, the index is re-saved with the longest remaining TTL.
     *
     * @param string $ip IP address to remove from index
     * @return void
     */
    private static function remove_from_active_locks_index(string $ip): void
    {
        $indexKey = self::key('meta', 'active_locked_ips');
        $lockedIPs = self::cache_get($indexKey);
        
        if (!is_array($lockedIPs) || !isset($lockedIPs[$ip])) {
            return;
        }
        
        unset($lockedIPs[$ip]);
        
        // Delete index if empty, otherwise re-save with longest remaining TTL
        if (empty($lockedIPs)) {
            self::cache_del($indexKey);
        } else {
            $maxTTL = (int) max(array_column($lockedIPs, 'ttl'));
            self::cache_set($indexKey, $lockedIPs, $maxTTL);
        }
    }

    /**
     * is_locked_ip()
     * --------------
     * Returns true if IP lock is active.
     *
     * @param string $ip
     * @return bool
     */
    private static function is_locked_ip(string $ip): bool
    {
        $lockKey = self::key('lock_ip', $ip);
        $lockValue = self::cache_get($lockKey);
        $logger = new CQtrLogger();
        $logger->Debug('is_locked_ip() check: IP=' . $ip . ' lockKey=' . $lockKey . ' lockValue=' . var_export($lockValue, true) . ' result=' . ($lockValue ? 'LOCKED' : 'NOT LOCKED'));
        return (bool) $lockValue;
    }

    /**
     * clear_lock_ip()
     * ---------------
     * Removes IP lock and updates the active locks index.
     *
     * @param string $ip
     * @return void
     */
    private static function clear_lock_ip(string $ip): void
    {
        self::cache_del(self::key('lock_ip', $ip));
        
        // Remove from active locks index
        self::remove_from_active_locks_index($ip);
        $logger = new CQtrLogger();
        $logger->Debug('clear_lock_ip() cleared lock for IP=' . $ip);
    }

    /**
     * GetActiveLockedIPs()
     * ------------------
     * Public API to retrieve all currently active IP locks.
     *
     * Returns an array of locked IPs with expiry information, filtering out expired entries.
     * This is used by the admin settings page to display blocked IPs.
     *
     * Example return value:
     *  [
     *      '192.168.1.1' => [
     *          'locked_at' => 1704700000,
     *          'expires_at' => 1704703600,
     *          'ttl' => 3600,
     *          'expiry_relative' => '1 hour from now'
     *      ]
     *  ]
     *
     * @return array Array of active locked IPs with expiry details
     */
    public static function GetActiveLockedIPs(): array
    {
        $indexKey = self::key('meta', 'active_locked_ips');
        $data = self::cache_get($indexKey);
        
        if (!is_array($data)) {
            return [];
        }
        
        $now = time();
        $active = [];
        $needsCleanup = false;
        $logger = new CQtrLogger();
        $logger->Debug('GetActiveLockedIPs() retrieved indexKey=' . $indexKey . ' total_locked_ips=' . count($data));

        foreach ($data as $ip => $info) {
            if ($info['expires_at'] > $now) {
                // Calculate relative expiry time for display
                $secondsRemaining = $info['expires_at'] - $now;
                $expiry_relative = self::format_seconds_human($secondsRemaining);
                
                $active[$ip] = array_merge($info, [
                    'locked_full' => date('Y-m-d H:i:s', $info['locked_at']),
                    'expiry_full' => date('Y-m-d H:i:s', $info['expires_at']),
                    'expiry_relative' => $expiry_relative
                ]);
            } else {
                $needsCleanup = true;
            }
        }
        
        // Clean up expired entries from index
        if ($needsCleanup) {
            if (empty($active)) {
                self::cache_del($indexKey);
            } else {
                $maxTTL = (int) max(array_column($active, 'ttl'));
                self::cache_set($indexKey, $active, $maxTTL);
            }
        }
        
        return $active;
    }

    /**
     * update_combo_locks_index()
     * -------------------------
     * Maintains an index of all combo locks (IP+username) for a given IP.
     * This allows efficient clearing of all combo locks when unlocking an IP.
     *
     * @param string $ip    IP address
     * @param string $uname Username that was locked
     * @param int    $ttl   Lock duration in seconds
     * @return void
     */
    private static function update_combo_locks_index(string $ip, string $uname, int $ttl): void
    {
        $indexKey = self::key('meta', 'combo_locks_' . $ip);
        $combos = self::cache_get($indexKey);
        
        if (!is_array($combos)) {
            $combos = [];
        }
        
        $combos[$uname] = time() + $ttl;
        self::cache_set($indexKey, $combos, $ttl);
    }

    /**
     * remove_combo_locks_index()
     * -------------------------
     * Clears the combo locks index for a given IP.
     *
     * @param string $ip IP address
     * @return void
     */
    private static function remove_combo_locks_index(string $ip): void
    {
        $indexKey = self::key('meta', 'combo_locks_' . $ip);
        self::cache_del($indexKey);
    }

    /**
     * UnlockIP()
     * ----------
     * Public API to remove a brute-force lock on a specific IP address.
     *
     * Use cases:
     *  - Admin manually unlocks an IP from settings page
     *  - Bulk unlock operation
     *  - Integration with third-party IP management
     *
     * @param string $ip The IP address to unlock (IPv4 or IPv6)
     * @return bool True if unlock was successful, false if IP was not locked
     *
     * Example:
     *  if (CQtrBruteForce::UnlockIP('192.168.1.1')) {
     *      echo 'IP successfully unlocked';
     *  } else {
     *      echo 'IP was not locked';
     *  }
     */
    public static function UnlockIP(string $ip): bool
    {
        $logger = new CQtrLogger();
        if (empty($ip)) {
            $logger->Warning('UnlockIP() called with empty IP');
            return false;
        }

        // Check if IP is actually locked before attempting unlock
        $active_locks = self::GetActiveLockedIPs();
        if (!isset($active_locks[$ip])) {
            $logger->Info('UnlockIP() called for IP=' . $ip . ' but no active lock found');
            return false;
        }

        // Clear the IP lock
        self::clear_lock_ip($ip);
        $logger->Info('Cleared IP lock for IP=' . $ip);
        
        // Clear all combo locks for this IP
        $indexKey = self::key('meta', 'combo_locks_' . $ip);
        $combos = self::cache_get($indexKey);
        
        if (is_array($combos)) {
            $comboCount = count($combos);
            foreach (array_keys($combos) as $uname) {
                self::clear_lock_combo($ip, $uname);
                $logger->Debug('Cleared combo lock for IP=' . $ip . ' user=' . $uname);
            }
            $logger->Info('Cleared ' . $comboCount . ' combo locks for IP=' . $ip);
        }
        
        self::remove_combo_locks_index($ip);
        
        $logger->Info('UnlockIP() successfully unlocked IP=' . $ip . ' and all associated combo locks');
        return true;
    }

    /**
     * format_seconds_human()
     * ---------------------
     * Converts seconds into human-readable format.
     *
     * Examples:
     *  3661 => "1 hour, 1 minute"
     *  120 => "2 minutes"
     *  1 => "1 second"
     *
     * @param int $seconds Number of seconds
     * @return string Human-readable time string
     */
    private static function format_seconds_human(int $seconds): string
    {
        if ($seconds < 0) {
            return 'expired';
        }
        
        $parts = [];
        
        $days = intdiv($seconds, 86400);
        if ($days > 0) {
            $parts[] = $days . ' day' . ($days > 1 ? 's' : '');
            $seconds %= 86400;
        }
        
        $hours = intdiv($seconds, 3600);
        if ($hours > 0) {
            $parts[] = $hours . ' hour' . ($hours > 1 ? 's' : '');
            $seconds %= 3600;
        }
        
        $minutes = intdiv($seconds, 60);
        if ($minutes > 0) {
            $parts[] = $minutes . ' minute' . ($minutes > 1 ? 's' : '');
            $seconds %= 60;
        }
        
        if ($seconds > 0 || empty($parts)) {
            $parts[] = $seconds . ' second' . ($seconds > 1 ? 's' : '');
        }
        
        return implode(', ', array_slice($parts, 0, 2)); // Show max 2 parts
    }

    /**
     * set_lock_combo()
     * ----------------
     * Creates/extends an IP+username lock. While active, that user cannot log in from that IP.
     *
     * @param string $ip
     * @param string $uname Normalized username
     * @param int    $ttl Seconds until lock expires
     * @return void
     */
    private static function set_lock_combo(string $ip, string $uname, int $ttl): void
    {
        $comboKey = self::combo_key($ip, $uname);
        $lockKey = self::key('lock_combo', $comboKey);
        $logger = new CQtrLogger();
        $logger->Debug('set_lock_combo() called: IP=' . $ip . ' user=' . $uname . ' TTL=' . $ttl . ' lockKey=' . $lockKey);
        self::cache_set($lockKey, 1, $ttl);
        self::update_active_locks_index($ip, $ttl);
        self::update_combo_locks_index($ip, $uname, $ttl);
    }

    /**
     * clear_lock_combo()
     * ------------------
     * Removes combo lock.
     *
     * @param string $ip
     * @param string $uname
     * @return void
     */
    private static function clear_lock_combo(string $ip, string $uname): void
    {
        if (empty($uname)) return;
        self::cache_del(self::key('lock_combo', self::combo_key($ip, $uname)));
    }

    /**
     * is_locked_combo()
     * -----------------
     * Returns true if combo lock is active.
     *
     * @param string $ip
     * @param string $uname
     * @return bool
     */
    private static function is_locked_combo(string $ip, string $uname): bool
    {
        $comboKey = self::combo_key($ip, $uname);
        $lockKey = self::key('lock_combo', $comboKey);
        $lockValue = self::cache_get($lockKey);
        $logger = new CQtrLogger();
        $logger->Debug('is_locked_combo() check: IP=' . $ip . ' user=' . $uname . ' lockKey=' . $lockKey . ' lockValue=' . var_export($lockValue, true) . ' result=' . ($lockValue ? 'LOCKED' : 'NOT LOCKED'));
        return (bool) $lockValue;
    }

    /* =======================================================================
     * Allowlist + IP resolution
     * ======================================================================= */

    /**
     * is_blacklisted()
     * ----------------
     * Checks whether the given IP should be blocked immediately.
     *
     * Blacklisted IPs are blocked before any other checks (including allowlist).
     * This is useful for known malicious IPs that need immediate blocking.
     *
     * Sources:
     *  1) Filter 'qtr_bf_blacklist_ips'  => array of exact IPs
     *  2) Filter 'qtr_bf_blacklist_cidrs'=> array of CIDRs (IPv4/IPv6)
     *  3) Settings blacklist IPs         => from QutteraSettings::GetBlacklistIPs()
     *  4) Constant QTR_BF_BLACKLIST_IPS => "ip,ip,cidr,cidr" emergency block list
     *
     * @param string $ip
     * @return bool
     */
    private static function is_blacklisted(string $ip): bool
    {
        return CQtrIPManager::IsBlacklisted($ip, 'qtr_bf');
    }

    /**\n     * is_allowlisted()
     * ---------------
     * Checks whether the given IP should bypass protection.
     *
     * Sources:
     *  1) Filter 'qtr_bf_allowlist_ips'  => array of exact IPs
     *  2) Filter 'qtr_bf_allowlist_cidrs'=> array of CIDRs (IPv4/IPv6)
     *  3) Settings whitelist IPs          => from QutteraSettings::GetWhitelistIPs()
     *  4) Constant QTR_BF_ALLOWLIST_IPS  => "ip,ip,cidr,cidr" emergency break-glass
     *
     * @param string $ip
     * @return bool
     */
    private static function is_allowlisted(string $ip): bool
    {
        // 1) Exact IP allowlist from filters
        $ips = apply_filters('qtr_bf_allowlist_ips', []);
        if (is_array($ips) && in_array($ip, $ips, true)) return true;

        // 2) CIDR allowlist from filters
        $cidrs = apply_filters('qtr_bf_allowlist_cidrs', []);
        if (is_array($cidrs)) {
            foreach ($cidrs as $cidr) {
                $cidr = trim((string)$cidr);
                if ($cidr !== '' && self::ip_in_cidr($ip, $cidr)) return true;
            }
        }

        // 3) Whitelist IPs from settings
        $settings = QutteraSettings::LoadFromOptions();
        if ($settings) {
            $whitelist_ips = $settings->GetWhitelistIPs();
            if (!empty($whitelist_ips) && self::is_ip_in_list($ip, $whitelist_ips)) {
                return true;
            }
        }

        // 4) Constant allowlist for emergencies (wp-config.php)
        if (defined('QTR_BF_ALLOWLIST_IPS') && is_string(QTR_BF_ALLOWLIST_IPS) && QTR_BF_ALLOWLIST_IPS !== '') {
            $parts = array_map('trim', explode(',', QTR_BF_ALLOWLIST_IPS));
            foreach ($parts as $p) {
                if ($p === '') continue;
                if (strpos($p, '/') !== false) {
                    if (self::ip_in_cidr($ip, $p)) return true;
                } else {
                    if ($ip === $p) return true;
                }
            }
        }

        return false;
    }

    /**
     * client_ip()
     * ----------
     * Resolves the client IP address.
     * Now delegates to CQtrIPManager::ResolveClientIP().
     *
     * @return string IP address (validated) or "0.0.0.0" as fallback
     */
    private static function client_ip(): string
    {
        return CQtrIPManager::ResolveClientIP();
    }

    /**
     * is_valid_ip()
     * -------------
     * Validates IP address (IPv4 or IPv6).
     * Now delegates to CQtrIPManager::IsValidIP().
     *
     * @param string $ip
     * @return bool
     */
    private static function is_valid_ip(string $ip): bool
    {
        return CQtrIPManager::IsValidIP($ip);
    }

    /**
     * is_public_ip()
     * --------------
     * Checks if IP is public (not private/reserved ranges).
     * Now delegates to CQtrIPManager::IsPublicIP().
     *
     * @param string $ip
     * @return bool
     */
    private static function is_public_ip(string $ip): bool
    {
        return CQtrIPManager::IsPublicIP($ip);
    }

    /**
     * ip_in_cidr()
     * -----------
     * CIDR match supporting IPv4 and IPv6.
     * Now delegates to CQtrIPManager::IPInCIDR().
     *
     * @param string $ip
     * @param string $cidr Format: "x.x.x.x/24" or "2001:db8::/32"
     * @return bool
     */
    private static function ip_in_cidr(string $ip, string $cidr): bool
    {
        return CQtrIPManager::IPInCIDR($ip, $cidr);
    }

    /**
     * is_ip_in_list()
     * ---------------
     * Checks if an IP address matches any entry in a semicolon-separated list.
     * List can contain single IPs and/or CIDR ranges.
     * Now delegates to CQtrIPManager::IsIPInList().
     *
     * @param string $ip IP address to check
     * @param string $list Semicolon-separated list of IPs/CIDR ranges
     * @return bool True if IP matches any entry in the list
     */
    private static function is_ip_in_list(string $ip, string $list): bool
    {
        return CQtrIPManager::IsIPInList($ip, $list);
    }

    /* =======================================================================
     * General utilities
     * ======================================================================= */

    /**
     * maybe_raise_alarm()
     * ------------------
     * Integrates with your alarm manager if present.
     *
     * Expected signature:
     *  CQtrAlarmsManager::raise($alarm_type, $params, $send_email = true)
     *
     * @param string $alarm_type Alarm type constant from CQtrAlarmTypes
     * @param array  $params Parameters for template substitution
     * @param bool   $send_email
     * @return void
     */
    private static function maybe_raise_alarm(string $alarm_type, array $params, bool $send_email = true): void
    {
        CQtrAlarmsManager::raise($alarm_type, $params, $send_email);
    }

    /**
     * is_password_reset_flow()
     * -----------------------
     * Returns true if the current request is a WordPress password reset flow.
     *
     * We do NOT block these flows, because they are the primary recovery path for site owners.
     *
     * @return bool
     */
    private static function is_password_reset_flow(): bool
    {
        if (!isset($_REQUEST['action'])) return false;
        $a = sanitize_text_field((string) $_REQUEST['action']);
        return in_array($a, ['lostpassword', 'retrievepassword', 'resetpass', 'rp'], true);
    }

    /**
     * norm_username()
     * --------------
     * Normalizes username for stable keying:
     *  - lowercase
     *  - trimmed
     *  - length-limited
     *
     * @param string|null $username
     * @return string
     */
    private static function norm_username(?string $username): string
    {
        $u = is_string($username) ? trim(mb_strtolower($username)) : '';
        return substr($u, 0, 60);
    }

    /**
     * combo_key()
     * ----------
     * Creates a stable string for the (IP|username) combo key.
     *
     * @param string $ip
     * @param string $uname
     * @return string
     */
    private static function combo_key(string $ip, string $uname): string
    {
        return $ip . '|' . $uname;
    }

    /**
     * key()
     * -----
     * Generates a short cache key from a bucket + raw key string.
     * Uses md5 to avoid long transient keys and to keep DB/Redis memory small.
     *
     * @param string $bucket A namespace label like 'ip','combo','lock_ip','unlock'
     * @param string $raw    Arbitrary key content (IP, combo, token, etc.)
     * @return string
     */
    private static function key(string $bucket, string $raw): string
    {
        return self::KEY_PREFIX . $bucket . '_' . md5($raw);
    }

    /* =======================================================================
     * Cache wrappers (cache-first, transient fallback)
     * ======================================================================= */

    /**
     * cache_get()
     * ----------
     * Reads from object cache first, then falls back to transient.
     *
     * Why:
     *  - With persistent object cache, this avoids DB reads.
     *  - Without it, transients still work everywhere.
     *
     * NOTE:
     *  - wp_cache_get returns false on cache miss.
     *  - get_transient returns false on miss.
     *
     * @param string $key
     * @return mixed
     */
    private static function cache_get(string $key)
    {
        $v = wp_cache_get($key, self::CACHE_GROUP);
        if ($v !== false) return $v;

        $t = get_transient($key);
        return ($t !== false) ? $t : 0;
    }

    /**
     * cache_set()
     * ----------
     * Writes to object cache and transient.
     *
     * Why write both:
     *  - If object cache is enabled, wp_cache_* is fastest.
     *  - If not enabled, transient ensures persistence.
     *
     * @param string $key
     * @param mixed  $value
     * @param int    $ttl Seconds until expiration
     * @return void
     */
    private static function cache_set(string $key, $value, int $ttl): void
    {
        wp_cache_set($key, $value, self::CACHE_GROUP, $ttl);
        set_transient($key, $value, $ttl);
    }

    /**
     * cache_del()
     * ----------
     * Removes from object cache and transient.
     *
     * @param string $key
     * @return void
     */
    private static function cache_del(string $key): void
    {
        wp_cache_delete($key, self::CACHE_GROUP);
        delete_transient($key);
    }

    /**
     * is_emergency_bypass()
     * ---------------------
     * Check emergency bypass flag.
     *
     * Allows complete disable of brute-force protection for emergencies:
     *  - Constant: QTR_BRUTEFORCE_BYPASS = true
     *  - Filter: qtr_bruteforce_emergency_bypass = true
     *
     * When enabled, the init() method returns early and no hooks are registered.
     * This is useful for temporary disabling during maintenance or troubleshooting.
     *
     * @return bool True if bypass enabled
     */
    private static function is_emergency_bypass(): bool
    {
        // Constant bypass
        if (defined('QTR_BRUTEFORCE_BYPASS') && QTR_BRUTEFORCE_BYPASS === true) {
            return true;
        }

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