<?php

declare(strict_types=1);

namespace WPUltimateSecurity\UltimateSecurity\Support\Helpers;

/**
 * IP Address Helper Class
 *
 * Provides utilities for working with IP addresses including validation,
 * range checking, proxy detection, and anonymization.
 *
 * @package WPUltimateSecurity\UltimateSecurity\Support\Helpers
 * @since 1.0.0
 */
class IpHelper
{
    /**
     * Default trusted proxy headers in order of priority.
     *
     * @var array<string>
     */
    private const TRUSTED_HEADERS = [
        'HTTP_CF_CONNECTING_IP',     // Cloudflare
        'HTTP_CLIENT_IP',            // Proxy
        'HTTP_X_FORWARDED_FOR',      // Load balancer/proxy
        'HTTP_X_FORWARDED',          // Proxy
        'HTTP_X_CLUSTER_CLIENT_IP',  // Cluster
        'HTTP_FORWARDED_FOR',        // Proxy
        'HTTP_FORWARDED',            // Proxy
        'HTTP_X_REAL_IP',            // Nginx
        'REMOTE_ADDR',               // Standard
    ];

    /**
     * Private IPv4 ranges in CIDR notation.
     *
     * @var array<string>
     */
    private const PRIVATE_IPV4_RANGES = [
        '10.0.0.0/8',
        '172.16.0.0/12',
        '192.168.0.0/16',
        '127.0.0.0/8',
        '169.254.0.0/16',
    ];

    /**
     * Private IPv6 prefixes.
     *
     * @var array<string>
     */
    private const PRIVATE_IPV6_PREFIXES = [
        'fc',   // Unique local address
        'fd',   // Unique local address
        'fe80', // Link-local
        '::1',  // Loopback
    ];

    /**
     * Get the client's IP address, handling proxies and load balancers.
     *
     * Checks for debug IP cookie first (for admins), then iterates through
     * trusted proxy headers to find the real client IP.
     *
     * @param bool $allowDebug Whether to allow debug IP override (default: true)
     * @return string The client's IP address
     */
    public static function getClientIp(bool $allowDebug = true): string
    {
        // Check for debug IP override (only for administrators)
        if ($allowDebug && self::hasDebugIp()) {
            $debugIp = self::getDebugIp();
            if ($debugIp !== null) {
                return $debugIp;
            }
        }

        return self::getRealClientIp();
    }

    /**
     * Get the real client IP without debug override.
     *
     * @return string The real client IP address
     */
    public static function getRealClientIp(): string
    {
        foreach (self::TRUSTED_HEADERS as $header) {
            if (!empty($_SERVER[$header])) {
                $ip = sanitize_text_field(wp_unslash($_SERVER[$header]));

                // Handle comma-separated IPs (from proxies)
                if (str_contains($ip, ',')) {
                    $ipList = explode(',', $ip);
                    $ip = trim($ipList[0]);
                }

                // Validate IP (excluding private/reserved for proxy headers)
                if ($header !== 'REMOTE_ADDR') {
                    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                        return $ip;
                    }
                } else {
                    // For REMOTE_ADDR, accept any valid IP
                    if (filter_var($ip, FILTER_VALIDATE_IP)) {
                        return $ip;
                    }
                }
            }
        }

        // Fallback to REMOTE_ADDR even if it's private/reserved
        return isset($_SERVER['REMOTE_ADDR'])
            ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']))
            : '127.0.0.1';
    }

    /**
     * Check if debug IP is set and user has permission.
     *
     * @return bool True if debug IP mode is active
     */
    public static function hasDebugIp(): bool
    {
        return current_user_can('manage_options')
            && isset($_COOKIE['ultimate_security_debug_ip'])
            && !empty($_COOKIE['ultimate_security_debug_ip']);
    }

    /**
     * Get the debug IP if set and valid.
     *
     * @return string|null The debug IP or null if not set/invalid
     */
    public static function getDebugIp(): ?string
    {
        if (!self::hasDebugIp()) {
            return null;
        }

        $debugIp = sanitize_text_field(wp_unslash($_COOKIE['ultimate_security_debug_ip']));

        return self::isValid($debugIp) ? $debugIp : null;
    }

    /**
     * Validate an IP address (supports both IPv4 and IPv6).
     *
     * @param string $ip The IP address to validate
     * @return bool True if the IP is valid
     */
    public static function isValid(string $ip): bool
    {
        return filter_var($ip, FILTER_VALIDATE_IP) !== false;
    }

    /**
     * Check if an IP is a valid IPv4 address.
     *
     * @param string $ip The IP address to check
     * @return bool True if the IP is a valid IPv4 address
     */
    public static function isIPv4(string $ip): bool
    {
        return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
    }

    /**
     * Check if an IP is a valid IPv6 address.
     *
     * @param string $ip The IP address to check
     * @return bool True if the IP is a valid IPv6 address
     */
    public static function isIPv6(string $ip): bool
    {
        return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
    }

    /**
     * Check if an IP address is within a given CIDR range.
     *
     * Supports both IPv4 and IPv6 addresses with CIDR notation.
     *
     * @param string $ip The IP address to check
     * @param string $range The CIDR range (e.g., "192.168.1.0/24" or "2001:db8::/32")
     * @return bool True if the IP is within the range
     */
    public static function isInRange(string $ip, string $range): bool
    {
        if (!self::isValid($ip)) {
            return false;
        }

        // Handle single IP (no CIDR notation)
        if (!str_contains($range, '/')) {
            return $ip === $range;
        }

        [$subnet, $bits] = explode('/', $range, 2);
        $bits = (int) $bits;

        if (self::isIPv4($ip) && self::isIPv4($subnet)) {
            return self::isInRangeIPv4($ip, $subnet, $bits);
        }

        if (self::isIPv6($ip) && self::isIPv6($subnet)) {
            return self::isInRangeIPv6($ip, $subnet, $bits);
        }

        return false;
    }

    /**
     * Check if an IPv4 address is within a CIDR range.
     *
     * @param string $ip The IPv4 address
     * @param string $subnet The subnet address
     * @param int $bits The number of network bits
     * @return bool True if within range
     */
    private static function isInRangeIPv4(string $ip, string $subnet, int $bits): bool
    {
        if ($bits < 0 || $bits > 32) {
            return false;
        }

        $ipLong = ip2long($ip);
        $subnetLong = ip2long($subnet);

        if ($ipLong === false || $subnetLong === false) {
            return false;
        }

        // Handle edge case where bits = 0 (matches all)
        if ($bits === 0) {
            return true;
        }

        $mask = -1 << (32 - $bits);

        return ($ipLong & $mask) === ($subnetLong & $mask);
    }

    /**
     * Check if an IPv6 address is within a CIDR range.
     *
     * @param string $ip The IPv6 address
     * @param string $subnet The subnet address
     * @param int $bits The number of network bits
     * @return bool True if within range
     */
    private static function isInRangeIPv6(string $ip, string $subnet, int $bits): bool
    {
        if ($bits < 0 || $bits > 128) {
            return false;
        }

        $ipBin = inet_pton($ip);
        $subnetBin = inet_pton($subnet);

        if ($ipBin === false || $subnetBin === false) {
            return false;
        }

        // Handle edge case where bits = 0 (matches all)
        if ($bits === 0) {
            return true;
        }

        // Compare the relevant bits
        $fullBytes = (int) floor($bits / 8);
        $remainingBits = $bits % 8;

        // Compare full bytes
        for ($i = 0; $i < $fullBytes; $i++) {
            if ($ipBin[$i] !== $subnetBin[$i]) {
                return false;
            }
        }

        // Compare remaining bits if any
        if ($remainingBits > 0 && $fullBytes < 16) {
            $mask = 0xFF << (8 - $remainingBits);
            if ((ord($ipBin[$fullBytes]) & $mask) !== (ord($subnetBin[$fullBytes]) & $mask)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Check if an IP address is private or local.
     *
     * @param string $ip The IP address to check
     * @return bool True if the IP is private or local
     */
    public static function isPrivate(string $ip): bool
    {
        if (!self::isValid($ip)) {
            return false;
        }

        // Use PHP's built-in filter for basic check
        $isPrivate = filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        ) === false;

        if ($isPrivate) {
            return true;
        }

        // Additional checks for IPv4
        if (self::isIPv4($ip)) {
            foreach (self::PRIVATE_IPV4_RANGES as $range) {
                if (self::isInRange($ip, $range)) {
                    return true;
                }
            }
        }

        // Additional checks for IPv6
        if (self::isIPv6($ip)) {
            $ipLower = strtolower($ip);

            // Check for loopback
            if ($ipLower === '::1' || str_starts_with($ipLower, '0:0:0:0:0:0:0:1')) {
                return true;
            }

            // Check for private prefixes
            foreach (self::PRIVATE_IPV6_PREFIXES as $prefix) {
                if (str_starts_with($ipLower, strtolower($prefix))) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Convert an IP address to a numeric representation for comparison.
     *
     * @param string $ip The IP address to convert
     * @return string|false The numeric representation or false on failure
     */
    public static function toNumeric(string $ip): string|false
    {
        if (!self::isValid($ip)) {
            return false;
        }

        if (self::isIPv4($ip)) {
            $long = ip2long($ip);
            return $long !== false ? (string) sprintf('%u', $long) : false;
        }

        // IPv6 - convert to hexadecimal string
        $bin = inet_pton($ip);
        if ($bin === false) {
            return false;
        }

        $hex = '';
        foreach (str_split($bin) as $byte) {
            $hex .= sprintf('%02x', ord($byte));
        }

        // Convert to decimal using bcmath if available, otherwise return hex
        if (function_exists('bcdiv')) {
            return self::hexToDecimal($hex);
        }

        return $hex;
    }

    /**
     * Convert a hexadecimal string to decimal using BCMath.
     *
     * @param string $hex The hexadecimal string
     * @return string The decimal representation
     */
    private static function hexToDecimal(string $hex): string
    {
        $dec = '0';
        $len = strlen($hex);

        for ($i = 0; $i < $len; $i++) {
            $dec = bcmul($dec, '16');
            $dec = bcadd($dec, (string) hexdec($hex[$i]));
        }

        return $dec;
    }

    /**
     * Get the list of trusted proxy headers.
     *
     * @return array<string> List of header names
     */
    public static function getTrustedHeaders(): array
    {
        /**
         * Filter the list of trusted proxy headers.
         *
         * @param array<string> $headers The default trusted headers
         * @return array<string> The filtered headers
         */
        return apply_filters('ultimate_security_trusted_proxy_headers', self::TRUSTED_HEADERS);
    }

    /**
     * Anonymize an IP address for privacy-compliant logging.
     *
     * For IPv4: Zeroes the last octet (e.g., 192.168.1.100 -> 192.168.1.0)
     * For IPv6: Zeroes the last 80 bits (keeps the /48 prefix)
     *
     * @param string $ip The IP address to anonymize
     * @param int $maskBits Number of bits to mask (default: 8 for IPv4, 80 for IPv6)
     * @return string The anonymized IP address
     */
    public static function anonymize(string $ip, ?int $maskBits = null): string
    {
        if (!self::isValid($ip)) {
            return $ip;
        }

        if (self::isIPv4($ip)) {
            $bits = $maskBits ?? 8;
            return self::anonymizeIPv4($ip, $bits);
        }

        if (self::isIPv6($ip)) {
            $bits = $maskBits ?? 80;
            return self::anonymizeIPv6($ip, $bits);
        }

        return $ip;
    }

    /**
     * Anonymize an IPv4 address by masking the last N bits.
     *
     * @param string $ip The IPv4 address
     * @param int $bits Number of bits to mask
     * @return string The anonymized IP
     */
    private static function anonymizeIPv4(string $ip, int $bits): string
    {
        $bits = max(0, min(32, $bits));
        $long = ip2long($ip);

        if ($long === false) {
            return $ip;
        }

        // Zero out the last N bits
        $mask = $bits > 0 ? (-1 << $bits) : -1;
        $anonymized = $long & $mask;

        return long2ip($anonymized);
    }

    /**
     * Anonymize an IPv6 address by masking the last N bits.
     *
     * @param string $ip The IPv6 address
     * @param int $bits Number of bits to mask
     * @return string The anonymized IP
     */
    private static function anonymizeIPv6(string $ip, int $bits): string
    {
        $bits = max(0, min(128, $bits));
        $bin = inet_pton($ip);

        if ($bin === false) {
            return $ip;
        }

        $bytes = str_split($bin);
        $keepBits = 128 - $bits;
        $fullBytes = (int) floor($keepBits / 8);
        $remainingBits = $keepBits % 8;

        // Zero out bytes after the mask
        for ($i = $fullBytes; $i < 16; $i++) {
            if ($i === $fullBytes && $remainingBits > 0) {
                // Partial mask for the transition byte
                $mask = 0xFF << (8 - $remainingBits);
                $bytes[$i] = chr(ord($bytes[$i]) & $mask);
            } else {
                $bytes[$i] = chr(0);
            }
        }

        $anonymized = inet_ntop(implode('', $bytes));

        return $anonymized !== false ? $anonymized : $ip;
    }

    /**
     * Get detailed IP information including debug status.
     *
     * @return array{
     *     current_ip: string,
     *     real_ip: string,
     *     debug_ip: string|null,
     *     is_debugging: bool,
     *     is_private: bool,
     *     ip_version: int
     * }
     */
    public static function getIpInfo(): array
    {
        $currentIp = self::getClientIp();
        $realIp = self::getRealClientIp();
        $debugIp = self::getDebugIp();

        return [
            'current_ip' => $currentIp,
            'real_ip' => $realIp,
            'debug_ip' => $debugIp,
            'is_debugging' => $debugIp !== null,
            'is_private' => self::isPrivate($currentIp),
            'ip_version' => self::isIPv4($currentIp) ? 4 : (self::isIPv6($currentIp) ? 6 : 0),
        ];
    }

    /**
     * Check if an IP matches any in a list of IPs or ranges.
     *
     * @param string $ip The IP to check
     * @param array<string> $list List of IPs or CIDR ranges
     * @return bool True if the IP matches any entry
     */
    public static function matchesAny(string $ip, array $list): bool
    {
        if (!self::isValid($ip)) {
            return false;
        }

        foreach ($list as $entry) {
            $entry = trim($entry);

            if (empty($entry)) {
                continue;
            }

            // Check if it's a range (CIDR) or single IP
            if (str_contains($entry, '/')) {
                if (self::isInRange($ip, $entry)) {
                    return true;
                }
            } elseif ($ip === $entry) {
                return true;
            }
        }

        return false;
    }

    /**
     * Expand an IPv6 address to its full form.
     *
     * @param string $ip The IPv6 address (possibly compressed)
     * @return string|false The expanded IPv6 address or false on failure
     */
    public static function expandIPv6(string $ip): string|false
    {
        if (!self::isIPv6($ip)) {
            return false;
        }

        $bin = inet_pton($ip);
        if ($bin === false) {
            return false;
        }

        $hex = bin2hex($bin);

        // Format as eight groups of four hex digits
        $groups = str_split($hex, 4);

        return implode(':', $groups);
    }

    /**
     * Compress an IPv6 address to its shortest form.
     *
     * @param string $ip The IPv6 address
     * @return string|false The compressed IPv6 address or false on failure
     */
    public static function compressIPv6(string $ip): string|false
    {
        if (!self::isIPv6($ip)) {
            return false;
        }

        $bin = inet_pton($ip);
        if ($bin === false) {
            return false;
        }

        $compressed = inet_ntop($bin);

        return $compressed !== false ? $compressed : false;
    }
}
