<?php

/**
 * GeoIP - Country lookup provider chain (Local binary, MMDB, and API)
 */
class GeoIP
{
    private array $config;
    private ?FileCache $cache = null;

    public function __construct(array $analyticsConfig = [])
    {
        $this->config = $analyticsConfig;
        $cacheDir = dirname(__DIR__, 3) . '/admin/storage/cache';
        $this->cache = new FileCache($cacheDir);
    }

    /**
     * Resolve country code (2-letter ISO alpha-2, upper-case) from an IP address
     *
     * @param string $ip IPv4 or IPv6 address
     * @return string|null Country code or null if unresolved/private
     */
    public function lookupCountry(string $ip): ?string
    {
        $ip = trim($ip);
        if ($ip === '' || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
            return null;
        }

        $provider = $this->config['geoip_provider'] ?? 'local';

        switch ($provider) {
            case 'mmdb':
                $mmdbPath = $this->config['geoip_mmdb_path'] ?? '';
                if ($mmdbPath !== '' && file_exists($mmdbPath)) {
                    $code = $this->lookupMMDB($ip, $mmdbPath);
                    if ($code !== null) return self::normalizeCode($code);
                }
                // Fallback to local
                return self::normalizeCode($this->lookupLocal($ip));

            case 'api':
                $code = $this->lookupApi($ip);
                if ($code !== null) return self::normalizeCode($code);
                // Fallback to local
                return self::normalizeCode($this->lookupLocal($ip));

            case 'local':
            default:
                return self::normalizeCode($this->lookupLocal($ip));
        }
    }

    /**
     * Normalize a country code; placeholder codes (ZZ/XX) count as unknown
     */
    private static function normalizeCode(?string $code): ?string
    {
        if ($code === null) return null;
        $code = strtoupper(trim($code));
        if (!preg_match('/^[A-Z]{2}$/', $code)) return null;
        if (in_array($code, ['ZZ', 'XX'], true)) return null;
        return $code;
    }

    /**
     * Binary search lookup in local DB-IP IPv4/IPv6 binary files
     */
    public function lookupLocal(string $ip): ?string
    {
        $baseDir = dirname(__DIR__, 3) . '/admin/storage/geoip';

        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
            $binPath = $baseDir . '/ipv4.bin';
            if (!file_exists($binPath)) return null;

            $ipLong = sprintf('%u', ip2long($ip));
            $recordSize = 10; // 4 bytes start_ip, 4 bytes end_ip, 2 bytes country
            $fileSize = filesize($binPath);
            if ($fileSize < $recordSize) return null;

            $totalRecords = (int)($fileSize / $recordSize);
            $low = 0;
            $high = $totalRecords - 1;

            $handle = @fopen($binPath, 'rb');
            if (!$handle) return null;

            while ($low <= $high) {
                $mid = (int)(($low + $high) / 2);
                fseek($handle, $mid * $recordSize);
                $data = fread($handle, $recordSize);
                if (strlen($data) < $recordSize) break;

                $unpacked = unpack('Nstart/Nend/a2country', $data);
                $start = sprintf('%u', $unpacked['start']);
                $end = sprintf('%u', $unpacked['end']);

                if ($ipLong >= $start && $ipLong <= $end) {
                    fclose($handle);
                    return strtoupper($unpacked['country']);
                }

                if ($ipLong < $start) {
                    $high = $mid - 1;
                } else {
                    $low = $mid + 1;
                }
            }

            fclose($handle);
            return null;
        }

        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
            $binPath = $baseDir . '/ipv6.bin';
            if (!file_exists($binPath)) return null;

            $ipBin = inet_pton($ip);
            if ($ipBin === false || strlen($ipBin) !== 16) return null;

            $recordSize = 34; // 16 bytes start, 16 bytes end, 2 bytes country
            $fileSize = filesize($binPath);
            if ($fileSize < $recordSize) return null;

            $totalRecords = (int)($fileSize / $recordSize);
            $low = 0;
            $high = $totalRecords - 1;

            $handle = @fopen($binPath, 'rb');
            if (!$handle) return null;

            while ($low <= $high) {
                $mid = (int)(($low + $high) / 2);
                fseek($handle, $mid * $recordSize);
                $data = fread($handle, $recordSize);
                if (strlen($data) < $recordSize) break;

                $startBin = substr($data, 0, 16);
                $endBin = substr($data, 16, 16);
                $country = substr($data, 32, 2);

                if (strcmp($ipBin, $startBin) >= 0 && strcmp($ipBin, $endBin) <= 0) {
                    fclose($handle);
                    return strtoupper($country);
                }

                if (strcmp($ipBin, $startBin) < 0) {
                    $high = $mid - 1;
                } else {
                    $low = $mid + 1;
                }
            }

            fclose($handle);
            return null;
        }

        return null;
    }

    /**
     * External API lookup with caching
     */
    private function lookupApi(string $ip): ?string
    {
        $cacheKey = 'geoip_api_' . md5($ip);
        if ($this->cache->has($cacheKey)) {
            return $this->cache->get($cacheKey);
        }

        $apiUrl = $this->config['geoip_api_url'] ?? 'http://ip-api.com/json/{ip}?fields=countryCode';
        $apiUrl = str_replace('{ip}', urlencode($ip), $apiUrl);
        if (!empty($this->config['geoip_api_key'])) {
            $apiUrl .= (str_contains($apiUrl, '?') ? '&' : '?') . 'key=' . urlencode($this->config['geoip_api_key']);
        }

        $ctx = stream_context_create(['http' => ['timeout' => 3, 'user_agent' => 'CodePressCMS/1.9.0']]);
        $response = @file_get_contents($apiUrl, false, $ctx);
        if ($response) {
            $json = json_decode($response, true);
            $code = $json['countryCode'] ?? $json['country_code'] ?? null;
            if ($code && strlen($code) === 2) {
                $code = strtoupper($code);
                $this->cache->set($cacheKey, $code, 86400 * 7); // Cache for 24h * 7
                return $code;
            }
        }

        return null;
    }

    /**
     * Pure-PHP MaxMind MMDB reader
     */
    private function lookupMMDB(string $ip, string $filePath): ?string
    {
        try {
            $reader = new MMDBReader($filePath);
            $record = $reader->get($ip);
            return $record['country']['iso_code'] ?? $record['registered_country']['iso_code'] ?? null;
        } catch (\Throwable $e) {
            return null;
        }
    }

    /**
     * Convert 2-letter ISO country code to regional indicator flag emoji
     */
    public static function getCountryFlagEmoji(?string $code): string
    {
        if (!$code || strlen($code) !== 2) {
            return '🌐';
        }

        $code = strtoupper($code);
        $first = ord($code[0]) - 65 + 0x1F1E6;
        $second = ord($code[1]) - 65 + 0x1F1E6;

        return mb_chr($first, 'UTF-8') . mb_chr($second, 'UTF-8');
    }

    /**
     * Get country name in Dutch or English
     */
    public static function getCountryName(?string $code, string $lang = 'nl'): string
    {
        if (!$code) return 'Lokaal / Onbekend';

        $code = strtoupper($code);
        $names = [
            'NL' => ['nl' => 'Nederland', 'en' => 'Netherlands'],
            'BE' => ['nl' => 'België', 'en' => 'Belgium'],
            'DE' => ['nl' => 'Duitsland', 'en' => 'Germany'],
            'FR' => ['nl' => 'Frankrijk', 'en' => 'France'],
            'GB' => ['nl' => 'Verenigd Koninkrijk', 'en' => 'United Kingdom'],
            'US' => ['nl' => 'Verenigde Staten', 'en' => 'United States'],
            'CA' => ['nl' => 'Canada', 'en' => 'Canada'],
            'ES' => ['nl' => 'Spanje', 'en' => 'Spain'],
            'IT' => ['nl' => 'Italië', 'en' => 'Italy'],
            'PL' => ['nl' => 'Polen', 'en' => 'Poland'],
            'AT' => ['nl' => 'Oostenrijk', 'en' => 'Austria'],
            'CH' => ['nl' => 'Zwitserland', 'en' => 'Switzerland'],
            'SE' => ['nl' => 'Zweden', 'en' => 'Sweden'],
            'NO' => ['nl' => 'Noorwegen', 'en' => 'Norway'],
            'DK' => ['nl' => 'Denemarken', 'en' => 'Denmark'],
            'FI' => ['nl' => 'Finland', 'en' => 'Finland'],
            'IE' => ['nl' => 'Ierland', 'en' => 'Ireland'],
            'PT' => ['nl' => 'Portugal', 'en' => 'Portugal'],
            'GR' => ['nl' => 'Griekenland', 'en' => 'Greece'],
            'CZ' => ['nl' => 'Tsjechië', 'en' => 'Czechia'],
            'CN' => ['nl' => 'China', 'en' => 'China'],
            'JP' => ['nl' => 'Japan', 'en' => 'Japan'],
            'IN' => ['nl' => 'India', 'en' => 'India'],
            'BR' => ['nl' => 'Brazilië', 'en' => 'Brazil'],
            'AU' => ['nl' => 'Australië', 'en' => 'Australia'],
            'RU' => ['nl' => 'Rusland', 'en' => 'Russia'],
            'ZA' => ['nl' => 'Zuid-Afrika', 'en' => 'South Africa'],
            'TR' => ['nl' => 'Turkije', 'en' => 'Turkey'],
            'UA' => ['nl' => 'Oekraïne', 'en' => 'Ukraine'],
            'MX' => ['nl' => 'Mexico', 'en' => 'Mexico'],
            'ID' => ['nl' => 'Indonesië', 'en' => 'Indonesia'],
            'SG' => ['nl' => 'Singapore', 'en' => 'Singapore'],
            'KR' => ['nl' => 'Zuid-Korea', 'en' => 'South Korea'],
            'AR' => ['nl' => 'Argentinië', 'en' => 'Argentina'],
        ];

        if (isset($names[$code][$lang])) {
            return $names[$code][$lang];
        }

        return $code;
    }
}

/**
 * Built-in pure-PHP MaxMind DB Reader
 */
class MMDBReader
{
    private string $file;
    private $handle;
    private array $meta;

    public function __construct(string $file)
    {
        if (!file_exists($file)) {
            throw new \InvalidArgumentException("MMDB file does not exist: {$file}");
        }
        $this->file = $file;
        $this->handle = fopen($file, 'rb');
        $this->loadMetadata();
    }

    public function __destruct()
    {
        if ($this->handle) {
            fclose($this->handle);
        }
    }

    private function loadMetadata(): void
    {
        $stat = fstat($this->handle);
        $size = $stat['size'];
        $marker = "\xab\xcd\xefMaxMind.com\x01";
        
        fseek($this->handle, max(0, $size - 128000));
        $buffer = fread($this->handle, 128000);
        $pos = strrpos($buffer, $marker);
        
        if ($pos === false) {
            throw new \RuntimeException("Invalid MMDB file format: {$this->file}");
        }

        $metaOffset = $size - 128000 + $pos + strlen($marker);
        fseek($this->handle, $metaOffset);
        $this->meta = $this->decodeData($metaOffset)[0];
    }

    public function get(string $ip): ?array
    {
        $ipBin = inet_pton($ip);
        if ($ipBin === false) return null;

        $isV4 = strlen($ipBin) === 4;
        $nodeCount = $this->meta['node_count'] ?? 0;
        $recordSize = $this->meta['record_size'] ?? 28;
        $ipVersion = $this->meta['ip_version'] ?? 6;

        // Start node search
        $node = 0;
        $bitLength = $isV4 ? 32 : 128;

        // If IPv4 in IPv6 tree
        if ($isV4 && $ipVersion === 6) {
            $node = $this->meta['ipv4_instance_count'] ?? 0;
        }

        for ($i = 0; $i < $bitLength; $i++) {
            if ($node >= $nodeCount) break;

            $byteIndex = (int)($i / 8);
            $bit = (ord($ipBin[$byteIndex]) >> (7 - ($i % 8))) & 1;

            $node = $this->readNode($node, $bit, $recordSize);
        }

        if ($node >= $nodeCount) {
            $dataOffset = $node - $nodeCount + ($nodeCount * ($recordSize * 2 / 8)) + 16;
            return $this->decodeData($dataOffset)[0];
        }

        return null;
    }

    private function readNode(int $node, int $bit, int $recordSize): int
    {
        $bytesPerRecord = $recordSize / 4; // 28-bit -> 3.5 bytes per record
        $nodeOffset = (int)($node * $recordSize * 2 / 8);

        fseek($this->handle, $nodeOffset);
        $bytes = fread($this->handle, 8);

        if ($recordSize === 28) {
            $left = (ord($bytes[0]) << 16) | (ord($bytes[1]) << 8) | ord($bytes[2]) | ((ord($bytes[3]) & 0xf0) << 20);
            $right = (ord($bytes[4]) << 16) | (ord($bytes[5]) << 8) | ord($bytes[6]) | ((ord($bytes[3]) & 0x0f) << 24);
            return $bit === 0 ? $left : $right;
        }

        return 0;
    }

    private function decodeData(int $offset): array
    {
        fseek($this->handle, $offset);
        $ctrl = ord(fread($this->handle, 1));
        $type = $ctrl >> 5;
        $size = $ctrl & 0x1f;

        if ($type === 0) {
            $type = ord(fread($this->handle, 1)) + 7;
        }

        if ($size >= 29) {
            $bytesToRead = $size - 28;
            $extSize = 0;
            for ($i = 0; $i < $bytesToRead; $i++) {
                $extSize = ($extSize << 8) | ord(fread($this->handle, 1));
            }
            $size = $extSize + 29;
            if ($bytesToRead === 1) $size += 0;
            elseif ($bytesToRead === 2) $size += 248;
            elseif ($bytesToRead === 3) $size += 65816;
        }

        switch ($type) {
            case 1: // Pointer
                return [$this->decodeData(ftell($this->handle) + $size)[0], ftell($this->handle)];
            case 2: // String
                return [fread($this->handle, $size), ftell($this->handle)];
            case 3: // Double
                return [0.0, ftell($this->handle)];
            case 5: // Uint32/64
                $val = 0;
                for ($i = 0; $i < $size; $i++) {
                    $val = ($val << 8) | ord(fread($this->handle, 1));
                }
                return [$val, ftell($this->handle)];
            case 7: // Map
                $map = [];
                for ($i = 0; $i < $size; $i++) {
                    [$key, ] = $this->decodeData(ftell($this->handle));
                    [$val, ] = $this->decodeData(ftell($this->handle));
                    $map[$key] = $val;
                }
                return [$map, ftell($this->handle)];
            case 11: // Bool
                return [$size === 1, ftell($this->handle)];
            default:
                return [null, ftell($this->handle)];
        }
    }
}
