<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Guide;

use RuntimeException;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;

/**
 * The only reader of `catalog/guides/*.md`, and the prose counterpart of {@see
 * \Plugin\tnt_mcp\src\Mcp\Action\ActionCatalog}.
 *
 * Pure by design: no DB, no shop container, no `Shop::` — a guide has to be loadable in a unit test
 * and in a build-time check, not only inside a booted shop.
 *
 * Loading is all-or-nothing, for a reason specific to this catalog: `get_guide` advertises its
 * topics as a hardcoded enum, so a half-loaded directory would mean the tool promises a topic it
 * then cannot answer. Failing the whole load turns that into an authoring error instead.
 *
 * Topics are unique per directory for free — the schema requires `topic` to equal the file name.
 */
final class GuideCatalog
{
    /** @var array<string,GuideSpec> topic => spec, sorted by topic asc */
    private array $byTopic = [];

    private function __construct()
    {
    }

    /**
     * @throws GuideValidationException
     */
    public static function fromFile(string $path): self
    {
        return self::fromFiles([$path]);
    }

    /**
     * @throws GuideValidationException
     */
    public static function fromDirectory(string $directory): self
    {
        if (!\is_dir($directory)) {
            throw new RuntimeException('guide catalog directory not found: ' . $directory);
        }
        $paths = \glob(\rtrim($directory, '/') . '/*.md');
        if ($paths === false) {
            throw new RuntimeException('guide catalog directory is unreadable: ' . $directory);
        }
        \sort($paths);

        return self::fromFiles($paths);
    }

    /** @return list<string> topics, sorted asc */
    public function topics(): array
    {
        return \array_keys($this->byTopic);
    }

    /**
     * The topic names in a guide directory WITHOUT loading, parsing or validating a single file —
     * `basename($path, '.md')`, which the schema already requires to equal the `topic:` front-matter
     * field.
     *
     * Exists so `get_guide` can advertise a customer-added guide. `inputSchema()` answers every
     * `tools/list`, so it must stay cheap and must never throw: one `glob()`, no YAML, and an
     * unreadable directory answers `[]` rather than failing the handshake. A malformed guide still
     * fails loudly — but at {@see self::get()} time, where the caller asked for it, not on
     * everyone's `tools/list`.
     *
     * Deliberately NOT memoised: the cost is one directory read on a per-session handshake, and a
     * process-lifetime cache would hand a stale list to the next request after an author drops a
     * file in.
     *
     * @return list<string> sorted asc
     */
    public static function topicsIn(string $directory): array
    {
        $paths = \is_dir($directory) ? \glob(\rtrim($directory, '/') . '/*.md') : false;
        if ($paths === false) {
            return [];
        }
        $topics = \array_map(self::topicOf(...), $paths);
        \sort($topics);

        return $topics;
    }

    public function has(string $topic): bool
    {
        return isset($this->byTopic[$topic]);
    }

    /**
     * @throws RuntimeException when the topic is not in this catalog — callers that let a user
     *                          pick the topic must ask {@see self::has()} first and answer with a
     *                          list of the real ones
     */
    public function get(string $topic): GuideSpec
    {
        return $this->byTopic[$topic] ?? throw new RuntimeException('unknown guide topic: ' . $topic);
    }

    /**
     * topic => title, for callers that want the one-line "what lives here" without the bodies —
     * {@see \Plugin\tnt_mcp\src\Mcp\Action\ActionDescriber} builds its area map from this.
     *
     * @return array<string,string> sorted by topic asc
     */
    public function titles(): array
    {
        return \array_map(static fn (GuideSpec $spec): string => $spec->title, $this->byTopic);
    }

    /** @return list<GuideSpec> sorted by topic asc */
    public function all(): array
    {
        return \array_values($this->byTopic);
    }

    /**
     * @param list<string> $paths
     *
     * @throws GuideValidationException
     */
    private static function fromFiles(array $paths): self
    {
        $self      = new self();
        $validator = new GuideCatalogValidator();
        $errors    = [];
        $parsed    = [];

        foreach ($paths as $path) {
            if (!\is_file($path)) {
                throw new RuntimeException('guide catalog file not found: ' . $path);
            }
            $raw = \file_get_contents($path);
            if ($raw === false) {
                throw new RuntimeException('guide catalog file is unreadable: ' . $path);
            }
            $split = self::split($raw);
            if ($split === null) {
                $errors[] = GuideValidationError::at(
                    $path,
                    '',
                    'expected a file starting with "---", a YAML front matter block and a closing "---"',
                );
                continue;
            }
            [$frontMatterText, $body] = $split;
            try {
                $frontMatter = Yaml::parse($frontMatterText);
            } catch (ParseException $e) {
                $errors[] = GuideValidationError::at($path, '', 'YAML parse error: ' . $e->getMessage());
                continue;
            }
            $parsed[$path] = [$frontMatter, $body];
            foreach ($validator->validate($path, $frontMatter, $body, self::topicOf($path)) as $error) {
                $errors[] = $error;
            }
        }
        if ($errors !== []) {
            throw GuideValidationException::fromErrors($errors);
        }
        foreach ($parsed as [$frontMatter, $body]) {
            $self->add(\is_array($frontMatter) ? $frontMatter : [], $body);
        }
        \ksort($self->byTopic);

        return $self;
    }

    /**
     * @param array<mixed> $frontMatter already validated
     */
    private function add(array $frontMatter, string $body): void
    {
        $topic = (string)$frontMatter['topic'];

        $this->byTopic[$topic] = new GuideSpec(
            $topic,
            (string)$frontMatter['title'],
            (string)$frontMatter['summary'],
            self::stringList($frontMatter['appliesTo'] ?? []),
            self::stringList($frontMatter['derivedFrom'] ?? []),
            \trim($body),
        );
    }

    /**
     * Front matter is delimited by a `---` line at the very start and a second `---` on its own
     * line. Deliberately hand-rolled rather than `Yaml::parseFile` on the whole file: the body is
     * markdown, not YAML, and must survive verbatim.
     *
     * @return array{0:string,1:string}|null the front-matter text and the body, or null when the
     *                                       delimiters are absent
     */
    private static function split(string $raw): ?array
    {
        $normalised = \str_replace("\r\n", "\n", $raw);
        if (\preg_match("/\A---\n(.*?)\n---(?:\n(.*))?\z/s", $normalised, $matches) !== 1) {
            return null;
        }

        return [$matches[1], $matches[2] ?? ''];
    }

    private static function topicOf(string $path): string
    {
        return \basename($path, '.md');
    }

    /**
     * @return list<string>
     */
    private static function stringList(mixed $value): array
    {
        $out = [];
        foreach (\is_array($value) ? $value : [] as $item) {
            if (\is_string($item)) {
                $out[] = $item;
            }
        }

        return $out;
    }
}
