<?php

declare(strict_types=1);

namespace SecurWP\Includes;

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

/**
 * Central IP detection for SecurWP.
 *
 * All modules MUST use the static IpDetector::get_ip() entry point instead
 * of reading $_SERVER['REMOTE_ADDR'] directly. This guarantees consistent
 * IP resolution across the firewall, rate limiter, audit log, login lockout,
 * 2FA, and every other subsystem.
 *
 * Detection flow:
 *   1. Return per-request cache if already resolved.
 *   2. Apply 'securwp_pre_get_ip' filter (allows full override).
 *   3. Read proxy_detection mode from firewall settings.
 *   4. If 'disabled', return REMOTE_ADDR.
 *   5. If trusted proxy check fails, return REMOTE_ADDR.
 *   6. If 'manual', read the configured header.
 *   7. If 'automatic': check IntelEngine probe recommendation first, then
 *      fall back to iterating known proxy headers in priority order.
 *   8. Fallback to REMOTE_ADDR.
 *   9. Normalize (strip port, convert IPv6-mapped-IPv4).
 *  10. Cache and return.
 *
 * X-Forwarded-For parsing: uses RIGHTMOST public IP (not leftmost).
 * The leftmost value is client-controlled and trivially spoofable.
 * The rightmost public IP was appended by the last trusted proxy.
 * Both major security plugins use this strategy.
 */
class IpDetector {

    /** Transient key for the cached readiness result (computed in real HTTP context). */
    public const READINESS_TRANSIENT = 'securwp_ip_readiness';

    /**
     * Supported proxy headers in priority order.
     *
     * CF-Connecting-IP is first because CloudFlare controls it (not spoofable
     * by the client). X-Forwarded-For is the most common proxy header.
     * RFC 7239 Forwarded is the standards-based replacement.
     */
    public const PROXY_HEADERS = [
        'HTTP_CF_CONNECTING_IP',
        'HTTP_X_FORWARDED_FOR',
        'HTTP_FORWARDED',
        'HTTP_X_REAL_IP',
        'HTTP_X_CLIENT_IP',
        'HTTP_CLIENT_IP',
        'HTTP_X_CLUSTER_CLIENT_IP',
    ];

    /**
     * Cached IP for the current request.
     *
     * @var string|null
     */
    private static ?string $cached_ip = null;

    /**
     * Cached settings for the current request.
     *
     * @var array|null
     */
    private static ?array $cached_settings = null;

    // ------------------------------------------------------------------
    // Public API
    // ------------------------------------------------------------------

    /**
     * Get the visitor's IP address.
     *
     * This is the single entry point all modules should use. It loads
     * firewall settings once, detects the IP, caches per-request, and
     * returns a normalized, validated IP string.
     *
     * @param bool       $refresh          Force re-detection (bypasses cache).
     * @param array|null $settings_override Optional sanitized firewall settings override.
     * @return string Validated IP address, or REMOTE_ADDR fallback, or ''.
     */
    public static function get_ip( bool $refresh = false, ?array $settings_override = null ): string {
        if ( ! $refresh && null !== self::$cached_ip && $settings_override === null ) {
            return self::$cached_ip;
        }

        // Allow full override via filter (like other security plugins' 'itsec-get-ip').
        $override = apply_filters( 'securwp_pre_get_ip', null );
        if ( is_string( $override ) && self::is_valid_ip( $override ) ) {
            $override = self::normalize_ip( $override );
            if ( $settings_override === null ) {
                self::$cached_ip = $override;
            }
            return $override;
        }

        $settings = is_array( $settings_override ) ? $settings_override : self::get_settings();
        $detection_mode = $settings['proxy_detection'] ?? 'automatic';
        $remote_addr = self::get_remote_addr();

        // Mode: disabled. Only trust the TCP connection IP.
        if ( 'disabled' === $detection_mode ) {
            $detected = self::normalize_ip( $remote_addr );
            if ( $settings_override === null ) {
                self::$cached_ip = $detected;
            }
            return $detected;
        }

        // Only read proxy headers if REMOTE_ADDR belongs to a trusted proxy.
        // If no trusted proxies are configured, proxy headers are never trusted
        // (secure default). This prevents attackers from injecting arbitrary IPs.
        //
        // Exception: automatic Cloudflare zero-config detection. If the user
        // has not configured any trusted proxy preset but REMOTE_ADDR matches a
        // known Cloudflare CIDR and CF-Connecting-IP is present, we can safely
        // trust it without manual configuration. REMOTE_ADDR validation against
        // Cloudflare's published ranges prevents header injection from non-CF
        // connections.
        if ( ! self::is_trusted_proxy( $remote_addr, $settings ) ) {
            if ( self::should_trust_cloudflare_header_without_preset( $detection_mode, (string) ( $settings['proxy_header'] ?? '' ) ) ) {
                $cf_ip = self::extract_ip_from_header( 'HTTP_CF_CONNECTING_IP', $settings );
                if ( '' !== $cf_ip && self::is_valid_ip( $cf_ip ) ) {
                    $detected = self::normalize_ip( $cf_ip );
                    if ( $settings_override === null ) {
                        self::$cached_ip = $detected;
                    }
                    return $detected;
                }
            }
            $detected = self::normalize_ip( $remote_addr );
            if ( $settings_override === null ) {
                self::$cached_ip = $detected;
            }
            return $detected;
        }

        $ip = '';

        // Mode: manual. Read only the user-configured header.
        if ( 'manual' === $detection_mode ) {
            $manual_header = $settings['proxy_header'] ?? '';
            if ( ! empty( $manual_header ) && in_array( $manual_header, self::PROXY_HEADERS, true ) ) {
                $ip = self::extract_ip_from_header( $manual_header, $settings );
            }
        }

        // Mode: automatic. Check IntelEngine probe recommendation first (most
        // accurate -- verified against real external traffic), then fall back
        // to iterating all known proxy headers in priority order.
        if ( 'automatic' === $detection_mode ) {
            if ( ! class_exists( 'SecurWP\\Includes\\IpProbe' ) ) {
                require_once __DIR__ . '/IpProbe.php';
            }
            $probe = IpProbe::get_recommendation();
            if ( $probe && ( $probe['status'] ?? '' ) === 'proxy' ) {
                $rec_header = $probe['header'] ?? '';
                if ( in_array( $rec_header, self::PROXY_HEADERS, true ) ) {
                    $candidate = self::extract_ip_from_header( $rec_header, $settings );
                    if ( '' !== $candidate ) {
                        $detected = self::normalize_ip( $candidate );
                        if ( $settings_override === null ) {
                            self::$cached_ip = $detected;
                        }
                        return $detected;
                    }
                }
            } elseif ( $probe && ( $probe['status'] ?? '' ) === 'direct' ) {
                // Only trust 'direct' when REMOTE_ADDR is actually a public client IP.
                // If REMOTE_ADDR is a known CDN/proxy IP (e.g. Cloudflare), the probe
                // is stale -- it ran before the proxy was in place. Discard it and fall
                // through to header iteration so the correct header is used.
                // Note: do NOT call request_probe() here. get_ip() runs on every
                // visitor request; probe dispatch is an admin-only operation handled
                // by compute_readiness() and refresh_ip_readiness_cache().
                if ( ! self::is_cloudflare_connection( $remote_addr ) ) {
                    $detected = self::normalize_ip( $remote_addr );
                    if ( $settings_override === null ) {
                        self::$cached_ip = $detected;
                    }
                    return $detected;
                }
                // Stale probe: fall through to header iteration.
            }

            foreach ( self::PROXY_HEADERS as $header ) {
                $candidate = self::extract_ip_from_header( $header, $settings );
                if ( '' !== $candidate ) {
                    $ip = $candidate;
                    break;
                }
            }
        }

        if ( '' === $ip || ! self::is_valid_ip( $ip ) ) {
            $ip = $remote_addr;
        }

        $detected = self::normalize_ip( $ip );
        if ( $settings_override === null ) {
            self::$cached_ip = $detected;
        }

        return $detected;
    }

    /**
     * Describe the effective runtime IP resolution for the current request.
     *
     * This diagnostic helper is intentionally separate from get_ip() so the
     * visitor-facing hot path stays minimal. Admin surfaces can use this to
     * explain whether SecurWP is relying on a proxy header or on REMOTE_ADDR
     * that was already restored upstream by the web server/proxy layer.
     *
     * @param array|null $settings_override Optional sanitized firewall settings override.
     * @return array{ip: string, source: string, source_key: string, source_mode: string, restored_from: string}
     */
    public static function get_detection_details( ?array $settings_override = null ): array {
        $settings      = is_array( $settings_override ) ? $settings_override : self::get_settings();
        $detected_ip   = self::get_ip( false, $settings );
        $remote_addr   = self::get_remote_addr();
        $restored_from = '';

        if ( '' !== $detected_ip && $detected_ip === $remote_addr ) {
            $restored_from = self::find_matching_header_for_ip( $detected_ip );
            if ( '' !== $restored_from && self::is_public_ip( $detected_ip ) ) {
                return [
                    'ip'            => $detected_ip,
                    'source'        => 'REMOTE_ADDR (server-restored)',
                    'source_key'    => 'REMOTE_ADDR',
                    'source_mode'   => 'restored_remote_addr',
                    'restored_from' => $restored_from,
                ];
            }

            return [
                'ip'            => $detected_ip,
                'source'        => 'REMOTE_ADDR',
                'source_key'    => 'REMOTE_ADDR',
                'source_mode'   => 'direct_remote_addr',
                'restored_from' => '',
            ];
        }

        $detection_mode = (string) ( $settings['proxy_detection'] ?? 'automatic' );
        $source_header  = '';
        $source_mode    = 'automatic_header';

        if ( 'manual' === $detection_mode && ! empty( $settings['proxy_header'] ) ) {
            $source_header = (string) $settings['proxy_header'];
            $source_mode   = 'manual_header';
        } else {
            $source_header = self::find_matching_header_for_ip( $detected_ip );
            if ( 'HTTP_CF_CONNECTING_IP' === $source_header && self::is_current_request_cloudflare_connection() ) {
                $source_mode = 'cloudflare_verified_header';
            }
        }

        return [
            'ip'            => $detected_ip,
            'source'        => '' !== $source_header ? self::format_source_label( $source_header ) : 'REMOTE_ADDR',
            'source_key'    => '' !== $source_header ? $source_header : 'REMOTE_ADDR',
            'source_mode'   => '' !== $source_header ? $source_mode : 'direct_remote_addr',
            'restored_from' => '',
        ];
    }

    /**
     * Diagnostic tool: scan all headers and report what each one contains.
     *
     * Used by the admin "IP Detection" settings panel to help users pick
     * the correct header. Intentionally bypasses trusted proxy validation
     * because it is an admin-only tool.
     *
     * Returns ALL known proxy headers (including absent ones with present=false)
     * so the frontend can render a complete diagnostic table.
     *
     * @return array{recommended: array, all_headers: array, remote_addr: string, suggested_preset: string, has_proxy: bool, recommended_mode: string, origin_restored: bool, restored_from: string}
     */
    public static function detect_best_configuration(): array {
        $results = [];
        $recommended = null;

        foreach ( self::PROXY_HEADERS as $header ) {
            $raw = self::get_server_value( $header );
            $present = ! empty( $raw );
            $ip = '';
            $is_public = false;

            if ( $present ) {
                $ip = self::extract_ip_from_header( $header, [] );
                if ( '' !== $ip && self::is_valid_ip( $ip ) ) {
                    $is_public = self::is_public_ip( $ip );
                } else {
                    $ip = '';
                }
            }

            $entry = [
                'header'    => $header,
                'ip'        => $ip,
                'is_public' => $is_public,
                'raw'       => (string) $raw,
                'present'   => $present,
            ];
            $results[] = $entry;

            // Recommend the first present header that has a public IP.
            if ( null === $recommended && $present && $is_public && '' !== $ip ) {
                $recommended = $entry;
            }
        }

        $remote_addr = self::get_remote_addr();
        $restored_from = '';
        if ( self::is_public_ip( $remote_addr ) ) {
            $restored_from = self::find_matching_header_for_ip( $remote_addr );
        }

        if ( '' !== $restored_from ) {
            $recommended = [
                'header'    => 'REMOTE_ADDR',
                'ip'        => $remote_addr,
                'is_public' => true,
                'raw'       => $remote_addr,
                'present'   => true,
            ];
        }

        // If no proxy header has a public IP, recommend REMOTE_ADDR (disabled mode).
        if ( null === $recommended ) {
            $recommended = [
                'header'    => 'REMOTE_ADDR',
                'ip'        => $remote_addr,
                'is_public' => self::is_public_ip( $remote_addr ),
                'raw'       => $remote_addr,
                'present'   => true,
            ];
        }

        // Suggest Cloudflare preset if CF-Connecting-IP is present.
        $suggested_preset = 'none';
        $cf_raw = self::get_server_value( 'HTTP_CF_CONNECTING_IP' );
        if ( ! empty( $cf_raw ) && '' === $restored_from ) {
            $suggested_preset = 'cloudflare';
        }

        $has_proxy = ( $recommended['header'] !== 'REMOTE_ADDR' ) || '' !== $restored_from;
        $recommended_mode = 'disabled';
        if ( '' !== $restored_from ) {
            $recommended_mode = 'automatic';
        } elseif ( $has_proxy ) {
            $recommended_mode = 'manual';
        }

        return [
            'recommended'      => $recommended,
            'all_headers'      => $results,
            'remote_addr'      => $remote_addr,
            'suggested_preset' => $suggested_preset,
            'has_proxy'        => $has_proxy,
            'recommended_mode' => $recommended_mode,
            'origin_restored'  => '' !== $restored_from,
            'restored_from'    => $restored_from,
        ];
    }

    /**
     * Get the list of supported proxy headers.
     *
     * @return string[]
     */
    public static function get_proxy_headers(): array {
        return self::PROXY_HEADERS;
    }

    /**
     * Read a string value from $_SERVER with consistent unslashing and sanitization.
     *
     * @param string $key Server array key.
     * @return string
     */
    public static function get_server_value( string $key ): string {
        if ( ! isset( $_SERVER[ $key ] ) || is_array( $_SERVER[ $key ] ) ) {
            return '';
        }

        return sanitize_text_field( wp_unslash( (string) $_SERVER[ $key ] ) );
    }

    /**
     * Reset the per-request cache.
     *
     * Primarily for unit testing. In production, the cache lives for the
     * entire PHP request and is automatically discarded at shutdown.
     */
    public static function reset_cache(): void {
        self::$cached_ip       = null;
        self::$cached_settings = null;
    }

    /**
     * Build a signature for the IP-detection settings that affect readiness.
     *
     * This prevents stale readiness snapshots from one proxy configuration
     * from being reused after firewall settings change remotely.
     *
     * @param array<string,mixed>|null $settings_override
     */
    public static function get_readiness_signature( ?array $settings_override = null ): string {
        $settings = is_array( $settings_override ) ? $settings_override : self::get_settings();

        $trusted_proxies = $settings['trusted_proxies'] ?? '';
        if ( is_array( $trusted_proxies ) ) {
            $trusted_entries = array_map( 'strval', $trusted_proxies );
        } else {
            $trusted_entries = preg_split( '/[\r\n,]+/', (string) $trusted_proxies, -1, PREG_SPLIT_NO_EMPTY );
        }

        $trusted_entries = array_values(
            array_filter(
                array_map(
                    static fn( string $entry ): string => trim( $entry ),
                    is_array( $trusted_entries ) ? $trusted_entries : []
                ),
                static fn( string $entry ): bool => $entry !== ''
            )
        );

        return md5(
            wp_json_encode(
                [
                    'proxy_detection'      => (string) ( $settings['proxy_detection'] ?? 'automatic' ),
                    'proxy_header'         => (string) ( $settings['proxy_header'] ?? '' ),
                    'trusted_proxy_preset' => (string) ( $settings['trusted_proxy_preset'] ?? 'none' ),
                    'trusted_proxies'      => $trusted_entries,
                ]
            )
        );
    }

    /**
     * Determine whether a cached readiness snapshot still matches the current
     * IP-detection configuration.
     *
     * @param array<string,mixed>|null $readiness
     * @param array<string,mixed>|null $settings_override
     */
    public static function is_readiness_signature_compatible( ?array $readiness, ?array $settings_override = null ): bool {
        if ( ! is_array( $readiness ) || ! isset( $readiness['ready'] ) ) {
            return false;
        }

        $signature = (string) ( $readiness['settings_signature'] ?? '' );
        if ( '' === $signature ) {
            return false;
        }

        return hash_equals( self::get_readiness_signature( $settings_override ), $signature );
    }

    // ------------------------------------------------------------------
    // Header parsing
    // ------------------------------------------------------------------

    /**
     * Extract the best IP from a specific proxy header.
     *
     * For X-Forwarded-For and similar comma-separated headers, returns
     * the RIGHTMOST public IP. The leftmost value is client-controlled
     * and easily spoofed; the rightmost public IP was appended by the
     * last trusted reverse proxy (the one connecting to our server).
     *
     * For single-value headers (CF-Connecting-IP, X-Real-IP), returns
     * the value directly after validation.
     *
     * For RFC 7239 Forwarded, extracts the 'for=' parameter.
     *
     * @param string $header   The $_SERVER key (e.g. 'HTTP_X_FORWARDED_FOR').
     * @param array  $settings Firewall settings (for trusted_proxies list).
     * @return string Validated IP, or '' if none found.
     */
    private static function extract_ip_from_header( string $header, array $settings ): string {
        $raw = self::get_server_value( $header );
        if ( '' === $raw ) {
            return '';
        }

        // RFC 7239 Forwarded header uses structured syntax: for="[ip]:port"
        if ( 'HTTP_FORWARDED' === $header ) {
            return self::parse_rfc7239( $raw, $settings );
        }

        // Comma-separated headers (X-Forwarded-For and others).
        // Format: client, proxy1, proxy2
        if ( strpos( $raw, ',' ) !== false ) {
            return self::parse_chain( $raw, $settings );
        }

        // Single-value header.
        $ip = self::sanitize_ip( trim( $raw ) );
        if ( self::is_valid_ip( $ip ) ) {
            return self::normalize_cloudflare_header_ip( $header, $ip );
        }

        return '';
    }

    private static function normalize_cloudflare_header_ip( string $header, string $ip ): string {
        if ( 'HTTP_CF_CONNECTING_IP' !== $header || ! self::is_cloudflare_pseudo_ipv4( $ip ) ) {
            return $ip;
        }

        $ipv6 = self::sanitize_ip( self::get_server_value( 'HTTP_CF_CONNECTING_IPV6' ) );
        if ( ! self::is_valid_ip( $ipv6 ) || ! str_contains( $ipv6, ':' ) ) {
            return $ip;
        }

        return $ipv6;
    }

    /**
     * Parse a comma-separated IP chain (X-Forwarded-For style).
     *
     * Iterates RIGHT-to-LEFT. Skips IPs that match the trusted proxies list.
     * Returns the first (rightmost) non-trusted, valid, public IP. If no
     * public IP is found, falls back to the first valid private IP.
     *
     * @param string $value    Raw header value.
     * @param array  $settings Firewall settings.
     * @return string Best IP found, or ''.
     */
    private static function parse_chain( string $value, array $settings ): string {
        $parts            = array_map( 'trim', explode( ',', $value ) );
        $trusted          = self::resolve_trusted_proxies( $settings );
        $fallback_private = '';

        // Walk right-to-left (proxy closest to server first).
        for ( $i = count( $parts ) - 1; $i >= 0; $i-- ) {
            $ip = self::sanitize_ip( $parts[ $i ] );

            if ( ! self::is_valid_ip( $ip ) ) {
                continue;
            }

            // Skip if this IP is one of our trusted proxies (it's infrastructure).
            if ( ! empty( $trusted ) && self::ip_matches_list( $ip, $trusted ) ) {
                continue;
            }

            if ( self::is_public_ip( $ip ) ) {
                return $ip;
            }

            // Remember the first valid private IP as fallback.
            if ( '' === $fallback_private ) {
                $fallback_private = $ip;
            }
        }

        return $fallback_private;
    }

    /**
     * Parse RFC 7239 Forwarded header.
     *
     * Example: Forwarded: for=192.0.2.60;proto=https, for="[2001:db8::1]"
     *
     * Extracts all 'for=' values and processes them right-to-left,
     * same as X-Forwarded-For chain parsing.
     *
     * @param string $value    Raw Forwarded header value.
     * @param array  $settings Firewall settings.
     * @return string Best IP found, or ''.
     */
    private static function parse_rfc7239( string $value, array $settings ): string {
        // Extract all for= directives. Handles quoted, bracketed IPv6, and plain.
        if ( ! preg_match_all( '/for=["\']?\[?([a-f0-9.:]+)\]?["\']?/i', $value, $matches ) ) {
            return '';
        }

        // Rebuild as comma-separated for unified chain parsing.
        return self::parse_chain( implode( ',', $matches[1] ), $settings );
    }

    /**
     * Determine whether a raw proxy header contains a specific IP anywhere in
     * its value. This is for diagnostics only and does not apply trusted-proxy
     * skipping because we want to know whether the origin already restored the
     * real visitor IP into REMOTE_ADDR upstream.
     */
    private static function header_contains_ip( string $header, string $expected_ip ): bool {
        $raw = self::get_server_value( $header );
        if ( '' === $raw || '' === $expected_ip ) {
            return false;
        }

        $expected = self::normalize_ip( $expected_ip );
        $candidates = [];

        if ( 'HTTP_FORWARDED' === $header ) {
            if ( preg_match_all( '/for=["\']?\[?([a-f0-9.:]+)\]?["\']?/i', $raw, $matches ) ) {
                $candidates = $matches[1];
            }
        } elseif ( strpos( $raw, ',' ) !== false ) {
            $candidates = array_map( 'trim', explode( ',', $raw ) );
        } else {
            $candidates = [ trim( $raw ) ];
        }

        foreach ( $candidates as $candidate ) {
            $candidate = self::sanitize_ip( (string) $candidate );
            if ( self::is_valid_ip( $candidate ) && self::normalize_ip( $candidate ) === $expected ) {
                return true;
            }
        }

        return false;
    }

    // ------------------------------------------------------------------
    // IP validation and normalization
    // ------------------------------------------------------------------

    /**
     * Sanitize a raw IP string: strip port, trim whitespace.
     *
     * Some proxies append a port (e.g. "192.168.1.1:8080"). PHP's
     * filter_var(FILTER_VALIDATE_IP) rejects IPs with ports, so we
     * must strip them first. IPv6 addresses with ports use bracket
     * notation ([::1]:8080) which is also handled.
     *
     * @param string $ip Raw IP string.
     * @return string Cleaned IP.
     */
    private static function sanitize_ip( string $ip ): string {
        $ip = trim( $ip );

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

        // IPv6 in brackets: [::1]:port or [::1]
        if ( str_starts_with( $ip, '[' ) ) {
            $close = strpos( $ip, ']' );
            if ( false !== $close ) {
                $ip = substr( $ip, 1, $close - 1 );
            }
        } elseif (
            // IPv4 with port: only strip if it looks like IPv4 (has dots, single colon).
            // Avoids mangling IPv6 addresses which use colons extensively.
            strpos( $ip, '.' ) !== false
            && substr_count( $ip, ':' ) === 1
        ) {
            $ip = explode( ':', $ip, 2 )[0];
        }

        return $ip;
    }

    /**
     * Normalize a validated IP address.
     *
     * Converts IPv6-mapped-IPv4 addresses (::ffff:192.168.1.1) to plain
     * IPv4 for consistent matching across blocklists and audit logs.
     * Uses inet_pton/inet_ntop for canonical formatting.
     *
     * @param string $ip Validated IP address.
     * @return string Normalized IP.
     */
    private static function normalize_ip( string $ip ): string {
        if ( '' === $ip ) {
            return '';
        }

        // Convert IPv6-mapped-IPv4 to plain IPv4.
        // Pattern: ::ffff:x.x.x.x or 0:0:0:0:0:ffff:x.x.x.x
        $lower = strtolower( $ip );
        if ( str_contains( $lower, '::ffff:' ) || str_contains( $lower, ':ffff:' ) ) {
            $packed = @inet_pton( $ip );
            if ( false !== $packed && 16 === strlen( $packed ) ) {
                // Check if the first 12 bytes are the IPv4-mapped prefix.
                $prefix = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff";
                if ( str_starts_with( $packed, $prefix ) ) {
                    $ipv4 = inet_ntop( substr( $packed, 12 ) );
                    if ( false !== $ipv4 ) {
                        return $ipv4;
                    }
                }
            }
        }

        return $ip;
    }

    /**
     * Validate an IP address (IPv4 or IPv6).
     *
     * @param string $ip The IP to validate.
     * @return bool
     */
    private static function is_valid_ip( string $ip ): bool {
        if ( '' === $ip ) {
            return false;
        }
        return false !== filter_var( $ip, FILTER_VALIDATE_IP );
    }

    /**
     * Check if an IP is public (not private, not reserved).
     *
     * @param string $ip The IP to check.
     * @return bool
     */
    private static function is_public_ip( string $ip ): bool {
        if ( '' === $ip ) {
            return false;
        }
        return false !== filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        );
    }

    private static function is_cloudflare_pseudo_ipv4( string $ip ): bool {
        return false !== filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 )
            && IpUtils::ip_in_range( $ip, '240.0.0.0/4' );
    }

    // ------------------------------------------------------------------
    // Trusted proxy management
    // ------------------------------------------------------------------

    /**
     * Resolve the effective trusted proxy list from settings and presets.
     *
     * Combines provider preset ranges (e.g. Cloudflare CIDRs) with any
     * user-supplied custom IPs/CIDRs and the wp-config.php constant.
     *
     * @param array $settings Firewall settings.
     * @return string[] Merged list of trusted IP/CIDR entries.
     */
    public static function resolve_trusted_proxies( array $settings ): array {
        $preset = $settings['trusted_proxy_preset'] ?? 'none';
        $trusted = [];

        // Load preset ranges.
        if ( $preset !== 'none' && $preset !== 'custom' ) {
            if ( ! class_exists( 'SecurWP\\Includes\\ProxyPresets' ) ) {
                require_once __DIR__ . '/ProxyPresets.php';
            }
            $trusted = ProxyPresets::get_ranges( $preset );
        }

        // Merge user-supplied custom proxies.
        $custom = $settings['trusted_proxies'] ?? '';
        if ( is_array( $custom ) ) {
            $custom_list = $custom;
        } elseif ( is_string( $custom ) && '' !== trim( $custom ) ) {
            $custom_list = preg_split( '/[\r\n,]+/', $custom, -1, PREG_SPLIT_NO_EMPTY );
            $custom_list = array_map( 'trim', $custom_list );
        } else {
            $custom_list = [];
        }

        if ( ! empty( $custom_list ) ) {
            $trusted = array_merge( $trusted, $custom_list );
        }

        // Fallback to wp-config.php constant (useful for environments where
        // the database may not be available yet, or for host-level config).
        if ( empty( $trusted ) && defined( 'SECURWP_TRUSTED_PROXIES' ) ) {
            $trusted = (array) SECURWP_TRUSTED_PROXIES;
        }

        return $trusted;
    }

    /**
     * Check if a given IP belongs to the trusted proxies list.
     *
     * Trusted proxies are resolved from three sources:
     *   1. Provider preset (e.g. Cloudflare CIDR ranges).
     *   2. Custom user-supplied IPs/CIDRs in firewall settings.
     *   3. SECURWP_TRUSTED_PROXIES constant in wp-config.php.
     *
     * If no trusted proxies are configured from any source, returns false
     * (secure default: no proxy headers are trusted). This prevents header
     * injection attacks on sites that don't use reverse proxies.
     *
     * @param string $ip       The IP to check (typically REMOTE_ADDR).
     * @param array  $settings Firewall settings.
     * @return bool
     */
    private static function is_trusted_proxy( string $ip, array $settings ): bool {
        if ( '' === $ip ) {
            return false;
        }

        $trusted = self::resolve_trusted_proxies( $settings );

        if ( empty( $trusted ) ) {
            return false;
        }

        return self::ip_matches_list( $ip, $trusted );
    }

    /**
     * Check if a connection is coming from Cloudflare's network.
     *
     * Used for zero-config Cloudflare detection in automatic mode. Returns
     * true only when CF-Connecting-IP is present AND REMOTE_ADDR falls within
     * Cloudflare's published CIDR ranges. Validating REMOTE_ADDR prevents
     * attackers on non-CF connections from injecting the header.
     *
     * @param string $remote_addr The raw TCP connection IP (REMOTE_ADDR).
     * @return bool
     */
    private static function is_cloudflare_connection( string $remote_addr ): bool {
        if ( '' === self::get_server_value( 'HTTP_CF_CONNECTING_IP' ) ) {
            return false;
        }

        if ( ! class_exists( 'SecurWP\\Includes\\ProxyPresets' ) ) {
            require_once __DIR__ . '/ProxyPresets.php';
        }

        return self::ip_matches_list( $remote_addr, ProxyPresets::get_ranges( ProxyPresets::PRESET_CLOUDFLARE ) );
    }

    /**
     * Determine whether the current request is a verified Cloudflare edge hop.
     *
     * Public for configuration flows that need to normalize trusted-proxy
     * state from the real admin request context without re-implementing the
     * Cloudflare CIDR check.
     */
    public static function is_current_request_cloudflare_connection(): bool {
        if ( ! class_exists( 'SecurWP\\Includes\\RequestProvenance' ) ) {
            require_once __DIR__ . '/RequestProvenance.php';
        }

        $provenance = RequestProvenance::resolve_current_request( null, [
            'cloudflare_ranges' => ProxyPresets::get_ranges( ProxyPresets::PRESET_CLOUDFLARE ),
        ] );

        return ! empty( $provenance['cloudflare_peer_verified'] );
    }

    /**
     * Find the first known proxy header that contains the supplied IP.
     *
     * This is used by admin diagnostics to detect the common architecture
     * where a CDN or reverse proxy is present but the origin server has
     * already restored the real visitor IP into REMOTE_ADDR.
     */
    private static function find_matching_header_for_ip( string $ip ): string {
        if ( '' === $ip ) {
            return '';
        }

        foreach ( self::PROXY_HEADERS as $header ) {
            if ( self::header_contains_ip( $header, $ip ) ) {
                return $header;
            }
        }

        return '';
    }

    /**
     * Format an internal source key for admin-facing labels.
     */
    private static function format_source_label( string $source_key ): string {
        if ( 'REMOTE_ADDR' === $source_key ) {
            return 'REMOTE_ADDR';
        }

        return str_replace( '_', '-', str_replace( 'HTTP_', '', $source_key ) );
    }

    /**
     * Allow Cloudflare's verified header without an explicit preset when it is
     * the selected manual header or when automatic mode is active.
     *
     * This preserves the secure default for generic proxy headers while still
     * letting a verified Cloudflare edge hop use CF-Connecting-IP immediately.
     */
    private static function should_trust_cloudflare_header_without_preset( string $detection_mode, string $manual_header ): bool {
        if ( ! self::is_current_request_cloudflare_connection() ) {
            return false;
        }

        if ( 'automatic' === $detection_mode ) {
            return true;
        }

        return 'manual' === $detection_mode && 'HTTP_CF_CONNECTING_IP' === $manual_header;
    }

    /**
     * Check if an IP matches any entry in a list of IPs/CIDRs.
     *
     * @param string $ip   The IP to check.
     * @param array  $list Array of IPs or CIDR ranges.
     * @return bool
     */
    private static function ip_matches_list( string $ip, array $list ): bool {
        foreach ( $list as $entry ) {
            $entry = trim( (string) $entry );
            if ( '' === $entry ) {
                continue;
            }
            if ( IpUtils::ip_in_range( $ip, $entry ) ) {
                return true;
            }
        }
        return false;
    }

    // ------------------------------------------------------------------
    // Settings and REMOTE_ADDR helpers
    // ------------------------------------------------------------------

    /**
     * Load firewall settings (cached per-request).
     *
     * @return array
     */
    private static function get_settings(): array {
        if ( null !== self::$cached_settings ) {
            return self::$cached_settings;
        }

        // In the WAF bootstrap context, WordPress may not be loaded yet.
        if ( ! function_exists( 'get_option' ) ) {
            self::$cached_settings = [];
            return self::$cached_settings;
        }

        self::$cached_settings = \SecurWP\Modules\Firewall\FirewallSettings::get();
        return self::$cached_settings;
    }

    /**
     * Get the raw REMOTE_ADDR value (TCP connection IP).
     *
     * @return string
     */
    private static function get_remote_addr(): string {
        $addr = self::sanitize_ip( self::get_server_value( 'REMOTE_ADDR' ) );
        return self::is_valid_ip( $addr ) ? $addr : '';
    }

    /**
     * Compute readiness in the current HTTP context and cache the result.
     *
     * Must only be called from a real HTTP request (admin page load, REST
     * API call from a browser). Never call this from WP-Cron or Action
     * Scheduler, where REMOTE_ADDR is the loopback IP.
     */
    public static function cache_readiness(): void {
        self::refresh_readiness();
    }

    /**
     * Compute readiness in the current real HTTP context and persist it.
     *
     * In cron contexts, returns the cached readiness instead of recomputing,
     * because REMOTE_ADDR would usually be loopback and produce a false result.
     *
     * @return array{ready: bool, severity: string, reason: string, action: string, detected_ip: string, remote_addr: string, probe_status?: string, probe_refresh_recommended?: bool}
     */
    public static function refresh_readiness( ?array $settings_override = null ): array {
        if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
            return self::get_readiness( $settings_override );
        }

        self::reset_cache();
        $result = self::compute_readiness( $settings_override );
        $result['cached_at'] = time();
        $result['settings_signature'] = self::get_readiness_signature( $settings_override );
        set_transient( self::READINESS_TRANSIENT, $result, DAY_IN_SECONDS );

        return $result;
    }

    /**
     * Evaluate whether IP detection is correctly configured for firewall use.
     *
     * In WP-Cron / Action Scheduler contexts, returns the last cached result
     * (computed during a real admin HTTP request) rather than computing fresh,
     * because REMOTE_ADDR in those contexts is the loopback IP and would
     * always produce a false "misconfigured" result.
     *
     * @return array{ready: bool, severity: string, reason: string, action: string, detected_ip: string, remote_addr: string, probe_status?: string, probe_refresh_recommended?: bool}
     */
    public static function get_readiness( ?array $settings_override = null ): array {
        if ( is_array( $settings_override ) ) {
            self::reset_cache();
            return self::compute_readiness( $settings_override );
        }

        if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
            $cached = get_transient( self::READINESS_TRANSIENT );
            if ( self::is_readiness_signature_compatible( is_array( $cached ) ? $cached : null, $settings_override ) ) {
                return $cached;
            }
            return [
                'ready'       => false,
                'severity'    => 'unknown',
                'reason'      => '',
                'action'      => '',
                'detected_ip' => '',
                'remote_addr' => '',
            ];
        }

        return self::compute_readiness();
    }

    /**
     * Internal: compute readiness from the current HTTP request context.
     *
     * @return array{ready: bool, severity: string, reason: string, action: string, detected_ip: string, remote_addr: string, probe_status?: string, probe_refresh_recommended?: bool}
     */
    private static function compute_readiness( ?array $settings_override = null ): array {
        $remote_addr = self::get_remote_addr();
        $settings    = is_array( $settings_override ) ? $settings_override : self::get_settings();
        $detected_ip = self::get_ip( false, $settings );

        if ( ! class_exists( 'SecurWP\\Includes\\IpProbe' ) ) {
            require_once __DIR__ . '/IpProbe.php';
        }
        $probe = IpProbe::get_recommendation();
        $probe_fresh = $probe && IpProbe::is_fresh();
        $trusted     = self::resolve_trusted_proxies( $settings );
        $has_trusted = ! empty( $trusted );

        $base = [
            'detected_ip'  => $detected_ip,
            'remote_addr'  => $remote_addr,
            'probe_status' => $probe_fresh ? ( $probe['status'] ?? 'pending' ) : 'pending',
            'probe_header' => $probe_fresh ? ( $probe['header'] ?? '' ) : '',
        ];

        if ( self::is_public_ip( $remote_addr ) ) {
            if ( self::is_cloudflare_connection( $remote_addr ) ) {
                if ( self::is_public_ip( $detected_ip ) && $detected_ip !== $remote_addr ) {
                    return $base + [
                        'ready'    => true,
                        'severity' => 'ok',
                        'reason'   => '',
                        'action'   => '',
                    ];
                }

                return $base + [
                    'ready'    => false,
                    'severity' => 'error',
                    'reason'   => 'Your site is behind Cloudflare but IP Detection is reading Cloudflare\'s own edge IP instead of the real visitor IP. All visitors would share the same IP address.',
                    'action'   => 'Set Detection Mode to Automatic, or select the Cloudflare preset under Trusted Proxies. Do not use the Disabled mode when Cloudflare is active.',
                    'probe_refresh_recommended' => true,
                ];
            }

            if ( $has_trusted && self::ip_matches_list( $remote_addr, $trusted ) ) {
                if ( self::is_public_ip( $detected_ip ) && $detected_ip !== $remote_addr ) {
                    return $base + [
                        'ready'    => true,
                        'severity' => 'ok',
                        'reason'   => '',
                        'action'   => '',
                    ];
                }

                return $base + [
                    'ready'    => false,
                    'severity' => 'error',
                    'reason'   => 'A trusted proxy matched this request, but IP Detection is still resolving to the proxy address instead of the real visitor IP.',
                    'action'   => 'Review the selected proxy header and make sure your reverse proxy sends the expected client IP header.',
                ];
            }

            // Trust runtime evidence. When the connection IP is public, the
            // detected visitor IP is public, and they match, this is either a
            // direct connection or the origin already restored the visitor IP
            // into REMOTE_ADDR. In both cases the firewall sees the correct
            // address and no probe verification is required to start enforcing.
            //
            // Exception: if the admin picked Manual mode with a specific
            // header that is NOT present in the current request, detection
            // fell back to REMOTE_ADDR silently. Surface that as an error so
            // the admin can fix their choice instead of hiding it behind a
            // green checkmark.
            if ( self::is_public_ip( $detected_ip ) && $detected_ip === $remote_addr ) {
                $proxy_detection = (string) ( $settings['proxy_detection'] ?? 'automatic' );
                $proxy_header    = (string) ( $settings['proxy_header'] ?? '' );
                $manual_header_missing = $proxy_detection === 'manual'
                    && $proxy_header !== ''
                    && empty( $_SERVER[ $proxy_header ] );

                if ( $manual_header_missing ) {
                    return $base + [
                        'ready'    => false,
                        'severity' => 'error',
                        'reason'   => 'Manual mode is set to ' . str_replace( [ 'HTTP_', '_' ], [ '', '-' ], $proxy_header ) . ' but that header is not present in requests. SecurWP is silently using the direct connection IP instead of your selected header.',
                        'action'   => 'Switch Detection Mode to Automatic, or pick a header that your proxy actually sends. Run the diagnostic to see which headers are available.',
                    ];
                }

                return $base + [
                    'ready'    => true,
                    'severity' => 'ok',
                    'reason'   => '',
                    'action'   => '',
                    'probe_refresh_recommended' => ! $probe_fresh,
                ];
            }

            if ( $probe_fresh ) {
                if ( ( $probe['status'] ?? '' ) === 'direct' ) {
                    return $base + [
                        'ready'    => true,
                        'severity' => 'ok',
                        'reason'   => '',
                        'action'   => '',
                    ];
                }

                if (
                    ( $probe['status'] ?? '' ) === 'proxy'
                    && self::is_public_ip( $remote_addr )
                    && self::is_public_ip( $detected_ip )
                    && $detected_ip === $remote_addr
                ) {
                    // Either the probe explicitly confirmed origin restoration,
                    // or runtime evidence shows the origin is restoring the
                    // visitor IP into REMOTE_ADDR. Keep evaluation read-only and
                    // let the admin-owned scheduler refresh stale probe metadata.
                    return $base + [
                        'ready'    => true,
                        'severity' => 'ok',
                        'reason'   => '',
                        'action'   => '',
                        'probe_refresh_recommended' => empty( $probe['origin_restored'] ),
                    ];
                }

                return $base + [
                    'ready'    => false,
                    'severity' => 'error',
                    'reason'   => 'SecurWP confirmed this site is behind a reverse proxy or CDN, but the current IP Detection settings still resolve to the connection IP.',
                    'action'   => 'Configure the trusted proxy or CDN that sits in front of the site, then verify the detected IP changes to the real visitor IP before enabling local enforcement.',
                ];
            }

            return $base + [
                'ready'    => false,
                'severity' => 'unknown',
                'reason'   => 'SecurWP is still validating whether this origin receives direct visitor IPs or sits behind a public proxy or CDN.',
                'action'   => 'Keep the firewall in validation mode until a fresh IP probe completes, or configure the trusted proxy or CDN that sits in front of the site if one is present.',
            ];
        }

        if ( self::is_public_ip( $detected_ip ) ) {
            return $base + [
                'ready'    => true,
                'severity' => 'ok',
                'reason'   => '',
                'action'   => '',
            ];
        }

        if ( ! $has_trusted ) {
            return $base + [
                'ready'    => false,
                'severity' => 'error',
                'reason'   => 'Your server is behind a reverse proxy but trusted proxies are not configured. All visitors would appear to share one IP address, which would cause mass false positives if the firewall is enabled.',
                'action'   => 'Configure a trusted proxy preset (e.g. Cloudflare) or enter your proxy IP / CIDR in the IP Detection settings, then verify the detected IP is your real public address.',
            ];
        }

        return $base + [
            'ready'    => false,
            'severity' => 'error',
            'reason'   => 'Trusted proxies are configured but the detected IP is still a private address. The proxy may not be sending the expected header.',
            'action'   => 'Run the IP Diagnosis tool on the Advanced tab to see which headers your proxy is sending, then select the correct one.',
        ];
    }
}
