<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Ai;

use InvalidArgumentException;
use Plugin\tnt_mcp\src\Enums\AiProviders;
use Plugin\tnt_mcp\src\Http\CurlHttpClient;
use Plugin\tnt_mcp\src\Http\HttpClient;
use RuntimeException;

/**
 * Asks a provider whether a key works, by listing the models it may use (epic 4.2 "validated before
 * saving", 4.3 `test_ai_connection` / `list_available_models`).
 *
 * ## Why the models endpoint and not a one-token completion
 *
 * A completion costs money, needs a model name the merchant has not chosen yet (chicken and egg on
 * first setup), and can fail for reasons that have nothing to do with the key — a model the account
 * lacks access to, a rate limit, a content filter. `GET /models` is free, needs no model, and both
 * supported wire formats expose it with the same `{"data":[{"id":…}]}` shape. It answers the two
 * questions setup actually has — "is the key accepted?" and "what can I pick?" — in one call.
 *
 * ## Why failure messages are built from the status code
 *
 * ⚠️ A provider's error body is attacker-influenced text from outside the shop, and it is echoed
 * into an admin page and into an MCP result. Rather than sanitising it, this class never reads it:
 * the status code carries everything a merchant can act on, and the mapping below says it in a
 * sentence. The body is not logged either — a 401 body from some proxies contains the request
 * headers, which is to say the key.
 *
 * The request itself goes through {@see HttpClient}, so the SSRF guard applies to the merchant's
 * `baseUrl` exactly as it does to a snippet's webhook: a "compatible" provider pointed at
 * `http://169.254.169.254/` is refused here, not discovered in production.
 */
final class AiConnectionTester
{
    /**
     * Shorter than the client default. This runs inside an admin form submit and inside an MCP
     * call, both of which have a human waiting; a provider that needs longer than this to list
     * models is unusable for a workflow anyway.
     */
    public const TIMEOUT = 8;

    /** Never hand back an unbounded model list — some compatible endpoints expose thousands. */
    public const MAX_MODELS = 200;

    private readonly HttpClient $http;

    /** @param HttpClient|null $http the test seam; production uses the guarded cURL client. */
    public function __construct(?HttpClient $http = null)
    {
        $this->http = $http ?? new CurlHttpClient();
    }

    /**
     * Tests the configuration that is STORED. Returns a failed result rather than throwing when
     * nothing usable is stored — "not configured" is an answer to "does it work?", not an error.
     */
    public function testStored(AiConfigurationStore $store): AiConnectionResult
    {
        $config = $store->load();
        // Deliberately NOT isComplete(): the model is what this call exists to discover, so
        // demanding one first is the chicken-and-egg the merchant hit on their first setup.
        if (!$config->isConnectionTestable()) {
            return new AiConnectionResult(false, $config->connectionMissingReason());
        }

        $key = $store->apiKey();
        if ($key === null) {
            return new AiConnectionResult(false, 'The stored API key could not be read.');
        }

        /** @var AiProviders $provider it cannot be null once isConnectionTestable() held */
        $provider = $config->provider;

        return $this->test($provider, $key, $config->effectiveBaseUrl());
    }

    /**
     * Tests credentials that have not been stored yet — the "validate before saving" path.
     *
     * @param string $baseUrl absolute API root without a trailing slash; '' uses the provider default
     */
    public function test(AiProviders $provider, string $apiKey, string $baseUrl = ''): AiConnectionResult
    {
        $root = \rtrim($baseUrl === '' ? $provider->defaultBaseUrl() : $baseUrl, '/');
        if ($root === '') {
            return new AiConnectionResult(
                false,
                'Provider ' . $provider->value . ' has no default API URL, so a baseUrl is required.'
            );
        }

        try {
            $result = $this->http->request(
                'GET',
                $root . $provider->modelsPath(),
                $provider->authHeaders($apiKey),
                null,
                self::TIMEOUT
            );
        } catch (InvalidArgumentException $e) {
            // The request was refused before it left the shop — a bad URL or the SSRF guard. This
            // message is ours, not the provider's, so it is safe to pass on verbatim.
            return new AiConnectionResult(false, 'The API URL was refused: ' . $e->getMessage());
        } catch (RuntimeException) {
            return new AiConnectionResult(
                false,
                'No response from ' . $root . ' — DNS, TLS or a timeout after ' . self::TIMEOUT . 's. '
                . 'Check the base URL and whether this shop may reach it.'
            );
        }

        if (!$result->ok()) {
            return new AiConnectionResult(false, $this->explain($result->status), $result->status);
        }

        return new AiConnectionResult(
            true,
            'The API key is valid.',
            $result->status,
            $this->parseModels($result->json())
        );
    }

    /**
     * @return list<string> sorted, deduplicated, capped; empty when the payload has no model list,
     *                      which is a valid answer for a minimal compatible endpoint
     */
    private function parseModels(mixed $payload): array
    {
        if (!\is_array($payload) || !\is_array($payload['data'] ?? null)) {
            return [];
        }

        $ids = [];
        foreach ($payload['data'] as $entry) {
            $id = \is_array($entry) ? ($entry['id'] ?? null) : null;
            if (\is_string($id) && $id !== '') {
                $ids[$id] = true;
            }
        }

        $ids = \array_keys($ids);
        \sort($ids);

        return \array_slice($ids, 0, self::MAX_MODELS);
    }

    /** Status code → what the merchant should do about it. The body is deliberately unread. */
    private function explain(int $status): string
    {
        return match (true) {
            $status === 401, $status === 403 => 'The provider rejected the API key (HTTP ' . $status
                . '). Check that the key is current and belongs to this provider.',
            $status === 404 => 'The API URL answered, but has no model listing (HTTP 404). Check the '
                . 'base URL — it must point at the API root, e.g. https://api.openai.com/v1.',
            $status === 429 => 'The provider is rate-limiting this key (HTTP 429). The key is '
                . 'probably valid; try again shortly.',
            $status >= 500  => 'The provider is currently failing (HTTP ' . $status . '). This says '
                . 'nothing about the key; try again later.',
            default => 'The provider answered HTTP ' . $status . ', which is not a success.',
        };
    }
}
