<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Ai;

/**
 * Everything worth knowing about ONE completion, in the shape the execution log stores (epic 4.5).
 *
 * It exists because the four things phase 4.5 has to record — what was asked, what came back, what
 * it cost, how long it took — are known in three different places: the request, the result, and the
 * stopwatch around them. {@see BudgetedCompleter} is the only code that sees all three, so it
 * assembles this once and everything downstream reads it instead of re-deriving it.
 *
 * ## What goes in a log, and what does not
 *
 * The prompt and the answer are stored as EXCERPTS. A workflow prompt can carry an entire order and
 * an answer can be a page of prose; the execution log's context column holds 4000 characters for
 * ALL of an entry's data, and a log that truncates the token counts off the end to fit a long
 * answer would have recorded the wrong thing. {@see self::EXCERPT_CHARS} of each is enough to
 * answer "what did we ask, and did the answer look right?", which is what the merchant opens the
 * log for.
 *
 * ⚠️ `error` is this plugin's own sentence, never the provider's response body — see
 * {@see AiChatResult} for why that distinction is load-bearing and not stylistic.
 */
final class AiCallRecord
{
    /**
     * Characters of the prompt and of the answer kept in the log.
     *
     * Below the 255 at which {@see \Plugin\tnt_mcp\src\DomainObjects\ExecutionLogEntry}
     * summarises any string, so the excerpt reaches the log whole instead of being truncated twice
     * with two different ellipses.
     */
    public const EXCERPT_CHARS = 240;

    /**
     * @param bool     $blocked          true when the budget guard refused: no request was sent
     * @param int|null $costMicros       micro-dollars, or null when the model has no known price
     * @param int      $durationMs       wall clock around the whole call, retries included
     */
    private function __construct(
        public readonly bool $ok,
        public readonly bool $blocked,
        public readonly string $model,
        public readonly string $error,
        public readonly int $promptTokens,
        public readonly int $completionTokens,
        public readonly ?int $costMicros,
        public readonly int $durationMs,
        public readonly int $attempts,
        public readonly int $status,
        public readonly string $promptExcerpt,
        public readonly string $answerExcerpt,
    ) {
    }

    public static function of(
        AiPromptRequest $request,
        AiChatResult $result,
        ?int $costMicros,
        int $durationMs,
    ): self {
        return new self(
            ok: $result->ok,
            blocked: false,
            // The provider reports the model it actually served; falling back to the asked-for one
            // keeps the log useful when it reports nothing, and '' means "the shop default".
            model: $result->model !== '' ? $result->model : $request->model,
            error: $result->error,
            promptTokens: $result->promptTokens,
            completionTokens: $result->completionTokens,
            costMicros: $costMicros,
            durationMs: $durationMs,
            attempts: $result->attempts,
            status: $result->status,
            promptExcerpt: self::excerpt($request->prompt),
            answerExcerpt: self::excerpt($result->text),
        );
    }

    /** A call the guard refused. No request was sent, so there are no tokens and no cost. */
    public static function blocked(AiPromptRequest $request, string $reason): self
    {
        return new self(
            ok: false,
            blocked: true,
            model: $request->model,
            error: $reason,
            promptTokens: 0,
            completionTokens: 0,
            costMicros: 0,
            durationMs: 0,
            attempts: 0,
            status: 0,
            promptExcerpt: self::excerpt($request->prompt),
            answerExcerpt: '',
        );
    }

    public function totalTokens(): int
    {
        return $this->promptTokens + $this->completionTokens;
    }

    /**
     * The execution log's view of this call.
     *
     * `costKnown` is carried as its own flag rather than left implicit in a null cost, because the
     * log context is JSON that a human reads: `costUsd: "0.0000"` next to `costKnown: false` says
     * "we could not price this", where a bare zero would say "this was free".
     *
     * @return array<string, scalar>
     */
    public function toDetail(): array
    {
        return [
            'ai'            => true,
            'aiModel'       => $this->model,
            'aiOk'          => $this->ok,
            'aiBlocked'     => $this->blocked,
            'aiStatus'      => $this->status,
            'aiAttempts'    => $this->attempts,
            'aiDurationMs'  => $this->durationMs,
            'aiPromptTok'   => $this->promptTokens,
            'aiOutputTok'   => $this->completionTokens,
            'aiCostUsd'     => AiBudget::formatUsd($this->costMicros ?? 0),
            'aiCostKnown'   => $this->costMicros !== null,
            'aiPrompt'      => $this->promptExcerpt,
            'aiAnswer'      => $this->answerExcerpt,
            'aiError'       => $this->error,
        ];
    }

    private static function excerpt(string $text): string
    {
        if (\mb_strlen($text) <= self::EXCERPT_CHARS) {
            return $text;
        }

        return \mb_substr($text, 0, self::EXCERPT_CHARS - 1) . "\u{2026}";
    }
}
