<?php
/**
 * General Utility functions for Guard Dog.
 *
 * @package GuardDog
 * @since 1.6.0 
 */

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

class Guard_Dog_Utils {

    /**
     * Cached trusted proxy list for the current request.
     *
     * @var array|null
     */
    private static $trusted_proxy_cache = null;

    /**
     * Get the user's real IP address, considering proxies and configured detection method.
     *
     * Respects the guard_dog_ip_detection_method and guard_dog_trusted_proxy_ips
     * settings.
     *
     * Forwarded headers are only trusted when REMOTE_ADDR matches a configured
     * trusted proxy IP/CIDR. Without a trusted proxy match, this returns
     * REMOTE_ADDR to prevent client-supplied header spoofing.
     *
     * @since 1.6.0
     * @since 1.9.33 Added proxy-aware detection with configurable method.
     * @since 1.9.35 Restored legacy header behavior when trusted proxy list is empty.
     * @since 1.9.41 Require a trusted proxy before accepting forwarded headers.
     * @return string|false IP address as a string, or false if no valid IP is found.
     */
    public static function get_user_ip() {
        // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotValidated
        // 1. Context: Reading REMOTE_ADDR for IP detection, a core server variable
        // 2. Security: Validated with filter_var(FILTER_VALIDATE_IP) before use
        // 3. Sanitization: sanitize_text_field + wp_unslash applied
        // 4. Justification: REMOTE_ADDR is set by the web server, not user input
        $remote_addr = isset( $_SERVER['REMOTE_ADDR'] )
            ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
            : '';
        // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotValidated

        $remote_addr_valid = filter_var( $remote_addr, FILTER_VALIDATE_IP ) ? $remote_addr : false;

        $method = get_option( 'guard_dog_ip_detection_method', 'auto' );

        // "remote_addr" mode: never trust forwarded headers.
        if ( 'remote_addr' === $method ) {
            return $remote_addr_valid;
        }

        // Forwarded headers are only trustworthy when the direct peer is trusted.
        if ( ! self::should_trust_forwarded_headers( $remote_addr ) ) {
            return $remote_addr_valid;
        }

        // Specific detection method modes.
        $method_header_map = array(
            'cloudflare'      => 'HTTP_CF_CONNECTING_IP',
            'x_forwarded_for' => 'HTTP_X_FORWARDED_FOR',
            'x_real_ip'       => 'HTTP_X_REAL_IP',
        );

        if ( isset( $method_header_map[ $method ] ) ) {
            $ip = self::get_ip_from_header( $method_header_map[ $method ] );
            return $ip ? $ip : $remote_addr_valid;
        }

        // "auto" mode: try headers in priority order (existing behavior when trusted).
        $auto_headers = array(
            'HTTP_CF_CONNECTING_IP',
            'HTTP_CLIENT_IP',
            'HTTP_X_FORWARDED_FOR',
            'HTTP_X_FORWARDED',
            'HTTP_X_CLUSTER_CLIENT_IP',
            'HTTP_FORWARDED_FOR',
            'HTTP_FORWARDED',
        );

        foreach ( $auto_headers as $header ) {
            $ip = self::get_ip_from_header( $header );
            if ( $ip ) {
                return $ip;
            }
        }

        return $remote_addr_valid;
    }

    /**
     * Check whether any trusted proxy entries are configured.
     *
     * @since 1.9.35
     * @return bool True when at least one valid trusted proxy entry exists.
     */
    private static function has_trusted_proxy_list() {
        return ! empty( self::get_trusted_proxy_list() );
    }

    /**
     * Determine if forwarded request headers should be trusted for this request.
     *
     * @since 1.9.41
     * @param string $remote_addr The direct peer IP from REMOTE_ADDR.
     * @return bool True when a trusted proxy list is configured and REMOTE_ADDR matches it.
     */
    private static function should_trust_forwarded_headers( $remote_addr ) {
        return self::has_trusted_proxy_list() && self::is_trusted_proxy( $remote_addr );
    }

    /**
     * Get the client's IP address (alias for get_user_ip).
     *
     * @since 1.9.1
     * @return string|false IP address as a string, or false if no valid IP is found.
     */
    public static function get_client_ip() {
        return self::get_user_ip();
    }

    /**
     * Check if an IP address is a trusted proxy.
     *
     * When no trusted proxies are configured (empty list), no addresses are
     * treated as trusted proxies. Forwarded headers are only trusted when a
     * proxy list is explicitly configured.
     *
     * @since 1.9.33
     * @param string $ip The IP address to check.
     * @return bool True if the IP is a configured trusted proxy.
     */
    public static function is_trusted_proxy( $ip ) {
        if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
            return false;
        }

        $proxies = self::get_trusted_proxy_list();

        // Empty list means trust none (secure default).
        if ( empty( $proxies ) ) {
            return false;
        }

        foreach ( $proxies as $entry ) {
            if ( strpos( $entry, '/' ) !== false ) {
                // CIDR notation.
                if ( self::ip_in_cidr( $ip, $entry ) ) {
                    return true;
                }
            } elseif ( $ip === $entry ) {
                // Exact match.
                return true;
            }
        }

        return false;
    }

    /**
     * Get the parsed trusted proxy list from the option, cached per-request.
     *
     * @since 1.9.33
     * @return array Array of IP addresses and CIDR ranges.
     */
    private static function get_trusted_proxy_list() {
        if ( self::$trusted_proxy_cache !== null ) {
            return self::$trusted_proxy_cache;
        }

        $raw = get_option( 'guard_dog_trusted_proxy_ips', '' );
        if ( empty( $raw ) || ! is_string( $raw ) ) {
            self::$trusted_proxy_cache = array();
            return self::$trusted_proxy_cache;
        }

        $lines = explode( "\n", $raw );
        $valid = array();

        foreach ( $lines as $line ) {
            $line = trim( $line );
            if ( empty( $line ) ) {
                continue;
            }

            if ( strpos( $line, '/' ) !== false ) {
                list( $ip, $mask ) = explode( '/', $line, 2 );
                if ( filter_var( $ip, FILTER_VALIDATE_IP ) && is_numeric( $mask ) ) {
                    $mask_int = (int) $mask;
                    $max_bits = filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ? 32 : 128;
                    if ( $mask_int >= 0 && $mask_int <= $max_bits ) {
                        $valid[] = $line;
                    }
                }
            } elseif ( filter_var( $line, FILTER_VALIDATE_IP ) ) {
                $valid[] = $line;
            }
        }

        self::$trusted_proxy_cache = $valid;
        return self::$trusted_proxy_cache;
    }

    /**
     * Reset the trusted proxy cache (for testing).
     *
     * @since 1.9.33
     */
    public static function reset_trusted_proxy_cache() {
        self::$trusted_proxy_cache = null;
    }

    /**
     * Check if an IP address is private or reserved (not publicly routable).
     *
     * Covers RFC 1918 (10/8, 172.16/12, 192.168/16), RFC 6598 CGNAT (100.64/10),
     * loopback (127/8), link-local (169.254/16), and IPv6 equivalents (fc00::/7,
     * ::1, fe80::/10). Uses PHP's built-in FILTER_FLAG_NO_PRIV_RANGE and
     * FILTER_FLAG_NO_RES_RANGE flags which cover all of these.
     *
     * @since 1.9.36
     * @param string $ip The IP address to check.
     * @return bool True if the IP is private or reserved, false for public or invalid.
     */
    public static function is_private_or_reserved_ip( $ip ) {
        if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
            return false;
        }

        return ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE );
    }

    /**
     * Extract the client IP from a specific server header.
     *
     * When trusted proxies are configured, walks the X-Forwarded-For chain from
     * right to left (most trusted to least trusted), skipping trusted proxy IPs,
     * and returns the first non-proxy IP. This prevents IP spoofing in multi-hop
     * proxy setups where attackers can inject fake IPs at the beginning of the chain.
     *
     * When no trusted proxies are configured, returns false. Callers should only
     * use forwarded headers after verifying the direct peer is trusted.
     *
     * @since 1.9.33
     * @param string $server_key The $_SERVER key to read (e.g., 'HTTP_X_FORWARDED_FOR').
     * @return string|false The extracted IP or false if not available.
     */
    private static function get_ip_from_header( $server_key ) {
        if ( ! isset( $_SERVER[ $server_key ] ) ) {
            return false;
        }

        $server_value = sanitize_text_field( wp_unslash( $_SERVER[ $server_key ] ) );
        $ips = explode( ',', $server_value );

        $proxies = self::get_trusted_proxy_list();

        // When trusted proxies are configured, walk right-to-left to find the
        // first non-proxy IP. This is the standard secure approach: only the
        // rightmost entries (added by trusted proxies) are trustworthy.
        if ( ! empty( $proxies ) ) {
            $ips_reversed = array_reverse( $ips );
            foreach ( $ips_reversed as $ip_candidate ) {
                $ip_candidate = trim( $ip_candidate );
                if ( filter_var( $ip_candidate, FILTER_VALIDATE_IP ) === false ) {
                    continue;
                }
                if ( ! self::is_trusted_proxy( $ip_candidate ) ) {
                    return $ip_candidate;
                }
            }
            // All IPs were proxies; no client IP found.
            return false;
        }

        return false;
    }

    /**
     * Check if an IP address falls within a CIDR range.
     *
     * Supports both IPv4 and IPv6 addresses.
     *
     * @since 1.9.33
     * @param string $ip   The IP address to check.
     * @param string $cidr The CIDR range (e.g., '10.0.0.0/8' or '2001:db8::/32').
     * @return bool True if the IP is within the CIDR range.
     */
    private static function ip_in_cidr( $ip, $cidr ) {
        list( $subnet, $mask_bits ) = explode( '/', $cidr, 2 );
        $mask_bits = (int) $mask_bits;

        $ip_bin     = @inet_pton( $ip );
        $subnet_bin = @inet_pton( $subnet );

        if ( false === $ip_bin || false === $subnet_bin ) {
            return false;
        }

        // Both must be the same address family (IPv4 = 4 bytes, IPv6 = 16 bytes).
        if ( strlen( $ip_bin ) !== strlen( $subnet_bin ) ) {
            return false;
        }

        $byte_len = strlen( $ip_bin );
        $max_bits = $byte_len * 8;

        if ( $mask_bits < 0 || $mask_bits > $max_bits ) {
            return false;
        }

        // Build the mask.
        $mask = str_repeat( "\xff", (int) floor( $mask_bits / 8 ) );
        $remaining_bits = $mask_bits % 8;
        if ( $remaining_bits > 0 ) {
            $mask .= chr( 256 - ( 1 << ( 8 - $remaining_bits ) ) );
        }
        $mask = str_pad( $mask, $byte_len, "\x00" );

        return ( $ip_bin & $mask ) === ( $subnet_bin & $mask );
    }

    /**
     * Get country code from IP address using multiple methods.
     *
     * Priority order:
     * 1. HTTP headers from trusted proxies/WAFs (Cloudflare CF-IPCountry, AWS CloudFront CloudFront-Viewer-Country, etc.)
     * 2. Cached geolocation data (for API results)
     * 3. API calls (ip-api.com with ipapi.co fallback) - Only if headers don't provide country
     *
     * Supported headers:
     * - Cloudflare: HTTP_CF_IPCOUNTRY
     * - AWS CloudFront: HTTP_CLOUDFRONT_VIEWER_COUNTRY (CloudFront-Viewer-Country header)
     *   Reference: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location
     * - Other CDNs/WAFs: HTTP_X_COUNTRY_CODE, HTTP_X_GEOIP_COUNTRY, etc.
     *
     * @since 1.8.48
     * @param string $ip_address The IP address to look up (optional, will use current request IP if not provided)
     * @return string|false Country code (ISO 3166-1 alpha-2) or false on failure
     */
    public static function get_country_from_ip(string $ip_address = '') {
        $remote_addr = isset( $_SERVER['REMOTE_ADDR'] )
            ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
            : '';
        $trust_forwarded_headers = self::should_trust_forwarded_headers( $remote_addr );

        // Method 1: Check trusted HTTP headers first (most reliable when available, no API calls needed)
        if ( $trust_forwarded_headers ) {
            // Check Cloudflare header first (most common)
            if (isset($_SERVER['HTTP_CF_IPCOUNTRY'])) {
                $cf_country = strtoupper(trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_IPCOUNTRY']))));
                if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                    Guard_Dog()->debug->info('[Geolocation] Cloudflare header found: ' . $cf_country);
                }
                // Validate: 2 letters, alphabetic, not XX (unknown), not empty
                if (strlen($cf_country) === 2 && ctype_alpha($cf_country) && $cf_country !== 'XX' && $cf_country !== 'T1') {
                    // T1 is Cloudflare's code for Tor, skip it
                    if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                        Guard_Dog()->debug->info('[Geolocation] Using Cloudflare country: ' . $cf_country);
                    }
                    return $cf_country;
                }
            }

            // Check AWS CloudFront header (CloudFront-Viewer-Country)
            // Reference: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location
            if (isset($_SERVER['HTTP_CLOUDFRONT_VIEWER_COUNTRY'])) {
                $cfront_country = strtoupper(trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_CLOUDFRONT_VIEWER_COUNTRY']))));
                if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                    Guard_Dog()->debug->info('[Geolocation] CloudFront header found: ' . $cfront_country);
                }
                // Validate: 2 letters, alphabetic (ISO 3166-1 alpha-2 format)
                if (strlen($cfront_country) === 2 && ctype_alpha($cfront_country)) {
                    if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                        Guard_Dog()->debug->info('[Geolocation] Using CloudFront country: ' . $cfront_country);
                    }
                    return $cfront_country;
                }
            }

            // Check other common proxy/WAF headers
            $country_headers = [
                'HTTP_X_COUNTRY_CODE',        // Some CDNs/WAFs
                'HTTP_X_GEOIP_COUNTRY',       // Some proxies
                'HTTP_X_COUNTRY',             // Alternative format
                'HTTP_GEOIP_COUNTRY_CODE',    // GeoIP services
                'HTTP_X_VERIFIED_COUNTRY',    // Some security services
            ];

            foreach ($country_headers as $header) {
                if (isset($_SERVER[$header])) {
                    $country_code = strtoupper(trim(sanitize_text_field(wp_unslash($_SERVER[$header]))));
                    // Validate: 2 letters, alphabetic, not XX (unknown)
                    if (strlen($country_code) === 2 && ctype_alpha($country_code) && $country_code !== 'XX') {
                        return $country_code;
                    }
                }
            }
        } elseif (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Geolocation] Ignoring country headers because REMOTE_ADDR is not a configured trusted proxy.');
        }

        // Method 2: If headers didn't provide country, use API services as fallback
        // Get IP address if not provided
        if (empty($ip_address)) {
            $ip_address = self::get_user_ip();
        }

        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Geolocation] No headers found, using IP for API lookup: ' . $ip_address);
        }

        // Validate IP address
        if (empty($ip_address) || !filter_var($ip_address, FILTER_VALIDATE_IP)) {
            if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->info('[Geolocation] Invalid IP address: ' . $ip_address);
            }
            return false;
        }

        // Skip private/local IPs for API calls (they won't have valid geolocation)
        if (!filter_var($ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
            if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->info('[Geolocation] Private/local IP detected, skipping API lookup: ' . $ip_address);
            }
            return false;
        }

        // Check cache first (24 hour cache for API results)
        $cache_key = 'guard_dog_country_' . md5($ip_address);
        $cached_country = get_transient($cache_key);
        
        if ($cached_country !== false && $cached_country !== '') {
            return $cached_country;
        }

        // Method 3: Use API services as fallback
        // Try ip-api.com first (free, no API key required, 45 requests/minute limit)
        $api_url = 'http://ip-api.com/json/' . urlencode($ip_address) . '?fields=status,countryCode';
        
        $response = wp_remote_get($api_url, [
            'timeout' => 5,
            'user-agent' => 'Guard Dog WordPress Plugin',
        ]);

        if (!is_wp_error($response)) {
            $body = wp_remote_retrieve_body($response);
            $data = json_decode($body, true);

            if (isset($data['status']) && $data['status'] === 'success' && isset($data['countryCode'])) {
                $country_code = strtoupper($data['countryCode']);
                if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                    Guard_Dog()->debug->info('[Geolocation] API (ip-api.com) returned country: ' . $country_code);
                }
                // Cache for 24 hours
                set_transient($cache_key, $country_code, DAY_IN_SECONDS);
                return $country_code;
            }
        }

        // Fallback to alternative service: ipapi.co
        $fallback_url = 'https://ipapi.co/' . urlencode($ip_address) . '/country_code/';
        $fallback_response = wp_remote_get($fallback_url, [
            'timeout' => 5,
            'user-agent' => 'Guard Dog WordPress Plugin',
        ]);

        if (!is_wp_error($fallback_response)) {
            $country_code = trim(wp_remote_retrieve_body($fallback_response));
            if (strlen($country_code) === 2 && ctype_alpha($country_code)) {
                if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                    Guard_Dog()->debug->info('[Geolocation] Fallback API (ipapi.co) returned country: ' . strtoupper($country_code));
                }
                // Cache for 24 hours
                set_transient($cache_key, strtoupper($country_code), DAY_IN_SECONDS);
                return strtoupper($country_code);
            }
        }

        // If all methods failed, cache false for a shorter time to avoid repeated failures
        if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
            Guard_Dog()->debug->info('[Geolocation] All methods failed to determine country for IP: ' . $ip_address);
        }
        set_transient($cache_key, false, HOUR_IN_SECONDS);
        return false;
    }

    //--------------------------------------------------------------------------
    // Login State Token Helpers (replaces PHP session usage)
    //--------------------------------------------------------------------------

    /**
     * Login state data consumed from URL state token (cached per request).
     *
     * @var array|false|null Null = not yet read, false = no state, array = state data.
     */
    private static $login_state = null;

    /**
     * Get the base login page URL for Guard Dog state-token redirects.
     *
     * Always resolve the login page through wp_login_url() rather than building
     * it from the `guard_dog_login_slug` option. Installs activated before 1.9.50
     * have that option stored as an empty string, and get_option() only falls back
     * to its default when the option does not exist - so
     * `get_option( 'guard_dog_login_slug', 'wp-login' )` returns '' there and
     * `home_url( '' )` yields the site home page, silently dropping users onto the
     * front end mid-login. When a custom slug is configured, Guard_Dog_Login_Url
     * filters `login_url` to it, so this is correct in both configurations.
     *
     * @since 1.9.50
     * @return string Login page URL.
     */
    public static function get_login_base_url() {
        return wp_login_url();
    }

    /**
     * Create a login state token and return a URL with it appended.
     *
     * Stores arbitrary data in a transient keyed by a random token,
     * then adds the token as a `gd_state` query parameter to the URL.
     *
     * @since 1.9.20
     * @param string $base_url URL to append the state token to.
     * @param array  $data     State data to store.
     * @param int    $ttl      Transient TTL in seconds (default 300).
     * @return string URL with gd_state query parameter.
     */
    public static function create_login_state_url( $base_url, $data, $ttl = 300 ) {
        $token = wp_generate_password( 32, false );
        set_transient( 'guard_dog_login_state_' . $token, $data, $ttl );
        return add_query_arg( 'gd_state', $token, $base_url );
    }

    /**
     * Consume login state from the gd_state URL parameter (one-time read).
     *
     * On first call, reads the `gd_state` query parameter, retrieves and deletes
     * the corresponding transient, and caches the result for the request.
     * Subsequent calls return the cached result.
     *
     * @since 1.9.20
     * @return array|false State data array, or false if not available/expired.
     */
    public static function consume_login_state() {
        if ( null !== self::$login_state ) {
            return self::$login_state;
        }

        // phpcs:disable WordPress.Security.NonceVerification.Recommended
        // 1. Context: Reading state token from login page redirect URL
        // 2. Security: Token is a random 32-char string pointing to a server-side transient
        // 3. Sanitization: Token sanitized with sanitize_text_field()
        // 4. Justification: State token replaces PHP session for pre-auth login flows
        // 5. Nonce limitation: Nonces require authenticated sessions; login is pre-auth
        // 6. Risk assessment: Minimal - token is opaque, transient is server-side and one-time-use
        $token = isset( $_GET['gd_state'] ) ? sanitize_text_field( wp_unslash( $_GET['gd_state'] ) ) : '';
        // phpcs:enable WordPress.Security.NonceVerification.Recommended

        if ( empty( $token ) ) {
            self::$login_state = false;
            return false;
        }

        $key  = 'guard_dog_login_state_' . $token;
        $data = get_transient( $key );
        if ( false !== $data ) {
            delete_transient( $key );
        }

        self::$login_state = $data;
        return $data;
    }

    /**
     * Reset the login state cache (for testing only).
     *
     * @since 1.9.20
     */
    public static function reset_login_state_cache() {
        self::$login_state = null;
    }

} // End class Guard_Dog_Utils
