<?php

/**
 * LolaCore - YamlParser
 *
 * PHP-pure parser of the documented YAML subset used by Skill
 * frontmatter blocks. Loud-failure policy: any construct outside
 * the subset throws SkillParseError with line+token context.
 *
 * Supported subset (see DESIGN_MASTER §5.4):
 *  - Plain scalars (string, int, semver-like, true/false, null)
 *  - Quoted strings (single or double) without escape sequences
 *  - Inline arrays of scalars: keywords: [a, b, c]
 *  - Block arrays of scalars (- item)
 *  - Flat key/value maps at top level
 *  - Nested maps depth <= 2 (inline {} or block form)
 *  - Comments (# ...)
 *
 * @package LolaCore
 * @license GPL-2.0-or-later
 */

declare(strict_types=1);

namespace LolaCore\Skills;

use LolaCore\Skills\Exceptions\SkillParseError;

// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Parser internals: exceptions carry line/token diagnostic data; all messages are escaped at the presentation layer (Settings, SkillsHandler).

class YamlParser
{
    /**
     * Parse a frontmatter block.
     *
     * @param string $content  Verbatim block between the two `---` markers.
     * @param string $filePath Source path for error reporting (optional).
     * @return array<string,mixed> Parsed frontmatter or throws.
     * @throws SkillParseError on any unsupported construct.
     */
    public function parse(string $content, string $filePath = ''): array
    {
        if (!mb_check_encoding($content, 'UTF-8')) {
            throw new SkillParseError(
                'Invalid UTF-8 encoding in frontmatter',
                1,
                '<non-utf8>',
                $filePath
            );
        }

        $lines = preg_split("/\r\n|\r|\n/", $content);
        if ($lines === false) {
            $lines = [];
        }

        $result = [];
        $i      = 0;
        $total  = count($lines);

        while ($i < $total) {
            $i = $this->skipBlankAndComment($lines, $i);
            if ($i >= $total) {
                break;
            }

            $raw     = $lines[$i];
            $lineNum = $i + 1;

            if (preg_match('/^[ \t]/', $raw)) {
                throw new SkillParseError(
                    'Unexpected indentation at top level',
                    $lineNum,
                    ltrim($raw),
                    $filePath
                );
            }

            $line = $this->stripComments($raw);

            $this->detectUnsupportedConstruct($line, $lineNum, $filePath);

            if (!preg_match('/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/u', $line, $m)) {
                throw new SkillParseError(
                    "Expected 'key:' or 'key: value' at top level",
                    $lineNum,
                    $line,
                    $filePath
                );
            }

            $key      = $m[1];
            $valueStr = trim($m[2]);

            if ($valueStr === '') {
                [$value, $newIdx] = $this->parseBlockContinuation($lines, $i + 1, '', $filePath, 1);
                $result[$key]     = $value;
                $i                = $newIdx;
            } else {
                $result[$key] = $this->parseInlineValue($valueStr, $lineNum, $filePath, true);
                $i++;
            }
        }

        return $result;
    }

    /**
     * Strip a `# ...` trailing comment from a line, respecting quoted strings.
     */
    private function stripComments(string $line): string
    {
        $len      = strlen($line);
        $inQuote  = false;
        $quoteChr = '';

        for ($i = 0; $i < $len; $i++) {
            $ch = $line[$i];
            if ($inQuote) {
                if ($ch === $quoteChr) {
                    $inQuote = false;
                }
                continue;
            }
            if ($ch === '"' || $ch === "'") {
                $inQuote  = true;
                $quoteChr = $ch;
                continue;
            }
            if ($ch === '#') {
                return rtrim(substr($line, 0, $i));
            }
        }

        return rtrim($line);
    }

    /**
     * Advance the index past blank and comment-only lines.
     */
    private function skipBlankAndComment(array $lines, int $startIdx): int
    {
        $total = count($lines);
        $i     = $startIdx;
        while ($i < $total) {
            $stripped = $this->stripComments($lines[$i]);
            if (trim($stripped) === '') {
                $i++;
                continue;
            }
            break;
        }
        return $i;
    }

    /**
     * Detect constructs outside the supported subset and throw.
     *
     * Operates on the stripped line (post-comment removal).
     */
    private function detectUnsupportedConstruct(string $line, int $lineNum, string $filePath): void
    {
        if (preg_match('/:\s*[|>][+-]?\s*$/', $line)) {
            throw new SkillParseError(
                'Multiline scalars (| or >) are not supported',
                $lineNum,
                '|',
                $filePath
            );
        }

        if (preg_match('/:\s*&[A-Za-z_]/', $line)) {
            throw new SkillParseError(
                'Anchors (&) are not supported',
                $lineNum,
                '&',
                $filePath
            );
        }

        if (preg_match('/:\s*\*[A-Za-z_]/', $line)) {
            throw new SkillParseError(
                'Aliases (*) are not supported',
                $lineNum,
                '*',
                $filePath
            );
        }

        if (preg_match('/:\s*!!?[A-Za-z]/', $line)) {
            throw new SkillParseError(
                'Tags (! or !!) are not supported',
                $lineNum,
                '!',
                $filePath
            );
        }
    }

    /**
     * Parse a block continuation following a "key:" line with empty inline value.
     *
     * Determines whether the indented block is a block array of scalars
     * or a block map. Block maps at depth > 2 are rejected.
     *
     * @param array<int,string> $lines
     * @param int               $startIdx     Index of the first candidate line.
     * @param string            $parentIndent The indent of the parent key (empty for top-level).
     * @param string            $filePath
     * @param int               $depth        1 for top-level value (creates depth-2 map if map).
     * @return array{0: mixed, 1: int}
     */
    private function parseBlockContinuation(array $lines, int $startIdx, string $parentIndent, string $filePath, int $depth): array
    {
        $total = count($lines);
        $next  = $this->skipBlankAndComment($lines, $startIdx);

        if ($next >= $total) {
            return [null, $next];
        }

        $nextLine = $lines[$next];

        $indentLen = strspn($nextLine, " \t");
        if ($indentLen <= strlen($parentIndent)) {
            return [null, $next];
        }

        $indent      = substr($nextLine, 0, $indentLen);
        $afterIndent = substr($nextLine, $indentLen);

        if (str_starts_with($afterIndent, '- ') || $afterIndent === '-') {
            return $this->parseBlockArray($lines, $next, $indent, $filePath);
        }

        if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*\s*:/', $afterIndent)) {
            if ($depth >= 2) {
                throw new SkillParseError(
                    'Nested map depth > 2',
                    $next + 1,
                    $afterIndent,
                    $filePath
                );
            }
            return $this->parseBlockMap($lines, $next, $indent, $filePath, $depth + 1);
        }

        throw new SkillParseError(
            'Unrecognised token in block continuation',
            $next + 1,
            $afterIndent,
            $filePath
        );
    }

    /**
     * Parse consecutive lines of "- item" at the given indent.
     *
     * @param array<int,string> $lines
     * @return array{0: array<int,mixed>, 1: int}
     */
    private function parseBlockArray(array $lines, int $startIdx, string $indent, string $filePath): array
    {
        $items = [];
        $i     = $startIdx;
        $total = count($lines);

        while ($i < $total) {
            $i = $this->skipBlankAndComment($lines, $i);
            if ($i >= $total) {
                break;
            }

            $raw = $lines[$i];
            if (!str_starts_with($raw, $indent . '- ') && !($raw === $indent . '-')) {
                break;
            }

            $line     = $this->stripComments($raw);
            $valueStr = trim(substr($line, strlen($indent) + 1));

            $this->detectUnsupportedConstruct($line, $i + 1, $filePath);

            $items[] = $this->parseInlineValue($valueStr, $i + 1, $filePath, false);
            $i++;
        }

        return [$items, $i];
    }

    /**
     * Parse a block map at the given indent and depth.
     *
     * @param array<int,string> $lines
     * @return array{0: array<string,mixed>, 1: int}
     */
    private function parseBlockMap(array $lines, int $startIdx, string $indent, string $filePath, int $depth): array
    {
        $map   = [];
        $i     = $startIdx;
        $total = count($lines);

        while ($i < $total) {
            $i = $this->skipBlankAndComment($lines, $i);
            if ($i >= $total) {
                break;
            }

            $raw = $lines[$i];
            if (!str_starts_with($raw, $indent)) {
                break;
            }

            $afterIndentRaw = substr($raw, strlen($indent));
            if (preg_match('/^[ \t]/', $afterIndentRaw)) {
                break;
            }

            $line        = $this->stripComments($raw);
            $afterIndent = substr($line, strlen($indent));

            $this->detectUnsupportedConstruct($afterIndent, $i + 1, $filePath);

            if (!preg_match('/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/u', $afterIndent, $m)) {
                throw new SkillParseError(
                    "Invalid block-map entry",
                    $i + 1,
                    $afterIndent,
                    $filePath
                );
            }

            $key      = $m[1];
            $valueStr = trim($m[2]);

            if ($valueStr === '') {
                [$value, $newIdx] = $this->parseBlockContinuation($lines, $i + 1, $indent, $filePath, $depth);
                $map[$key]        = $value;
                $i                = $newIdx;
            } else {
                $allowInlineMap = ($depth < 2);
                $map[$key]      = $this->parseInlineValue($valueStr, $i + 1, $filePath, $allowInlineMap);
                $i++;
            }
        }

        return [$map, $i];
    }

    /**
     * Parse an inline value (scalar, array, or map).
     *
     * @param bool $allowMap When false, inline maps trigger SkillParseError (depth guard).
     */
    private function parseInlineValue(string $str, int $lineNum, string $filePath, bool $allowMap): mixed
    {
        $trimmed = trim($str);
        if ($trimmed === '') {
            return null;
        }

        $first = $trimmed[0];

        if ($first === '[') {
            return $this->parseInlineArray($trimmed, $lineNum, $filePath);
        }

        if ($first === '{') {
            if (!$allowMap) {
                throw new SkillParseError(
                    'Nested map depth > 2',
                    $lineNum,
                    '{',
                    $filePath
                );
            }
            return $this->parseInlineMap($trimmed, $lineNum, $filePath);
        }

        return $this->parseScalar($trimmed, $lineNum, $filePath);
    }

    /**
     * Parse an inline array of scalars: [a, b, "c"].
     *
     * @return array<int,mixed>
     */
    private function parseInlineArray(string $str, int $lineNum, string $filePath): array
    {
        if (!str_starts_with($str, '[') || !str_ends_with($str, ']')) {
            throw new SkillParseError(
                'Unclosed inline array',
                $lineNum,
                $str,
                $filePath
            );
        }

        $inner = trim(substr($str, 1, -1));
        if ($inner === '') {
            return [];
        }

        $segments = $this->splitInline($inner, $lineNum, $filePath);
        $items    = [];
        foreach ($segments as $segment) {
            $segTrim = trim($segment);
            if ($segTrim === '') {
                throw new SkillParseError(
                    'Trailing comma or empty element in inline array',
                    $lineNum,
                    ',',
                    $filePath
                );
            }
            $items[] = $this->parseScalar($segTrim, $lineNum, $filePath);
        }
        return $items;
    }

    /**
     * Parse an inline map: { key: value, key: value }.
     *
     * Values inside an inline map cannot themselves be maps (depth-3 guard).
     *
     * @return array<string,mixed>
     */
    private function parseInlineMap(string $str, int $lineNum, string $filePath): array
    {
        if (!str_starts_with($str, '{') || !str_ends_with($str, '}')) {
            throw new SkillParseError(
                'Unclosed inline map',
                $lineNum,
                $str,
                $filePath
            );
        }

        $inner = trim(substr($str, 1, -1));
        if ($inner === '') {
            return [];
        }

        $segments = $this->splitInline($inner, $lineNum, $filePath);
        $map      = [];
        foreach ($segments as $segment) {
            $segTrim = trim($segment);
            if ($segTrim === '') {
                throw new SkillParseError(
                    'Trailing comma or empty element in inline map',
                    $lineNum,
                    ',',
                    $filePath
                );
            }
            if (!preg_match('/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/u', $segTrim, $m)) {
                throw new SkillParseError(
                    'Invalid inline-map entry',
                    $lineNum,
                    $segTrim,
                    $filePath
                );
            }
            $key             = $m[1];
            $value           = trim($m[2]);
            $map[$key]       = $this->parseInlineValue($value, $lineNum, $filePath, false);
        }
        return $map;
    }

    /**
     * Split an inline expression on top-level commas, respecting nested
     * brackets and quoted strings.
     *
     * @return array<int,string>
     */
    private function splitInline(string $str, int $lineNum, string $filePath): array
    {
        $segments    = [];
        $current     = '';
        $depthSquare = 0;
        $depthCurly  = 0;
        $inQuote     = false;
        $quoteChr    = '';
        $len         = strlen($str);

        for ($i = 0; $i < $len; $i++) {
            $ch = $str[$i];
            if ($inQuote) {
                $current .= $ch;
                if ($ch === $quoteChr) {
                    $inQuote = false;
                }
                continue;
            }
            if ($ch === '"' || $ch === "'") {
                $inQuote  = true;
                $quoteChr = $ch;
                $current .= $ch;
                continue;
            }
            if ($ch === '[') {
                $depthSquare++;
                $current .= $ch;
                continue;
            }
            if ($ch === ']') {
                $depthSquare--;
                if ($depthSquare < 0) {
                    throw new SkillParseError(
                        'Unbalanced inline brackets',
                        $lineNum,
                        ']',
                        $filePath
                    );
                }
                $current .= $ch;
                continue;
            }
            if ($ch === '{') {
                $depthCurly++;
                $current .= $ch;
                continue;
            }
            if ($ch === '}') {
                $depthCurly--;
                if ($depthCurly < 0) {
                    throw new SkillParseError(
                        'Unbalanced inline braces',
                        $lineNum,
                        '}',
                        $filePath
                    );
                }
                $current .= $ch;
                continue;
            }
            if ($ch === ',' && $depthSquare === 0 && $depthCurly === 0) {
                $segments[] = $current;
                $current    = '';
                continue;
            }
            $current .= $ch;
        }

        if ($inQuote) {
            throw new SkillParseError(
                'Unclosed quoted string',
                $lineNum,
                $quoteChr,
                $filePath
            );
        }
        if ($depthSquare !== 0 || $depthCurly !== 0) {
            throw new SkillParseError(
                'Unbalanced inline brackets or braces',
                $lineNum,
                $str,
                $filePath
            );
        }

        $segments[] = $current;
        return $segments;
    }

    /**
     * Parse a scalar value: null, bool, int, quoted string, or unquoted string.
     */
    private function parseScalar(string $str, int $lineNum, string $filePath): mixed
    {
        if ($str === '' || $str === '~') {
            return null;
        }

        if ($str === 'null' || $str === 'Null' || $str === 'NULL') {
            return null;
        }

        $first = $str[0];
        $last  = $str[strlen($str) - 1];

        if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
            if (strlen($str) < 2) {
                throw new SkillParseError(
                    'Malformed quoted string',
                    $lineNum,
                    $str,
                    $filePath
                );
            }
            $inner = substr($str, 1, -1);
            if (preg_match('/\\\\[ntr"\']/', $inner)) {
                throw new SkillParseError(
                    'Escape sequences in quoted strings are not supported',
                    $lineNum,
                    $str,
                    $filePath
                );
            }
            $closingQuote = $first;
            if (str_contains($inner, $closingQuote)) {
                throw new SkillParseError(
                    'Unescaped quote inside quoted string',
                    $lineNum,
                    $str,
                    $filePath
                );
            }
            return $inner;
        }

        if ($first === '"' || $first === "'" || $last === '"' || $last === "'") {
            throw new SkillParseError(
                'Mismatched or unclosed quoted string',
                $lineNum,
                $str,
                $filePath
            );
        }

        if ($str === 'true' || $str === 'True' || $str === 'TRUE') {
            return true;
        }
        if ($str === 'false' || $str === 'False' || $str === 'FALSE') {
            return false;
        }

        if (preg_match('/^-?\d+$/', $str)) {
            return (int) $str;
        }

        if (str_starts_with($str, '&') || str_starts_with($str, '*') || str_starts_with($str, '!')) {
            throw new SkillParseError(
                'Anchors, aliases and tags are not supported',
                $lineNum,
                $str[0],
                $filePath
            );
        }

        return $str;
    }
}
