<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Tools;

use Closure;
use Plugin\tnt_mcp\src\Mcp\Action\TokenBudget;
use Plugin\tnt_mcp\src\Mcp\Contracts\Tool;
use Plugin\tnt_mcp\src\Mcp\Guide\GuideCatalog;
use Plugin\tnt_mcp\src\Mcp\Mode\EndpointClass;
use RuntimeException;

/**
 * READ tool: the task-level walkthrough for one shop area — `read-logs-undo-guidance.md` §5.3.
 *
 * `describe_admin_action` answers "what does this one action take?". This answers the question that
 * comes before it: which reads resolve the ids, in what order the steps go, which three things
 * always go wrong, and — the section that earns the tool — what is simply not reachable from here.
 * A caller that skips it re-derives all of that by trial and error against a live shop.
 *
 * Topic `index` is generated from the shipped guides' `summary:` lines rather than being a file, so
 * adding a guide cannot leave a stale index behind.
 *
 * Reversible: it reads markdown files. Reading a guide performs nothing.
 */
final class GetGuide implements Tool
{
    /** §5.1, row `get_guide`. */
    private const TOKEN_BUDGET = 2500;

    /**
     * The shipped topics, sorted asc. NOT the source of the schema enum — that is discovered from
     * the guide directory so a customer-added guide is advertised, not merely served. This is the
     * last-resort answer for an unreadable directory, and `GetGuideTest` pins it against the real
     * one so the shipped set and this list cannot silently disagree.
     *
     * @var list<string>
     */
    private const SHIPPED_TOPICS = [
        'ai-setup',
        'coupon',
        'cron',
        'customer-fields',
        'email-blocklist',
        'index',
        'logs',
        'payment',
        'redirect',
        'separator',
        'shipping',
        'shipping-surcharge',
        'statistics',
    ];

    /**
     * @param Closure(): GuideCatalog $catalogFactory must not read the guides until run() —
     *                                {@see \Plugin\tnt_mcp\src\Mcp\OAuth\ScopeCatalog} builds every
     *                                tool just to read its scope()
     * @param Closure(): list<string> $topicsFactory  the topic NAMES only, for the schema enum —
     *                                {@see GuideCatalog::topicsIn()}. Called from `inputSchema()`,
     *                                which `ScopeCatalog` never reaches, so it costs one `glob()`
     *                                per `tools/list` and parses nothing. Null keeps the shipped
     *                                list.
     */
    public function __construct(
        private readonly Closure $catalogFactory,
        private readonly ?Closure $topicsFactory = null,
    ) {
    }

    public function name(): string
    {
        return 'get_guide';
    }

    public function description(): string
    {
        // Hard limit: 140 characters. Tool descriptions are prompt, paid on every request.
        return 'Task guide for a shop area: the reads that resolve ids, an ordered walkthrough, the '
            . 'usual failures, and what is impossible. Start at index.';
    }

    /** @return array<string,mixed> */
    public function inputSchema(): array
    {
        return [
            'type'       => 'object',
            'properties' => [
                'topic' => [
                    'type'        => 'string',
                    'enum'        => $this->schemaTopics(),
                    'description' => 'Guide to read. "index" lists every topic with a one-line '
                        . 'summary; the others return the full walkthrough.',
                ],
            ],
            'required'             => ['topic'],
            'additionalProperties' => false,
        ];
    }

    public function isWrite(): bool
    {
        return false;
    }

    public function scope(): string
    {
        return 'shop:read';
    }

    public function endpointClass(): EndpointClass
    {
        return EndpointClass::Reversible;
    }

    /**
     * An unknown topic answers with `error` + `knownTopics` rather than throwing — the same
     * precedent as {@see DescribeAdminAction::run()}: guessing is a normal step, and the real list
     * costs less than a failed tool call.
     *
     * @param array<string,mixed> $args
     * @return array<string,mixed>
     */
    public function run(array $args): array
    {
        $topic = $args['topic'] ?? null;
        if (!\is_string($topic) || \trim($topic) === '') {
            throw new RuntimeException('get_guide: "topic" is required');
        }
        $topic   = \trim($topic);
        $catalog = ($this->catalogFactory)();

        if ($topic === 'index') {
            return $this->index($catalog);
        }
        if (!$catalog->has($topic)) {
            return [
                'error'       => \sprintf('Unknown guide topic "%s".', $topic),
                'knownTopics' => self::knownTopics($catalog),
            ];
        }

        return $this->guide($catalog, $topic);
    }

    /**
     * @return array<string,mixed>
     */
    private function index(GuideCatalog $catalog): array
    {
        $topics = [];
        foreach ($catalog->all() as $guide) {
            $topics[] = [
                'topic'   => $guide->topic,
                'title'   => $guide->title,
                'summary' => $guide->summary,
            ];
        }

        return [
            'topics' => $topics,
            'note'   => 'Call get_guide with one of these topics for the full walkthrough.',
        ];
    }

    /**
     * The markdown is never truncated. A guide cut mid-section is worse than an honest overrun:
     * the caller cannot tell that the step it needed is the one that went missing, so the payload
     * says how big it is and hands over everything.
     *
     * @return array<string,mixed>
     */
    private function guide(GuideCatalog $catalog, string $topic): array
    {
        $guide   = $catalog->get($topic);
        $payload = [
            'topic'       => $guide->topic,
            'title'       => $guide->title,
            'summary'     => $guide->summary,
            'guide'       => $guide->body,
            'appliesTo'   => $guide->appliesTo,
            'derivedFrom' => $guide->derivedFrom,
        ];

        // Self-referential by definition — the number is part of what it measures. Counted with
        // the key already in place, so it is the response's own size to within its own digits,
        // which is well inside the ±20% {@see TokenBudget} is honest about.
        $payload['tokensEstimated'] = 0;
        $tokens                     = TokenBudget::estimate($payload);
        $payload['tokensEstimated'] = $tokens;
        if ($tokens > self::TOKEN_BUDGET) {
            $payload['overBudget'] = true;
            $payload['note']       = \sprintf(
                'This guide is %d tokens, over the %d-token budget. It is returned in full; '
                . 'nothing was dropped.',
                $tokens,
                self::TOKEN_BUDGET,
            );
        }

        return $payload;
    }

    /**
     * The schema enum. Discovered from the guide directory rather than hardcoded, because
     * `catalog/guides/` is customer-extensible: a dropped-in `refunds.md` was already answerable by
     * {@see self::run()} but, while the enum was a const, no client could ever learn it existed.
     *
     * `index` is prepended for the same reason as in {@see self::knownTopics()} — it is answerable
     * but is not a file. An empty discovery (directory gone or unreadable) falls back to the
     * shipped list rather than advertising a tool with a one-value enum.
     *
     * @return list<string> sorted asc
     */
    private function schemaTopics(): array
    {
        $discovered = $this->topicsFactory === null ? [] : ($this->topicsFactory)();
        if ($discovered === []) {
            return self::SHIPPED_TOPICS;
        }
        $topics = ['index', ...$discovered];
        \sort($topics);

        return $topics;
    }

    /**
     * `index` is answerable but is not a file, so it is prepended rather than read from the
     * catalog — and the result stays sorted asc, like the schema enum.
     *
     * @return list<string>
     */
    private static function knownTopics(GuideCatalog $catalog): array
    {
        $topics = ['index', ...$catalog->topics()];
        \sort($topics);

        return $topics;
    }
}
