<?php
/**
 * Handles Access Control (IP/Username Blacklist/Whitelist) for Guard Dog.
 *
 * @package GuardDog
 * @since 1.3.0
 * @todo Future Enhancement: Add an option to block blacklisted IPs from accessing the *entire* frontend, not just the login process. This would require hooking earlier (e.g., 'init' or 'wp_loaded') and checking the IP before any significant WordPress processing occurs for frontend requests.
 */

if (!defined('ABSPATH')) exit; // Exit if accessed directly

class Guard_Dog_Access_Control {

    /**
     * Option key for the access control mode ('blacklist' or 'whitelist').
     * @var string
     */
    const OPTION_MODE = 'guard_dog_access_mode';

    /**
     * Option key for the IP blacklist.
     * @var string
     */
    const OPTION_IP_BLACKLIST = 'guard_dog_ip_blacklist';

    /**
     * Option key for the IP whitelist.
     * @var string
     */
    const OPTION_IP_WHITELIST = 'guard_dog_ip_whitelist';

    /**
     * Option key for the username blacklist.
     * @var string
     */
    const OPTION_USER_BLACKLIST = 'guard_dog_user_blacklist';

    /**
     * Option key for the username whitelist.
     * @var string
     */
    const OPTION_USER_WHITELIST = 'guard_dog_user_whitelist';

    /**
     * Option key for site-wide access control.
     * @var string
     */
    const OPTION_SITE_WIDE_BLOCKING = 'guard_dog_site_wide_blocking';

    /**
     * Option key for custom access denied message.
     * @var string
     */
    const OPTION_ACCESS_DENIED_MESSAGE = 'guard_dog_access_denied_message';

    /**
     * Option key for IP blacklist version (to track migration status)
     * @var string
     */
    const OPTION_IP_BLACKLIST_VERSION = 'guard_dog_ip_blacklist_version';

    /**
     * Option key for the country blacklist.
     * @var string
     */
    const OPTION_COUNTRY_BLACKLIST = 'guard_dog_country_blacklist';

    /**
     * Current blacklist format version
     * @var int
     */
    const BLACKLIST_VERSION = 2;

    /**
     * Instance of the Activity Log handler.
     *
     * @var Guard_Dog_Activity_Log|null
     * @since 1.6.0
     */
    private $activity_log = null;

    /**
     * Constructor. Adds hooks.
     *
     * @since 1.3.0
     * @param Guard_Dog_Activity_Log|null $activity_log Instance of the activity logger.
     */
    public function __construct($activity_log = null) {
        $this->activity_log = $activity_log;
        $this->add_hooks();
        $this->maybe_migrate_blacklist();
    }

    /**
     * Adds the necessary WordPress hooks.
     *
     * @since 1.3.0
     */
    private function add_hooks() {
        // Hook into authentication check (run at highest priority) - handles user blacklist/whitelist
        add_filter('authenticate', [$this, 'check_access_control'], 1, 3);
        
        // Access control checks run in priority order on 'init' hook:
        // Priority 1: Site-wide blocking (if enabled) - highest priority
        add_action('init', [$this, 'check_site_wide_access'], 1);
        
        // Priority 2: IP blacklist check - runs on all requests
        add_action('init', [$this, 'check_ip_blacklist'], 2);
        
        // Priority 3: Country blacklist check - runs on all requests
        add_action('init', [$this, 'check_country_blacklist'], 3);
        
        // Schedule cleanup of expired blacklist entries
        add_action('guard_dog_cleanup_expired_blacklist', [$this, 'cleanup_expired_entries']);
        add_action('guard_dog_cleanup_expired_blacklist', [$this, 'cleanup_expired_country_entries']);
        $this->schedule_cleanup();
        
        // Note: Hooks for adding settings fields will be called by the Admin_Settings class
    }

    /**
     * Schedule cleanup cron job for expired blacklist entries
     *
     * @since 1.8.47
     * @return void
     */
    private function schedule_cleanup(): void {
        if (!wp_next_scheduled('guard_dog_cleanup_expired_blacklist')) {
            wp_schedule_event(time(), 'daily', 'guard_dog_cleanup_expired_blacklist');
        }
    }

    /**
     * Check site-wide access control rules.
     * This runs early in the WordPress lifecycle to block access before any significant processing.
     *
     * @since 1.1.0
     */
    public function check_site_wide_access() {
        // Skip if running via WP CLI
        if (defined('WP_CLI') && WP_CLI) {
            return;
        }

        // Skip if site-wide blocking is not enabled
        if (!get_option(self::OPTION_SITE_WIDE_BLOCKING, false)) {
            return;
        }

        $current_ip = Guard_Dog_Utils::get_user_ip();
        
        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Site-Wide Access Control] Checking IP: ' . $current_ip);
        }

        // Check if IP is in whitelist
        $ip_whitelist_str = get_option(self::OPTION_IP_WHITELIST, '');
        $ip_whitelist = $this->parse_ip_whitelist($ip_whitelist_str);
        
        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Site-Wide Access Control] IP Whitelist: ' . json_encode($ip_whitelist));
        }

        // If whitelist is empty, block all access
        if (empty($ip_whitelist)) {
            if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->info('[Site-Wide Access Control] No whitelist configured, blocking all access');
            }
            $this->block_site_access($current_ip, 'no_whitelist_configured');
            return;
        }

        // Check if current IP is in whitelist
        if (!$this->is_ip_in_list($current_ip, $ip_whitelist)) {
            if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->info('[Site-Wide Access Control] IP not in whitelist, blocking access');
            }
            $this->block_site_access($current_ip, 'ip_not_in_whitelist');
            return;
        }

        // Note: IP and country blacklist checks are handled separately in their own methods
        // This ensures they work independently of site-wide blocking

        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Site-Wide Access Control] IP is whitelisted, allowing access');
        }
    }

    /**
     * Block site access and display custom message.
     *
     * @since 1.1.0
     * @param string $ip The IP address being blocked
     * @param string $reason The reason for blocking
     */
    private function block_site_access($ip, $reason) {
        // Log the block attempt
        if ($this->activity_log) {
            $this->activity_log->add_log_entry(
                'site_access_denied',
                [
                    'ip_address' => $ip,
                    'details'    => ['reason' => $reason]
                ]
            );
        }

        // Clear any output that might have been sent
        if (!headers_sent()) {
            header('HTTP/1.1 403 Forbidden');
            header('Content-Type: text/html; charset=UTF-8');
        }

        // Try to use the customizer if available
        if (class_exists('Guard_Dog_Access_Denied_Customizer')) {
            $customizer = new Guard_Dog_Access_Denied_Customizer();

            // Check if theme override exists
            $theme_path = get_stylesheet_directory() . '/guard-dog/access-denied.php';
            $parent_theme_path = get_template_directory() . '/guard-dog/access-denied.php';

            if (file_exists($theme_path)) {
                // Use theme override
                $ip_address = $ip;
                include $theme_path;
            } elseif (file_exists($parent_theme_path)) {
                // Use parent theme override
                $ip_address = $ip;
                include $parent_theme_path;
            } else {
                // Use customizer to generate output
                // Sanitize values before passing to customizer
                $sanitized_ip = sanitize_text_field($ip);
                $sanitized_reason = sanitize_text_field($reason);
                // Output the generated HTML directly
                // Note: wp_kses_post() would strip <style> tags, causing CSS to render as plain text
                // The customizer template already properly escapes all dynamic content internally
                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
                echo $customizer->generate_output([
                    'ip_address' => $sanitized_ip,
                    'reason' => $sanitized_reason,
                ]);
            }
        } else {
            // Fallback to legacy rendering if customizer not available
            $this->render_legacy_access_denied_page($ip);
        }

        exit;
    }

    /**
     * Render legacy access denied page (fallback)
     *
     * @since 1.8.46
     * @param string $ip The IP address being blocked
     */
    private function render_legacy_access_denied_page($ip) {
        // Get custom message or use default
        $custom_message = get_option(self::OPTION_ACCESS_DENIED_MESSAGE, '');
        $message = !empty($custom_message) ? $custom_message : __('Access denied. This site is currently restricted to authorized users only.', 'guard-dog');

        ?>
        <!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title><?php esc_html_e('Access Denied', 'guard-dog'); ?></title>
            <?php
            // Use WordPress enqueue functions even in this context.
            wp_enqueue_style('guard-dog-access-denied', GUARD_DOG_URL . 'assets/css/guard-dog-access-denied.css', [], GUARD_DOG_VERSION);
            wp_print_styles('guard-dog-access-denied');
            ?>
        </head>
        <body>
            <div class="access-denied-container">
                <div class="access-denied-icon">🚫</div>
                <h1 class="access-denied-title"><?php esc_html_e('Access Denied', 'guard-dog'); ?></h1>
                <div class="access-denied-message">
                    <?php echo wp_kses_post($message); ?>
                </div>
                <div class="ip-info">
                    <?php
                    // Translators: %s is the visitor's IP address.
                    printf(esc_html__('Your IP address: %s', 'guard-dog'), esc_html($ip)); ?>
                </div>
                <div class="access-denied-footer">
                    <?php esc_html_e('This site is protected by Guard Dog security plugin.', 'guard-dog'); ?>
                </div>
            </div>
        </body>
        </html>
        <?php
    }

    /**
     * Check access control rules during authentication.
     *
     * @since 1.3.0
     *
     * @param WP_User|WP_Error|null $user     User object, WP_Error, or null.
     * @param string                $username Submitted username.
     * @param string                $password Submitted password (unused here).
     * @return WP_User|WP_Error User object or WP_Error if access denied.
     */
    public function check_access_control($user, $username, $password) {
        // If authentication has already failed or no username, don't interfere.
        if (is_wp_error($user) || empty($username)) {
            return $user;
        }

        $current_ip = Guard_Dog_Utils::get_user_ip();
        $attempted_username = strtolower(trim($username));

        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Access Control] Checking login for username: ' . $attempted_username . ', IP: ' . $current_ip);
        }

        // --- 1. IP Whitelist Check (Highest Priority) ---
        $ip_whitelist_str = get_option(self::OPTION_IP_WHITELIST, '');
        $ip_whitelist = $this->parse_ip_whitelist($ip_whitelist_str);
        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Access Control] IP Whitelist: ' . json_encode($ip_whitelist));
        }
        if (!empty($ip_whitelist)) {
            if ($this->is_ip_in_list($current_ip, $ip_whitelist)) {
                if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                    Guard_Dog()->debug->info('[Access Control] IP is whitelisted, allowing login.');
                }
                return $user; // IP is whitelisted, grant access immediately.
            }
        }

        // --- 2. Username Blacklist Check (Takes Priority) ---
        $user_blacklist_str = get_option(self::OPTION_USER_BLACKLIST, '');
        $user_blacklist = $this->parse_comma_list($user_blacklist_str);
        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Access Control] Username Blacklist: ' . json_encode($user_blacklist));
        }
        if (!empty($user_blacklist) && in_array($attempted_username, $user_blacklist)) {
            if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->info('[Access Control] Username is blacklisted, blocking login.');
            }
            return $this->log_and_block('user_blacklist', $attempted_username, $current_ip, __('Access denied. This username has been blocked from logging in.', 'guard-dog'));
        }

        // --- 3. IP Blacklist Check (for login attempts) ---
        // Note: IP blacklist is also checked on all requests via check_ip_blacklist()
        // This check here is redundant but provides login-specific messaging
        $ip_blacklist = $this->get_active_blacklist();
        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Access Control] IP Blacklist: ' . json_encode($ip_blacklist));
        }
        if (!empty($ip_blacklist) && $this->is_ip_in_list($current_ip, $ip_blacklist)) {
            if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->info('[Access Control] IP is blacklisted, blocking login.');
            }
            return $this->log_and_block('ip_blacklist', $attempted_username, $current_ip, __('Access denied. Your IP address has been blocked from logging in.', 'guard-dog'));
        }

        // --- 3.5. Country Blacklist Check (for login attempts) ---
        // Note: Country blacklist is also checked on all requests via check_country_blacklist()
        // This check here is redundant but provides login-specific messaging
        $country_blacklist = $this->get_active_country_blacklist();
        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Access Control] Country Blacklist: ' . json_encode($country_blacklist));
        }
        if (!empty($country_blacklist)) {
            // get_country_from_ip checks headers first, then falls back to API with IP
            $country_code = Guard_Dog_Utils::get_country_from_ip($current_ip);
            if ($country_code && in_array(strtoupper($country_code), array_map('strtoupper', $country_blacklist))) {
                if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                    Guard_Dog()->debug->info('[Access Control] Country is blacklisted, blocking login.');
                }
                return $this->log_and_block('country_blacklist', $attempted_username, $current_ip, __('Access denied. Your country has been blocked from accessing this site.', 'guard-dog'));
            }
        }

        // --- 4. Username Whitelist Check ---
        $user_whitelist_str = get_option(self::OPTION_USER_WHITELIST, '');
        $user_whitelist = $this->parse_comma_list($user_whitelist_str);
        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Access Control] Username Whitelist: ' . json_encode($user_whitelist));
        }
        if (!empty($user_whitelist)) {
            if (in_array($attempted_username, $user_whitelist)) {
                if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                    Guard_Dog()->debug->info('[Access Control] Username is whitelisted, allowing login.');
                }
                return $user; // Username is whitelisted, grant access.
            }
        }
        
        // --- 5. Main Access Control Mode (if no specific IP/user rule matched) ---
        $mode = get_option(self::OPTION_MODE, 'blacklist'); // Default to blacklist mode
        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Access Control] Access mode: ' . $mode);
        }
        if ('whitelist' === $mode) {
            // In whitelist mode, if we reached here, it means the user/IP was NOT on any whitelist.
            // We need to check if any whitelist is active. If so, block.
            if (!empty($ip_whitelist) || !empty($user_whitelist)) {
                if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                    Guard_Dog()->debug->info('[Access Control] Not in whitelist, blocking login.');
                }
                 return $this->log_and_block('not_in_whitelist', $attempted_username, $current_ip, __('Access denied. You are not on the allowed list for this site.', 'guard-dog'));
            }
        }

        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Access Control] No block triggered, allowing login.');
        }
        // If no rules blocked the user, allow the login process to continue.
        return $user;
    }

    /**
     * Helper function to log the block event and return a WP_Error.
     *
     * @since 1.1.0
     * @param string $reason The reason for the block.
     * @param string $username The username that was blocked.
     * @param string $ip The IP address that was blocked.
     * @param string $custom_message The custom error message to show the user.
     */
    private function log_and_block($reason, $username, $ip, $custom_message = null) {
        if ($this->activity_log) {
            $this->activity_log->add_log_entry(
                'login_access_denied',
                [
                    'username'   => $username,
                    'ip_address' => $ip,
                    'details'    => ['reason' => $reason]
                ]
            );
        }

        // Return a clear error message
        $error_message = $custom_message ?: __('<strong>ERROR</strong>: Access denied.', 'guard-dog');
        return new WP_Error(
            'invalid_username',
            $error_message,
            array(
                'guard_dog_source' => 'access_control',
                'guard_dog_reason' => $reason,
            )
        );
    }

    /**
     * Parse a newline-separated string into an array of trimmed, non-empty, unique strings.
     *
     * @since 1.3.0
     * @param string $list_str The string list.
     * @return array The parsed list.
     */
    public function parse_newline_list($list_str) {
        if (empty($list_str)) {
            return [];
        }
        $items = preg_split('/\r\n|\r|\n/', $list_str);
        return array_values(array_unique(array_filter(array_map('trim', $items))));
    }

    /**
     * Parse IP whitelist entries into IP/CIDR strings only.
     *
     * Whitelist lines can include human notes using "IP | Note" while runtime
     * enforcement still receives only the IP/range portion.
     *
     * @since 1.9.40
     * @param string|array $whitelist Whitelist option value.
     * @return array Array of IP addresses or CIDR ranges.
     */
    public function parse_ip_whitelist($whitelist): array {
        $entries = self::parse_ip_whitelist_entries($whitelist);
        $ips     = [];

        foreach ($entries as $entry) {
            if (!empty($entry['ip'])) {
                $ips[] = $entry['ip'];
            }
        }

        return array_values(array_unique($ips));
    }

    /**
     * Get full whitelist entries with notes for admin display.
     *
     * @since 1.9.40
     * @return array
     */
    public function get_full_whitelist(): array {
        return self::parse_ip_whitelist_entries(get_option(self::OPTION_IP_WHITELIST, ''));
    }

    /**
     * Sanitize the IP whitelist option while preserving notes.
     *
     * @since 1.9.40
     * @param string|array $input Raw whitelist option value.
     * @return string Newline-separated whitelist entries.
     */
    public static function sanitize_ip_whitelist($input): string {
        $entries = self::parse_ip_whitelist_entries($input);
        $lines   = [];
        $seen    = [];

        foreach ($entries as $entry) {
            $ip = $entry['ip'] ?? '';

            if ('' === $ip || isset($seen[$ip])) {
                continue;
            }

            $seen[$ip] = true;
            $lines[]   = self::format_ip_whitelist_entry($entry);
        }

        return implode("\n", $lines);
    }

    /**
     * Parse whitelist data into structured entries.
     *
     * @since 1.9.40
     * @param string|array $input Raw whitelist data.
     * @return array<int, array{ip:string,note:string}>
     */
    public static function parse_ip_whitelist_entries($input): array {
        if (empty($input)) {
            return [];
        }

        $raw_entries = [];

        if (is_array($input)) {
            $raw_entries = $input;
        } elseif (is_string($input)) {
            $raw_entries = preg_split('/\r\n|\r|\n/', $input);
        }

        $entries = [];

        foreach ($raw_entries as $raw_entry) {
            $entry = self::parse_ip_whitelist_entry($raw_entry);

            if (null !== $entry) {
                $entries[] = $entry;
            }
        }

        return $entries;
    }

    /**
     * Parse one whitelist entry.
     *
     * @since 1.9.40
     * @param string|array $entry Raw whitelist entry.
     * @return array{ip:string,note:string}|null
     */
    private static function parse_ip_whitelist_entry($entry): ?array {
        if (is_array($entry)) {
            $ip   = sanitize_text_field((string) ($entry['ip'] ?? $entry['ip_address'] ?? ''));
            $note = sanitize_text_field((string) ($entry['note'] ?? $entry['reason'] ?? ''));

            return '' !== $ip ? ['ip' => $ip, 'note' => $note] : null;
        }

        if (!is_string($entry)) {
            return null;
        }

        $line = trim($entry);

        if ('' === $line) {
            return null;
        }

        $parts = [$line, ''];

        if (false !== strpos($line, '|')) {
            $parts = explode('|', $line, 2);
        } elseif (false !== strpos($line, "\t")) {
            $parts = explode("\t", $line, 2);
        } elseif (preg_match('/\s+#\s+/', $line)) {
            $parts = preg_split('/\s+#\s+/', $line, 2);
        }

        $ip   = sanitize_text_field(trim((string) ($parts[0] ?? '')));
        $note = sanitize_text_field(trim((string) ($parts[1] ?? '')));

        if ('' === $ip) {
            return null;
        }

        return [
            'ip'   => $ip,
            'note' => $note,
        ];
    }

    /**
     * Format one structured whitelist entry for storage.
     *
     * @since 1.9.40
     * @param array $entry Whitelist entry.
     * @return string
     */
    private static function format_ip_whitelist_entry(array $entry): string {
        $ip   = sanitize_text_field((string) ($entry['ip'] ?? ''));
        $note = sanitize_text_field((string) ($entry['note'] ?? ''));

        if ('' === $note) {
            return $ip;
        }

        return $ip . ' | ' . $note;
    }

    /**
     * Parse a comma-separated string into an array of trimmed, non-empty, unique, lowercase strings.
     *
     * @since 1.3.0
     * @param string $list_str The string list.
     * @return array The parsed list.
     */
    public function parse_comma_list($list_str) {
        if (empty($list_str)) {
            return [];
        }
        $items = explode(',', $list_str);
         // Trim, filter empty, ensure unique, and convert to lowercase for case-insensitive comparison
        return array_values(array_unique(array_filter(array_map('strtolower', array_map('trim', $items)))));
    }

    /**
     * Check if a given IP address matches any entry in a list (including CIDR ranges).
     *
     * Optimized for performance with early exits and efficient matching.
     *
     * @since 1.3.0
     * @param string $ip The IP address to check.
     * @param array  $list An array of IP addresses or CIDR ranges.
     * @return bool True if the IP matches an entry in the list, false otherwise.
     */
    public function is_ip_in_list($ip, $list) {
        // Early exit for empty inputs
        if (empty($ip) || empty($list) || !filter_var($ip, FILTER_VALIDATE_IP)) {
            return false;
        }

        // Determine IP version once
        $ip_version = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? 4 : 
                     (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 6 : 0);
        
        if ($ip_version === 0) {
            return false; // Invalid IP
        }

        // Pre-filter list by IP version for better performance with large lists
        // Check exact matches first (faster than CIDR)
        $exact_matches = [];
        $cidr_ranges = [];
        
        foreach ($list as $item) {
            if (strpos($item, '/') !== false) {
                $cidr_ranges[] = $item;
            } else {
                $exact_matches[] = $item;
            }
        }

        // Check exact matches first (much faster)
        if (!empty($exact_matches)) {
            if (in_array($ip, $exact_matches, true)) {
                return true;
            }
        }

        // Then check CIDR ranges (slower, so do this second)
        foreach ($cidr_ranges as $item) {
            list($subnet, $mask_bits) = explode('/', $item, 2);
            $mask_bits = intval($mask_bits);
            
            // Validate subnet IP
            $subnet_version = filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? 4 : 
                             (filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 6 : 0);
            
            if ($subnet_version === 0 || $subnet_version !== $ip_version) {
                continue; // Invalid subnet or version mismatch
            }
            
            // Validate mask bits
            $max_mask = ($ip_version === 4) ? 32 : 128;
            if ($mask_bits < 0 || $mask_bits > $max_mask) {
                continue; // Invalid CIDR mask
            }
            
            if ($this->is_ip_in_cidr_range($ip, $subnet, $mask_bits, $ip_version)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Check if an IP address is within a CIDR range.
     *
     * @since 1.6.0
     * @param string $ip The IP address to check.
     * @param string $subnet The subnet address.
     * @param int $mask_bits The number of mask bits.
     * @param int $ip_version The IP version (4 or 6).
     * @return bool True if the IP is in the range, false otherwise.
     */
    private function is_ip_in_cidr_range($ip, $subnet, $mask_bits, $ip_version) {
        if ($ip_version === 4) {
            // IPv4 CIDR check using bitwise operations
            $ip_long = ip2long($ip);
            $subnet_long = ip2long($subnet);
            
            if ($ip_long === false || $subnet_long === false) {
                return false;
            }
            
            $mask = -1 << (32 - $mask_bits);
            return ($ip_long & $mask) === ($subnet_long & $mask);
        } else {
            // IPv6 CIDR check
            return $this->is_ipv6_in_cidr_range($ip, $subnet, $mask_bits);
        }
    }

    /**
     * Check if an IPv6 address is within a CIDR range.
     *
     * @since 1.6.0
     * @param string $ip The IPv6 address to check.
     * @param string $subnet The IPv6 subnet address.
     * @param int $mask_bits The number of mask bits.
     * @return bool True if the IP is in the range, false otherwise.
     */
    private function is_ipv6_in_cidr_range($ip, $subnet, $mask_bits) {
        // Normalize IPv6 addresses
        $ip_normalized = $this->normalize_ipv6($ip);
        $subnet_normalized = $this->normalize_ipv6($subnet);
        
        if ($ip_normalized === false || $subnet_normalized === false) {
            return false;
        }
        
        // Convert to binary strings
        $ip_binary = $this->ipv6_to_binary($ip_normalized);
        $subnet_binary = $this->ipv6_to_binary($subnet_normalized);
        
        if ($ip_binary === false || $subnet_binary === false) {
            return false;
        }
        
        // Compare the first $mask_bits
        return substr($ip_binary, 0, $mask_bits) === substr($subnet_binary, 0, $mask_bits);
    }

    /**
     * Normalize an IPv6 address to full form.
     *
     * @since 1.6.0
     * @param string $ip The IPv6 address.
     * @return string|false The normalized IPv6 address or false on failure.
     */
    private function normalize_ipv6($ip) {
        // Handle IPv4-mapped IPv6 addresses
        if (strpos($ip, '.') !== false) {
            // This is an IPv4-mapped IPv6 address
            $ipv4_part = substr($ip, strrpos($ip, ':') + 1);
            $ipv6_prefix = substr($ip, 0, strrpos($ip, ':') + 1);
            
            // Convert IPv4 part to hex
            $ipv4_parts = explode('.', $ipv4_part);
            if (count($ipv4_parts) !== 4) {
                return false;
            }
            
            $hex_parts = array_map(function($part) {
                return sprintf('%02x', intval($part));
            }, $ipv4_parts);
            
            $ip = $ipv6_prefix . implode('', $hex_parts);
        }
        
        // Expand compressed IPv6 address
        if (strpos($ip, '::') !== false) {
            $parts = explode('::', $ip);
            $left = $parts[0];
            $right = isset($parts[1]) ? $parts[1] : '';
            
            $left_parts = $left ? explode(':', $left) : [];
            $right_parts = $right ? explode(':', $right) : [];
            
            $missing_parts = 8 - count($left_parts) - count($right_parts);
            $zeros = array_fill(0, $missing_parts, '0000');
            
            $ip = implode(':', array_merge($left_parts, $zeros, $right_parts));
        }
        
        // Ensure each part is 4 characters
        $parts = explode(':', $ip);
        if (count($parts) !== 8) {
            return false;
        }
        
        $normalized_parts = array_map(function($part) {
            return str_pad($part, 4, '0', STR_PAD_LEFT);
        }, $parts);
        
        return implode(':', $normalized_parts);
    }

    /**
     * Convert an IPv6 address to a binary string.
     *
     * @since 1.6.0
     * @param string $ip The normalized IPv6 address.
     * @return string|false The binary string or false on failure.
     */
    private function ipv6_to_binary($ip) {
        $parts = explode(':', $ip);
        if (count($parts) !== 8) {
            return false;
        }
        
        $binary = '';
        foreach ($parts as $part) {
            $hex = hexdec($part);
            if ($hex === false || $hex < 0 || $hex > 0xFFFF) {
                return false;
            }
            $binary .= str_pad(decbin($hex), 16, '0', STR_PAD_LEFT);
        }
        
        return $binary;
    }

    /**
     * Migrate IP blacklist from text format to structured format with expiration dates
     *
     * @since 1.8.47
     * @return void
     */
    private function maybe_migrate_blacklist(): void {
        $current_version = get_option(self::OPTION_IP_BLACKLIST_VERSION, 1);
        
        if ($current_version >= self::BLACKLIST_VERSION) {
            return; // Already migrated
        }

        $old_blacklist_str = get_option(self::OPTION_IP_BLACKLIST, '');
        
        // If old format is empty, just update version
        if (empty($old_blacklist_str)) {
            update_option(self::OPTION_IP_BLACKLIST_VERSION, self::BLACKLIST_VERSION);
            return;
        }

        // Check if it's already in new format (array)
        $existing_blacklist = get_option(self::OPTION_IP_BLACKLIST, '');
        if (is_array($existing_blacklist)) {
            // Already in new format, just update version
            update_option(self::OPTION_IP_BLACKLIST_VERSION, self::BLACKLIST_VERSION);
            return;
        }

        // Migrate from text format to structured format
        $old_list = $this->parse_newline_list($old_blacklist_str);
        $new_blacklist = [];

        foreach ($old_list as $ip_entry) {
            $new_blacklist[] = [
                'ip' => $ip_entry,
                'added_date' => current_time('mysql'),
                'expires_date' => null, // Permanent by default for migrated entries
                'added_by' => get_current_user_id() ?: 0,
                'reason' => __('Migrated from legacy blacklist', 'guard-dog'),
                'source' => 'migration',
            ];
        }

        // Save new format
        update_option(self::OPTION_IP_BLACKLIST, $new_blacklist);
        update_option(self::OPTION_IP_BLACKLIST_VERSION, self::BLACKLIST_VERSION);

        if ($this->activity_log) {
            $this->activity_log->add_log_entry('settings_action', [
                'details' => [
                    'action' => 'ip_blacklist_migrated',
                    'entries_migrated' => count($new_blacklist),
                ]
            ]);
        }

        Guard_Dog()->debug->info('Guard Dog: Migrated ' . count($new_blacklist) . ' IP blacklist entries to structured format with expiration support.');
    }

    /**
     * Get active IP blacklist (filtered to remove expired entries)
     *
     * Uses caching to minimize database queries and processing overhead.
     *
     * @since 1.8.45
     * @param bool $force_refresh Skip cache and force fresh data
     * @return array Array of IP addresses (strings) that are currently active
     */
    public function get_active_blacklist(bool $force_refresh = false): array {
        // Cache key includes option name to invalidate when option changes
        $cache_key = 'guard_dog_active_ip_blacklist';
        $cache_group = 'guard_dog_access_control';
        
        // Try object cache first (faster than transients)
        if (!$force_refresh) {
            $cached = wp_cache_get($cache_key, $cache_group);
            if ($cached !== false) {
                return $cached;
            }
        }

        $blacklist = get_option(self::OPTION_IP_BLACKLIST, []);
        
        // Early exit if empty
        if (empty($blacklist)) {
            wp_cache_set($cache_key, [], $cache_group, 300); // Cache empty result for 5 minutes
            return [];
        }
        
        // Handle legacy text format
        if (is_string($blacklist) && !empty($blacklist)) {
            $result = $this->parse_newline_list($blacklist);
            wp_cache_set($cache_key, $result, $cache_group, 300);
            return $result;
        }

        // Handle new structured format
        if (!is_array($blacklist)) {
            wp_cache_set($cache_key, [], $cache_group, 300);
            return [];
        }

        $active_ips = [];
        $current_time = current_time('mysql', true); // GMT time
        $current_timestamp = strtotime($current_time);
        $expired_count = 0;

        foreach ($blacklist as $entry) {
            // Handle legacy format (just IP string)
            if (is_string($entry)) {
                $active_ips[] = $entry;
                continue;
            }

            // Handle structured format
            if (!is_array($entry) || !isset($entry['ip'])) {
                continue;
            }

            $ip = $entry['ip'];
            $expires_date = isset($entry['expires_date']) ? $entry['expires_date'] : null;

            // Check if expired (use timestamp comparison for better performance)
            if ($expires_date !== null && $expires_date !== '') {
                $expires_timestamp = strtotime($expires_date);
                if ($expires_timestamp !== false && $expires_timestamp < $current_timestamp) {
                    $expired_count++;
                    continue; // Skip expired entries
                }
            }

            $active_ips[] = $ip;
        }

        // Clean up expired entries if any were found (occasionally, not every time for performance)
        if ($expired_count > 0 && wp_rand(1, 100) <= 10) { // 10% chance
            $this->cleanup_expired_entries();
            // Force refresh after cleanup
            return $this->get_active_blacklist(true);
        }

        // Cache result for 5 minutes (or until option changes)
        wp_cache_set($cache_key, $active_ips, $cache_group, 300);
        
        return $active_ips;
    }

    /**
     * Add IP to blacklist with expiration
     *
     * @since 1.8.45
     * @param string $ip_address The IP address to add
     * @param int $expiration_days Number of days until expiration (0 for permanent)
     * @param string $reason Optional reason for blacklisting
     * @param string $source Source of the blacklist entry (e.g., 'manual', 'ip_reputation_check')
     * @return bool True on success, false on failure
     */
    public function add_ip_to_blacklist(string $ip_address, int $expiration_days = 0, string $reason = '', string $source = 'manual'): bool {
        // Validate IP
        if (!filter_var($ip_address, FILTER_VALIDATE_IP)) {
            return false;
        }

        // Ensure we're using structured format
        $this->maybe_migrate_blacklist();

        $blacklist = get_option(self::OPTION_IP_BLACKLIST, []);
        
        // Ensure it's an array
        if (!is_array($blacklist)) {
            $blacklist = [];
        }

        // Check if IP already exists
        foreach ($blacklist as $key => $entry) {
            if (is_string($entry) && $entry === $ip_address) {
                // Update legacy entry to structured format
                $blacklist[$key] = [
                    'ip' => $ip_address,
                    'added_date' => current_time('mysql'),
                    'expires_date' => $expiration_days > 0 ? gmdate('Y-m-d H:i:s', time() + ($expiration_days * DAY_IN_SECONDS)) : null,
                    'added_by' => get_current_user_id() ?: 0,
                    'reason' => $reason ?: __('Added to blacklist', 'guard-dog'),
                    'source' => $source,
                ];
                update_option(self::OPTION_IP_BLACKLIST, $blacklist);
                return true;
            }
            
            if (is_array($entry) && isset($entry['ip']) && $entry['ip'] === $ip_address) {
                // Update existing entry
                $blacklist[$key]['expires_date'] = $expiration_days > 0 ? gmdate('Y-m-d H:i:s', time() + ($expiration_days * DAY_IN_SECONDS)) : null;
                $blacklist[$key]['reason'] = $reason ?: $entry['reason'];
                $blacklist[$key]['source'] = $source;
                update_option(self::OPTION_IP_BLACKLIST, $blacklist);
                return true;
            }
        }

        // Add new entry
        $blacklist[] = [
            'ip' => $ip_address,
            'added_date' => current_time('mysql'),
            'expires_date' => $expiration_days > 0 ? gmdate('Y-m-d H:i:s', time() + ($expiration_days * DAY_IN_SECONDS)) : null,
            'added_by' => get_current_user_id() ?: 0,
            'reason' => $reason ?: __('Added to blacklist', 'guard-dog'),
            'source' => $source,
        ];
        
        // The Settings API sanitize callback interferes with saving arrays.
        // Use a direct database update to bypass all WordPress filters.
        global $wpdb;
        
        // Serialize the array for database storage
        $serialized = maybe_serialize($blacklist);
        
        // Use REPLACE to insert or update, completely bypassing WordPress filters
        $wpdb_result = $wpdb->replace(
            $wpdb->options,
            [
                'option_name' => self::OPTION_IP_BLACKLIST,
                'option_value' => $serialized,
                'autoload' => 'no'
            ],
            ['%s', '%s', '%s']
        );
        
        if ($wpdb_result === false && !empty($wpdb->last_error)) {
            if (Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->error('Add IP to blacklist: Database error: ' . $wpdb->last_error);
            }
            return false;
        }
        
        // Clear all caches to ensure fresh data
        wp_cache_delete(self::OPTION_IP_BLACKLIST, 'options');
        wp_cache_delete('alloptions', 'options');
        // Clear our cached active blacklist
        wp_cache_delete('guard_dog_active_ip_blacklist', 'guard_dog_access_control');
        
        // Verify the option was saved by reading it back
        $verify = get_option(self::OPTION_IP_BLACKLIST, []);
        
        // Check if the IP was actually added
        if (is_array($verify)) {
            foreach ($verify as $entry) {
                if ((is_array($entry) && isset($entry['ip']) && $entry['ip'] === $ip_address) ||
                    (is_string($entry) && $entry === $ip_address)) {
                    return true;
                }
            }
        }
        
        return false;
    }

    /**
     * Remove IP from blacklist
     *
     * @since 1.8.45
     * @param string $ip_address The IP address to remove
     * @return bool True on success, false on failure
     */
    public function remove_ip_from_blacklist(string $ip_address): bool {
        $blacklist = get_option(self::OPTION_IP_BLACKLIST, []);
        
        if (!is_array($blacklist)) {
            return false;
        }

        $found = false;
        foreach ($blacklist as $key => $entry) {
            if (is_string($entry) && $entry === $ip_address) {
                unset($blacklist[$key]);
                $found = true;
            } elseif (is_array($entry) && isset($entry['ip']) && $entry['ip'] === $ip_address) {
                unset($blacklist[$key]);
                $found = true;
            }
        }

        if ($found) {
            $blacklist = array_values($blacklist); // Re-index array
            $result = update_option(self::OPTION_IP_BLACKLIST, $blacklist);
            // Clear cache after update
            wp_cache_delete('guard_dog_active_ip_blacklist', 'guard_dog_access_control');
            return $result;
        }

        return false;
    }

    /**
     * Clean up expired blacklist entries
     *
     * @since 1.8.45
     * @return int Number of entries removed
     */
    public function cleanup_expired_entries(): int {
        $blacklist = get_option(self::OPTION_IP_BLACKLIST, []);
        
        if (!is_array($blacklist) || empty($blacklist)) {
            return 0;
        }

        $current_time = current_time('mysql', true); // GMT time
        $removed_count = 0;
        $original_count = count($blacklist);

        foreach ($blacklist as $key => $entry) {
            // Skip legacy string entries (they're permanent)
            if (is_string($entry)) {
                continue;
            }

            // Check expiration
            if (is_array($entry) && isset($entry['expires_date']) && $entry['expires_date'] !== null && $entry['expires_date'] !== '') {
                if (strtotime($entry['expires_date']) < strtotime($current_time)) {
                    unset($blacklist[$key]);
                    $removed_count++;
                }
            }
        }

        if ($removed_count > 0) {
            $blacklist = array_values($blacklist); // Re-index array
            update_option(self::OPTION_IP_BLACKLIST, $blacklist);
            // Clear cache after cleanup
            wp_cache_delete('guard_dog_active_ip_blacklist', 'guard_dog_access_control');

            if ($this->activity_log) {
                $this->activity_log->add_log_entry('settings_action', [
                    'details' => [
                        'action' => 'ip_blacklist_cleanup',
                        'entries_removed' => $removed_count,
                        'entries_remaining' => count($blacklist),
                    ]
                ]);
            }

            Guard_Dog()->debug->info("Guard Dog: Cleaned up {$removed_count} expired IP blacklist entries.");
        }

        return $removed_count;
    }

    /**
     * Get full blacklist with metadata (for admin display)
     *
     * @since 1.8.45
     * @return array Array of blacklist entries with full metadata
     */
    public function get_full_blacklist(): array {
        $blacklist = get_option(self::OPTION_IP_BLACKLIST, []);
        
        if (!is_array($blacklist)) {
            return [];
        }

        $full_list = [];
        foreach ($blacklist as $entry) {
            if (is_string($entry)) {
                // Legacy format - convert to structured
                $full_list[] = [
                    'ip' => $entry,
                    'added_date' => null,
                    'expires_date' => null,
                    'added_by' => 0,
                    'reason' => __('Legacy entry', 'guard-dog'),
                    'source' => 'legacy',
                ];
            } else {
                $full_list[] = $entry;
            }
        }

        return $full_list;
    }

    /**
     * Get active country blacklist (filtered to remove expired entries)
     *
     * Uses caching to minimize database queries and processing overhead.
     *
     * @since 1.8.48
     * @param bool $force_refresh Skip cache and force fresh data
     * @return array Array of country codes (ISO 3166-1 alpha-2) that are currently active
     */
    public function get_active_country_blacklist(bool $force_refresh = false): array {
        // Cache key includes option name to invalidate when option changes
        $cache_key = 'guard_dog_active_country_blacklist';
        $cache_group = 'guard_dog_access_control';
        
        // Try object cache first (faster than transients)
        if (!$force_refresh) {
            $cached = wp_cache_get($cache_key, $cache_group);
            if ($cached !== false) {
                return $cached;
            }
        }

        $blacklist = get_option(self::OPTION_COUNTRY_BLACKLIST, []);
        
        // Early exit if empty
        if (empty($blacklist)) {
            wp_cache_set($cache_key, [], $cache_group, 300); // Cache empty result for 5 minutes
            return [];
        }
        
        // Handle legacy text format
        if (is_string($blacklist) && !empty($blacklist)) {
            $result = array_map('strtoupper', array_filter(array_map('trim', explode(',', $blacklist))));
            wp_cache_set($cache_key, $result, $cache_group, 300);
            return $result;
        }

        // Handle new structured format
        if (!is_array($blacklist)) {
            wp_cache_set($cache_key, [], $cache_group, 300);
            return [];
        }

        $active_countries = [];
        $current_time = current_time('mysql', true); // GMT time
        $current_timestamp = strtotime($current_time);
        $expired_count = 0;

        foreach ($blacklist as $entry) {
            // Handle legacy format (just country code string)
            if (is_string($entry)) {
                $active_countries[] = strtoupper(trim($entry));
                continue;
            }

            // Handle structured format
            if (!is_array($entry) || !isset($entry['country_code'])) {
                continue;
            }

            $country_code = strtoupper($entry['country_code']);
            $expires_date = isset($entry['expires_date']) ? $entry['expires_date'] : null;

            // Check if expired (use timestamp comparison for better performance)
            if ($expires_date !== null && $expires_date !== '') {
                $expires_timestamp = strtotime($expires_date);
                if ($expires_timestamp !== false && $expires_timestamp < $current_timestamp) {
                    $expired_count++;
                    continue; // Skip expired entries
                }
            }

            $active_countries[] = $country_code;
        }

        // Clean up expired entries if any were found (occasionally, not every time for performance)
        if ($expired_count > 0 && wp_rand(1, 100) <= 10) { // 10% chance
            $this->cleanup_expired_country_entries();
            // Force refresh after cleanup
            return $this->get_active_country_blacklist(true);
        }

        $result = array_unique($active_countries);
        
        // Cache result for 5 minutes (or until option changes)
        wp_cache_set($cache_key, $result, $cache_group, 300);
        
        return $result;
    }

    /**
     * Add country to blacklist with expiration
     *
     * @since 1.8.48
     * @param string $country_code The country code (ISO 3166-1 alpha-2) to add
     * @param int $expiration_days Number of days until expiration (0 for permanent)
     * @param string $reason Optional reason for blacklisting
     * @param string $source Source of the blacklist entry (e.g., 'manual', 'automated')
     * @return bool True on success, false on failure
     */
    public function add_country_to_blacklist(string $country_code, int $expiration_days = 0, string $reason = '', string $source = 'manual'): bool {
        // Validate country code (ISO 3166-1 alpha-2)
        $country_code = strtoupper(trim($country_code));
        if (strlen($country_code) !== 2 || !ctype_alpha($country_code)) {
            return false;
        }

        $blacklist = get_option(self::OPTION_COUNTRY_BLACKLIST, []);
        
        // Ensure it's an array
        if (!is_array($blacklist)) {
            $blacklist = [];
        }

        // Check if country already exists
        foreach ($blacklist as $key => $entry) {
            if (is_string($entry) && strtoupper(trim($entry)) === $country_code) {
                // Update legacy entry to structured format
                $blacklist[$key] = [
                    'country_code' => $country_code,
                    'added_date' => current_time('mysql'),
                    'expires_date' => $expiration_days > 0 ? gmdate('Y-m-d H:i:s', time() + ($expiration_days * DAY_IN_SECONDS)) : null,
                    'added_by' => get_current_user_id() ?: 0,
                    'reason' => $reason ?: __('Added to blacklist', 'guard-dog'),
                    'source' => $source,
                ];
                update_option(self::OPTION_COUNTRY_BLACKLIST, $blacklist);
                return true;
            }
            
            if (is_array($entry) && isset($entry['country_code']) && strtoupper($entry['country_code']) === $country_code) {
                // Update existing entry
                $blacklist[$key]['expires_date'] = $expiration_days > 0 ? gmdate('Y-m-d H:i:s', time() + ($expiration_days * DAY_IN_SECONDS)) : null;
                $blacklist[$key]['reason'] = $reason ?: $entry['reason'];
                $blacklist[$key]['source'] = $source;
                update_option(self::OPTION_COUNTRY_BLACKLIST, $blacklist);
                return true;
            }
        }

        // Add new entry
        $blacklist[] = [
            'country_code' => $country_code,
            'added_date' => current_time('mysql'),
            'expires_date' => $expiration_days > 0 ? gmdate('Y-m-d H:i:s', time() + ($expiration_days * DAY_IN_SECONDS)) : null,
            'added_by' => get_current_user_id() ?: 0,
            'reason' => $reason ?: __('Added to blacklist', 'guard-dog'),
            'source' => $source,
        ];
        
        // Use direct database update to bypass WordPress filters
        global $wpdb;
        
        // Serialize the array for database storage
        $serialized = maybe_serialize($blacklist);
        
        // Use REPLACE to insert or update, completely bypassing WordPress filters
        $wpdb_result = $wpdb->replace(
            $wpdb->options,
            [
                'option_name' => self::OPTION_COUNTRY_BLACKLIST,
                'option_value' => $serialized,
                'autoload' => 'no'
            ],
            ['%s', '%s', '%s']
        );
        
        if ($wpdb_result === false && !empty($wpdb->last_error)) {
            if (Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->error('Add country to blacklist: Database error: ' . $wpdb->last_error);
            }
            return false;
        }
        
        // Clear all caches to ensure fresh data
        wp_cache_delete(self::OPTION_COUNTRY_BLACKLIST, 'options');
        wp_cache_delete('alloptions', 'options');
        wp_cache_delete('notoptions', 'options');
        // Clear our cached active country blacklist
        wp_cache_delete('guard_dog_active_country_blacklist', 'guard_dog_access_control');
        
        return true;
    }

    /**
     * Remove country from blacklist
     *
     * @since 1.8.48
     * @param string $country_code The country code to remove
     * @return bool True on success, false on failure
     */
    public function remove_country_from_blacklist(string $country_code): bool {
        $blacklist = get_option(self::OPTION_COUNTRY_BLACKLIST, []);
        
        if (!is_array($blacklist)) {
            return false;
        }

        $country_code = strtoupper(trim($country_code));
        $found = false;
        
        foreach ($blacklist as $key => $entry) {
            if (is_string($entry) && strtoupper(trim($entry)) === $country_code) {
                unset($blacklist[$key]);
                $found = true;
            } elseif (is_array($entry) && isset($entry['country_code']) && strtoupper($entry['country_code']) === $country_code) {
                unset($blacklist[$key]);
                $found = true;
            }
        }

        if ($found) {
            $blacklist = array_values($blacklist); // Re-index array
            $result = update_option(self::OPTION_COUNTRY_BLACKLIST, $blacklist);
            // Clear cache after update
            wp_cache_delete('guard_dog_active_country_blacklist', 'guard_dog_access_control');
            return $result;
        }

        return false;
    }

    /**
     * Clean up expired country blacklist entries
     *
     * @since 1.8.48
     * @return int Number of entries removed
     */
    public function cleanup_expired_country_entries(): int {
        $blacklist = get_option(self::OPTION_COUNTRY_BLACKLIST, []);
        
        if (!is_array($blacklist) || empty($blacklist)) {
            return 0;
        }

        $current_time = current_time('mysql', true); // GMT time
        $removed_count = 0;

        foreach ($blacklist as $key => $entry) {
            // Skip legacy string entries (they're permanent)
            if (is_string($entry)) {
                continue;
            }

            // Check expiration
            if (is_array($entry) && isset($entry['expires_date']) && $entry['expires_date'] !== null && $entry['expires_date'] !== '') {
                if (strtotime($entry['expires_date']) < strtotime($current_time)) {
                    unset($blacklist[$key]);
                    $removed_count++;
                }
            }
        }

        if ($removed_count > 0) {
            $blacklist = array_values($blacklist); // Re-index array
            update_option(self::OPTION_COUNTRY_BLACKLIST, $blacklist);
            // Clear cache after cleanup
            wp_cache_delete('guard_dog_active_country_blacklist', 'guard_dog_access_control');

            if ($this->activity_log) {
                $this->activity_log->add_log_entry('settings_action', [
                    'details' => [
                        'action' => 'country_blacklist_cleanup',
                        'entries_removed' => $removed_count,
                        'entries_remaining' => count($blacklist),
                    ]
                ]);
            }

            Guard_Dog()->debug->info("Guard Dog: Cleaned up {$removed_count} expired country blacklist entries.");
        }

        return $removed_count;
    }

    /**
     * Get full country blacklist with metadata (for admin display)
     *
     * @since 1.8.48
     * @return array Array of blacklist entries with full metadata
     */
    public function get_full_country_blacklist(): array {
        $blacklist = get_option(self::OPTION_COUNTRY_BLACKLIST, []);
        
        if (!is_array($blacklist)) {
            return [];
        }

        $full_list = [];
        foreach ($blacklist as $entry) {
            if (is_string($entry)) {
                // Legacy format - convert to structured
                $full_list[] = [
                    'country_code' => strtoupper(trim($entry)),
                    'added_date' => null,
                    'expires_date' => null,
                    'added_by' => 0,
                    'reason' => __('Legacy entry', 'guard-dog'),
                    'source' => 'legacy',
                ];
            } else {
                $full_list[] = $entry;
            }
        }

        return $full_list;
    }

    /**
     * Check IP blacklist on all requests (independent of site-wide blocking)
     *
     * Priority: 2 (after site-wide blocking, before country blocking)
     * Optimized with early exits and caching.
     *
     * @since 1.8.48
     * @return void
     */
    public function check_ip_blacklist(): void {
        // Skip if running via WP CLI
        if (defined('WP_CLI') && WP_CLI) {
            return;
        }

        // Skip admin AJAX requests to avoid blocking admin functionality
        if (defined('DOING_AJAX') && DOING_AJAX && is_admin()) {
            return;
        }

        // Skip admin pages (but allow frontend)
        if (is_admin() && !wp_doing_ajax()) {
            return;
        }

        // Early exit: Check if IP blacklist option exists and has content (fast check)
        $blacklist_option = get_option(self::OPTION_IP_BLACKLIST, []);
        if (empty($blacklist_option)) {
            return; // No blacklist configured, skip expensive operations
        }

        $current_ip = Guard_Dog_Utils::get_user_ip();
        
        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[IP Blacklist Check] Checking IP: ' . $current_ip);
        }

        // Get active blacklist (uses cache)
        $ip_blacklist = $this->get_active_blacklist();
        
        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[IP Blacklist Check] Active IP blacklist count: ' . count($ip_blacklist));
        }
        
        // Early exit if no active blacklist entries
        if (empty($ip_blacklist)) {
            return;
        }

        // Check if current IP is in blacklist (optimized matching)
        if ($this->is_ip_in_list($current_ip, $ip_blacklist)) {
            if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->info('[IP Blacklist Check] IP ' . $current_ip . ' is blacklisted, blocking access');
            }
            $this->block_site_access($current_ip, 'ip_blacklist');
            return;
        }
    }

    /**
     * Check country blacklist on all requests (independent of site-wide blocking)
     *
     * Priority: 3 (after site-wide blocking and IP blacklist)
     * Optimized with early exits and caching.
     *
     * @since 1.8.48
     * @return void
     */
    public function check_country_blacklist(): void {
        // Skip if running via WP CLI
        if (defined('WP_CLI') && WP_CLI) {
            return;
        }

        // Skip admin AJAX requests to avoid blocking admin functionality
        if (defined('DOING_AJAX') && DOING_AJAX && is_admin()) {
            return;
        }

        // Skip admin pages (but allow frontend)
        if (is_admin() && !wp_doing_ajax()) {
            return;
        }

        // Early exit: Check if country blacklist option exists and has content (fast check)
        $blacklist_option = get_option(self::OPTION_COUNTRY_BLACKLIST, []);
        if (empty($blacklist_option)) {
            return; // No blacklist configured, skip expensive operations
        }

        $current_ip = Guard_Dog_Utils::get_user_ip();
        
        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Country Blacklist Check] Checking IP: ' . $current_ip);
        }

        // Get active country blacklist (uses cache)
        $country_blacklist = $this->get_active_country_blacklist();
        
        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Country Blacklist Check] Active country blacklist count: ' . count($country_blacklist));
        }
        
        // Early exit if no active blacklist entries
        if (empty($country_blacklist)) {
            return;
        }

        // Get country code from IP (already has caching built-in)
        $country_code = Guard_Dog_Utils::get_country_from_ip($current_ip);
        
        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Country Blacklist Check] Detected country code: ' . ($country_code ? $country_code : 'Unable to determine'));
        }
        
        // Early exit if country couldn't be determined
        if (!$country_code) {
            return;
        }

        // Use case-insensitive comparison (blacklist is already uppercase)
        $country_code_upper = strtoupper($country_code);
        if (in_array($country_code_upper, $country_blacklist, true)) {
            if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->info('[Country Blacklist Check] Country ' . $country_code . ' is blacklisted, blocking access');
            }
            $this->block_site_access($current_ip, 'country_blacklist');
            return;
        }
    }

} // End class Guard_Dog_Access_Control
