<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Guide;

/**
 * The guide front-matter schema, enforced.
 *
 * Every rule here protects something downstream. `topic` must match the file name because
 * `get_guide`'s enum is hardcoded and a topic that only exists inside a file would be unreachable.
 * `summary` is capped and single-line because it *is* the `index` answer, one line per guide.
 * `appliesTo` is a list of action ids the tests cross-check against `catalog/actions/*.yaml` — this
 * class deliberately does not, so that the catalog stays loadable without the action catalog.
 *
 * It collects, it never stops: same discipline as
 * {@see \Plugin\tnt_mcp\src\Mcp\Action\ActionCatalogValidator}. Use one instance per load.
 */
final class GuideCatalogValidator
{
    private const KEYS = ['topic', 'title', 'summary', 'appliesTo', 'derivedFrom'];

    private const TOPIC_PATTERN = '/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/';

    private const TITLE_MAX_LENGTH = 80;

    private const SUMMARY_MAX_LENGTH = 160;

    /** @var list<GuideValidationError> */
    private array $errors = [];

    private string $file = '';

    /**
     * @param string $expectedTopic the file's basename without `.md`
     *
     * @return list<GuideValidationError> every problem in $frontMatter and $body, in document order
     */
    public function validate(string $file, mixed $frontMatter, string $body, string $expectedTopic): array
    {
        $this->file   = $file;
        $this->errors = [];

        if (!\is_array($frontMatter) || \array_is_list($frontMatter)) {
            $this->error('', 'expected a map in the YAML front matter');

            return $this->errors;
        }
        $this->keys($frontMatter);

        if (\array_key_exists('topic', $frontMatter)) {
            $this->topic($frontMatter['topic'], $expectedTopic);
        }
        if (\array_key_exists('title', $frontMatter)) {
            $this->boundedString('title', $frontMatter['title'], self::TITLE_MAX_LENGTH);
        }
        if (\array_key_exists('summary', $frontMatter)) {
            $this->summary($frontMatter['summary']);
        }
        foreach (['appliesTo', 'derivedFrom'] as $key) {
            if (\array_key_exists($key, $frontMatter)) {
                $this->stringList($key, $frontMatter[$key]);
            }
        }
        if (\trim($body) === '') {
            $this->error('body', 'expected non-empty markdown after the front matter');
        }

        return $this->errors;
    }

    /**
     * @param array<mixed> $frontMatter
     */
    private function keys(array $frontMatter): void
    {
        foreach (\array_keys($frontMatter) as $key) {
            if (!\in_array((string)$key, self::KEYS, true)) {
                $this->error((string)$key, \sprintf('unknown key "%s"', $key));
            }
        }
        foreach (self::KEYS as $key) {
            if (!\array_key_exists($key, $frontMatter)) {
                $this->error($key, \sprintf('missing required key "%s"', $key));
            }
        }
    }

    private function topic(mixed $topic, string $expectedTopic): void
    {
        if (!$this->nonEmptyString('topic', $topic)) {
            return;
        }
        /** @var string $topic */
        if (\preg_match(self::TOPIC_PATTERN, $topic) !== 1) {
            $this->error('topic', 'expected to match ' . self::TOPIC_PATTERN);

            return;
        }
        if ($topic !== $expectedTopic) {
            $this->error(
                'topic',
                \sprintf('expected "%s" — topic must equal the file name without ".md"', $expectedTopic),
            );
        }
    }

    private function summary(mixed $summary): void
    {
        if (!$this->boundedString('summary', $summary, self::SUMMARY_MAX_LENGTH)) {
            return;
        }
        /** @var string $summary */
        if (\preg_match('/[\r\n]/', $summary) === 1) {
            $this->error('summary', 'expected a single line — it is one row of the index answer');
        }
    }

    private function boundedString(string $path, mixed $value, int $max): bool
    {
        if (!$this->nonEmptyString($path, $value)) {
            return false;
        }
        /** @var string $value */
        if (\mb_strlen($value) > $max) {
            $this->error($path, \sprintf('expected at most %d characters', $max));

            return false;
        }

        return true;
    }

    /** Empty is allowed — a guide that applies to no catalogued action is still worth shipping. */
    private function stringList(string $path, mixed $value): void
    {
        if (!\is_array($value) || !\array_is_list($value)) {
            $this->error($path, 'expected a list of strings (use [] when there are none)');

            return;
        }
        foreach ($value as $index => $item) {
            $this->nonEmptyString($path . '[' . $index . ']', $item);
        }
    }

    private function nonEmptyString(string $path, mixed $value): bool
    {
        if (\is_string($value) && \trim($value) !== '') {
            return true;
        }
        $this->error($path, 'expected a non-empty string');

        return false;
    }

    private function error(string $path, string $message): void
    {
        $this->errors[] = GuideValidationError::at($this->file, $path, $message);
    }
}
