<?php
declare(strict_types=1);

namespace SafeTun\Backend\Config;

use SafeTun\Backend\Exception\ConfigException;

/**
 * Parses the deliberately tiny config.php grammar without executing the file.
 * Accepted program: open-tag, declare(strict_types=1), return one [] literal.
 * Values are nested literals or __DIR__ concatenated with safe string suffixes.
 */
final class LiteralConfigParser
{
    /** @var list<array{id:int|string,text:string,line:int}> */
    private array $tokens = [];
    private int $position = 0;
    private string $baseDir = '';

    public function parseFile(string $path): ParsedConfig
    {
        if (!is_file($path) || !is_readable($path)) {
            throw new ConfigException(["Configuration file is not readable: {$path}"]);
        }

        $source = file_get_contents($path);
        if (!is_string($source)) {
            throw new ConfigException(["Unable to read configuration file: {$path}"]);
        }

        return $this->parseSource($source, $path);
    }

    public function parseSource(string $source, string $virtualPath): ParsedConfig
    {
        if (strlen($source) > 1024 * 1024) {
            throw new ConfigException(['Configuration exceeds the 1 MiB safety limit.']);
        }

        $this->baseDir = dirname($virtualPath);
        $this->position = 0;
        $this->tokens = [];

        foreach (token_get_all($source) as $token) {
            if (is_array($token)) {
                [$id, $text, $line] = $token;
                if (in_array($id, [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) {
                    continue;
                }
                $this->tokens[] = ['id' => $id, 'text' => $text, 'line' => $line];
                continue;
            }

            $line = $this->tokens === [] ? 1 : $this->tokens[array_key_last($this->tokens)]['line'];
            $this->tokens[] = ['id' => $token, 'text' => $token, 'line' => $line];
        }

        $this->expect(T_OPEN_TAG, 'PHP open tag');
        $this->expect(T_DECLARE, 'declare');
        $this->expect('(', '(');
        $strict = $this->expect(T_STRING, 'strict_types');
        if (strtolower($strict['text']) !== 'strict_types') {
            $this->fail('Only declare(strict_types=1) is allowed.', $strict);
        }
        $this->expect('=', '=');
        $one = $this->expect(T_LNUMBER, '1');
        if ($one['text'] !== '1') {
            $this->fail('strict_types must equal integer 1.', $one);
        }
        $this->expect(')', ')');
        $this->expect(';', ';');
        $this->expect(T_RETURN, 'return');

        $value = $this->parseValue();
        if (!is_array($value)) {
            $this->fail('The returned value must be an array literal.');
        }
        $this->expect(';', ';');

        if ($this->peekId() === T_CLOSE_TAG) {
            $this->position++;
        }
        if ($this->position !== count($this->tokens)) {
            $this->fail('No statements or inline content are allowed after the returned array.');
        }

        /** @var array<string,mixed> $value */
        return new ParsedConfig($value, hash('sha256', $source));
    }

    private function parseValue(): mixed
    {
        $token = $this->peek();
        if ($token === null) {
            $this->fail('Unexpected end of configuration.');
        }

        if ($token['id'] === '[') {
            return $this->parseArray();
        }

        if ($token['id'] === T_CONSTANT_ENCAPSED_STRING) {
            $this->position++;
            return $this->decodeStringLiteral($token);
        }

        if ($token['id'] === T_LNUMBER) {
            $this->position++;
            if (!preg_match('/^(0|[1-9][0-9]*)$/D', $token['text'])) {
                $this->fail('Only unsigned decimal integer literals are allowed.', $token);
            }
            $value = filter_var($token['text'], FILTER_VALIDATE_INT);
            if (!is_int($value)) {
                $this->fail('Integer literal is outside the supported range.', $token);
            }
            return $value;
        }

        if ($token['id'] === T_DNUMBER) {
            $this->position++;
            if (!preg_match('/^(?:0|[1-9][0-9]*)\.[0-9]+$/D', $token['text'])) {
                $this->fail('Only ordinary non-negative decimal literals are allowed.', $token);
            }
            return (float) $token['text'];
        }

        if ($token['id'] === T_STRING) {
            $this->position++;
            return match (strtolower($token['text'])) {
                'true' => true,
                'false' => false,
                'null' => null,
                default => $this->fail('Constants and function calls are not allowed.', $token),
            };
        }

        if ($token['id'] === T_DIR) {
            return $this->parseSafeDirPath();
        }

        $this->fail('Only scalar, array, and safe __DIR__ path literals are allowed.', $token);
    }

    /** @return array<mixed> */
    private function parseArray(): array
    {
        $this->expect('[', '[');
        $result = [];
        $mode = null;
        $seenKeys = [];

        if ($this->peekId() === ']') {
            $this->position++;
            return $result;
        }

        while (true) {
            $first = $this->parseValue();
            if ($this->peekId() === T_DOUBLE_ARROW) {
                if ($mode === 'list') {
                    $this->fail('Mixing keyed and unkeyed array entries is not allowed.');
                }
                $mode = 'map';
                $this->position++;
                if (!is_string($first) && !is_int($first)) {
                    $this->fail('Array keys must be string or integer literals.');
                }
                if (is_string($first) && preg_match('/^-?(?:0|[1-9][0-9]*)$/D', $first)) {
                    $this->fail('Numeric-string array keys are ambiguous and are not allowed.');
                }
                $fingerprint = get_debug_type($first) . ':' . (string) $first;
                if (isset($seenKeys[$fingerprint])) {
                    $this->fail('Duplicate array key is not allowed.');
                }
                $seenKeys[$fingerprint] = true;
                $result[$first] = $this->parseValue();
            } else {
                if ($mode === 'map') {
                    $this->fail('Mixing keyed and unkeyed array entries is not allowed.');
                }
                $mode = 'list';
                $result[] = $first;
            }

            if ($this->peekId() === ',') {
                $this->position++;
                if ($this->peekId() === ']') {
                    $this->position++;
                    return $result;
                }
                continue;
            }

            $this->expect(']', ']');
            return $result;
        }
    }

    private function parseSafeDirPath(): string
    {
        $this->expect(T_DIR, '__DIR__');
        $parts = [];
        do {
            $this->expect('.', '.');
            $literal = $this->expect(T_CONSTANT_ENCAPSED_STRING, 'path string literal');
            $part = $this->decodeStringLiteral($literal);
            if ($part === '' || str_contains($part, "\0")) {
                $this->fail('Empty or NUL-containing __DIR__ suffix is not allowed.', $literal);
            }
            $parts[] = $part;
        } while ($this->peekId() === '.');

        $suffix = implode('', $parts);
        if (!str_starts_with($suffix, '/') && !str_starts_with($suffix, '\\')) {
            $this->fail('__DIR__ paths must concatenate an absolute-looking suffix.');
        }
        $segments = preg_split('~[\\\\/]+~', $suffix, -1, PREG_SPLIT_NO_EMPTY);
        if ($segments === false || in_array('..', $segments, true) || in_array('.', $segments, true)) {
            $this->fail('Path traversal segments are not allowed in __DIR__ paths.');
        }

        return rtrim($this->baseDir, '/\\') . DIRECTORY_SEPARATOR
            . implode(DIRECTORY_SEPARATOR, $segments);
    }

    /** @param array{id:int|string,text:string,line:int} $token */
    private function decodeStringLiteral(array $token): string
    {
        $raw = $token['text'];
        if (strlen($raw) < 2) {
            $this->fail('Invalid string literal.', $token);
        }
        $quote = $raw[0];
        if (($quote !== "'" && $quote !== '"') || $raw[strlen($raw) - 1] !== $quote) {
            $this->fail('Only ordinary quoted strings are allowed.', $token);
        }

        $body = substr($raw, 1, -1);
        if ($quote === "'") {
            $result = '';
            for ($i = 0, $length = strlen($body); $i < $length; $i++) {
                if ($body[$i] === '\\' && $i + 1 < $length && ($body[$i + 1] === '\\' || $body[$i + 1] === "'")) {
                    $result .= $body[++$i];
                } else {
                    $result .= $body[$i];
                }
            }
            return $result;
        }

        $result = '';
        for ($i = 0, $length = strlen($body); $i < $length; $i++) {
            $char = $body[$i];
            if ($char === '$') {
                $this->fail('String interpolation is not allowed.', $token);
            }
            if ($char !== '\\') {
                $result .= $char;
                continue;
            }
            if (++$i >= $length) {
                $this->fail('Invalid trailing escape in string literal.', $token);
            }
            $escape = $body[$i];
            $result .= match ($escape) {
                'n' => "\n", 'r' => "\r", 't' => "\t", 'v' => "\v", 'e' => "\x1b", 'f' => "\f",
                '\\' => '\\', '"' => '"', '$' => '$',
                default => $this->fail('Unsupported escape in double-quoted string.', $token),
            };
        }
        return $result;
    }

    /** @return array{id:int|string,text:string,line:int}|null */
    private function peek(): ?array
    {
        return $this->tokens[$this->position] ?? null;
    }

    private function peekId(): int|string|null
    {
        return $this->tokens[$this->position]['id'] ?? null;
    }

    /** @return array{id:int|string,text:string,line:int} */
    private function expect(int|string $id, string $description): array
    {
        $token = $this->peek();
        if ($token === null || $token['id'] !== $id) {
            $this->fail("Expected {$description}.", $token);
        }
        $this->position++;
        return $token;
    }

    /** @param array{id:int|string,text:string,line:int}|null $token */
    private function fail(string $message, ?array $token = null): never
    {
        $token ??= $this->peek();
        $line = $token['line'] ?? 1;
        throw new ConfigException(["Literal config parse error at line {$line}: {$message}"]);
    }
}
