<?php
declare(strict_types=1);

namespace Celena\Plugin\Analytics\Mmdb;

/**
 * Минимальный read-only ридер формата MaxMind DB (.mmdb) — первокодная реализация
 * по открытой спецификации MaxMind DB File Format. Без внешних зависимостей.
 *
 * Поддерживает базы IPv4 и IPv6 (DB-IP City Lite / GeoLite2), record_size 24/28/32.
 * Используется для резолва города/координат по IP на сервере. IP нигде не сохраняется.
 *
 * Принцип: бинарное дерево поиска (по битам IP) → указатель в data-секцию →
 * рекурсивный декодер (map/array/string/double/uint/pointer/…).
 */
final class Reader
{
    private const METADATA_MARKER = "\xab\xcd\xefMaxMind.com";
    private const DATA_SEP = 16; // 16-байтовый разделитель дерева и data-секции

    /** @var resource */
    private $fh;
    private int $nodeCount;
    private int $recordSize;     // бит: 24 | 28 | 32
    private int $nodeByteSize;   // байт на узел = recordSize*2/8
    private int $ipVersion;      // 4 | 6
    private int $searchTreeSize;
    private int $dataStart;      // абсолютное смещение начала data-секции

    public function __construct(string $path)
    {
        $fh = @fopen($path, 'rb');
        if ($fh === false) {
            throw new \RuntimeException("MMDB: не открыть $path");
        }
        $this->fh = $fh;
        $this->readMetadata();
        $this->nodeByteSize = intdiv($this->recordSize * 2, 8);
        $this->searchTreeSize = $this->nodeCount * $this->nodeByteSize;
        $this->dataStart = $this->searchTreeSize + self::DATA_SEP;
    }

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

    /**
     * Найти запись для IP. Возвращает декодированную структуру (map) или null.
     */
    public function get(string $ip): mixed
    {
        $bits = $this->ipBits($ip);
        if ($bits === null) {
            return null;
        }
        $node = 0;
        $n = count($bits);
        for ($i = 0; $i < $n; $i++) {
            if ($node >= $this->nodeCount) {
                break;
            }
            $node = $this->readNode($node, $bits[$i]);
        }
        if ($node === $this->nodeCount) {
            return null; // нет данных
        }
        if ($node > $this->nodeCount) {
            $offset = $node - $this->nodeCount + $this->searchTreeSize;
            [$value] = $this->decode($offset);
            return $value;
        }
        return null;
    }

    // ───────────────────────── метаданные ─────────────────────────

    private function readMetadata(): void
    {
        $size = fstat($this->fh)['size'] ?? 0;
        $scan = min($size, 128 * 1024); // метаданные в хвосте файла
        fseek($this->fh, $size - $scan);
        $tail = fread($this->fh, $scan) ?: '';
        $pos = strrpos($tail, self::METADATA_MARKER);
        if ($pos === false) {
            throw new \RuntimeException('MMDB: метаданные не найдены');
        }
        // абсолютное смещение начала метаданных (после маркера)
        $metaStart = ($size - $scan) + $pos + strlen(self::METADATA_MARKER);
        // в метаданных указатели относительны начала метаданных
        [$meta] = $this->decode($metaStart, $metaStart);
        if (!is_array($meta)) {
            throw new \RuntimeException('MMDB: метаданные повреждены');
        }
        $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('MMDB: неподдерживаемые метаданные');
        }
    }

    // ───────────────────────── дерево ─────────────────────────

    /** @return int номер следующего узла / значение записи */
    private function readNode(int $node, int $bit): int
    {
        $base = $node * $this->nodeByteSize;
        fseek($this->fh, $base);
        $bytes = fread($this->fh, $this->nodeByteSize);
        $b = array_values(unpack('C*', $bytes));

        switch ($this->recordSize) {
            case 24:
                return $bit === 0
                    ? ($b[0] << 16) | ($b[1] << 8) | $b[2]
                    : ($b[3] << 16) | ($b[4] << 8) | $b[5];
            case 28:
                return $bit === 0
                    ? (($b[3] & 0xF0) << 20) | ($b[0] << 16) | ($b[1] << 8) | $b[2]
                    : (($b[3] & 0x0F) << 24) | ($b[4] << 16) | ($b[5] << 8) | $b[6];
            default: // 32
                return $bit === 0
                    ? ($b[0] << 24) | ($b[1] << 16) | ($b[2] << 8) | $b[3]
                    : ($b[4] << 24) | ($b[5] << 16) | ($b[6] << 8) | $b[7];
        }
    }

    /** Биты IP (MSB-first). IPv4 в IPv6-базе → 96 нулей + 32 бита. */
    private function ipBits(string $ip): ?array
    {
        $packed = @inet_pton($ip);
        if ($packed === false) {
            return null;
        }
        $len = strlen($packed); // 4 или 16
        if ($len === 4 && $this->ipVersion === 6) {
            $packed = str_repeat("\x00", 12) . $packed; // ::a.b.c.d
            $len = 16;
        }
        if ($len === 16 && $this->ipVersion === 4) {
            return null; // IPv6 в IPv4-базе не ищем
        }
        $bits = [];
        foreach (unpack('C*', $packed) as $byte) {
            for ($i = 7; $i >= 0; $i--) {
                $bits[] = ($byte >> $i) & 1;
            }
        }
        return $bits;
    }

    // ───────────────────────── декодер data-секции ─────────────────────────

    /**
     * Декодировать значение по абсолютному смещению.
     * @param int $offset смещение в файле
     * @param int|null $ptrBase база для указателей (data-секция или начало метаданных)
     * @return array{0:mixed,1:int} [значение, смещение после него]
     */
    private function decode(int $offset, ?int $ptrBase = null): array
    {
        $ptrBase ??= $this->dataStart;
        $ctrl = $this->byteAt($offset);
        $offset++;
        $type = $ctrl >> 5;

        if ($type === 0) { // расширенный тип
            $type = $this->byteAt($offset) + 7;
            $offset++;
        }

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

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

        switch ($type) {
            case 2: // utf8 string
                $val = $size > 0 ? $this->bytesAt($offset, $size) : '';
                return [$val, $offset + $size];
            case 3: // double
                return [$this->unpackNum('E', $offset, 8), $offset + 8];
            case 4: // bytes
                return [$this->bytesAt($offset, $size), $offset + $size];
            case 5: // uint16
            case 6: // uint32
            case 9: // uint64
            case 10: // uint128
                return [$this->uint($offset, $size), $offset + $size];
            case 7: // map
                $map = [];
                for ($i = 0; $i < $size; $i++) {
                    [$k, $offset] = $this->decode($offset, $ptrBase);
                    [$v, $offset] = $this->decode($offset, $ptrBase);
                    $map[(string) $k] = $v;
                }
                return [$map, $offset];
            case 8: // int32
                return [$this->int32($offset, $size), $offset + $size];
            case 11: // array
                $arr = [];
                for ($i = 0; $i < $size; $i++) {
                    [$v, $offset] = $this->decode($offset, $ptrBase);
                    $arr[] = $v;
                }
                return [$arr, $offset];
            case 14: // boolean
                return [$size !== 0, $offset];
            case 15: // float
                return [$this->unpackNum('G', $offset, 4), $offset + 4];
            default: // 12 data_cache_container, 13 end_marker — не встречаются в обходе
                return [null, $offset];
        }
    }

    private function decodePointer(int $ctrl, int $offset, int $ptrBase): array
    {
        $pSize = ($ctrl >> 3) & 0x3;
        $val = $ctrl & 0x7;
        switch ($pSize) {
            case 0:
                $ptr = ($val << 8) | $this->byteAt($offset);
                $offset += 1;
                break;
            case 1:
                $ptr = ($val << 16) | $this->uint($offset, 2);
                $ptr += 2048;
                $offset += 2;
                break;
            case 2:
                $ptr = ($val << 24) | $this->uint($offset, 3);
                $ptr += 526336;
                $offset += 3;
                break;
            default: // 3
                $ptr = $this->uint($offset, 4);
                $offset += 4;
        }
        [$value] = $this->decode($ptrBase + $ptr, $ptrBase);
        return [$value, $offset]; // курсор продолжается после указателя
    }

    /** Размер значения из control-байта (+ возможные доп. байты). */
    private function size(int $ctrl, int $offset): array
    {
        $size = $ctrl & 0x1f;
        if ($size < 29) {
            return [$size, $offset];
        }
        if ($size === 29) {
            return [29 + $this->byteAt($offset), $offset + 1];
        }
        if ($size === 30) {
            return [285 + $this->uint($offset, 2), $offset + 2];
        }
        return [65821 + $this->uint($offset, 3), $offset + 3]; // 31
    }

    // ───────────────────────── низкоуровневое чтение ─────────────────────────

    private function byteAt(int $offset): int
    {
        fseek($this->fh, $offset);
        $c = fread($this->fh, 1);
        return $c === '' || $c === false ? 0 : ord($c);
    }

    private function bytesAt(int $offset, int $len): string
    {
        if ($len <= 0) {
            return '';
        }
        fseek($this->fh, $offset);
        return (string) fread($this->fh, $len);
    }

    private function uint(int $offset, int $len): int
    {
        if ($len === 0) {
            return 0;
        }
        $bytes = $this->bytesAt($offset, $len);
        $n = 0;
        foreach (unpack('C*', $bytes) as $b) {
            $n = ($n << 8) | $b;
        }
        return $n;
    }

    private function int32(int $offset, int $len): int
    {
        if ($len === 0) {
            return 0;
        }
        // дополняем ведущими нулями до 4 байт → знаковое 32-бит (ведущие нули = положительное)
        $bytes = str_pad($this->bytesAt($offset, $len), 4, "\x00", STR_PAD_LEFT);
        $u = unpack('N', $bytes)[1];
        return $u >= 0x80000000 ? $u - 0x100000000 : $u;
    }

    private function unpackNum(string $fmt, int $offset, int $len): float
    {
        $bytes = $this->bytesAt($offset, $len);
        $u = unpack($fmt, $bytes);
        return (float) ($u[1] ?? 0.0);
    }
}
