<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Tools;

use JTL\Shop;
use Plugin\tnt_mcp\src\Ai\AiBudget;
use Plugin\tnt_mcp\src\Ai\AiConfiguration;
use Plugin\tnt_mcp\src\Ai\AiConfigurationStore;
use Plugin\tnt_mcp\src\Ai\AiConnectionTester;
use Plugin\tnt_mcp\src\Ai\AiStatus;
use Plugin\tnt_mcp\src\Ai\AiStatusRecord;
use Plugin\tnt_mcp\src\Ai\AiStatusStore;
use Plugin\tnt_mcp\src\Ai\AiUsageRepository;
use Plugin\tnt_mcp\src\Enums\AiProviders;
use Plugin\tnt_mcp\src\Mcp\Admin\PluginAdminUrl;
use Plugin\tnt_mcp\src\Mcp\Contracts\Tool;
use Plugin\tnt_mcp\src\Mcp\Mode\EndpointClass;
use Plugin\tnt_mcp\src\Mcp\Mode\Mode;
use Plugin\tnt_mcp\src\Mcp\Mode\ModeSetting;
use Plugin\tnt_mcp\src\Settings\AiWriteSetting;

/**
 * READ tool: can this shop run AI-backed automation, and what is the ONE next action (epic 4.3).
 *
 * The tool a client calls FIRST when a merchant says "set up AI for my shop", and the only AI read
 * tool it needs: `get_ai_configuration` was folded in here and deleted, because two read tools split
 * the assistant's attention and let it report a configuration without the verdict on whether that
 * configuration works.
 *
 * ## `nextStep` is the point
 *
 * A payload of eight booleans leaves the client to infer an order of operations, and every client
 * infers a different one. This tool names exactly one action — a tool to call, or an admin link for
 * the human — in a sentence, and the server picks it. Everything else in the payload exists so the
 * assistant can EXPLAIN that step, not so it can choose its own.
 *
 * ## Why the state vocabulary is finer than {@see AiStatus}
 *
 * `AiStatus` is the merchant-facing verdict, four words wide, and it is what the KI tab paints. An
 * assistant needs two distinctions the banner does not: whether the missing piece is the API KEY
 * (which the assistant may never receive, so the route is `begin_ai_key_entry`) versus any other
 * field (which it can set itself), and whether a stored key has ever actually been VERIFIED. Those
 * split `none` into `unconfigured`/`key_missing` and `active` into `untested`/`active`. The green/red
 * judgement itself is never re-derived here — it comes from `AiStatus::evaluate()`, so the banner and
 * the assistant can never disagree.
 *
 * ## The live check writes what it learns
 *
 * `checkConnection` is opt-in (status reads happen in loops; a provider round trip is neither free
 * nor fast), and its result is RECORDED. A merchant whose assistant just discovered a revoked key
 * must not find a green banner in the admin a second later.
 *
 * Instructions are English, like every other tool string in this plugin: they are read by a model,
 * which relays them to the merchant in whatever language the conversation is in.
 */
final class GetAiStatus implements Tool
{
    /** Setup is incomplete in a way the assistant can fix itself. */
    public const STATE_UNCONFIGURED = 'unconfigured';

    /** Everything but the key is set; only a human can supply the key. */
    public const STATE_KEY_MISSING = 'key_missing';

    /** Complete, but the provider has never confirmed it. */
    public const STATE_UNTESTED = 'untested';

    public const STATE_ACTIVE = 'active';

    public const STATE_DEGRADED = 'degraded';

    public const STATE_DOWN = 'down';

    /** @var \Closure(): AiConfigurationStore */
    private readonly \Closure $store;

    /** @var \Closure(): AiConnectionTester */
    private readonly \Closure $tester;

    /** @var \Closure(): bool */
    private readonly \Closure $writeEnabled;

    /** @var \Closure(): AiStatusStore */
    private readonly \Closure $statusStore;

    /** @var \Closure(): AiUsageRepository */
    private readonly \Closure $usage;

    /** @var \Closure(): AiBudget */
    private readonly \Closure $budget;

    /** @var \Closure(): Mode */
    private readonly \Closure $mode;

    /** @var \Closure(): string */
    private readonly \Closure $settingsUrl;

    /**
     * Every dependency is lazy and injectable: each one of them resolves the shop DB, and this tool
     * is constructed for every request whether or not it is the one being called.
     *
     * @param \Closure(): AiConfigurationStore      $store
     * @param (\Closure(): AiConnectionTester)|null $tester
     * @param (\Closure(): bool)|null               $writeEnabled
     * @param (\Closure(): AiStatusStore)|null      $statusStore
     * @param (\Closure(): AiUsageRepository)|null  $usage
     * @param (\Closure(): AiBudget)|null           $budget
     * @param (\Closure(): Mode)|null               $mode
     * @param (\Closure(): string)|null             $settingsUrl
     */
    public function __construct(
        \Closure $store,
        ?\Closure $tester = null,
        ?\Closure $writeEnabled = null,
        ?\Closure $statusStore = null,
        ?\Closure $usage = null,
        ?\Closure $budget = null,
        ?\Closure $mode = null,
        ?\Closure $settingsUrl = null,
    ) {
        $this->store        = $store;
        $this->tester       = $tester ?? static fn (): AiConnectionTester => new AiConnectionTester();
        $this->writeEnabled = $writeEnabled ?? static fn (): bool => AiWriteSetting::enabled();
        $this->statusStore  = $statusStore ?? static fn (): AiStatusStore => new AiStatusStore();
        $this->usage        = $usage ?? static fn (): AiUsageRepository => new AiUsageRepository();
        $this->budget       = $budget ?? static fn (): AiBudget => AiBudget::fromSettings();
        $this->mode         = $mode ?? static fn (): Mode => ModeSetting::fromDb(Shop::Container()->getDB());
        $this->settingsUrl  = $settingsUrl ?? static fn (): string => PluginAdminUrl::tab('Einstellungen');
    }

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

    public function description(): string
    {
        return 'Start here for anything AI: whether this shop\'s AI is configured and working, its '
            . 'settings and budgets, and the exact next step.';
    }

    /** @return array<string,mixed> */
    public function inputSchema(): array
    {
        return [
            'type'       => 'object',
            'properties' => [
                'checkConnection' => [
                    'type'        => 'boolean',
                    'description' => 'Also call the provider to verify the stored key really works. '
                        . 'Costs one HTTP round trip. Default false, in which case connection.valid '
                        . 'is null meaning "not checked".',
                ],
            ],
            'additionalProperties' => false,
        ];
    }

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

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

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

    /**
     * @param array<string,mixed> $args
     *
     * @return array<string,mixed>
     */
    public function run(array $args): array
    {
        $store       = ($this->store)();
        $config      = $store->load();
        $statusStore = ($this->statusStore)();

        $connectionValid   = null;
        $connectionMessage = '';
        // Testable, not complete: "does this key work / which models may it use" is answerable
        // before a default model exists, and that is exactly when it is most useful.
        if (($args['checkConnection'] ?? false) === true && $config->isConnectionTestable()) {
            $result            = ($this->tester)()->testStored($store);
            $connectionValid   = $result->ok;
            $connectionMessage = $result->message;
            $statusStore->recordConnectionResult($result, source: AiStatusRecord::SOURCE_TEST);
        }

        $record = $statusStore->load();
        $usage  = ($this->usage)();
        $budget = ($this->budget)();
        $spent  = $usage->spentTodayMicros();
        $runs   = $usage->runsThisHour();
        $status = AiStatus::evaluate($config, $record, $budget, $spent, $runs);

        $mayConfigure = ($this->writeEnabled)();
        $state        = $this->state($status, $config, $record);

        return [
            'state'         => $state,
            'configuration' => $config->jsonSerialize(),
            'providers'     => $this->providers(),
            'budget'        => [
                'spentTodayUsd'     => AiBudget::formatUsd($spent),
                'maxSpendPerDayUsd' => $budget->hasSpendLimit()
                    ? AiBudget::formatUsd($budget->maxSpendPerDayMicros)
                    : null,
                'runsThisHour'   => $runs,
                'maxRunsPerHour' => $budget->hasRunLimit() ? $budget->maxRunsPerHour : null,
            ],
            'permissions' => [
                'aiConfigWritable' => $mayConfigure,
                'mode'             => ($this->mode)()->value,
            ],
            'missing'    => $this->missing($config),
            'connection' => [
                'valid'   => $connectionValid,
                'message' => $connectionMessage,
            ],
            'lastSignal' => [
                'checkedAt'   => $record->lastCheckedAt,
                'okAt'        => $record->lastOkAt,
                'errorKind'   => $record->lastErrorKind,
                'errorDetail' => $record->lastErrorMessage,
            ],
            'notices'  => $status->notices,
            'nextStep' => $this->nextStep($state, $config, $record, $budget, $mayConfigure),
        ];
    }

    /**
     * The assistant-facing state: {@see AiStatus}'s verdict, with `none` and `active` subdivided.
     * See the class doc block for why those two splits exist and why nothing else is re-judged.
     */
    private function state(AiStatus $status, AiConfiguration $config, AiStatusRecord $record): string
    {
        if ($status->state === AiStatus::STATE_NONE) {
            return ($config->provider !== null && (!$config->apiKeyConfigured || $config->apiKeyUnreadable))
                ? self::STATE_KEY_MISSING
                : self::STATE_UNCONFIGURED;
        }

        if ($status->state === AiStatus::STATE_ACTIVE && $record->isBlank()) {
            return self::STATE_UNTESTED;
        }

        return match ($status->state) {
            AiStatus::STATE_DEGRADED => self::STATE_DEGRADED,
            AiStatus::STATE_DOWN => self::STATE_DOWN,
            default => self::STATE_ACTIVE,
        };
    }

    /**
     * What still has to be filled in, as field names rather than prose — the machine-readable twin of
     * {@see AiConfiguration::missingReason()}.
     *
     * @return list<string>
     */
    private function missing(AiConfiguration $config): array
    {
        if ($config->provider === null) {
            return ['provider'];
        }

        $missing = [];
        if (!$config->apiKeyConfigured || $config->apiKeyUnreadable) {
            $missing[] = 'apiKey';
        }
        if ($config->model === '') {
            $missing[] = 'model';
        }
        if ($config->effectiveBaseUrl() === '') {
            $missing[] = 'baseUrl';
        }

        return $missing;
    }

    /**
     * Exactly one recommended action. `tool` and `adminUrl` are alternatives, never both: a step the
     * assistant can take itself, or one only the human can.
     *
     * The switch being off overrides every state, because in that world the assistant's own route is
     * closed no matter what is missing.
     *
     * @return array{tool: string|null, adminUrl: string|null, instruction: string}
     */
    private function nextStep(
        string $state,
        AiConfiguration $config,
        AiStatusRecord $record,
        AiBudget $budget,
        bool $mayConfigure,
    ): array {
        if (!$mayConfigure && $state !== self::STATE_ACTIVE) {
            return $this->step(
                null,
                ($this->settingsUrl)(),
                'Writing the AI configuration is disabled on this shop. Ask the merchant to enable '
                . '"KI darf die KI-Zugangsdaten des Shops setzen" at this link, then try again. '
                . 'They can also enter everything by hand under Plugins → JTL Shop - AI Connector → KI.'
            );
        }

        return match ($state) {
            self::STATE_UNCONFIGURED => $this->step(
                'set_ai_configuration',
                null,
                ($config->provider === null
                    ? 'No AI provider is configured. Ask the merchant which provider they use '
                        . '(OpenAI, Anthropic, or an OpenAI-compatible service), then call '
                        . 'set_ai_configuration with provider and, if known, a default model. '
                    : $config->missingReason() . ' Call set_ai_configuration to supply it. ')
                . 'Do NOT ask for the API key — it cannot be sent through this connection.'
            ),
            self::STATE_KEY_MISSING => $this->step(
                'begin_ai_key_entry',
                null,
                ($config->apiKeyUnreadable
                    ? 'A key is stored but this shop can no longer decrypt it (restored backup or '
                        . 'rotated installation secret), so it has to be entered again. '
                    : 'Provider and model are set, but no API key is stored. ')
                . 'Call begin_ai_key_entry and give the merchant the returned link — they must type '
                . 'the key into the shop admin themselves. Then call get_ai_status with '
                . 'checkConnection true to verify.'
            ),
            self::STATE_UNTESTED => $this->step(
                'test_ai_connection',
                null,
                'A key is stored but has never been verified. Call test_ai_connection now and report '
                . 'the result to the merchant.'
            ),
            self::STATE_DEGRADED => $this->degradedStep($budget),
            self::STATE_DOWN => $record->lastErrorKind === AiStatusRecord::KIND_AUTH
                ? $this->step(
                    'begin_ai_key_entry',
                    null,
                    'The provider rejects the stored key. The merchant needs to enter a new key — '
                    . 'call begin_ai_key_entry and give them the returned link.'
                )
                : $this->step(
                    'test_ai_connection',
                    null,
                    'The last call to the provider failed: ' . $record->lastErrorMessage
                    . ' Call test_ai_connection to see whether it still fails, and report the error '
                    . 'to the merchant.'
                ),
            default => $this->step(
                null,
                null,
                \sprintf('AI is configured and working (model %s). Nothing to do.', $config->model)
            ),
        };
    }

    /**
     * A budget ceiling is not a fault, so it gets its own sentence: the merchant chose the limit and
     * the only question is whether they want to raise it.
     *
     * @return array{tool: string|null, adminUrl: string|null, instruction: string}
     */
    private function degradedStep(AiBudget $budget): array
    {
        if ($budget->hasSpendLimit() || $budget->hasRunLimit()) {
            return $this->step(
                null,
                ($this->settingsUrl)(),
                'AI works, but a limit the merchant set is reached, so calls are being refused until '
                . 'it resets. Ask them whether to raise it under Plugins → JTL Shop - AI Connector → '
                . 'KI, or to wait.'
            );
        }

        return $this->step(
            'test_ai_connection',
            null,
            'AI is working again after a recent failure. Call test_ai_connection to confirm before '
            . 'relying on it.'
        );
    }

    /**
     * The providers this build supports, so a client can offer the merchant a choice without
     * hardcoding a vocabulary a later version changes. Inherited from the deleted
     * `get_ai_configuration`, whose only other content is now the `configuration` key above.
     *
     * @return list<array{value: string, label: string, defaultBaseUrl: string, baseUrlRequired: bool}>
     */
    private function providers(): array
    {
        return \array_map(
            static fn (AiProviders $p): array => [
                'value'           => $p->value,
                'label'           => $p->label(),
                'defaultBaseUrl'  => $p->defaultBaseUrl(),
                'baseUrlRequired' => $p->defaultBaseUrl() === '',
            ],
            AiProviders::cases()
        );
    }

    /** @return array{tool: string|null, adminUrl: string|null, instruction: string} */
    private function step(?string $tool, ?string $adminUrl, string $instruction): array
    {
        return [
            'tool'        => $tool,
            'adminUrl'    => ($adminUrl ?? '') === '' ? null : $adminUrl,
            'instruction' => $instruction,
        ];
    }
}
