<?php

declare(strict_types=1);

namespace PpPhp\Core\Analytics;

/**
 * A small, dependency-free reader for the MaxMind DB (.mmdb) binary format.
 *
 * Why hand-rolled rather than the official library: this project ships without
 * Composer, and the MaxMind reader pulls in a package tree (and BCMath/GMP for
 * some databases). The .mmdb format is small and well specified, and for the
 * fields an IP→country/city lookup needs (maps, strings, pointers, ints) a
 * couple hundred lines is enough — no big-number maths required.
 *
 * It reads ANY database in the MaxMind DB format: MaxMind's own GeoLite2/GeoIP2,
 * DB-IP Lite, and IP2Location's .mmdb builds all share it. So the owner can drop
 * in whichever database they prefer and this reads it unchanged.
 *
 * Spec: https://maxmind.github.io/MaxMind-DB/
 */
final class MmdbReader
{
    private const METADATA_MARKER = "\xAB\xCD\xEFMaxMind.com";
    private const DATA_SEPARATOR  = 16;   // zero bytes between search tree and data

    private string $buf;
    private int $size;

    /** @var array<string,mixed> */
    private array $meta;
    private int $nodeCount;
    private int $recordSize;
    private int $nodeByteSize;   // bytes per node = recordSize/4
    private int $searchTreeSize; // bytes
    private int $ipVersion;      // 4 or 6
    private int $dataSectionStart;

    private function __construct(string $buf)
    {
        $this->buf  = $buf;
        $this->size = strlen($buf);
        $this->readMetadata();
    }

    /**
     * Open a database file. Returns null (never throws) when the file is missing
     * or not a valid .mmdb — the caller falls back to another geo source.
     */
    public static function open(string $path): ?self
    {
        try {
            if ($path === '' || !is_file($path) || !is_readable($path)) return null;
            $buf = @file_get_contents($path);
            if ($buf === false || $buf === '') return null;
            return new self($buf);
        } catch (\Throwable $e) {
            return null;
        }
    }

    /** database_type string, e.g. "DBIP-Country-Lite" / "GeoLite2-City". */
    public function type(): string
    {
        return (string) ($this->meta['database_type'] ?? '');
    }

    /** UNIX build epoch from the metadata (0 if absent). */
    public function buildEpoch(): int
    {
        return (int) ($this->meta['build_epoch'] ?? 0);
    }

    /**
     * Look up an IP. Returns the decoded record (a nested array) or null when the
     * address is absent, private, or malformed.
     *
     * @return array<string,mixed>|null
     */
    public function get(string $ip): ?array
    {
        try {
            $bits = $this->ipToBits($ip);
            if ($bits === null) return null;

            $node = 0;
            $count = count($bits);
            for ($i = 0; $i < $count; $i++) {
                if ($node >= $this->nodeCount) break;
                $record = $this->readNode($node, $bits[$i]);
                if ($record === $this->nodeCount) return null;      // empty record
                if ($record > $this->nodeCount) {                   // points into data
                    $offset = $record - $this->nodeCount + $this->searchTreeSize;
                    [$value] = $this->decode($offset);
                    return is_array($value) ? $value : null;
                }
                $node = $record;                                    // descend
            }
            return null;
        } catch (\Throwable $e) {
            return null;
        }
    }

    // ── Metadata ──────────────────────────────────────────────────────

    private function readMetadata(): void
    {
        $marker = strrpos($this->buf, self::METADATA_MARKER);
        if ($marker === false) {
            throw new \RuntimeException('Not a MaxMind DB file (no metadata marker).');
        }
        $offset = $marker + strlen(self::METADATA_MARKER);
        [$meta] = $this->decode($offset);
        if (!is_array($meta)) {
            throw new \RuntimeException('Corrupt MaxMind DB metadata.');
        }
        $this->meta           = $meta;
        $this->nodeCount      = (int) ($meta['node_count'] ?? 0);
        $this->recordSize     = (int) ($meta['record_size'] ?? 0);
        $this->ipVersion      = (int) ($meta['ip_version'] ?? 6);
        if ($this->nodeCount <= 0 || !in_array($this->recordSize, [24, 28, 32], true)) {
            throw new \RuntimeException('Unsupported MaxMind DB (record_size ' . $this->recordSize . ').');
        }
        $this->nodeByteSize   = intdiv($this->recordSize, 4);
        $this->searchTreeSize = $this->nodeCount * $this->nodeByteSize;
        $this->dataSectionStart = $this->searchTreeSize + self::DATA_SEPARATOR;
    }

    // ── Search tree ───────────────────────────────────────────────────

    /** Read the left ($bit=0) or right ($bit=1) record of a node. */
    private function readNode(int $node, int $bit): int
    {
        $base = $node * $this->nodeByteSize;
        $b = $this->buf;

        if ($this->recordSize === 24) {
            $i = $base + ($bit ? 3 : 0);
            return (ord($b[$i]) << 16) | (ord($b[$i + 1]) << 8) | ord($b[$i + 2]);
        }
        if ($this->recordSize === 32) {
            $i = $base + ($bit ? 4 : 0);
            return (ord($b[$i]) << 24) | (ord($b[$i + 1]) << 16) | (ord($b[$i + 2]) << 8) | ord($b[$i + 3]);
        }
        // 28-bit: the middle byte carries the high nibble of each record.
        $mid = ord($b[$base + 3]);
        if ($bit === 0) {
            return (($mid & 0xF0) << 20) | (ord($b[$base]) << 16) | (ord($b[$base + 1]) << 8) | ord($b[$base + 2]);
        }
        return (($mid & 0x0F) << 24) | (ord($b[$base + 4]) << 16) | (ord($b[$base + 5]) << 8) | ord($b[$base + 6]);
    }

    /**
     * Turn an IP into the array of bits to walk. IPv4 in a v6 database is the
     * low 32 bits with a 96-bit zero prefix (how MaxMind stores it).
     *
     * @return array<int,int>|null
     */
    private function ipToBits(string $ip): ?array
    {
        $packed = @inet_pton($ip);
        if ($packed === false) return null;
        $len = strlen($packed);

        if ($len === 4 && $this->ipVersion === 6) {
            $packed = str_repeat("\x00", 12) . $packed;   // ::/96 + IPv4
            $len = 16;
        } elseif ($len === 16 && $this->ipVersion === 4) {
            return null;                                    // v6 address, v4-only DB
        }

        $bits = [];
        for ($i = 0; $i < $len; $i++) {
            $byte = ord($packed[$i]);
            for ($j = 7; $j >= 0; $j--) $bits[] = ($byte >> $j) & 1;
        }
        return $bits;
    }

    // ── Data section decoder ──────────────────────────────────────────

    /**
     * Decode the value at $offset. Returns [value, offsetAfterValue].
     *
     * @return array{0:mixed,1:int}
     */
    private function decode(int $offset): array
    {
        $ctrl = ord($this->buf[$offset]);
        $offset++;
        $type = $ctrl >> 5;                         // top 3 bits

        if ($type === 0) {                          // extended type
            $type = ord($this->buf[$offset]) + 7;
            $offset++;
        }

        if ($type === 1) {                          // pointer
            return $this->decodePointer($ctrl, $offset);
        }

        [$size, $offset] = $this->decodeSize($ctrl, $offset);

        switch ($type) {
            case 2:  // utf8 string
                $val = $size === 0 ? '' : substr($this->buf, $offset, $size);
                return [$val, $offset + $size];
            case 3:  // double (8 bytes)
                $val = unpack('E', substr($this->buf, $offset, 8))[1] ?? 0.0;
                return [$val, $offset + 8];
            case 4:  // bytes
                return [substr($this->buf, $offset, $size), $offset + $size];
            case 5:  // uint16
            case 6:  // uint32
            case 9:  // uint64  (kept as int; fine on 64-bit PHP for these DBs)
            case 10: // uint128 (rare here; read as int, may lose precision but unused)
                return [$this->readUint($offset, $size), $offset + $size];
            case 7:  // map
                return $this->decodeMap($size, $offset);
            case 8:  // int32 (signed)
                return [$this->readInt32($offset, $size), $offset + $size];
            case 11: // array
                return $this->decodeArray($size, $offset);
            case 14: // boolean
                return [$size !== 0, $offset];       // size carries the value; no bytes
            case 15: // float (4 bytes)
                $val = unpack('G', substr($this->buf, $offset, 4))[1] ?? 0.0;
                return [$val, $offset + 4];
            case 12: // data cache container (unused)
            case 13: // end marker
            default:
                return [null, $offset + $size];
        }
    }

    /** @return array{0:int,1:int} [size, newOffset] */
    private function decodeSize(int $ctrl, int $offset): array
    {
        $size = $ctrl & 0x1F;
        if ($size < 29) return [$size, $offset];
        if ($size === 29) return [29 + ord($this->buf[$offset]), $offset + 1];
        if ($size === 30) return [285 + $this->readUint($offset, 2), $offset + 2];
        return [65821 + $this->readUint($offset, 3), $offset + 3];   // 31
    }

    /** @return array{0:int,1:int} */
    private function decodePointer(int $ctrl, int $offset): array
    {
        $psize = ($ctrl >> 3) & 0x3;                 // pointer size 0..3
        $v = $ctrl & 0x7;
        if ($psize === 0) {
            $ptr = ($v << 8) | ord($this->buf[$offset]);
            $offset += 1;
        } elseif ($psize === 1) {
            $ptr = ($v << 16) | $this->readUint($offset, 2);
            $ptr += 2048;
            $offset += 2;
        } elseif ($psize === 2) {
            $ptr = ($v << 24) | $this->readUint($offset, 3);
            $ptr += 526336;
            $offset += 3;
        } else {
            $ptr = $this->readUint($offset, 4);       // full 32-bit value
            $offset += 4;
        }
        [$value] = $this->decode($this->dataSectionStart + $ptr);
        return [$value, $offset];                     // caller continues after the pointer
    }

    /** @return array{0:array<string,mixed>,1:int} */
    private function decodeMap(int $count, int $offset): array
    {
        $map = [];
        for ($i = 0; $i < $count; $i++) {
            [$key, $offset] = $this->decode($offset);
            [$val, $offset] = $this->decode($offset);
            $map[(string) $key] = $val;
        }
        return [$map, $offset];
    }

    /** @return array{0:array<int,mixed>,1:int} */
    private function decodeArray(int $count, int $offset): array
    {
        $arr = [];
        for ($i = 0; $i < $count; $i++) {
            [$val, $offset] = $this->decode($offset);
            $arr[] = $val;
        }
        return [$arr, $offset];
    }

    private function readUint(int $offset, int $size): int
    {
        $n = 0;
        for ($i = 0; $i < $size; $i++) $n = ($n << 8) | ord($this->buf[$offset + $i]);
        return $n;
    }

    private function readInt32(int $offset, int $size): int
    {
        if ($size === 0) return 0;
        $n = $this->readUint($offset, $size);
        $bits = $size * 8;
        $signBit = 1 << ($bits - 1);
        return ($n & $signBit) ? $n - (1 << $bits) : $n;
    }
}
