<?php

namespace App\Services\PlanDigitizer;

/**
 * Tokenises a page content stream and flattens every painted path into device
 * space segments, keeping the AutoCAD layer (optional content group), the colour
 * and the stroke width that produced each one.
 */
class ContentStreamParser
{
    private const WHITESPACE = " \t\r\n\0\x0c";

    private const DELIMITERS = " \t\r\n\0\x0c/[]<>(){}%";

    /** Maximum segments emitted before the parser gives up, to bound memory. */
    private const SEGMENT_LIMIT = 1_500_000;

    private VectorContent $content;

    /** @var array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} */
    private array $matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];

    /** @var array{0: float, 1: float, 2: float} */
    private array $strokeColor = [0.0, 0.0, 0.0];

    /** @var array{0: float, 1: float, 2: float} */
    private array $fillColor = [0.0, 0.0, 0.0];

    private float $lineWidth = 1.0;

    /** @var list<array<string, mixed>> */
    private array $stack = [];

    /** @var list<string> */
    private array $layerStack = [];

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

    /** @var list<list<float>> current path, one flat point list per subpath */
    private array $subpaths = [];

    /** @var list<float> */
    private array $current = [];

    private float $startX = 0.0;

    private float $startY = 0.0;

    private float $cursorX = 0.0;

    private float $cursorY = 0.0;

    /** @var array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} */
    private array $textMatrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];

    private float $fontSize = 1.0;

    /** @param array<string, string> $layerNames content tag => AutoCAD layer name */
    public function parse(string $stream, array $layerNames, float $baseScale = 1.0): VectorContent
    {
        $this->content = new VectorContent;
        $this->matrix = [$baseScale, 0.0, 0.0, $baseScale, 0.0, 0.0];
        $this->stack = [];
        $this->layerStack = [];
        $this->colorIndex = [];
        $this->resetPath();

        $operands = [];
        $length = strlen($stream);
        $cursor = 0;

        while ($cursor < $length) {
            $cursor += strspn($stream, self::WHITESPACE, $cursor);

            if ($cursor >= $length) {
                break;
            }

            $character = $stream[$cursor];

            if ($character === '%') {
                $cursor += strcspn($stream, "\r\n", $cursor);

                continue;
            }

            if ($character === '(') {
                $token = $this->readLiteralString($stream, $cursor, $length);
                $operands[] = $token;

                continue;
            }

            if ($character === '<') {
                if (($stream[$cursor + 1] ?? '') === '<') {
                    $operands[] = $this->readDictionary($stream, $cursor, $length);

                    continue;
                }

                $end = strpos($stream, '>', $cursor);
                $end = $end === false ? $length : $end + 1;
                $operands[] = substr($stream, $cursor, $end - $cursor);
                $cursor = $end;

                continue;
            }

            if ($character === '[' || $character === ']' || $character === '{' || $character === '}') {
                $operands[] = $character;
                $cursor++;

                continue;
            }

            if ($character === '/') {
                $cursor++;
                $span = strcspn($stream, self::DELIMITERS, $cursor);
                $operands[] = '/'.substr($stream, $cursor, $span);
                $cursor += $span;

                continue;
            }

            $span = strcspn($stream, self::DELIMITERS, $cursor);

            if ($span === 0) {
                $cursor++;

                continue;
            }

            $token = substr($stream, $cursor, $span);
            $cursor += $span;

            if ($token === '' || is_numeric($token) || $token[0] === '+' || $token[0] === '-' || $token[0] === '.') {
                $operands[] = $token;

                continue;
            }

            $this->execute($token, $operands, $layerNames);
            $operands = [];

            if ($this->content->segmentCount() > self::SEGMENT_LIMIT) {
                break;
            }
        }

        return $this->content;
    }

    private function readLiteralString(string $stream, int &$cursor, int $length): string
    {
        $start = $cursor;
        $depth = 0;

        while ($cursor < $length) {
            $character = $stream[$cursor];

            if ($character === '\\') {
                $cursor += 2;

                continue;
            }

            if ($character === '(') {
                $depth++;
            } elseif ($character === ')') {
                $depth--;

                if ($depth === 0) {
                    $cursor++;

                    return substr($stream, $start, $cursor - $start);
                }
            }

            $cursor++;
        }

        return substr($stream, $start);
    }

    private function readDictionary(string $stream, int &$cursor, int $length): string
    {
        $start = $cursor;
        $depth = 0;

        while ($cursor < $length - 1) {
            if ($stream[$cursor] === '<' && $stream[$cursor + 1] === '<') {
                $depth++;
                $cursor += 2;

                continue;
            }

            if ($stream[$cursor] === '>' && $stream[$cursor + 1] === '>') {
                $depth--;
                $cursor += 2;

                if ($depth === 0) {
                    return substr($stream, $start, $cursor - $start);
                }

                continue;
            }

            $cursor++;
        }

        $cursor = $length;

        return substr($stream, $start);
    }

    /**
     * @param  list<string>  $operands
     * @param  array<string, string>  $layerNames
     */
    private function execute(string $operator, array $operands, array $layerNames): void
    {
        switch ($operator) {
            case 'q':
                $this->stack[] = [
                    'matrix' => $this->matrix,
                    'stroke' => $this->strokeColor,
                    'fill' => $this->fillColor,
                    'width' => $this->lineWidth,
                    'layers' => $this->layerStack,
                ];
                break;

            case 'Q':
                $state = array_pop($this->stack);

                if ($state !== null) {
                    $this->matrix = $state['matrix'];
                    $this->strokeColor = $state['stroke'];
                    $this->fillColor = $state['fill'];
                    $this->lineWidth = $state['width'];
                    $this->layerStack = $state['layers'];
                }
                break;

            case 'cm':
                if (count($operands) >= 6) {
                    $this->matrix = $this->multiply($this->matrixOperands($operands), $this->matrix);
                }
                break;

            case 'w':
                $this->lineWidth = (float) end($operands);
                break;

            case 'm':
                $this->flushSubpath();
                [$this->cursorX, $this->cursorY] = $this->point($operands);
                $this->startX = $this->cursorX;
                $this->startY = $this->cursorY;
                $this->current = [$this->cursorX, $this->cursorY];
                break;

            case 'l':
                [$x, $y] = $this->point($operands);
                $this->current[] = $x;
                $this->current[] = $y;
                $this->cursorX = $x;
                $this->cursorY = $y;
                break;

            case 'c':
            case 'v':
            case 'y':
                $this->appendCurve($operator, $operands);
                break;

            case 'h':
                if ($this->current !== []) {
                    $this->current[] = $this->startX;
                    $this->current[] = $this->startY;
                    $this->cursorX = $this->startX;
                    $this->cursorY = $this->startY;
                }
                break;

            case 're':
                $this->appendRectangle($operands);
                break;

            case 'S':
            case 's':
                $this->paint(stroke: true, fill: false, close: $operator === 's');
                break;

            case 'f':
            case 'F':
            case 'f*':
                $this->paint(stroke: false, fill: true, close: true);
                break;

            case 'B':
            case 'B*':
                $this->paint(stroke: true, fill: true, close: false);
                break;

            case 'b':
            case 'b*':
                $this->paint(stroke: true, fill: true, close: true);
                break;

            case 'n':
                $this->resetPath();
                break;

            case 'g':
                $gray = (float) end($operands);
                $this->fillColor = [$gray, $gray, $gray];
                break;

            case 'G':
                $gray = (float) end($operands);
                $this->strokeColor = [$gray, $gray, $gray];
                break;

            case 'rg':
            case 'sc':
            case 'scn':
                $this->fillColor = $this->colorOperand($operands, $this->fillColor);
                break;

            case 'RG':
            case 'SC':
            case 'SCN':
                $this->strokeColor = $this->colorOperand($operands, $this->strokeColor);
                break;

            case 'k':
                $this->fillColor = $this->fromCmyk($operands);
                break;

            case 'K':
                $this->strokeColor = $this->fromCmyk($operands);
                break;

            case 'BDC':
            case 'BMC':
                $this->layerStack[] = $this->layerFor($operands, $layerNames);
                break;

            case 'EMC':
                array_pop($this->layerStack);
                break;

            case 'BT':
                $this->textMatrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
                break;

            case 'Tf':
                $this->fontSize = (float) end($operands);
                break;

            case 'Tm':
                if (count($operands) >= 6) {
                    $this->textMatrix = $this->matrixOperands($operands);
                }
                break;

            case 'Td':
            case 'TD':
                if (count($operands) >= 2) {
                    $this->textMatrix[4] += (float) $operands[count($operands) - 2];
                    $this->textMatrix[5] += (float) $operands[count($operands) - 1];
                }
                break;

            case 'Tj':
            case 'TJ':
            case "'":
            case '"':
                $this->appendText($operands);
                break;
        }
    }

    /**
     * @param  list<string>  $operands
     * @param  array{0: float, 1: float, 2: float}  $fallback
     * @return array{0: float, 1: float, 2: float}
     */
    private function colorOperand(array $operands, array $fallback): array
    {
        $numbers = array_values(array_filter($operands, is_numeric(...)));
        $count = count($numbers);

        if ($count >= 4) {
            return $this->fromCmyk($numbers);
        }

        if ($count === 3) {
            return [(float) $numbers[0], (float) $numbers[1], (float) $numbers[2]];
        }

        if ($count === 1) {
            $gray = (float) $numbers[0];

            return [$gray, $gray, $gray];
        }

        return $fallback;
    }

    /**
     * @param  list<string>  $operands
     * @return array{0: float, 1: float, 2: float}
     */
    private function fromCmyk(array $operands): array
    {
        $numbers = array_values(array_filter($operands, is_numeric(...)));

        if (count($numbers) < 4) {
            return [0.0, 0.0, 0.0];
        }

        [$cyan, $magenta, $yellow, $black] = array_map(floatval(...), array_slice($numbers, -4));

        return [
            max(0.0, 1.0 - min(1.0, $cyan + $black)),
            max(0.0, 1.0 - min(1.0, $magenta + $black)),
            max(0.0, 1.0 - min(1.0, $yellow + $black)),
        ];
    }

    /**
     * @param  list<string>  $operands
     * @param  array<string, string>  $layerNames
     */
    private function layerFor(array $operands, array $layerNames): string
    {
        foreach ($operands as $operand) {
            if (str_starts_with($operand, '/') && $operand !== '/OC') {
                $tag = substr($operand, 1);

                if (isset($layerNames[$tag])) {
                    return $layerNames[$tag];
                }
            }
        }

        return $this->currentLayer();
    }

    private function currentLayer(): string
    {
        return $this->layerStack === [] ? '' : (string) end($this->layerStack);
    }

    /**
     * @param  list<string>  $operands
     * @return array{0: float, 1: float}
     */
    private function point(array $operands): array
    {
        $count = count($operands);
        $x = (float) ($operands[$count - 2] ?? 0);
        $y = (float) ($operands[$count - 1] ?? 0);

        return $this->transform($x, $y);
    }

    /** @return array{0: float, 1: float} */
    private function transform(float $x, float $y): array
    {
        return [
            $this->matrix[0] * $x + $this->matrix[2] * $y + $this->matrix[4],
            $this->matrix[1] * $x + $this->matrix[3] * $y + $this->matrix[5],
        ];
    }

    /**
     * @param  list<string>  $operands
     * @return array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float}
     */
    private function matrixOperands(array $operands): array
    {
        $values = array_slice($operands, -6);

        return [
            (float) $values[0],
            (float) $values[1],
            (float) $values[2],
            (float) $values[3],
            (float) $values[4],
            (float) $values[5],
        ];
    }

    /**
     * @param  array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float}  $a
     * @param  array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float}  $b
     * @return array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float}
     */
    private function multiply(array $a, array $b): array
    {
        return [
            $a[0] * $b[0] + $a[1] * $b[2],
            $a[0] * $b[1] + $a[1] * $b[3],
            $a[2] * $b[0] + $a[3] * $b[2],
            $a[2] * $b[1] + $a[3] * $b[3],
            $a[4] * $b[0] + $a[5] * $b[2] + $b[4],
            $a[4] * $b[1] + $a[5] * $b[3] + $b[5],
        ];
    }

    /** @param list<string> $operands */
    private function appendCurve(string $operator, array $operands): void
    {
        if ($this->current === []) {
            return;
        }

        $numbers = array_map(floatval(...), array_values(array_filter($operands, is_numeric(...))));
        $fromX = $this->cursorX;
        $fromY = $this->cursorY;

        if ($operator === 'c' && count($numbers) >= 6) {
            [$c1x, $c1y] = $this->transform($numbers[0], $numbers[1]);
            [$c2x, $c2y] = $this->transform($numbers[2], $numbers[3]);
            [$toX, $toY] = $this->transform($numbers[4], $numbers[5]);
        } elseif (count($numbers) >= 4) {
            [$firstX, $firstY] = $this->transform($numbers[0], $numbers[1]);
            [$toX, $toY] = $this->transform($numbers[2], $numbers[3]);
            $c1x = $operator === 'v' ? $fromX : $firstX;
            $c1y = $operator === 'v' ? $fromY : $firstY;
            $c2x = $operator === 'v' ? $firstX : $toX;
            $c2y = $operator === 'v' ? $firstY : $toY;
        } else {
            return;
        }

        $steps = $this->curveSteps($fromX, $fromY, $toX, $toY, $c1x, $c1y, $c2x, $c2y);

        for ($step = 1; $step <= $steps; $step++) {
            $t = $step / $steps;
            $inverse = 1 - $t;
            $this->current[] = $inverse ** 3 * $fromX + 3 * $inverse ** 2 * $t * $c1x
                + 3 * $inverse * $t ** 2 * $c2x + $t ** 3 * $toX;
            $this->current[] = $inverse ** 3 * $fromY + 3 * $inverse ** 2 * $t * $c1y
                + 3 * $inverse * $t ** 2 * $c2y + $t ** 3 * $toY;
        }

        $this->cursorX = $toX;
        $this->cursorY = $toY;
    }

    private function curveSteps(
        float $fromX,
        float $fromY,
        float $toX,
        float $toY,
        float $c1x,
        float $c1y,
        float $c2x,
        float $c2y,
    ): int {
        $span = abs($c1x - $fromX) + abs($c1y - $fromY)
            + abs($c2x - $c1x) + abs($c2y - $c1y)
            + abs($toX - $c2x) + abs($toY - $c2y);

        return max(4, min(24, (int) ceil($span / 4)));
    }

    /** @param list<string> $operands */
    private function appendRectangle(array $operands): void
    {
        $numbers = array_values(array_filter($operands, is_numeric(...)));

        if (count($numbers) < 4) {
            return;
        }

        [$x, $y, $width, $height] = array_map(floatval(...), array_slice($numbers, -4));
        $this->flushSubpath();
        $corners = [[$x, $y], [$x + $width, $y], [$x + $width, $y + $height], [$x, $y + $height], [$x, $y]];
        $this->current = [];

        foreach ($corners as $corner) {
            [$deviceX, $deviceY] = $this->transform($corner[0], $corner[1]);
            $this->current[] = $deviceX;
            $this->current[] = $deviceY;
        }

        $this->startX = $this->current[0];
        $this->startY = $this->current[1];
        $this->cursorX = $this->startX;
        $this->cursorY = $this->startY;
        $this->flushSubpath();
    }

    /** @param list<string> $operands */
    private function appendText(array $operands): void
    {
        $pieces = [];

        foreach ($operands as $operand) {
            if (str_starts_with($operand, '(') || str_starts_with($operand, '<')) {
                $pieces[] = PdfDocument::decodeString($operand);
            }
        }

        $text = trim(implode('', $pieces));

        if ($text === '') {
            return;
        }

        [$x, $y] = $this->transform($this->textMatrix[4], $this->textMatrix[5]);
        $scale = sqrt(abs($this->matrix[0] * $this->matrix[3] - $this->matrix[1] * $this->matrix[2])) ?: 1.0;

        $this->content->texts[] = [
            'text' => $text,
            'x' => $x,
            'y' => $y,
            'size' => abs($this->fontSize * $this->textMatrix[3]) * $scale,
        ];
    }

    private function flushSubpath(): void
    {
        if (count($this->current) >= 4) {
            $this->subpaths[] = $this->current;
        }

        $this->current = [];
    }

    private function resetPath(): void
    {
        $this->subpaths = [];
        $this->current = [];
    }

    private function paint(bool $stroke, bool $fill, bool $close): void
    {
        $this->flushSubpath();

        if ($this->subpaths === []) {
            $this->resetPath();

            return;
        }

        $layer = $this->content->layerIndex($this->currentLayer());
        $strokeColor = $this->colorSlot($this->strokeColor);
        $fillColor = $this->colorSlot($this->fillColor);
        $width = $this->scaledLineWidth();

        foreach ($this->subpaths as $points) {
            $count = count($points);

            if ($close && ($points[0] !== $points[$count - 2] || $points[1] !== $points[$count - 1])) {
                $points[] = $points[0];
                $points[] = $points[1];
                $count += 2;
            }

            if ($fill && $count >= 6) {
                $this->content->fills[] = [
                    'points' => $points,
                    'layer' => $layer,
                    'color' => $fillColor,
                    'closed' => true,
                ];
            }

            if (! $stroke) {
                continue;
            }

            for ($index = 0; $index + 3 < $count; $index += 2) {
                $this->content->coordinates[] = $points[$index];
                $this->content->coordinates[] = $points[$index + 1];
                $this->content->coordinates[] = $points[$index + 2];
                $this->content->coordinates[] = $points[$index + 3];
                $this->content->segmentLayers[] = $layer;
                $this->content->segmentColors[] = $strokeColor;
                $this->content->segmentWidths[] = $width;
            }
        }

        $this->resetPath();
    }

    private function scaledLineWidth(): float
    {
        $scale = sqrt(abs($this->matrix[0] * $this->matrix[3] - $this->matrix[1] * $this->matrix[2]));

        return max(0.1, $this->lineWidth * ($scale ?: 1.0));
    }

    /** @param array{0: float, 1: float, 2: float} $rgb */
    private function colorSlot(array $rgb): int
    {
        $key = VectorContent::colorKey($rgb);

        if (! isset($this->colorIndex[$key])) {
            $this->content->colors[] = $rgb;
            $this->colorIndex[$key] = count($this->content->colors) - 1;
        }

        return $this->colorIndex[$key];
    }
}
