<?php
/**
 * @file qtrIPManager.php
 * @brief Centralized IP management utilities for all security modules
 *
 * This class provides shared IP handling functionality used by:
 * - Brute-force protection
 * - Bot protection
 * - Other security modules
 *
 * Features:
 * - Client IP resolution with trusted proxy support (Cloudflare, X-Forwarded-For)
 * - IP validation (IPv4 and IPv6)
 * - CIDR range matching
 * - Whitelist/blacklist checking (from multiple sources: filters, settings, constants)
 * - Configuration via filters, WordPress settings, and emergency constants
 *
 * @author Quttera
 */

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

require_once('qtrSettings.php');

class CQtrIPManager
{
    /**
     * ResolveClientIP()
     * ----------------
     * Resolves the real client IP address with support for trusted proxies.
     *
     * Logic:
     *  1. Start with REMOTE_ADDR (direct connection)
     *  2. If REMOTE_ADDR is in the configured trusted proxy list:
     *     - Check Cloudflare header (CF-Connecting-IP)
     *     - Check generic reverse proxy headers (X-Forwarded-For)
     *     - Return first valid public IP found
     *  3. Otherwise return REMOTE_ADDR
     *
     * Supported headers:
     *  - CF-Connecting-IP (Cloudflare)
     *  - X-Forwarded-For (nginx, HAProxy, etc.)
     *
     * @return string IP address (validated) or "0.0.0.0" as fallback
     */
    public static function ResolveClientIP(): string
    {
        $remote = $_SERVER['REMOTE_ADDR'] ?? '';
        $remote = self::IsValidIP((string)$remote) ? (string)$remote : '0.0.0.0';

        // Load trusted proxy IPs from settings
        $settings = QutteraSettings::LoadFromOptions();
        $trust_proxy = false;

        if ($settings && $settings->GetTrustedProxyEnabled()) {
            $trusted_proxies = $settings->GetTrustedProxyIPs();
            // Check if REMOTE_ADDR is in the trusted proxy list.
            // SECURITY: Proxied IP from headers (X-Forwarded-For, CF-Connecting-IP) will ONLY be 
            // extracted and used if REMOTE_ADDR matches a trusted proxy IP. This prevents header 
            // spoofing attacks from untrusted sources. If proxy is not in trusted list, REMOTE_ADDR 
            // is returned directly, preserving security at the cost of accuracy.
            $trust_proxy = self::IsIPInList($remote, $trusted_proxies);
        }

        if ($trust_proxy) {
            // Cloudflare
            $cf = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '';
            if ($cf && self::IsValidIP($cf)) return $cf;

            // Generic reverse proxy
            $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
            if (is_string($xff) && $xff !== '') {
                $candidates = array_map('trim', explode(',', $xff));
                foreach ($candidates as $cand) {
                    if (self::IsValidIP($cand) && self::IsPublicIP($cand)) {
                        return $cand;
                    }
                }
            }
        }

        return $remote;
    }

    /**
     * IsValidIP()
     * ----------
     * Validates an IP address (IPv4 or IPv6).
     *
     * @param string $ip IP address to validate
     * @return bool True if valid IPv4 or IPv6
     */
    public static function IsValidIP(string $ip): bool
    {
        $ip = trim($ip);
        return (bool) filter_var($ip, FILTER_VALIDATE_IP);
    }

    /**
     * IsPublicIP()
     * -----------
     * Checks if an IP address is public (not in private or reserved ranges).
     *
     * Rejects:
     *  - Private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
     *  - Reserved ranges (127.0.0.0/8, 169.254.0.0/16, etc.)
     *
     * Accepts:
     *  - Public IPv4 and IPv6 addresses
     *
     * @param string $ip IP address to check
     * @return bool True if public IP
     */
    public static function IsPublicIP(string $ip): bool
    {
        return (bool) filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        );
    }

    /**
     * IPInCIDR()
     * ---------
     * Checks if an IP address matches a CIDR notation range.
     *
     * Supports:
     *  - IPv4: "192.168.1.0/24"
     *  - IPv6: "2001:db8::/32"
     *
     * Algorithm:
     *  1. Parse network and prefix length from CIDR
     *  2. Convert both IPs to binary
     *  3. Compare first N bits (where N = prefix length)
     *  4. Return match result
     *
     * @param string $ip IP address to check
     * @param string $cidr CIDR range (e.g., "192.168.1.0/24")
     * @return bool True if IP is within CIDR range
     */
    public static function IPInCIDR(string $ip, string $cidr): bool
    {
        $ip = trim($ip);
        $cidr = trim($cidr);

        if ($ip === '' || $cidr === '' || strpos($cidr, '/') === false) return false;

        [$net, $bits] = explode('/', $cidr, 2);
        $bits = (int) $bits;

        $ipBin  = @inet_pton($ip);
        $netBin = @inet_pton($net);

        if ($ipBin === false || $netBin === false) return false;
        if (strlen($ipBin) !== strlen($netBin)) return false;

        $len = strlen($ipBin); // 4 for IPv4, 16 for IPv6
        $maxBits = $len * 8;

        if ($bits < 0 || $bits > $maxBits) return false;

        $bytes = intdiv($bits, 8);
        $rem   = $bits % 8;

        // Compare full bytes
        if ($bytes > 0 && substr($ipBin, 0, $bytes) !== substr($netBin, 0, $bytes)) {
            return false;
        }

        // Compare remaining bits
        if ($rem === 0) return true;

        $mask = chr((0xFF << (8 - $rem)) & 0xFF);
        return ((ord($ipBin[$bytes]) & ord($mask)) === (ord($netBin[$bytes]) & ord($mask)));
    }

    /**
     * IsIPInList()
     * -----------
     * Checks if an IP address matches any entry in a list.
     *
     * List format: semicolon-separated entries of IPs and/or CIDR ranges
     * Example: "192.168.1.1;10.0.0.0/8;2001:db8::/32"
     *
     * Processing:
     *  1. Split list by semicolons
     *  2. For each entry:
     *     - If contains "/", treat as CIDR and use IPInCIDR()
     *     - Otherwise, treat as single IP and compare directly
     *  3. Return true on first match, false if no matches
     *
     * @param string $ip IP address to check
     * @param string $list Semicolon-separated list of IPs/CIDR ranges
     * @return bool True if IP matches any entry in the list
     */
    public static function IsIPInList(string $ip, string $list): bool
    {
        if (empty($list) || !self::IsValidIP($ip)) {
            return false;
        }

        $entries = array_map('trim', explode(';', $list));
        foreach ($entries as $entry) {
            if (empty($entry)) continue;

            // Check if it's a CIDR range (contains /)
            if (strpos($entry, '/') !== false) {
                if (self::IPInCIDR($ip, $entry)) {
                    return true;
                }
            } else {
                // Single IP comparison (case-insensitive for IPs)
                if (strcasecmp($ip, $entry) === 0) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * IsAllowlisted()
     * ---------------
     * Checks if an IP should bypass protection (whitelist).
     *
     * Sources checked (in order):
     *  1. Filter "{filter_prefix}_allowlist_ips" (exact IPs)
     *  2. Filter "{filter_prefix}_allowlist_cidrs" (CIDR ranges)
     *  3. QutteraSettings whitelist IPs (shared across modules)
     *  4. Constant "QTR_{FILTER_PREFIX}_ALLOWLIST_IPS" (emergency break-glass)
     *
     * Default filter_prefix is "qtr_bf" for brute-force.
     * Bot protection would use "qtr_bot" for independent configuration.
     *
     * Usage:
     *  - Brute-force: CQtrIPManager::IsAllowlisted($ip, 'qtr_bf')
     *  - Bot protection: CQtrIPManager::IsAllowlisted($ip, 'qtr_bot')
     *
     * @param string $ip IP address to check
     * @param string $filter_prefix Filter prefix for this module (default: 'qtr_bf')
     * @return bool True if IP is allowlisted
     */
    public static function IsAllowlisted(string $ip, string $filter_prefix = 'qtr_bf'): bool
    {
        if (!self::IsValidIP($ip)) {
            return false;
        }

        // 1) Exact IP allowlist from filters
        $ips = apply_filters("{$filter_prefix}_allowlist_ips", []);
        if (is_array($ips) && in_array($ip, $ips, true)) return true;

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

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

        // 4) Constant allowlist for emergencies (wp-config.php)
        // Format: QTR_QTR_BF_ALLOWLIST_IPS or QTR_QTR_BOT_ALLOWLIST_IPS
        $constant_name = 'QTR_' . strtoupper($filter_prefix) . '_ALLOWLIST_IPS';
        if (defined($constant_name) && is_string(constant($constant_name))) {
            $const_value = constant($constant_name);
            if ($const_value !== '') {
                $parts = array_map('trim', explode(',', $const_value));
                foreach ($parts as $p) {
                    if ($p === '') continue;
                    if (strpos($p, '/') !== false) {
                        if (self::IPInCIDR($ip, $p)) return true;
                    } else {
                        if ($ip === $p) return true;
                    }
                }
            }
        }

        return false;
    }

    /**
     * IsBlacklisted()
     * ---------------
     * Checks if an IP should be immediately blocked (blacklist).
     *
     * Sources checked (in order):
     *  1. Filter "{filter_prefix}_blacklist_ips" (exact IPs)
     *  2. Filter "{filter_prefix}_blacklist_cidrs" (CIDR ranges)
     *  3. QutteraSettings manually blocked IPs (shared across modules)
     *  4. Constant "QTR_{FILTER_PREFIX}_BLACKLIST_IPS" (emergency block)
     *
     * Default filter_prefix is "qtr_bf" for brute-force.
     * Bot protection would use "qtr_bot" for independent configuration.
     *
     * Usage:
     *  - Brute-force: CQtrIPManager::IsBlacklisted($ip, 'qtr_bf')
     *  - Bot protection: CQtrIPManager::IsBlacklisted($ip, 'qtr_bot')
     *
     * @param string $ip IP address to check
     * @param string $filter_prefix Filter prefix for this module (default: 'qtr_bf')
     * @return bool True if IP is blacklisted
     */
    public static function IsBlacklisted(string $ip, string $filter_prefix = 'qtr_bf'): bool
    {
        if (!self::IsValidIP($ip)) {
            return false;
        }

        // 1) Exact IP blacklist from filters
        $ips = apply_filters("{$filter_prefix}_blacklist_ips", []);
        if (is_array($ips) && in_array($ip, $ips, true)) return true;

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

        // 3) Blacklist IPs from settings (manually blocked)
        $settings = QutteraSettings::LoadFromOptions();
        if ($settings) {
            $blacklist_ips = $settings->GetManuallyBlockedIPs();
            if (!empty($blacklist_ips) && self::IsIPInList($ip, $blacklist_ips)) {
                return true;
            }
        }

        // 4) Constant blacklist for emergencies (wp-config.php)
        // Format: QTR_QTR_BF_BLACKLIST_IPS or QTR_QTR_BOT_BLACKLIST_IPS
        $constant_name = 'QTR_' . strtoupper($filter_prefix) . '_BLACKLIST_IPS';
        if (defined($constant_name) && is_string(constant($constant_name))) {
            $const_value = constant($constant_name);
            if ($const_value !== '') {
                $parts = array_map('trim', explode(',', $const_value));
                foreach ($parts as $p) {
                    if ($p === '') continue;
                    if (strpos($p, '/') !== false) {
                        if (self::IPInCIDR($ip, $p)) return true;
                    } else {
                        if ($ip === $p) return true;
                    }
                }
            }
        }

        return false;
    }
}

?>
