<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Ai;

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

/**
 * Turns a prompt into an answer using the shop's own provider configuration (epic 4.4).
 *
 * ## Why a client and not just `CallURL`
 *
 * `CallURL` can already POST JSON with an `Authorization` header, and D11 built it partly for this.
 * What it cannot do is the part that makes the feature usable: it would force the merchant to paste
 * their API key into a workflow argument (stored in clear, visible to anyone who can read a
 * workflow), to hand-write the provider's request envelope, and to dig the answer out of a
 * provider-specific JSON path. This class holds the key handling ({@see AiConfigurationStore} — the
 * plaintext never leaves this file), the two wire formats, and the retry policy, so the action's
 * arguments are "prompt, model, format" and nothing else.
 *
 * ## Provider swappability (D2)
 *
 * Two wire formats, three providers. OpenAI and every OpenAI-compatible runtime share
 * `/chat/completions` with `choices[0].message.content`; Anthropic uses `/messages` with a
 * `content[]` block list, a REQUIRED `max_tokens`, a top-level `system` instead of a system message,
 * and different usage field names. A third format means a new case here and in {@see AiProviders} —
 * and nowhere else, which is the point.
 *
 * ⚠️ Failure messages are built from the STATUS CODE, never from the provider's response body: the
 * body is attacker-influenced text that lands in the execution log and in MCP results, and some
 * proxies echo the request headers — the key — into it. Same rule and same reason as
 * {@see AiConnectionTester}; the sentences differ because the remedies differ (a failed completion
 * is usually a model or a quota, a failed key test is usually the key).
 */
final class AiChatClient implements AiCompleter
{
    /** Longer than the connection tester's: a completion legitimately takes tens of seconds. */
    public const DEFAULT_TIMEOUT = 60;

    /** Beyond this a workflow action holds a cron slot hostage. The client clamps rather than refuses. */
    public const MAX_TIMEOUT = 180;

    /** First backoff, in microseconds; doubled per attempt and capped by {@see self::MAX_BACKOFF_US}. */
    private const BASE_BACKOFF_US = 1000000;

    private const MAX_BACKOFF_US = 8000000;

    /** What we tell a model when JSON is asked for on a format that has no `response_format`. */
    private const JSON_INSTRUCTION = 'Answer with a single valid JSON document and nothing else — '
        . 'no explanation, no markdown fence.';

    private readonly HttpClient $http;

    private readonly AiConfigurationStore $store;

    /** @var callable(int): void */
    private $sleeper;

    /**
     * @param HttpClient|null           $http    the transport seam; production uses the guarded cURL
     *                                           client, so the SSRF guard applies to a merchant's
     *                                           `baseUrl` here exactly as it does everywhere else
     * @param AiConfigurationStore|null $store   where the provider, model and key come from
     * @param callable(int): void|null  $sleeper the backoff seam, so retry tests do not really wait
     */
    public function __construct(
        ?HttpClient $http = null,
        ?AiConfigurationStore $store = null,
        ?callable $sleeper = null,
    ) {
        $this->http    = $http ?? new CurlHttpClient();
        $this->store   = $store ?? new AiConfigurationStore();
        $this->sleeper = $sleeper ?? static function (int $microseconds): void {
            \usleep($microseconds);
        };
    }

    /**
     * Never throws. Every failure — unconfigured shop, refused URL, dead transport, provider error,
     * unusable answer — comes back as a failed {@see AiChatResult} with a sentence saying which,
     * because the caller is a workflow action whose contract is a bool and whose escaping exception
     * would be logged as an engine crash rather than as a configuration problem.
     */
    public function complete(AiPromptRequest $request): AiChatResult
    {
        if (\trim($request->prompt) === '') {
            return AiChatResult::failed('The prompt is empty, so there is nothing to ask.');
        }

        $config = $this->store->load();
        if (!$config->isComplete()) {
            return AiChatResult::failed($config->missingReason());
        }

        $key = $this->store->apiKey();
        if ($key === null) {
            return AiChatResult::failed('The stored API key could not be read.');
        }

        /** @var AiProviders $provider it cannot be null once isComplete() held */
        $provider = $config->provider;
        $model    = $request->model !== '' ? $request->model : $config->model;
        $url      = \rtrim($config->effectiveBaseUrl(), '/') . $provider->completionsPath();

        return $this->send($provider, $key, $url, $model, $request);
    }

    /**
     * The attempt loop. A 429 and a 5xx are retried because they are explicitly temporary; a 4xx is
     * not, because sending the same rejected request again only spends the merchant's rate limit.
     */
    private function send(
        AiProviders $provider,
        string $apiKey,
        string $url,
        string $model,
        AiPromptRequest $request,
    ): AiChatResult {
        $body     = $this->body($provider, $model, $request);
        $headers  = $provider->authHeaders($apiKey) + ['Content-Type' => 'application/json'];
        $timeout  = \max(1, \min($request->timeout, self::MAX_TIMEOUT));
        $attempts = 1 + \max(0, \min($request->retries, AiPromptRequest::MAX_RETRIES));

        $last = AiChatResult::failed('The request was never sent.', 0, 0, $model);
        for ($attempt = 1; $attempt <= $attempts; $attempt++) {
            if ($attempt > 1) {
                ($this->sleeper)($this->backoff($attempt));
            }

            try {
                $response = $this->http->request('POST', $url, $headers, $body, $timeout);
            } catch (InvalidArgumentException $e) {
                // The request never left the shop — a malformed base URL or the SSRF guard. Our own
                // message, so it is safe to pass on verbatim, and retrying cannot change it.
                return AiChatResult::failed('The API URL was refused: ' . $e->getMessage(), 0, $attempt, $model);
            } catch (RuntimeException) {
                $last = AiChatResult::failed(
                    'No response from the AI provider — DNS, TLS or a timeout after ' . $timeout . 's.',
                    0,
                    $attempt,
                    $model
                );
                continue;
            }

            if ($response->ok()) {
                return $this->read($provider, $response, $model, $request, $attempt);
            }

            $last = AiChatResult::failed($this->explain($response->status), $response->status, $attempt, $model);
            if (!$this->retryable($response->status)) {
                return $last;
            }
        }

        return $last;
    }

    /** Only the two statuses that mean "the same request may work shortly". */
    private function retryable(int $status): bool
    {
        return $status === 429 || $status >= 500;
    }

    private function backoff(int $attempt): int
    {
        return (int)\min(self::BASE_BACKOFF_US * 2 ** ($attempt - 2), self::MAX_BACKOFF_US);
    }

    /**
     * The provider's request envelope. The two formats are built side by side rather than through a
     * shared "message" abstraction: they disagree about where the system prompt lives, so an
     * abstraction would have to be reopened for the third one anyway.
     */
    private function body(AiProviders $provider, string $model, AiPromptRequest $request): string
    {
        $system = \trim($request->systemPrompt);
        $prompt = \trim($request->prompt);
        $max    = \max(1, \min($request->maxTokens, AiPromptRequest::MAX_MAX_TOKENS));

        if ($provider === AiProviders::ANTHROPIC) {
            // Anthropic has no `response_format`, so JSON is asked for in words. Appended to the
            // merchant's system prompt rather than to the user prompt so it cannot be mistaken for
            // part of the data the model is meant to work on.
            if ($request->json) {
                $system = $system === '' ? self::JSON_INSTRUCTION : $system . "\n\n" . self::JSON_INSTRUCTION;
            }
            $payload = [
                'model'      => $model,
                'max_tokens' => $max,
                'messages'   => [['role' => 'user', 'content' => $prompt]],
            ];
            if ($system !== '') {
                $payload['system'] = $system;
            }
            if ($request->temperature !== null) {
                $payload['temperature'] = $request->temperature;
            }

            return $this->encode($payload);
        }

        $messages = [];
        if ($system !== '') {
            $messages[] = ['role' => 'system', 'content' => $system];
        }
        $messages[] = ['role' => 'user', 'content' => $prompt];

        $payload = [
            'model'      => $model,
            'messages'   => $messages,
            'max_tokens' => $max,
        ];
        if ($request->temperature !== null) {
            $payload['temperature'] = $request->temperature;
        }
        if ($request->json) {
            $payload['response_format'] = ['type' => 'json_object'];
        }

        return $this->encode($payload);
    }

    /**
     * @param array<string, mixed> $payload
     */
    private function encode(array $payload): string
    {
        try {
            return \json_encode($payload, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
        } catch (JsonException) {
            // Only reachable through invalid UTF-8 in a merchant's prompt; the provider would reject
            // it anyway, and an empty body produces a 400 whose message says as much.
            return '{}';
        }
    }

    /**
     * Reads a 2xx. A success status with no usable text in it is a FAILURE here — an empty answer
     * that a workflow passes on as if it were content is worse than a logged error.
     */
    private function read(
        AiProviders $provider,
        HttpResult $response,
        string $model,
        AiPromptRequest $request,
        int $attempt,
    ): AiChatResult {
        $payload = $response->json();
        if (!\is_array($payload)) {
            return AiChatResult::failed(
                'The AI provider answered HTTP ' . $response->status . ' with something that is not JSON.',
                $response->status,
                $attempt,
                $model
            );
        }

        $text = $provider === AiProviders::ANTHROPIC
            ? $this->anthropicText($payload)
            : $this->openAiText($payload);

        if (\trim($text) === '') {
            return AiChatResult::failed(
                'The AI provider returned an empty answer. The model may have stopped at the token '
                . 'limit or filtered the request.',
                $response->status,
                $attempt,
                $model
            );
        }

        $json = null;
        if ($request->json) {
            $json = $this->decodeAnswer($text);
            if ($json === null) {
                return AiChatResult::failed(
                    'JSON output was requested but the model answered with something that is not JSON.',
                    $response->status,
                    $attempt,
                    $model
                );
            }
        }

        [$promptTokens, $completionTokens] = $this->usage($provider, $payload);

        return new AiChatResult(
            ok: true,
            text: $text,
            model: \is_string($payload['model'] ?? null) && $payload['model'] !== '' ? $payload['model'] : $model,
            status: $response->status,
            promptTokens: $promptTokens,
            completionTokens: $completionTokens,
            attempts: $attempt,
            json: $json,
        );
    }

    /**
     * @param array<mixed> $payload
     */
    private function openAiText(array $payload): string
    {
        $choices = $payload['choices'] ?? null;
        $first   = \is_array($choices) ? ($choices[0] ?? null) : null;
        $message = \is_array($first) ? ($first['message'] ?? null) : null;
        $content = \is_array($message) ? ($message['content'] ?? null) : null;

        return \is_string($content) ? $content : '';
    }

    /**
     * Anthropic answers with a list of blocks. Only the `text` ones are an answer — a tool-use block
     * is not something this action asked for — and they are concatenated because a long answer
     * legitimately arrives split.
     *
     * @param array<mixed> $payload
     */
    private function anthropicText(array $payload): string
    {
        $blocks = $payload['content'] ?? null;
        if (!\is_array($blocks)) {
            return '';
        }

        $text = '';
        foreach ($blocks as $block) {
            if (\is_array($block) && ($block['type'] ?? '') === 'text' && \is_string($block['text'] ?? null)) {
                $text .= $block['text'];
            }
        }

        return $text;
    }

    /**
     * A model told to answer in JSON still tends to wrap it in a markdown fence. Stripping one is
     * not leniency for its own sake — it is the difference between a workflow that works on every
     * model and one that works on the models that happen to obey.
     */
    private function decodeAnswer(string $text): mixed
    {
        $candidate = \trim($text);
        if (\str_starts_with($candidate, '```')) {
            $candidate = (string)\preg_replace('/^```[a-zA-Z]*\s*|\s*```$/', '', $candidate);
        }

        try {
            $decoded = \json_decode($candidate, true, 64, \JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            return null;
        }

        // A bare `null` answer decodes without error and is indistinguishable from a parse failure
        // to every caller, so it is treated as one.
        return $decoded;
    }

    /**
     * @param array<mixed> $payload
     *
     * @return array{0: int, 1: int} prompt tokens, completion tokens; 0 when not reported
     */
    private function usage(AiProviders $provider, array $payload): array
    {
        $usage = $payload['usage'] ?? null;
        if (!\is_array($usage)) {
            return [0, 0];
        }

        $keys = $provider === AiProviders::ANTHROPIC
            ? ['input_tokens', 'output_tokens']
            : ['prompt_tokens', 'completion_tokens'];

        return [
            \is_numeric($usage[$keys[0]] ?? null) ? (int)$usage[$keys[0]] : 0,
            \is_numeric($usage[$keys[1]] ?? null) ? (int)$usage[$keys[1]] : 0,
        ];
    }

    /** Status code → what the merchant should do about it. The body is deliberately unread. */
    private function explain(int $status): string
    {
        return match (true) {
            $status === 400 => 'The AI provider rejected the request (HTTP 400). Usually the model '
                . 'name is wrong for this provider, or the prompt exceeds its context window.',
            $status === 401, $status === 403 => 'The AI provider rejected the API key (HTTP ' . $status
                . '). Check the key on the AI configuration page.',
            $status === 404 => 'The AI provider has no completions endpoint at this base URL (HTTP 404). '
                . 'The base URL must point at the API root, e.g. https://api.openai.com/v1.',
            $status === 413, $status === 422 => 'The AI provider refused the request as too large or '
                . 'malformed (HTTP ' . $status . '). Shorten the prompt or lower the token limit.',
            $status === 429 => 'The AI provider is rate-limiting or the account is out of quota '
                . '(HTTP 429).',
            $status >= 500  => 'The AI provider is currently failing (HTTP ' . $status . '). This says '
                . 'nothing about the configuration.',
            default => 'The AI provider answered HTTP ' . $status . ', which is not a success.',
        };
    }
}
