<?php

namespace App\Services\PlanDigitizer;

use RuntimeException;

/**
 * Minimal PDF reader: indexes indirect objects by brute-force scan (robust against
 * broken xref tables), expands object streams and inflates stream payloads.
 *
 * Only the subset needed to digitize a plan is supported: pages, resources,
 * content streams, annotations and optional content groups.
 */
class PdfDocument
{
    /** @var array<int, int> object id => byte offset of its body */
    private array $offsets = [];

    /** @var array<int, string> object id => raw body (from object streams) */
    private array $embedded = [];

    /** @var array<int, string> */
    private array $bodyCache = [];

    private function __construct(private readonly string $raw)
    {
        $this->index();
    }

    public static function open(string $path): self
    {
        $raw = @file_get_contents($path);

        if ($raw === false || $raw === '') {
            throw new RuntimeException('El PDF está vacío o no se pudo leer.');
        }

        if (! str_starts_with($raw, '%PDF-')) {
            $start = strpos($raw, '%PDF-');

            if ($start === false) {
                throw new RuntimeException('El archivo no es un PDF válido.');
            }

            $raw = substr($raw, $start);
        }

        return new self($raw);
    }

    public function isEncrypted(): bool
    {
        return (bool) preg_match('/\/Encrypt\s+\d+\s+\d+\s+R/', $this->raw);
    }

    private function index(): void
    {
        preg_match_all('/(\d+)\s+(\d+)\s+obj\b/', $this->raw, $matches, PREG_OFFSET_CAPTURE);

        foreach ($matches[1] as $position => $match) {
            $id = (int) $match[0];
            $this->offsets[$id] = $matches[0][$position][1] + strlen($matches[0][$position][0]);
        }

        if ($this->offsets === []) {
            throw new RuntimeException('El PDF no contiene objetos legibles.');
        }

        $this->expandObjectStreams();
    }

    /** Object streams (PDF 1.5+) hide page and annotation dictionaries from a raw scan. */
    private function expandObjectStreams(): void
    {
        foreach (array_keys($this->offsets) as $id) {
            $body = $this->rawBody($id);

            if (! str_contains($body, '/ObjStm')) {
                continue;
            }

            $payload = $this->streamData($id);

            if ($payload === null) {
                continue;
            }

            $count = $this->dictInt($body, 'N') ?? 0;
            $first = $this->dictInt($body, 'First') ?? 0;

            if ($count < 1 || $first < 1) {
                continue;
            }

            $header = substr($payload, 0, $first);
            $pairs = preg_split('/\s+/', trim($header)) ?: [];

            for ($item = 0; $item < $count; $item++) {
                $objectId = (int) ($pairs[$item * 2] ?? -1);
                $offset = (int) ($pairs[$item * 2 + 1] ?? -1);

                if ($objectId < 0 || $offset < 0 || isset($this->offsets[$objectId])) {
                    continue;
                }

                $end = isset($pairs[($item + 1) * 2 + 1])
                    ? $first + (int) $pairs[($item + 1) * 2 + 1]
                    : strlen($payload);

                $this->embedded[$objectId] = substr($payload, $first + $offset, $end - $first - $offset);
            }
        }
    }

    public function rawBody(int $id): string
    {
        if (isset($this->bodyCache[$id])) {
            return $this->bodyCache[$id];
        }

        if (isset($this->embedded[$id])) {
            return $this->bodyCache[$id] = $this->embedded[$id];
        }

        $start = $this->offsets[$id] ?? null;

        if ($start === null) {
            return $this->bodyCache[$id] = '';
        }

        $end = strpos($this->raw, 'endobj', $start);

        return $this->bodyCache[$id] = $end === false
            ? substr($this->raw, $start)
            : substr($this->raw, $start, $end - $start);
    }

    /** @return list<int> */
    public function objectIds(): array
    {
        $ids = array_merge(array_keys($this->offsets), array_keys($this->embedded));
        sort($ids);

        return array_values(array_unique($ids));
    }

    public function streamData(int $id): ?string
    {
        $body = $this->rawBody($id);
        $marker = strpos($body, 'stream');

        if ($marker === false) {
            return null;
        }

        $dict = substr($body, 0, $marker);
        $cursor = $marker + 6;

        if (substr($body, $cursor, 2) === "\r\n") {
            $cursor += 2;
        } elseif (isset($body[$cursor]) && ($body[$cursor] === "\n" || $body[$cursor] === "\r")) {
            $cursor += 1;
        }

        $end = strrpos($body, 'endstream');
        $data = $end === false
            ? substr($body, $cursor)
            : substr($body, $cursor, $end - $cursor);

        return $this->decodeStream($dict, $data);
    }

    private function decodeStream(string $dict, string $data): ?string
    {
        if (preg_match('/\/Filter\s*\/(\w+)/', $dict, $filter) !== 1) {
            return $data;
        }

        $decoded = match ($filter[1]) {
            'FlateDecode', 'Fl' => $this->inflate($data),
            'ASCIIHexDecode', 'AHx' => $this->asciiHexDecode($data),
            'LZWDecode', 'RunLengthDecode', 'DCTDecode', 'JPXDecode', 'CCITTFaxDecode' => null,
            default => $data,
        };

        if ($decoded === null) {
            return null;
        }

        $predictor = $this->dictInt($dict, 'Predictor');

        if ($predictor !== null && $predictor >= 10) {
            $columns = $this->dictInt($dict, 'Columns') ?? 1;
            $colors = $this->dictInt($dict, 'Colors') ?? 1;
            $bits = $this->dictInt($dict, 'BitsPerComponent') ?? 8;
            $decoded = $this->undoPngPredictor($decoded, $columns, $colors, $bits);
        }

        return $decoded;
    }

    private function inflate(string $data): ?string
    {
        $result = @gzuncompress($data);

        if ($result !== false) {
            return $result;
        }

        $result = @gzinflate($data);

        if ($result !== false) {
            return $result;
        }

        $result = @gzinflate(substr($data, 2));

        return $result === false ? null : $result;
    }

    private function asciiHexDecode(string $data): string
    {
        $hex = preg_replace('/[^0-9a-fA-F]/', '', explode('>', $data)[0]) ?? '';

        if (strlen($hex) % 2 === 1) {
            $hex .= '0';
        }

        return (string) hex2bin($hex);
    }

    private function undoPngPredictor(string $data, int $columns, int $colors, int $bits): string
    {
        $bytesPerPixel = max(1, (int) ceil($colors * $bits / 8));
        $rowLength = (int) ceil($columns * $colors * $bits / 8);
        $output = '';
        $previous = str_repeat("\0", $rowLength);
        $cursor = 0;
        $length = strlen($data);

        while ($cursor + 1 + $rowLength <= $length) {
            $tag = ord($data[$cursor]);
            $row = substr($data, $cursor + 1, $rowLength);
            $cursor += 1 + $rowLength;
            $current = '';

            for ($index = 0; $index < $rowLength; $index++) {
                $rawByte = ord($row[$index]);
                $left = $index >= $bytesPerPixel ? ord($current[$index - $bytesPerPixel]) : 0;
                $up = ord($previous[$index]);
                $upLeft = $index >= $bytesPerPixel ? ord($previous[$index - $bytesPerPixel]) : 0;

                $value = match ($tag) {
                    0 => $rawByte,
                    1 => $rawByte + $left,
                    2 => $rawByte + $up,
                    3 => $rawByte + intdiv($left + $up, 2),
                    4 => $rawByte + $this->paeth($left, $up, $upLeft),
                    default => $rawByte,
                };

                $current .= chr($value & 0xFF);
            }

            $output .= $current;
            $previous = $current;
        }

        return $output;
    }

    private function paeth(int $left, int $up, int $upLeft): int
    {
        $estimate = $left + $up - $upLeft;
        $distLeft = abs($estimate - $left);
        $distUp = abs($estimate - $up);
        $distUpLeft = abs($estimate - $upLeft);

        if ($distLeft <= $distUp && $distLeft <= $distUpLeft) {
            return $left;
        }

        return $distUp <= $distUpLeft ? $up : $upLeft;
    }

    private function dictInt(string $dict, string $key): ?int
    {
        return preg_match('/\/'.$key.'\s+(\d+)/', $dict, $match) === 1
            ? (int) $match[1]
            : null;
    }

    /** Resolves `12 0 R` style references to the referenced object body. */
    public function resolve(string $value): string
    {
        return preg_match('/^\s*(\d+)\s+\d+\s+R\s*$/', $value, $match) === 1
            ? $this->rawBody((int) $match[1])
            : $value;
    }

    /** @return list<int> */
    public function referenceList(string $value): array
    {
        preg_match_all('/(\d+)\s+\d+\s+R/', $value, $matches);

        return array_map(intval(...), $matches[1]);
    }

    /** Decodes a PDF string literal or UTF-16BE hex string. */
    public static function decodeString(string $value): string
    {
        $value = trim($value);

        if (preg_match('/^<([0-9a-fA-F\s]*)>$/', $value, $match) === 1) {
            $hex = preg_replace('/\s+/', '', $match[1]) ?? '';

            if (strlen($hex) % 2 === 1) {
                $hex .= '0';
            }

            $binary = (string) hex2bin($hex);

            if (str_starts_with($binary, "\xfe\xff")) {
                return (string) mb_convert_encoding(substr($binary, 2), 'UTF-8', 'UTF-16BE');
            }

            return mb_check_encoding($binary, 'UTF-8')
                ? $binary
                : (string) mb_convert_encoding($binary, 'UTF-8', 'Windows-1252');
        }

        if (str_starts_with($value, '(') && str_ends_with($value, ')')) {
            $inner = substr($value, 1, -1);
            $inner = str_replace(['\\(', '\\)', '\\\\'], ['(', ')', '\\'], $inner);

            return mb_check_encoding($inner, 'UTF-8')
                ? $inner
                : (string) mb_convert_encoding($inner, 'UTF-8', 'Windows-1252');
        }

        return $value;
    }
}
