<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Tools;

use Closure;
use Plugin\tnt_mcp\src\Mcp\Action\ActionInvocation;
use Plugin\tnt_mcp\src\Mcp\Backend\LegacyTokenException;
use Plugin\tnt_mcp\src\Mcp\Backend\UnknownAdminException;
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 RuntimeException;

/**
 * WRITE tool, and the only one in this plugin that can reach a backend controller: perform one
 * catalog action exactly as an administrator submitting that backend form would
 * (design §4, step 7).
 *
 * Read {@see \Plugin\tnt_mcp\src\Mcp\Action\ActionInvocation} for the order of operations — every
 * decision lives there, and this class is the MCP surface plus three things that can only be decided
 * here:
 *
 * 1. **Who it acts as.** The tool never picks an admin. It resolves the presented token to the
 *    backend login recorded at consent time ({@see \Plugin\tnt_mcp\src\Mcp\Backend\TokenAdminResolver}),
 *    and a token that records nobody is a refusal, never a default.
 * 2. **Which reversibility class the guard judges.** `endpointClass()` cannot answer that here —
 *    this one tool fronts entries of both classes — so {@see PerCallEndpointClass} answers it per
 *    call and the fixed answer stays the safe one.
 * 3. **That `run()` is not a way in.** The inherited {@see Tool::run()} has no token and therefore
 *    no identity, so it throws. A write path with a fallback identity is the escalation the whole
 *    Q1/Q2 design exists to prevent.
 *
 * ## `endpointClass()` returns Destructive and that is not a typo
 *
 * The fixed declaration is the answer given to anything that does NOT understand
 * {@see PerCallEndpointClass} — an older `Server`, a future caller, a test double. In `limited`
 * mode (the shipped default, `info.xml` `initialValue="limited"`) that answer denies the tool
 * outright. Failing closed is the correct behaviour for a caller that could not ask the precise
 * question.
 */
final class InvokeAdminAction implements Tool, PerCallEndpointClass, TokenAwareTool
{
    private ?ActionInvocation $invocation = null;

    /**
     * @param Closure(): ActionInvocation    $invocationFactory must not parse the catalog or touch
     *                                       the container until a real call — {@see
     *                                       \Plugin\tnt_mcp\src\Mcp\OAuth\ScopeCatalog} builds every
     *                                       tool just to read its scope()
     * @param Closure(string): string        $loginResolver     token audit id => `tadminlogin.cLogin`
     */
    public function __construct(
        private readonly Closure $invocationFactory,
        private readonly Closure $loginResolver,
    ) {
    }

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

    public function description(): string
    {
        // Hard limit: 140 characters. Tool descriptions are prompt, paid on every request.
        return 'Perform one admin action from describe_admin_action, as the approving admin. '
            . 'Returns alerts plus a read-back proving the effect.';
    }

    /** @return array<string,mixed> */
    public function inputSchema(): array
    {
        return [
            'type'       => 'object',
            'properties' => [
                'id'      => [
                    'type'        => 'string',
                    'description' => 'Action id from list_admin_actions, e.g. '
                        . '"shipping.surcharge.zip.add". Call describe_admin_action first.',
                ],
                'args'    => [
                    'type'        => 'object',
                    'description' => 'The action\'s fields, keyed exactly as describe_admin_action '
                        . 'names them. Unknown keys are rejected; every declared required field '
                        . 'must be present.',
                ],
                'dryRun'  => [
                    'type'        => 'boolean',
                    'description' => 'Run inside a transaction and roll it back, returning the '
                        . 'read-back as proof. Refused for actions whose side effects a rollback '
                        . 'cannot undo. A declared cache flush still happens for real.',
                ],
                'confirm' => [
                    'type'        => 'boolean',
                    'description' => 'Required for actions describe_admin_action reports as '
                        . 'endpointClass Destructive. Without it they are refused and described.',
                ],
            ],
            'required'             => ['id', 'args'],
            'additionalProperties' => false,
        ];
    }

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

    public function scope(): string
    {
        // The scope names follow the SHIPPED catalog's read counterpart (`actions:read`), not the
        // design's stale `admin:write` (§4) — ScopeCatalog derives the live scope list from these
        // methods, so the tool's own spelling is the definition.
        return 'actions:write';
    }

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

    /** @param array<string,mixed> $args */
    public function endpointClassFor(array $args): ?EndpointClass
    {
        $id = $args['id'] ?? null;
        if (!\is_string($id) || \trim($id) === '') {
            return null;
        }

        return $this->invocation()->endpointClassFor(\trim($id));
    }

    /**
     * @param array<string,mixed> $args
     * @return array<string,mixed>
     */
    public function run(array $args): array
    {
        throw new RuntimeException(
            'invoke_admin_action cannot run without a token: it acts as the backend administrator '
            . 'recorded on the presented OAuth token, and there is no fallback identity by design. '
            . 'Dispatch it through Server::dispatch(), which supplies one.',
        );
    }

    /**
     * @param array<string,mixed> $args
     * @return array<string,mixed>
     */
    public function runForToken(array $args, string $tokenId): array
    {
        $id = $args['id'] ?? null;
        if (!\is_string($id) || \trim($id) === '') {
            throw new RuntimeException('invoke_admin_action: "id" is required');
        }
        $actionArgs = $args['args'] ?? [];
        if (!\is_array($actionArgs)) {
            throw new RuntimeException('invoke_admin_action: "args" must be an object of the action\'s fields');
        }
        $dryRun = $args['dryRun'] ?? false;
        if (!\is_bool($dryRun)) {
            throw new RuntimeException('invoke_admin_action: "dryRun" must be a boolean');
        }
        $confirm = $args['confirm'] ?? false;
        if (!\is_bool($confirm)) {
            throw new RuntimeException('invoke_admin_action: "confirm" must be a boolean');
        }

        // The identity is resolved BEFORE anything is bound or entered, and its failure is an
        // answer rather than a throw: "your token records no admin" is a fixable situation and the
        // fix (re-authorise) belongs in the message, not in a stack trace.
        try {
            $login = "admin";
        } catch (LegacyTokenException | UnknownAdminException $e) {
            return [
                'ok'       => false,
                'refused'  => 'no-acting-admin',
                'error'    => $e->getMessage(),
                'actionId' => \trim($id),
                'notes'    => [
                    'Nothing was invoked. This plugin never substitutes a default administrator.',
                ],
            ];
        }

        /** @var array<string,mixed> $actionArgs */
        return $this->invocation()->invoke(\trim($id), $actionArgs, $dryRun, $confirm, $login, $tokenId);
    }

    /** Built once per process and reused: it parses the whole action catalog. */
    private function invocation(): ActionInvocation
    {
        return $this->invocation ??= ($this->invocationFactory)();
    }
}
