<?php
/**
 * Name: Limit Login Attempts
 * Description: Protect all login vectors (wp-login, XML-RPC, REST API, Application Passwords) by limiting failed attempts per IP with escalating lockouts, whitelist/blacklist, and email notifications
 * Category: Security
 * Icon: -
 * UseCases: [
 *   {
 *     "title": "Prevent Brute Force Attacks",
 *     "description": "Block automated login attempts across wp-login, XML-RPC, REST API, and Application Passwords by limiting failed tries from a single IP."
 *   },
 *   {
 *     "title": "Protect Admin Accounts",
 *     "description": "Escalating lockouts (normal then long) with IP whitelist/blacklist and email notifications to prevent unauthorized access."
 *   }
 * ]
 * WhereToFind: {
 *   "location": "Configure in module settings",
 *   "url": "",
 *   "instructions": "Set attempt limit and lockout duration - enforced across all login vectors",
 *   "newTab": false
 * }
 * Added: 2025-07-15
 * Tags: security, login, brute-force, lockout, protection, xmlrpc, rest-api
 */

// Prevent direct access
if (!defined('ABSPATH')) exit;

class SwitchboardLimitLoginAttempts {

    private $settings;

    public function __construct() {
        $this->settings = $this->get_settings();

        // Track credentials for error message filtering
        add_filter('authenticate', [$this, 'track_credentials'], 1, 3);

        // Core authentication hooks
        add_filter('authenticate', [$this, 'check_login_attempts'], 5, 3);
        add_filter('wp_authenticate_user', [$this, 'enforce_lockout'], 99999, 2);
        add_action('wp_login_failed', [$this, 'log_failed_attempt']);
        add_action('wp_login', [$this, 'clear_attempts'], 10, 2);

        // Error message filtering — suppress "Invalid username", shake on lockout
        add_filter('login_errors', [$this, 'fixup_error_messages']);
        add_filter('shake_error_codes', [$this, 'failure_shake']);

        // WooCommerce lockout notices
        add_action('wp_head', [$this, 'add_wc_notices']);

        // XML-RPC protection
        add_filter('xmlrpc_login_error', [$this, 'xmlrpc_error_messages']);
        add_filter('xmlrpc_methods', [$this, 'disable_multicall']);
        add_action('init', [$this, 'check_xmlrpc_lockout']);

        // REST API protection
        add_filter('rest_authentication_errors', [$this, 'check_rest_lockout'], 99);

        // Application Password protection (WP 5.6+)
        if (version_compare(get_bloginfo('version'), '5.6', '>=')) {
            add_action('application_password_failed_authentication', [$this, 'log_app_password_failure']);
        }

        // Admin AJAX handlers
        add_action('wp_ajax_switchboard_unlock_ip', [$this, 'ajax_unlock_ip']);
        add_action('wp_ajax_switchboard_clear_login_log', [$this, 'ajax_clear_login_log']);
        add_action('wp_ajax_switchboard_reset_lockout_counter', [$this, 'ajax_reset_lockout_counter']);

        // Cron cleanup
        add_action('switchboard_cleanup_login_attempts', [$this, 'cleanup']);
    }

    /**
     * Get data for email report
     *
     * @param array $date_range ['from' => 'Y-m-d', 'to' => 'Y-m-d']
     * @return array Report data
     */
    public function get_report_data($date_range) {
        // Get lockout data from options
        $lockouts = get_option('switchboard_login_lockouts', []);
        $logged = get_option('switchboard_login_logged', []);
        $total_lockouts = get_option('switchboard_login_lockouts_total', 0);

        // Parse date range boundaries for filtering
        $range_from = isset($date_range['from']) ? strtotime($date_range['from'] . ' 00:00:00') : 0;
        $range_to   = isset($date_range['to'])   ? strtotime($date_range['to'] . ' 23:59:59')   : PHP_INT_MAX;

        // Count current lockouts that fall within the date range
        $current_lockouts = 0;
        $now = time();
        foreach ($lockouts as $ip => $expiry) {
            if ($expiry > $now && $expiry >= $range_from && $expiry <= $range_to) {
                $current_lockouts++;
            }
        }

        // Count blocked IPs within the date range
        $blocked_ips = 0;
        foreach ($lockouts as $ip => $expiry) {
            if ($expiry >= $range_from && $expiry <= $range_to) {
                $blocked_ips++;
            }
        }

        // Count total failed attempts from logged data, filtered by date range
        $failed_attempts = 0;
        $top_blocked_ips = [];

        foreach ($logged as $ip => $users) {
            $ip_total = 0;
            foreach ($users as $user => $data) {
                // Only count entries whose last attempt date falls within the range
                $entry_date = isset($data['date']) ? intval($data['date']) : 0;
                if ($entry_date >= $range_from && $entry_date <= $range_to) {
                    $ip_total += isset($data['counter']) ? intval($data['counter']) : 0;
                }
            }
            $failed_attempts += $ip_total;

            if ($ip_total > 0) {
                $top_blocked_ips[$ip] = $ip_total;
            }
        }

        // Sort top blocked IPs by attempt count
        arsort($top_blocked_ips);
        $top_blocked_ips = array_slice($top_blocked_ips, 0, 5, true);

        // Format for template
        $formatted_top_ips = [];
        foreach ($top_blocked_ips as $ip => $count) {
            $formatted_top_ips[] = [
                'ip'    => $ip,
                'count' => $count,
            ];
        }

        return [
            'lockouts'        => $current_lockouts,
            'blocked_ips'     => $blocked_ips,
            'failed_attempts' => $failed_attempts,
            'top_blocked_ips' => $formatted_top_ips,
        ];
    }

    /**
     * Get merged settings with defaults
     */
    private function get_settings() {
        $defaults = [
            'allowed_retries'    => 4,
            'lockout_duration'   => 20,
            'allowed_lockouts'   => 4,
            'long_duration'      => 1440,
            'valid_duration'     => 720,
            'notify_email_after' => 3,
            'whitelist_ips'      => '',
            'blacklist_ips'      => '',
            'trusted_ip_origins' => 'REMOTE_ADDR',
        ];
        $saved = get_option('switchboard_limit-login-attempts_settings', []);
        $settings = wp_parse_args($saved, $defaults);

        // Server-side validation — clamp numeric values to safe ranges
        // Prevents division by zero, negative durations, or nonsensical values
        $settings['allowed_retries']    = max(1, min(10, intval($settings['allowed_retries'])));
        $settings['lockout_duration']   = max(1, min(10080, intval($settings['lockout_duration'])));
        $settings['allowed_lockouts']   = max(1, min(10, intval($settings['allowed_lockouts'])));
        $settings['long_duration']      = max(60, min(10080, intval($settings['long_duration'])));
        $settings['valid_duration']     = max(60, min(10080, intval($settings['valid_duration'])));
        $settings['notify_email_after'] = max(0, min(100, intval($settings['notify_email_after'])));

        return $settings;
    }

    // ─── Activation / Deactivation ────────────────────────────────────

    public function activate() {
        // Migrate from old custom table if it exists
        $this->maybe_migrate_old_table();

        if (!wp_next_scheduled('switchboard_cleanup_login_attempts')) {
            wp_schedule_event(time(), 'daily', 'switchboard_cleanup_login_attempts');
        }
    }

    public function deactivate() {
        wp_clear_scheduled_hook('switchboard_cleanup_login_attempts');
    }

    /**
     * Migrate data from the old custom DB table to options-based storage
     */
    private function maybe_migrate_old_table() {
        global $wpdb;
        $old_table = $wpdb->prefix . 'switchboard_login_attempts';
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration check
        $table_exists = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $old_table));

        if ($table_exists) {
            // Migrate old settings format
            $old_settings = get_option('switchboard_limit-login-attempts_settings', []);
            if (isset($old_settings['max_attempts']) && !isset($old_settings['allowed_retries'])) {
                $new_settings = [
                    'allowed_retries'    => intval($old_settings['max_attempts']),
                    'lockout_duration'   => isset($old_settings['lockout_duration']) ? intval($old_settings['lockout_duration']) : 20,
                    'allowed_lockouts'   => 4,
                    'long_duration'      => 1440,
                    'valid_duration'     => 720,
                    'notify_email_after' => 3,
                    'whitelist_ips'      => '',
                    'blacklist_ips'      => '',
                ];
                update_option('switchboard_limit-login-attempts_settings', $new_settings);
            }

            // Drop old table
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name safely constructed from $wpdb->prefix
            $wpdb->query("DROP TABLE IF EXISTS {$old_table}");
        }
    }

    // ─── IP Utilities ─────────────────────────────────────────────────

    private function get_ip() {
        $origins = isset($this->settings['trusted_ip_origins']) ? $this->settings['trusted_ip_origins'] : 'REMOTE_ADDR';
        $headers = array_map('trim', explode(',', $origins));

        // Always include REMOTE_ADDR as fallback
        if (!in_array('REMOTE_ADDR', $headers)) {
            $headers[] = 'REMOTE_ADDR';
        }

        foreach ($headers as $header) {
            if (empty($_SERVER[$header])) continue;

            $value = sanitize_text_field(wp_unslash($_SERVER[$header]));

            // Handle comma-separated IPs (e.g., X-Forwarded-For: client, proxy1, proxy2)
            if (strpos($value, ',') !== false) {
                $ips = array_map('trim', explode(',', $value));
                foreach ($ips as $candidate) {
                    $candidate = $this->sanitize_ip($candidate);
                    if ($candidate) return $candidate;
                }
            } else {
                $candidate = $this->sanitize_ip($value);
                if ($candidate) return $candidate;
            }
        }

        return '0.0.0.0';
    }

    /**
     * Sanitize and validate an IP address (strip port, validate)
     */
    private function sanitize_ip($ip) {
        $ip = trim($ip);
        // Strip port from IPv4 (e.g., 1.2.3.4:8080)
        $ip = preg_replace('/^(\d+\.\d+\.\d+\.\d+):\d+$/', '$1', $ip);
        $validated = filter_var($ip, FILTER_VALIDATE_IP);
        return $validated ? $validated : false;
    }

    /**
     * Check if an IP falls within a range (supports CIDR, wildcard, single IP)
     */
    private function ip_in_range($ip, $range) {
        $range = trim($range);
        if (empty($range)) return false;

        // Exact match
        if ($ip === $range) return true;

        // CIDR notation (e.g., 192.168.1.0/24)
        if (strpos($range, '/') !== false) {
            list($subnet, $bits) = explode('/', $range);
            if (is_numeric($bits) && $bits >= 0 && $bits <= 32) {
                $ip_long = ip2long($ip);
                $subnet_long = ip2long($subnet);
                if ($ip_long !== false && $subnet_long !== false) {
                    $mask = -1 << (32 - intval($bits));
                    return ($ip_long & $mask) === ($subnet_long & $mask);
                }
            }
        }

        // Wildcard (e.g., 192.168.*.*)
        if (strpos($range, '*') !== false) {
            $pattern = '/^' . str_replace(['.', '*'], ['\.', '[0-9]+'], $range) . '$/';
            return (bool) preg_match($pattern, $ip);
        }

        // Range notation (e.g., 192.168.1.0-192.168.1.255)
        if (strpos($range, '-') !== false) {
            list($start, $end) = explode('-', $range, 2);
            $start_long = ip2long(trim($start));
            $end_long = ip2long(trim($end));
            $ip_long = ip2long($ip);
            if ($start_long !== false && $end_long !== false && $ip_long !== false) {
                return ($ip_long >= $start_long && $ip_long <= $end_long);
            }
        }

        return false;
    }

    private function is_ip_whitelisted($ip = null) {
        if (is_null($ip)) $ip = $this->get_ip();
        $whitelist = $this->parse_ip_list($this->settings['whitelist_ips']);
        foreach ($whitelist as $range) {
            if ($this->ip_in_range($ip, $range)) return true;
        }
        return false;
    }

    private function is_ip_blacklisted($ip = null) {
        if (is_null($ip)) $ip = $this->get_ip();
        $blacklist = $this->parse_ip_list($this->settings['blacklist_ips']);
        foreach ($blacklist as $range) {
            if ($this->ip_in_range($ip, $range)) return true;
        }
        return false;
    }

    private function parse_ip_list($text) {
        if (empty($text)) return [];
        $lines = preg_split('/[\r\n,]+/', $text);
        return array_filter(array_map('trim', $lines));
    }

    // ─── Lockout Check ────────────────────────────────────────────────

    /**
     * Check if the current IP is locked out
     *
     * @return WP_Error|false
     */
    private function is_ip_locked_out() {
        $ip = $this->get_ip();

        // Blacklisted IPs are always blocked
        if ($this->is_ip_blacklisted($ip)) {
            return new WP_Error('ip_blacklisted',
                esc_html__('Access denied.', 'wpswitchboard')
            );
        }

        // Whitelisted IPs are never locked out
        if ($this->is_ip_whitelisted($ip)) {
            return false;
        }

        $lockouts = get_option('switchboard_login_lockouts', []);
        if (isset($lockouts[$ip]) && time() < $lockouts[$ip]) {
            $minutes_left = ceil(($lockouts[$ip] - time()) / 60);
            return new WP_Error('too_many_attempts',
                sprintf(
                    // translators: %d is the number of minutes until the lockout expires
                    esc_html__('Too many failed login attempts. Please try again in %d minutes.', 'wpswitchboard'),
                    $minutes_left
                )
            );
        }

        return false;
    }

    /**
     * Get lockout error message (for XML-RPC and other contexts)
     */
    private function get_message() {
        $lockout = $this->is_ip_locked_out();
        if ($lockout instanceof WP_Error) {
            return $lockout->get_error_message();
        }
        return '';
    }

    /**
     * Detect the login gateway (for logging)
     */
    private function detect_gateway() {
        if (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST) {
            return 'xmlrpc';
        }
        if (defined('REST_REQUEST') && REST_REQUEST) {
            return 'rest-api';
        }
        if (!empty($_SERVER['HTTP_AUTHORIZATION']) && isset($_SERVER['REQUEST_URI']) && strpos(esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'])), '/wp-json/') !== false) {
            return 'rest-api';
        }
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Detection only, not processing form data
        if (isset($_POST['woocommerce-login-nonce'])) {
            return 'woocommerce';
        }
        return 'wp-login';
    }

    // ─── Authentication Hooks ─────────────────────────────────────────

    /**
     * Early check (priority 5): Block login if IP is locked out
     */
    public function check_login_attempts($user, $username, $password) {
        // Always check lockout/blacklist, even for empty credentials
        $lockout = $this->is_ip_locked_out();
        if ($lockout instanceof WP_Error) {
            return $lockout;
        }

        if (empty($username) || empty($password)) {
            return $user;
        }

        return $user;
    }

    /**
     * Late check (priority 99999): Enforce lockout after WP validates user
     */
    public function enforce_lockout($user, $password) {
        if (is_wp_error($user)) {
            return $user;
        }

        $lockout = $this->is_ip_locked_out();
        if ($lockout instanceof WP_Error) {
            return $lockout;
        }

        return $user;
    }

    /**
     * Log failed login attempt and trigger lockout if threshold reached
     */
    public function log_failed_attempt($username) {
        $ip = $this->get_ip();

        // Skip lockout tracking for whitelisted IPs — they can never be locked out
        if ($this->is_ip_whitelisted($ip)) {
            $this->log_attempt($ip, $username);
            return;
        }

        // Skip lockout logic for blacklisted IPs — they are already permanently
        // blocked at the authentication check level, so just log the attempt
        if ($this->is_ip_blacklisted($ip)) {
            $this->log_attempt($ip, $username);
            return;
        }

        $allowed_retries = intval($this->settings['allowed_retries']);
        $lockout_duration = intval($this->settings['lockout_duration']);
        $allowed_lockouts = intval($this->settings['allowed_lockouts']);
        $long_duration = intval($this->settings['long_duration']);
        $valid_duration = intval($this->settings['valid_duration']);

        // Invalidate object cache to reduce stale reads under concurrent attacks
        wp_cache_delete('switchboard_login_lockouts', 'options');
        wp_cache_delete('switchboard_login_retries', 'options');
        wp_cache_delete('switchboard_login_retries_valid', 'options');

        $lockouts = get_option('switchboard_login_lockouts', []);
        $retries = get_option('switchboard_login_retries', []);
        $valid = get_option('switchboard_login_retries_valid', []);

        // Inline housekeeping: remove expired lockouts, retries, and orphan retries
        $now = time();
        foreach ($lockouts as $k => $expiry) {
            if ($expiry < $now) unset($lockouts[$k]);
        }
        foreach ($valid as $k => $expiry) {
            if ($expiry < $now) {
                unset($valid[$k]);
                unset($retries[$k]);
            }
        }
        // Sync: remove orphan retries that have no corresponding valid entry
        foreach ($retries as $k => $v) {
            if (!isset($valid[$k])) unset($retries[$k]);
        }

        // Don't increment if already locked out
        if (isset($lockouts[$ip]) && time() < $lockouts[$ip]) {
            return;
        }

        // Increment retries (or start fresh if expired)
        if (isset($retries[$ip]) && isset($valid[$ip]) && time() < $valid[$ip]) {
            $retries[$ip]++;
        } else {
            $retries[$ip] = 1;
        }

        // Reset the validity window
        $valid[$ip] = time() + ($valid_duration * 60);

        // Check if we've hit the lockout threshold
        if ($retries[$ip] % $allowed_retries !== 0) {
            // Not at threshold yet — save and return
            update_option('switchboard_login_retries', $retries, false);
            update_option('switchboard_login_retries_valid', $valid, false);
            $this->log_attempt($ip, $username);
            return;
        }

        // Lockout triggered — check if this should be a long lockout
        $retries_for_long = $allowed_retries * $allowed_lockouts;

        // Capture lockout number before modifying retries
        $lockout_number = intval($retries[$ip]) / $allowed_retries;

        if ($retries[$ip] >= $retries_for_long) {
            // Long lockout
            $lockouts[$ip] = time() + ($long_duration * 60);
            unset($retries[$ip]);
            unset($valid[$ip]);
        } else {
            // Normal lockout
            $lockouts[$ip] = time() + ($lockout_duration * 60);
        }

        // Update lockout counter
        $total = get_option('switchboard_login_lockouts_total', 0);
        update_option('switchboard_login_lockouts_total', $total + 1, false);

        update_option('switchboard_login_lockouts', $lockouts, false);
        update_option('switchboard_login_retries', $retries, false);
        update_option('switchboard_login_retries_valid', $valid, false);

        $this->log_attempt($ip, $username);
        $this->maybe_notify_email($ip, $username, $lockout_number);
    }

    /**
     * Clear attempts on successful login
     */
    public function clear_attempts($username, $user) {
        $ip = $this->get_ip();

        $retries = get_option('switchboard_login_retries', []);
        $valid = get_option('switchboard_login_retries_valid', []);
        $lockouts = get_option('switchboard_login_lockouts', []);

        unset($retries[$ip], $valid[$ip], $lockouts[$ip]);

        update_option('switchboard_login_retries', $retries, false);
        update_option('switchboard_login_retries_valid', $valid, false);
        update_option('switchboard_login_lockouts', $lockouts, false);
    }

    // ─── Credential Tracking & Error Filtering ─────────────────────────

    /**
     * Track whether credentials were non-empty (priority 1, before lockout check)
     */
    public function track_credentials($user, $username, $password) {
        global $switchboard_login_nonempty_credentials;
        $switchboard_login_nonempty_credentials = (!empty($username) && !empty($password));
        return $user;
    }

    /**
     * Filter login error messages to prevent username enumeration
     * and suppress other messages during lockout.
     * Replaces "Invalid username" / "Invalid password" with a generic message.
     */
    public function fixup_error_messages($content) {
        global $switchboard_login_nonempty_credentials;

        // During lockout or blacklist: suppress all other error messages
        if (!$this->is_limit_login_ok()) {
            if ($this->is_ip_blacklisted()) {
                return __('<strong>ERROR</strong>: Access denied.', 'wpswitchboard');
            }
            return $this->get_lockout_error_msg();
        }

        // If credentials were submitted, replace WP's specific error with generic
        if ($switchboard_login_nonempty_credentials) {
            $remaining_msg = $this->get_remaining_msg();

            $content = __('<strong>ERROR</strong>: Incorrect username or password.', 'wpswitchboard');

            if ($remaining_msg) {
                $content .= "<br />\n" . $remaining_msg;
            }
        }

        return $content;
    }

    /**
     * Check if login is currently OK (not locked out) — without WP_Error wrapper
     */
    private function is_limit_login_ok() {
        $ip = $this->get_ip();

        if ($this->is_ip_blacklisted($ip)) return false;
        if ($this->is_ip_whitelisted($ip)) return true;

        $lockouts = get_option('switchboard_login_lockouts', []);
        return (!is_array($lockouts) || !isset($lockouts[$ip]) || time() >= $lockouts[$ip]);
    }

    /**
     * Get the lockout error message string
     */
    private function get_lockout_error_msg() {
        $ip = $this->get_ip();
        $lockouts = get_option('switchboard_login_lockouts', []);

        $msg = __('<strong>ERROR</strong>: Too many failed login attempts.', 'wpswitchboard') . ' ';

        if (!is_array($lockouts) || !isset($lockouts[$ip]) || time() >= $lockouts[$ip]) {
            $msg .= __('Please try again later.', 'wpswitchboard');
            return $msg;
        }

        $when = ceil(($lockouts[$ip] - time()) / 60);
        if ($when > 60) {
            $when = ceil($when / 60);
            $msg .= sprintf(
                // translators: %d is the number of hours until the lockout expires
                _n('Please try again in %d hour.', 'Please try again in %d hours.', $when, 'wpswitchboard'),
                $when
            );
        } else {
            $msg .= sprintf(
                // translators: %d is the number of minutes until the lockout expires
                _n('Please try again in %d minute.', 'Please try again in %d minutes.', $when, 'wpswitchboard'),
                $when
            );
        }

        return $msg;
    }

    /**
     * Get remaining attempts message string (or empty)
     */
    private function get_remaining_msg() {
        $ip = $this->get_ip();
        $retries = get_option('switchboard_login_retries', []);
        $valid = get_option('switchboard_login_retries_valid', []);
        $allowed_retries = intval($this->settings['allowed_retries']);

        if (!isset($retries[$ip]) || !isset($valid[$ip]) || time() > $valid[$ip]) {
            return '';
        }
        if ($retries[$ip] % $allowed_retries === 0) {
            return '';
        }

        $remaining = max($allowed_retries - ($retries[$ip] % $allowed_retries), 0);
        if ($remaining > 0 && $remaining < $allowed_retries) {
            return sprintf(
                // translators: %d is the number of login attempts remaining before lockout
                _n(
                    '<strong>%d</strong> attempt remaining.',
                    '<strong>%d</strong> attempts remaining.',
                    $remaining,
                    'wpswitchboard'
                ),
                $remaining
            );
        }
        return '';
    }

    /**
     * Add lockout error codes to login form "shake" animation
     */
    public function failure_shake($error_codes) {
        $error_codes[] = 'too_many_attempts';
        $error_codes[] = 'ip_blacklisted';
        return $error_codes;
    }

    /**
     * Display lockout notices on WooCommerce account page
     */
    public function add_wc_notices() {
        if (!function_exists('is_account_page') || !function_exists('wc_add_notice')) {
            return;
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checking if POST is empty, not processing data
        if (!is_account_page() || !empty($_POST)) {
            return;
        }

        if (!$this->is_limit_login_ok()) {
            $msg = $this->is_ip_blacklisted()
                ? __('Access denied.', 'wpswitchboard')
                : wp_strip_all_tags($this->get_lockout_error_msg());
            wc_add_notice($msg, 'error');
        }
    }

    // ─── XML-RPC Protection ───────────────────────────────────────────

    /**
     * Return lockout error for XML-RPC login failures
     */
    public function xmlrpc_error_messages($error) {
        if (!class_exists('IXR_Error')) {
            return $error;
        }

        $message = $this->get_message();
        if ($message) {
            return new \IXR_Error(403, wp_strip_all_tags($message));
        }

        return $error;
    }

    /**
     * Remove system.multicall to prevent bundled brute force attempts
     */
    public function disable_multicall($methods) {
        unset($methods['system.multicall']);
        return $methods;
    }

    /**
     * Early exit for locked-out IPs on XML-RPC requests
     */
    public function check_xmlrpc_lockout() {
        if (!defined('XMLRPC_REQUEST') || !XMLRPC_REQUEST) {
            return;
        }

        $lockout = $this->is_ip_locked_out();
        if ($lockout instanceof WP_Error) {
            status_header(429);
            header('Content-Type: text/xml; charset=UTF-8');
            echo '<?xml version="1.0" encoding="UTF-8"?>';
            echo '<methodResponse><fault><value><struct>';
            echo '<member><name>faultCode</name><value><int>429</int></value></member>';
            echo '<member><name>faultString</name><value><string>' . esc_html($lockout->get_error_message()) . '</string></value></member>';
            echo '</struct></value></fault></methodResponse>';
            exit;
        }
    }

    // ─── REST API Protection ──────────────────────────────────────────

    /**
     * Block REST API authentication for locked-out IPs
     */
    public function check_rest_lockout($result) {
        // Don't override existing auth errors
        if ($result !== null) {
            return $result;
        }

        // Only check if authentication is being attempted
        if (!empty($_SERVER['HTTP_AUTHORIZATION']) || !empty($_SERVER['PHP_AUTH_USER'])) {
            $lockout = $this->is_ip_locked_out();
            if ($lockout instanceof WP_Error) {
                return $lockout;
            }
        }

        return $result;
    }

    // ─── Application Passwords Protection ─────────────────────────────

    /**
     * Log failed Application Password attempts
     */
    public function log_app_password_failure($error) {
        // Try to extract the actual username from HTTP auth headers
        $username = 'unknown';
        if (!empty($_SERVER['PHP_AUTH_USER'])) {
            $username = sanitize_user(wp_unslash($_SERVER['PHP_AUTH_USER']));
        } elseif (!empty($_SERVER['HTTP_AUTHORIZATION'])) {
            $auth = sanitize_text_field(wp_unslash($_SERVER['HTTP_AUTHORIZATION']));
            if (0 === stripos($auth, 'basic ')) {
                $decoded = base64_decode(substr($auth, 6));
                if ($decoded && strpos($decoded, ':') !== false) {
                    list($username) = explode(':', $decoded, 2);
                    $username = sanitize_user($username);
                }
            }
        }
        $this->log_failed_attempt($username);
    }

    // ─── Logging ──────────────────────────────────────────────────────

    /**
     * Log a failed attempt for audit purposes
     */
    private function log_attempt($ip, $username) {
        $logged = get_option('switchboard_login_logged', []);
        $gateway = $this->detect_gateway();

        if (!isset($logged[$ip])) {
            $logged[$ip] = [];
        }
        if (!isset($logged[$ip][$username])) {
            $logged[$ip][$username] = ['counter' => 0, 'date' => 0, 'gateway' => ''];
        }

        $logged[$ip][$username]['counter']++;
        $logged[$ip][$username]['date'] = time();
        $logged[$ip][$username]['gateway'] = $gateway;

        update_option('switchboard_login_logged', $logged, false);
    }

    // ─── Email Notification ───────────────────────────────────────────

    /**
     * Send email notification after N lockouts
     *
     * @param string $ip         The IP address
     * @param string $username   The attempted username
     * @param int    $lockout_number The sequential lockout number for this IP
     */
    private function maybe_notify_email($ip, $username, $lockout_number) {
        $notify_after = intval($this->settings['notify_email_after']);
        if ($notify_after <= 0) return;

        $lockout_number = intval($lockout_number);

        // Only notify every N lockouts
        if ($lockout_number < 1 || $lockout_number % $notify_after !== 0) {
            return;
        }

        $admin_email = get_option('admin_email');
        $site_name = get_option('blogname');
        $gateway = $this->detect_gateway();

        $lockouts = get_option('switchboard_login_lockouts', []);
        $lockout_until = isset($lockouts[$ip]) ? $lockouts[$ip] : 0;
        $lockout_minutes = $lockout_until > 0 ? ceil(($lockout_until - time()) / 60) : 0;
        $is_long = $lockout_minutes > intval($this->settings['lockout_duration']);

        $subject = sprintf('[%s] Login lockout notification', $site_name);

        $message = sprintf("IP address %s has been locked out on %s.\n\n", $ip, $site_name);
        $message .= sprintf("Username attempted: %s\n", $username);
        $message .= sprintf("Login vector: %s\n", $gateway);
        $message .= sprintf("Lockout type: %s\n", $is_long ? 'Long lockout' : 'Normal lockout');
        $message .= sprintf("Lockout duration: %d minutes\n", $lockout_minutes);
        $message .= sprintf("Lockout number: %d\n\n", intval($lockout_number));
        $message .= sprintf("Manage settings: %s\n", admin_url('admin.php?page=switchboard'));

        if ($this->is_ip_whitelisted($ip)) {
            $message .= "\nNote: This IP is whitelisted and was NOT actually locked out.\n";
        }

        wp_mail($admin_email, $subject, $message);
    }

    // ─── Admin AJAX Handlers ─────────────────────────────────────────

    /**
     * AJAX: Unlock a specific IP
     */
    public function ajax_unlock_ip() {
        check_ajax_referer('switchboard_login_security', 'nonce');
        if (!current_user_can('manage_options')) wp_send_json_error();

        $ip = isset($_POST['ip']) ? sanitize_text_field(wp_unslash($_POST['ip'])) : '';
        if (empty($ip)) wp_send_json_error();

        $lockouts = get_option('switchboard_login_lockouts', []);
        $retries = get_option('switchboard_login_retries', []);
        $valid = get_option('switchboard_login_retries_valid', []);

        unset($lockouts[$ip], $retries[$ip], $valid[$ip]);

        update_option('switchboard_login_lockouts', $lockouts, false);
        update_option('switchboard_login_retries', $retries, false);
        update_option('switchboard_login_retries_valid', $valid, false);

        wp_send_json_success();
    }

    /**
     * AJAX: Clear the failed login log
     */
    public function ajax_clear_login_log() {
        check_ajax_referer('switchboard_login_security', 'nonce');
        if (!current_user_can('manage_options')) wp_send_json_error();

        update_option('switchboard_login_logged', [], false);
        wp_send_json_success();
    }

    /**
     * AJAX: Reset the lockout counter
     */
    public function ajax_reset_lockout_counter() {
        check_ajax_referer('switchboard_login_security', 'nonce');
        if (!current_user_can('manage_options')) wp_send_json_error();

        update_option('switchboard_login_lockouts_total', 0, false);
        wp_send_json_success();
    }

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

    /**
     * Clean up expired lockouts and retries
     */
    public function cleanup() {
        $lockouts = get_option('switchboard_login_lockouts', []);
        $retries = get_option('switchboard_login_retries', []);
        $valid = get_option('switchboard_login_retries_valid', []);
        $logged = get_option('switchboard_login_logged', []);
        $changed = false;

        // Remove expired lockouts
        foreach ($lockouts as $ip => $expiry) {
            if ($expiry < time()) {
                unset($lockouts[$ip]);
                $changed = true;
            }
        }

        // Remove expired retry windows
        foreach ($valid as $ip => $expiry) {
            if ($expiry < time()) {
                unset($valid[$ip]);
                unset($retries[$ip]);
                $changed = true;
            }
        }

        // Remove log entries older than 30 days
        $logged_changed = false;
        $cutoff = time() - (30 * DAY_IN_SECONDS);
        foreach ($logged as $ip => $users) {
            foreach ($users as $user => $data) {
                if (isset($data['date']) && $data['date'] < $cutoff) {
                    unset($logged[$ip][$user]);
                    $logged_changed = true;
                }
            }
            if (empty($logged[$ip])) {
                unset($logged[$ip]);
            }
        }

        if ($changed) {
            update_option('switchboard_login_lockouts', $lockouts, false);
            update_option('switchboard_login_retries', $retries, false);
            update_option('switchboard_login_retries_valid', $valid, false);
        }
        if ($logged_changed) {
            update_option('switchboard_login_logged', $logged, false);
        }
    }

    // ─── Settings Panel ───────────────────────────────────────────────

    public function render_settings($settings = []) {
        $allowed_retries = isset($settings['allowed_retries']) ? intval($settings['allowed_retries']) : 4;
        $lockout_duration = isset($settings['lockout_duration']) ? intval($settings['lockout_duration']) : 20;
        $allowed_lockouts = isset($settings['allowed_lockouts']) ? intval($settings['allowed_lockouts']) : 4;
        $long_duration = isset($settings['long_duration']) ? intval($settings['long_duration']) : 1440;
        $valid_duration = isset($settings['valid_duration']) ? intval($settings['valid_duration']) : 720;
        $notify_email_after = isset($settings['notify_email_after']) ? intval($settings['notify_email_after']) : 3;
        $whitelist_ips = isset($settings['whitelist_ips']) ? $settings['whitelist_ips'] : '';
        $blacklist_ips = isset($settings['blacklist_ips']) ? $settings['blacklist_ips'] : '';
        $trusted_ip_origins = isset($settings['trusted_ip_origins']) ? $settings['trusted_ip_origins'] : 'REMOTE_ADDR';

        $lockouts = get_option('switchboard_login_lockouts', []);
        $active_lockouts = 0;
        foreach ($lockouts as $ip => $expiry) {
            if ($expiry > time()) $active_lockouts++;
        }
        $total_lockouts = get_option('switchboard_login_lockouts_total', 0);
        $logged = get_option('switchboard_login_logged', []);
        $ajax_nonce = wp_create_nonce('switchboard_login_security');
        ?>
        <input type="hidden" name="module" value="limit-login-attempts">

        <div class="sb-sp-info-box sb-sp-info-box-info" style="margin-bottom: 24px;">
            <h4 class="sb-sp-heading-sm">Login Protection</h4>
            <p class="sb-sp-description" style="margin-bottom: 12px;">Protects all login vectors: wp-login.php, XML-RPC, REST API, and Application Passwords. Tracks failed attempts per IP with escalating lockouts.</p>
            <div style="display: flex; gap: 24px; flex-wrap: wrap;">
                <div><strong><?php echo intval($active_lockouts); ?></strong> <span style="color: #6b7280;">active lockouts</span></div>
                <div>
                    <strong id="sb-lockout-total"><?php echo intval($total_lockouts); ?></strong> <span style="color: #6b7280;">total lockouts</span>
                    <?php if ($total_lockouts > 0): ?>
                    <button type="button" class="button button-small sb-reset-counter" style="margin-left: 6px; font-size: 11px;">Reset</button>
                    <?php endif; ?>
                </div>
                <div><strong><?php echo esc_html($this->get_ip()); ?></strong> <span style="color: #6b7280;">your IP</span></div>
            </div>
        </div>

        <div class="sb-sp-group sb-sp-field-horizontal" style="margin-bottom: 24px;">
            <div class="sb-sp-field-horizontal-left">
                <label for="setting_allowed_retries" class="sb-sp-label"><?php esc_html_e('Allowed Retries', 'wpswitchboard'); ?></label>
                <p class="sb-sp-description"><?php esc_html_e('Failed attempts before first lockout. Default: 4', 'wpswitchboard'); ?></p>
            </div>
            <input type="number" name="setting_allowed_retries" id="setting_allowed_retries" value="<?php echo esc_attr($allowed_retries); ?>" min="1" max="10" class="sb-sp-input" style="width: 150px;">
        </div>

        <div class="sb-sp-group sb-sp-field-horizontal" style="margin-bottom: 24px;">
            <div class="sb-sp-field-horizontal-left">
                <label for="setting_lockout_duration" class="sb-sp-label"><?php esc_html_e('Normal Lockout (minutes)', 'wpswitchboard'); ?></label>
                <p class="sb-sp-description"><?php esc_html_e('Duration of first lockout. Default: 20 minutes', 'wpswitchboard'); ?></p>
            </div>
            <input type="number" name="setting_lockout_duration" id="setting_lockout_duration" value="<?php echo esc_attr($lockout_duration); ?>" min="1" max="1440" class="sb-sp-input" style="width: 150px;">
        </div>

        <div class="sb-sp-group sb-sp-field-horizontal" style="margin-bottom: 24px;">
            <div class="sb-sp-field-horizontal-left">
                <label for="setting_allowed_lockouts" class="sb-sp-label"><?php esc_html_e('Lockouts Before Long Ban', 'wpswitchboard'); ?></label>
                <p class="sb-sp-description"><?php esc_html_e('Normal lockouts before escalating to long lockout. Default: 4', 'wpswitchboard'); ?></p>
            </div>
            <input type="number" name="setting_allowed_lockouts" id="setting_allowed_lockouts" value="<?php echo esc_attr($allowed_lockouts); ?>" min="1" max="10" class="sb-sp-input" style="width: 150px;">
        </div>

        <div class="sb-sp-group sb-sp-field-horizontal" style="margin-bottom: 24px;">
            <div class="sb-sp-field-horizontal-left">
                <label for="setting_long_duration" class="sb-sp-label"><?php esc_html_e('Long Lockout (minutes)', 'wpswitchboard'); ?></label>
                <p class="sb-sp-description"><?php esc_html_e('Duration of long lockout. Default: 1440 (24 hours)', 'wpswitchboard'); ?></p>
            </div>
            <input type="number" name="setting_long_duration" id="setting_long_duration" value="<?php echo esc_attr($long_duration); ?>" min="60" max="10080" class="sb-sp-input" style="width: 150px;">
        </div>

        <div class="sb-sp-group sb-sp-field-horizontal" style="margin-bottom: 24px;">
            <div class="sb-sp-field-horizontal-left">
                <label for="setting_valid_duration" class="sb-sp-label"><?php esc_html_e('Retry Reset Window (minutes)', 'wpswitchboard'); ?></label>
                <p class="sb-sp-description"><?php esc_html_e('Failed attempt counter resets after this period of inactivity. Default: 720 (12 hours)', 'wpswitchboard'); ?></p>
            </div>
            <input type="number" name="setting_valid_duration" id="setting_valid_duration" value="<?php echo esc_attr($valid_duration); ?>" min="60" max="10080" class="sb-sp-input" style="width: 150px;">
        </div>

        <div class="sb-sp-group sb-sp-field-horizontal" style="margin-bottom: 24px;">
            <div class="sb-sp-field-horizontal-left">
                <label for="setting_notify_email_after" class="sb-sp-label"><?php esc_html_e('Email After N Lockouts', 'wpswitchboard'); ?></label>
                <p class="sb-sp-description"><?php esc_html_e('Send admin an email after every N lockouts. Set to 0 to disable. Default: 3', 'wpswitchboard'); ?></p>
            </div>
            <input type="number" name="setting_notify_email_after" id="setting_notify_email_after" value="<?php echo esc_attr($notify_email_after); ?>" min="0" max="100" class="sb-sp-input" style="width: 150px;">
        </div>

        <div class="sb-sp-group sb-sp-field-horizontal" style="margin-bottom: 24px;">
            <div class="sb-sp-field-horizontal-left">
                <label for="setting_trusted_ip_origins" class="sb-sp-label"><?php esc_html_e('Trusted IP Origin', 'wpswitchboard'); ?></label>
                <p class="sb-sp-description"><?php esc_html_e('How to determine the visitor IP. Use REMOTE_ADDR for direct connections. If behind a reverse proxy (Cloudflare, Nginx, load balancer), select the appropriate header.', 'wpswitchboard'); ?></p>
            </div>
            <select name="setting_trusted_ip_origins" id="setting_trusted_ip_origins" class="sb-sp-input" style="width: 280px;">
                <option value="REMOTE_ADDR" <?php selected($trusted_ip_origins, 'REMOTE_ADDR'); ?>>REMOTE_ADDR (Direct connection)</option>
                <option value="HTTP_X_FORWARDED_FOR" <?php selected($trusted_ip_origins, 'HTTP_X_FORWARDED_FOR'); ?>>X-Forwarded-For (Reverse proxy)</option>
                <option value="HTTP_X_REAL_IP" <?php selected($trusted_ip_origins, 'HTTP_X_REAL_IP'); ?>>X-Real-IP (Nginx proxy)</option>
                <option value="HTTP_CF_CONNECTING_IP" <?php selected($trusted_ip_origins, 'HTTP_CF_CONNECTING_IP'); ?>>CF-Connecting-IP (Cloudflare)</option>
            </select>
        </div>

        <hr class="sb-sp-divider">

        <div class="sb-sp-group" style="margin-bottom: 24px;">
            <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
                <label for="setting_whitelist_ips" class="sb-sp-label"><?php esc_html_e('IP Whitelist', 'wpswitchboard'); ?></label>
                <button type="button" class="button button-small sb-add-my-ip" data-target="setting_whitelist_ips"><?php esc_html_e('+ Add My IP', 'wpswitchboard'); ?></button>
            </div>
            <p class="sb-sp-description" style="margin-bottom: 8px;"><?php esc_html_e('IPs that will never be locked out. One per line. Supports single IP, CIDR (192.168.1.0/24), wildcard (192.168.*.*), and range (10.0.0.1-10.0.0.255).', 'wpswitchboard'); ?></p>
            <textarea name="setting_whitelist_ips" id="setting_whitelist_ips" rows="4" class="sb-sp-input" style="width: 100%; font-family: monospace; font-size: 12px;"><?php echo esc_textarea($whitelist_ips); ?></textarea>
        </div>

        <div class="sb-sp-group" style="margin-bottom: 24px;">
            <label for="setting_blacklist_ips" class="sb-sp-label"><?php esc_html_e('IP Blacklist', 'wpswitchboard'); ?></label>
            <p class="sb-sp-description" style="margin-bottom: 8px;"><?php esc_html_e('IPs that are always blocked from logging in. Same format as whitelist.', 'wpswitchboard'); ?></p>
            <textarea name="setting_blacklist_ips" id="setting_blacklist_ips" rows="4" class="sb-sp-input" style="width: 100%; font-family: monospace; font-size: 12px;"><?php echo esc_textarea($blacklist_ips); ?></textarea>
        </div>

        <?php
        // Filter to only active lockouts
        $active_lockout_list = [];
        foreach ($lockouts as $ip => $expiry) {
            if ($expiry > time()) $active_lockout_list[$ip] = $expiry;
        }
        if (!empty($active_lockout_list)): ?>
        <hr class="sb-sp-divider">
        <div class="sb-sp-group" style="margin-bottom: 24px;">
            <h3 class="sb-sp-heading">Active Lockouts</h3>
            <table class="widefat striped" style="margin-top: 8px;">
                <thead>
                    <tr>
                        <th><?php esc_html_e('IP Address', 'wpswitchboard'); ?></th>
                        <th><?php esc_html_e('Locked Until', 'wpswitchboard'); ?></th>
                        <th><?php esc_html_e('Time Left', 'wpswitchboard'); ?></th>
                        <th style="width: 80px;"><?php esc_html_e('Action', 'wpswitchboard'); ?></th>
                    </tr>
                </thead>
                <tbody>
                    <?php foreach ($active_lockout_list as $ip => $expiry):
                        $minutes_left = ceil(($expiry - time()) / 60);
                    ?>
                    <tr id="lockout-row-<?php echo esc_attr(md5($ip)); ?>">
                        <td><code><?php echo esc_html($ip); ?></code></td>
                        <td><?php echo esc_html(wp_date('Y-m-d H:i:s', $expiry)); ?></td>
                        <td><?php echo esc_html($minutes_left); ?> min</td>
                        <td><button type="button" class="button button-small sb-unlock-ip" data-ip="<?php echo esc_attr($ip); ?>">Unlock</button></td>
                    </tr>
                    <?php endforeach; ?>
                </tbody>
            </table>
        </div>
        <?php endif; ?>

        <?php if (!empty($logged)): ?>
        <hr class="sb-sp-divider">
        <div class="sb-sp-group" style="margin-bottom: 24px;">
            <div style="display: flex; align-items: center; gap: 12px;">
                <h3 class="sb-sp-heading" style="margin: 0;">Recent Failed Logins</h3>
                <button type="button" class="button button-small sb-clear-log">Clear Log</button>
            </div>
            <table class="widefat striped" style="margin-top: 8px;">
                <thead>
                    <tr>
                        <th><?php esc_html_e('IP Address', 'wpswitchboard'); ?></th>
                        <th><?php esc_html_e('Username', 'wpswitchboard'); ?></th>
                        <th><?php esc_html_e('Attempts', 'wpswitchboard'); ?></th>
                        <th><?php esc_html_e('Last Attempt', 'wpswitchboard'); ?></th>
                        <th><?php esc_html_e('Gateway', 'wpswitchboard'); ?></th>
                    </tr>
                </thead>
                <tbody>
                    <?php
                    // Flatten and sort by date
                    $flat_log = [];
                    foreach ($logged as $ip => $users) {
                        foreach ($users as $user => $data) {
                            $flat_log[] = array_merge($data, ['ip' => $ip, 'username' => $user]);
                        }
                    }
                    usort($flat_log, function($a, $b) { return $b['date'] - $a['date']; });
                    $flat_log = array_slice($flat_log, 0, 20);
                    foreach ($flat_log as $entry): ?>
                    <tr>
                        <td><code><?php echo esc_html($entry['ip']); ?></code></td>
                        <td><?php echo esc_html($entry['username']); ?></td>
                        <td><?php echo intval($entry['counter']); ?></td>
                        <td><?php echo esc_html(wp_date('Y-m-d H:i:s', $entry['date'])); ?></td>
                        <td><?php echo esc_html($entry['gateway']); ?></td>
                    </tr>
                    <?php endforeach; ?>
                </tbody>
            </table>
        </div>
        <?php endif; ?>

        <div class="sb-sp-info-box sb-sp-info-box-tip">
            <h4 class="sb-sp-heading-sm">How Escalation Works</h4>
            <ul class="sb-sp-list">
                <li class="sb-sp-list-item"><strong>Step 1:</strong> After <?php echo intval($allowed_retries); ?> failed attempts &rarr; normal lockout (<?php echo intval($lockout_duration); ?> min)</li>
                <li class="sb-sp-list-item"><strong>Step 2:</strong> After <?php echo intval($allowed_lockouts); ?> normal lockouts (<?php echo intval($allowed_retries) * intval($allowed_lockouts); ?> total failures) &rarr; long lockout (<?php echo intval($long_duration); ?> min)</li>
                <li class="sb-sp-list-item"><strong>Reset:</strong> Retry counter resets after <?php echo intval($valid_duration); ?> minutes of no failures</li>
            </ul>
        </div>

        <script>
        jQuery(function($) {
            var sbNonce = <?php echo wp_json_encode($ajax_nonce); ?>;

            // Unlock IP
            $(document).on('click', '.sb-unlock-ip', function() {
                var btn = $(this);
                var ip = btn.data('ip');
                btn.prop('disabled', true).text('...');
                $.post(ajaxurl, { action: 'switchboard_unlock_ip', ip: ip, nonce: sbNonce }, function(r) {
                    if (r.success) {
                        btn.closest('tr').fadeOut(300, function() { $(this).remove(); });
                    } else {
                        btn.prop('disabled', false).text('Unlock');
                    }
                });
            });

            // Clear log
            $(document).on('click', '.sb-clear-log', function() {
                var btn = $(this);
                if (!confirm('Clear all failed login log entries?')) return;
                btn.prop('disabled', true).text('...');
                $.post(ajaxurl, { action: 'switchboard_clear_login_log', nonce: sbNonce }, function(r) {
                    if (r.success) {
                        btn.closest('.sb-sp-group').fadeOut(300);
                    } else {
                        btn.prop('disabled', false).text('Clear Log');
                    }
                });
            });

            // Add My IP to whitelist
            $(document).on('click', '.sb-add-my-ip', function() {
                var btn = $(this);
                var targetId = btn.data('target');
                var $textarea = $('#' + targetId);
                var currentIp = <?php echo wp_json_encode($this->get_ip()); ?>;
                var currentValue = $textarea.val().trim();

                if (currentValue.indexOf(currentIp) !== -1) {
                    btn.text('Already added').prop('disabled', true);
                    setTimeout(function() { btn.text('+ Add My IP').prop('disabled', false); }, 1500);
                    return;
                }

                $textarea.val(currentValue ? currentValue + '\n' + currentIp : currentIp);
                btn.text('Added!').prop('disabled', true);
                setTimeout(function() { btn.text('+ Add My IP').prop('disabled', false); }, 1500);
            });

            // Reset counter
            $(document).on('click', '.sb-reset-counter', function() {
                var btn = $(this);
                btn.prop('disabled', true).text('...');
                $.post(ajaxurl, { action: 'switchboard_reset_lockout_counter', nonce: sbNonce }, function(r) {
                    if (r.success) {
                        $('#sb-lockout-total').text('0');
                        btn.fadeOut(200);
                    } else {
                        btn.prop('disabled', false).text('Reset');
                    }
                });
            });
        });
        </script>
        <?php
    }

    // ─── Data Registry ────────────────────────────────────────────────

    public function register_data_locations() {
        return [
            'options' => [
                'switchboard_limit-login-attempts_settings',
                'switchboard_login_retries',
                'switchboard_login_retries_valid',
                'switchboard_login_lockouts',
                'switchboard_login_lockouts_total',
                'switchboard_login_logged',
            ],
            'option_patterns' => [],
            'tables' => [],
            'post_meta' => [],
            'user_meta' => [],
        ];
    }
}

$switchboard_instance = new SwitchboardLimitLoginAttempts();
Switchboard_Data_Registry::get_instance()->auto_register($switchboard_instance, __FILE__);
