<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Tools;

use InvalidArgumentException;
use Plugin\tnt_mcp\src\Mcp\Contracts\Tool;
use Plugin\tnt_mcp\src\Mcp\Mode\EndpointClass;
use Plugin\tnt_mcp\src\Ai\AiConfigurationStore;
use Plugin\tnt_mcp\src\Ai\AiConnectionTester;
use Plugin\tnt_mcp\src\Enums\AiProviders;
use Plugin\tnt_mcp\src\Settings\AiWriteSetting;
use RuntimeException;

/**
 * WRITE tool: store the shop's LLM provider, key and default model (epic 4.2/4.3).
 *
 * ## Validate, then store
 *
 * A key is tried against the provider BEFORE it is written, and a rejected key is not stored at
 * all. The alternative — store first, let the merchant find out when a workflow silently stops
 * firing at 03:00 — is the failure mode this tool exists to prevent. `skipValidation` is there for
 * the endpoint that has no model listing or is temporarily down, and it says so in the result
 * rather than pretending the key was checked.
 *
 * ## Reversible, not Destructive
 *
 * Storing a credential changes nothing a merchant cannot change back in the same call, and nothing
 * outside the shop happens as a result — which is the {@see EndpointClass::Reversible} test. It is
 * gated instead on {@see AiWriteSetting}, its own switch, because the risk here is spending the
 * merchant's money rather than losing their data; see that class for why it is not one of the two
 * switches that already exist.
 *
 * ⚠️ The key never comes back out. `run()` returns `apiKeyLast4`, and the tool has no path that
 * could return more — see {@see \Plugin\tnt_mcp\src\Ai\AiConfiguration}.
 */
final class SetAiConfiguration implements Tool
{
    /** @var \Closure(): AiConfigurationStore */
    private readonly \Closure $store;

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

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

    /**
     * @param \Closure(): AiConfigurationStore      $store   lazy — it resolves the shop DB
     * @param (\Closure(): AiConnectionTester)|null $tester
     * @param (\Closure(): bool)|null               $enabled lazy read of the merchant's switch;
     *                                                       injectable so a test can drive both sides
     */
    public function __construct(\Closure $store, ?\Closure $tester = null, ?\Closure $enabled = null)
    {
        $this->store   = $store;
        $this->tester  = $tester ?? static fn (): AiConnectionTester => new AiConnectionTester();
        $this->enabled = $enabled ?? static fn (): bool => AiWriteSetting::enabled();
    }

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

    public function description(): string
    {
        return 'Store the shop\'s LLM provider, API key and default model. The key is validated, '
            . 'encrypted at rest and never readable back.';
    }

    /** @return array<string,mixed> */
    public function inputSchema(): array
    {
        return [
            'type'       => 'object',
            'properties' => [
                'provider' => [
                    'type'        => 'string',
                    'enum'        => AiProviders::values(),
                    'description' => 'openai, anthropic, or compatible for any OpenAI-wire-format '
                        . 'endpoint at your own baseUrl. get_ai_status lists the labels.',
                ],
                'apiKey' => [
                    'type'        => 'string',
                    'description' => 'The provider API key. Omit to keep the key already stored '
                        . '(there must be one). Never returned by any tool once stored.',
                ],
                'model' => [
                    'type'        => 'string',
                    'description' => 'Default model id, e.g. gpt-4.1-mini. list_available_models '
                        . 'shows what a key may use.',
                ],
                'baseUrl' => [
                    'type'        => 'string',
                    'description' => 'API root, e.g. https://api.openai.com/v1. Required for '
                        . 'provider "compatible"; leave empty for the provider default.',
                ],
                'skipValidation' => [
                    'type'        => 'boolean',
                    'description' => 'Store without calling the provider first. Only for an endpoint '
                        . 'with no model listing, or one that is temporarily down. Default false.',
                ],
            ],
            'required'             => ['provider', 'model'],
            'additionalProperties' => false,
        ];
    }

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

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

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

    /**
     * @param array<string,mixed> $args
     *
     * @return array<string,mixed>
     */
    public function run(array $args): array
    {
        if (!($this->enabled)()) {
            return [
                'ok'      => false,
                'refused' => 'disabled',
                'error'   => AiWriteSetting::DISABLED_HINT,
            ];
        }

        $provider = AiProviders::tryFrom((string)($args['provider'] ?? ''));
        if ($provider === null) {
            throw new InvalidArgumentException(
                'provider must be one of: ' . \implode(', ', AiProviders::values())
            );
        }

        $model = (string)($args['model'] ?? '');
        // The store now accepts a blank model (the admin form needs that half-finished state to
        // reach the model list at all), so the "model is mandatory" half of this tool's contract
        // has to be asserted here rather than relied upon from below.
        if (\trim($model) === '') {
            throw new InvalidArgumentException(
                'model must not be empty — call list_available_models to see what this key can use.'
            );
        }
        $baseUrl = \trim((string)($args['baseUrl'] ?? ''));
        $apiKey  = \array_key_exists('apiKey', $args) ? (string)$args['apiKey'] : null;

        $store = ($this->store)();

        $validation = null;
        if ($apiKey !== null && ($args['skipValidation'] ?? false) !== true) {
            $result = ($this->tester)()->test($provider, \trim($apiKey), $baseUrl);
            if (!$result->ok) {
                // Nothing is written. A key the provider rejects would otherwise sit there looking
                // configured until a workflow failed in the middle of the night.
                return [
                    'ok'      => false,
                    'refused' => 'invalid-key',
                    'error'   => $result->message,
                    'hint'    => 'Nothing was stored. Correct the key or pass skipValidation:true '
                        . 'if this endpoint has no model listing.',
                ];
            }
            $validation = $result->jsonSerialize();
        }

        try {
            $store->save($provider, $apiKey, $model, $baseUrl);
        } catch (InvalidArgumentException | RuntimeException $e) {
            return [
                'ok'      => false,
                'refused' => 'invalid-argument',
                'error'   => $e->getMessage(),
            ];
        }

        return [
            'ok'            => true,
            'configuration' => $store->load()->jsonSerialize(),
            'validated'     => $validation !== null,
            'validation'    => $validation,
            'hint'          => $validation === null && $apiKey !== null
                ? 'The key was stored WITHOUT being checked. Call test_ai_connection to verify it.'
                : '',
        ];
    }
}
