<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp;

use Plugin\tnt_mcp\src\Mcp\Auth\TokenGuard;
use Plugin\tnt_mcp\src\Mcp\Audit\AuditLogger;
use Plugin\tnt_mcp\src\Mcp\Batch\ArgumentShape;
use Plugin\tnt_mcp\src\Mcp\Batch\BatchRunner;
use Plugin\tnt_mcp\src\Mcp\Contracts\CheckableTool;
use Plugin\tnt_mcp\src\Mcp\Contracts\PerCallEndpointClass;
use Plugin\tnt_mcp\src\Mcp\Contracts\Tool;
use Plugin\tnt_mcp\src\Mcp\Contracts\TokenAwareTool;
use Plugin\tnt_mcp\src\Mcp\Mode\EndpointClass;
use Plugin\tnt_mcp\src\Mcp\Mode\Mode;
use Plugin\tnt_mcp\src\Mcp\Mode\ModeGuard;

/**
 * Thin wrapper over the PHP MCP SDK (php-mcp/server). Registers the enabled tools, enforces the
 * TokenGuard + scope on every call, and audits. Served from the plugin HTTP route (Streamable
 * HTTP transport) so ChatGPT/Claude connect remotely with a per-shop token.
 */
final class Server
{
    /**
     * Said in exactly one place: `dispatch()` throws it, and a `batch` dry run reports it for an
     * entry that has not been attempted. Two wordings for one refusal would have the model fixing a
     * plan against advice it will not get again when the call is real.
     */
    private const MODE_DENIED = 'This action is disabled: the server runs in limited mode and this '
        . 'endpoint is destructive/irreversible, or its target could not be resolved to a known, '
        . 'version-compatible action. Switch the plugin to full mode to enable it.';

    /**
     * The prefix every argument refusal carries. Named so the wording is one string and so a client
     * can tell "your call was wrong" apart from "the call ran and failed".
     */
    private const ARGS_REJECTED = 'invalid arguments: ';

    /** @param list<Tool> $tools */
    public function __construct(
        private readonly array $tools,
        private readonly TokenGuard $guard,
        private readonly AuditLogger $audit,
        private readonly Mode $mode = Mode::Limited,
    ) {
    }

    /**
     * @param array<string,mixed> $args
     * @return array<string,mixed>
     */
    public function dispatch(string $toolName, array $args, string $tokenId): array
    {
        if ($toolName === BatchRunner::TOOL) {
            return $this->dispatchBatch($args, $tokenId);
        }

        foreach ($this->tools as $tool) {
            if ($tool->name() !== $toolName) {
                continue;
            }
            if (!$this->guard->allows($tool->scope())) {
                $this->audit->record($tokenId, $toolName, $args, 'denied:scope');

                throw new \RuntimeException($this->scopeRefusal($tool->scope()));
            }
            // Central mode choke point: in limited mode only reversible endpoints may run; a
            // destructive or unclassified endpoint is denied here (never scattered elsewhere).
            if (!ModeGuard::permits($this->mode, self::endpointClassOf($tool, $args))) {
                $this->audit->record($tokenId, $toolName, $args, 'denied:mode');

                throw new \RuntimeException(self::MODE_DENIED);
            }
            // Every schema this server publishes says `additionalProperties:false`, and until this
            // check existed it said so without meaning it: an invented or misspelled argument was
            // dropped in silence and the tool answered as if it had been honoured — a filter that
            // never ran, a sort order that was never applied, reported as a confident result. The
            // same check `batch dryRun` already applies (ArgumentShape) is therefore applied to
            // every call, so the strictness a client learns from one tool holds for all of them.
            $problems = ArgumentShape::problems($args, $tool->inputSchema());
            if ($problems !== []) {
                $this->audit->record($tokenId, $toolName, $args, 'denied:args');

                throw new \RuntimeException(self::ARGS_REJECTED . \implode('; ', $problems));
            }
            // A tool that needs to know WHO is asking gets told; every other tool keeps the
            // identity-free `run()` it has always had. See Contracts\TokenAwareTool.
            $result = $tool instanceof TokenAwareTool
                ? $tool->runForToken($args, $tokenId)
                : $tool->run($args);
            $this->audit->record($tokenId, $toolName, $args, self::outcome($result));

            return $result;
        }

        throw new \RuntimeException('unknown tool: ' . $toolName . $this->didYouMean($toolName));
    }

    /**
     * `scope denied` on its own was twelve bytes that named nothing: not the scope the call needed,
     * not the scopes the token has. A client cannot tell a merchant "ask your admin for
     * workflows:write" from that — it can only guess, or give up. Both facts are safe to say; a
     * scope name is not a secret and the token is never held here in clear.
     */
    private function scopeRefusal(string $needed): string
    {
        $held = $this->guard->scopes();

        return 'scope denied: this call needs "' . $needed . '", and this token carries '
            . ($held === [] ? 'no scopes at all' : '"' . \implode('", "', $held) . '"')
            . '. An admin grants scopes on the token in the plugin settings.';
    }

    /**
     * The server is holding the tool list while it says "unknown tool", so withholding the nearest
     * name costs the client a whole turn to guess again — the same argument as
     * {@see ArgumentShape}'s "did you mean", and the same threshold.
     */
    private function didYouMean(string $toolName): string
    {
        $best     = '';
        $distance = \PHP_INT_MAX;
        foreach ($this->tools as $tool) {
            // Only names this token could actually call: `tools/list` already hides the rest, and a
            // suggestion is no place to disclose what a token was not given.
            if (!$this->guard->allows($tool->scope())) {
                continue;
            }
            $d = \levenshtein(\strtolower($toolName), \strtolower($tool->name()));
            if ($d < $distance) {
                $distance = $d;
                $best     = $tool->name();
            }
        }
        if ($best === '' || $distance > (int)\max(2, \floor(\strlen($toolName) / 3))) {
            return '';
        }

        return ' — did you mean "' . $best . '"?';
    }

    /**
     * `batch` is answered HERE and not by a {@see Tool} class, because a tool that dispatches tools
     * would have to hold the server that holds it. Handling it at the one place that already owns
     * the guard, the mode and the audit logger costs no cycle and no second construction site.
     *
     * Every entry re-enters {@see dispatch()}, so a batched call is checked and audited exactly like
     * the same call sent on its own — the batch buys the client one HTTP request, nothing else. The
     * extra row this records is the shape of the batch and how it ended (`batch:5/6`), so the audit
     * trail shows that six writes arrived together rather than six unrelated writes arriving fast.
     *
     * @param array<string,mixed> $args
     *
     * @return array<string,mixed>
     */
    private function dispatchBatch(array $args, string $tokenId): array
    {
        $result = BatchRunner::run(
            $args,
            fn (string $tool, array $inner): array => $this->dispatch($tool, $inner, $tokenId),
            fn (string $tool, array $inner): array => $this->inspect($tool, $inner),
        );
        $this->audit->record(
            $tokenId,
            BatchRunner::TOOL,
            ['calls' => self::batchedToolNames($args)],
            BatchRunner::outcome($result),
        );

        return $result;
    }

    /**
     * Everything that can be said about a call WITHOUT making it: does the tool exist, would this
     * token be allowed to make it, what shape does it take, and is there a write-free call that
     * decides whether it would work.
     *
     * It is the whole of `batch`'s `dryRun` support, and it lives here rather than in
     * {@see BatchRunner} because these are the server's four facts — the tool list, the guard, the
     * mode and the schemas — and handing all four to a helper so it can re-derive them is how a
     * second, subtly different set of rules gets born.
     *
     * NOT audited: nothing was called. The check call that {@see BatchRunner} may go on to make is
     * audited, by the ordinary {@see dispatch()} it goes through like anything else.
     *
     * @param array<string,mixed> $args
     *
     * @return array<string,mixed>
     */
    private function inspect(string $toolName, array $args): array
    {
        foreach ($this->tools as $tool) {
            if ($tool->name() !== $toolName) {
                continue;
            }
            $refusal = null;
            if (!$this->guard->allows($tool->scope())) {
                $refusal = 'scope denied: this token does not carry "' . $tool->scope() . '"';
            } elseif (!ModeGuard::permits($this->mode, self::endpointClassOf($tool, $args))) {
                $refusal = self::MODE_DENIED;
            }

            return [
                'known'   => true,
                'refusal' => $refusal,
                'schema'  => $tool->inputSchema(),
                // Asked only of a call that would be allowed to happen: a tool the token may not
                // call must not get to run its own validation as a consolation prize.
                'check' => $refusal === null && $tool instanceof CheckableTool
                    ? $this->dispatchable($tool->checkCall($args))
                    : null,
            ];
        }

        return ['known' => false];
    }

    /**
     * A check call the token could not make is no check at all.
     *
     * `save_snippet` (scope `snippets:write`) is checked by `validate_snippet` (scope
     * `snippets:read`), and the two scopes are grantable separately. Dispatching it regardless would
     * come back `scope denied` and be reported as a broken plan entry — a failure invented by the
     * dry run itself, about the one thing the real call would have done fine. Dropping the check
     * instead reports the entry as `unchecked`, which is what it is.
     *
     * @param array{tool:string,arguments:array<string,mixed>}|null $check
     *
     * @return array{tool:string,arguments:array<string,mixed>}|null
     */
    private function dispatchable(?array $check): ?array
    {
        if ($check === null) {
            return null;
        }
        foreach ($this->tools as $tool) {
            if ($tool->name() !== $check['tool']) {
                continue;
            }

            return $this->guard->allows($tool->scope())
                && ModeGuard::permits($this->mode, self::endpointClassOf($tool, $check['arguments']))
                    ? $check
                    : null;
        }

        return null;
    }

    /**
     * @param array<string,mixed> $args
     *
     * @return list<string>
     */
    private static function batchedToolNames(array $args): array
    {
        $names = [];
        foreach (\is_array($args['calls'] ?? null) ? $args['calls'] : [] as $entry) {
            $names[] = \is_array($entry) && \is_string($entry['tool'] ?? null) ? $entry['tool'] : '?';
        }

        return $names;
    }

    /**
     * The reversibility class {@see ModeGuard} judges for THIS call.
     *
     * Decision Q5: `invoke_admin_action` fronts catalog entries of both classes, so a fixed
     * per-tool answer would be wrong for half of them. A tool may therefore refine the question by
     * implementing {@see PerCallEndpointClass}; every other tool keeps its fixed declaration
     * untouched, and the {@see Tool} contract is unchanged (thirteen classes implement it).
     *
     * A `null` from the refinement is NOT a fallback to `endpointClass()` — it means "these
     * arguments name no action I can classify", which `ModeGuard` denies in `limited` mode. That is
     * the failing-closed half of Q5 and the reason the audit line reads `denied:mode`.
     *
     * @param array<string,mixed> $args
     */
    private static function endpointClassOf(Tool $tool, array $args): ?EndpointClass
    {
        return $tool instanceof PerCallEndpointClass
            ? $tool->endpointClassFor($args)
            : $tool->endpointClass();
    }

    /**
     * The audit outcome (design §5 R6). A result carrying no `ok` key is a read tool and stays the
     * plain `ok` every existing row uses; a write result says which action ran and how it ended, so
     * the audit trail distinguishes "invoked and worked", "invoked and did not" and "refused before
     * anything happened" — three very different things that a bare `ok` renders identical.
     *
     * `cOutcome` is `VARCHAR(64)` and {@see AuditLogger::record()} clips to it; the action id is
     * appended last so the verdict is never what gets cut.
     *
     * @param array<string,mixed> $result
     */
    private static function outcome(array $result): string
    {
        if (!\array_key_exists('ok', $result)) {
            return 'ok';
        }
        $action = \is_string($result['actionId'] ?? null) ? $result['actionId'] : '';
        if (($result['ok'] ?? false) === true) {
            return $action === '' ? 'ok' : 'ok:' . $action;
        }
        $refused = \is_string($result['refused'] ?? null) ? $result['refused'] : '';
        if ($refused !== '') {
            return 'refused:' . $refused . ($action === '' ? '' : ':' . $action);
        }

        return $action === '' ? 'failed' : 'failed:' . $action;
    }

    /**
     * Tool descriptors for a JSON-RPC `tools/list` response — name, human description and input
     * JSON-schema for each tool THIS token may actually call.
     *
     * The scope filter is not a second security boundary: {@see dispatch()} still refuses a tool
     * the token has no scope for, and it is the only check that matters. It is a CONTEXT decision.
     * The full list is 33 126 B (≈ 8 K tokens) and an MCP client re-sends it on every conversation
     * turn, so a tool the token can never run is a per-turn tax paid for nothing — and worse than
     * free: a listed tool is one the model will eventually try, spend a call on, and get
     * `scope denied` for. Measured on this shop, a read-only token drops 12 761 B (39 %).
     *
     * Deliberately NOT filtered by {@see Mode}: in `limited` mode a destructive tool is denied at
     * call time with a message that says the mode is why. Hiding it instead would leave the model
     * unable to tell the operator what to switch on — a silent absence teaches nothing, a refusal
     * that names its reason does.
     *
     * @return list<array<string,mixed>>
     */
    public function toolDefinitions(): array
    {
        $out = [];
        foreach ($this->tools as $tool) {
            if (!$this->guard->allows($tool->scope())) {
                continue;
            }
            $out[] = [
                'name'        => $tool->name(),
                'description' => $tool->description(),
                'inputSchema' => self::normaliseSchema($tool->inputSchema()),
            ];
        }

        // `batch` rides along only when there is something to batch: advertising it to a token that
        // may call nothing would be a tool the model can only fail with. It carries no scope of its
        // own on purpose — it grants nothing, every entry inside it is checked on its own merits.
        if ($out !== []) {
            $out[] = BatchRunner::definition();
        }

        return $out;
    }

    /**
     * A JSON-Schema `properties` MUST be an object. A tool with no parameters returns an empty PHP
     * array, which `json_encode` would emit as `[]` — invalid schema that strict clients (e.g. the
     * ChatGPT connector) reject, failing the whole tools/list. Force an empty `properties` to `{}`.
     *
     * @param array<string,mixed> $schema
     * @return array<string,mixed>
     */
    private static function normaliseSchema(array $schema): array
    {
        if (($schema['properties'] ?? null) === []) {
            $schema['properties'] = new \stdClass();
        }

        return $schema;
    }
}
