<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Ai;

/**
 * An {@see AiCompleter} that enforces the shop's budget and records what every call cost (4.5).
 *
 * ## Why a decorator and not two more constructor parameters
 *
 * {@see AiChatClient} already holds the key handling, two wire formats and a retry policy; the
 * seam {@see AiCompleter} was built in phase 4.4 exists precisely so this phase could wrap that
 * class rather than grow it. The split also puts the guard where it belongs: the client's job is
 * "make this request work", and the question of whether the request should be made at all is the
 * shop's policy, not the transport's. A shop with no budget configured runs through this class
 * unchanged, paying two integer comparisons.
 *
 * ## The guard runs BEFORE the request
 *
 * A limit checked after the call would have already spent the money. The counters are therefore
 * read first, and a refusal returns a failed {@see AiChatResult} naming the limit — the action's
 * contract is a bool, so the merchant's only way to learn WHY is the sentence in the log.
 *
 * ⚠️ The check is read-then-write with no lock. Two workflows firing in the same second can both
 * see 9 of 10 runs and both proceed, so a limit is a ceiling with a small overshoot, not a hard
 * cap. Locking the counter row on the request path would serialise every AI call in the shop to
 * prevent an overshoot of one call — the wrong trade for a spending ceiling. It is a ceiling on the
 * merchant's bill, not a security boundary.
 *
 * ## Recording is best-effort and never fails a call
 *
 * A completion that succeeded is a success even if writing its counter row did not. The repository
 * swallows its own write failures; nothing here re-raises them.
 */
final class BudgetedCompleter implements AiCompleter
{
    private ?AiCallRecord $lastRecord = null;

    private readonly AiBudget $budget;

    private readonly AiUsageRepository $usage;

    /** @var callable(): float monotonic-ish seconds; injectable so a test can measure nothing */
    private $timer;

    /**
     * Everything is injectable and everything is lazy for the same reason the client is: a workflow
     * ACTION is instantiated merely to be described (the admin form, `getTemplateData()`,
     * `describe_workflow_capabilities`), and describing a template must not touch the database.
     *
     * @param callable(): float|null $timer injected by tests
     */
    public function __construct(
        private readonly AiCompleter $inner,
        ?AiBudget $budget = null,
        ?AiUsageRepository $usage = null,
        ?callable $timer = null,
    ) {
        $this->budget = $budget ?? AiBudget::fromSettings();
        $this->usage  = $usage ?? new AiUsageRepository();
        $this->timer  = $timer ?? static fn (): float => \microtime(true);
    }

    public function complete(AiPromptRequest $request): AiChatResult
    {
        $refusal = $this->refusal();
        if ($refusal !== '') {
            $this->lastRecord = AiCallRecord::blocked($request, $refusal);

            return AiChatResult::failed($refusal, model: $request->model);
        }

        $started = ($this->timer)();
        $result  = $this->inner->complete($request);
        $elapsed = (int)\round((($this->timer)() - $started) * 1000);

        $model      = $result->model !== '' ? $result->model : $request->model;
        $costMicros = $this->budget->pricing->costMicros(
            $model,
            $result->promptTokens,
            $result->completionTokens
        );

        $this->lastRecord = AiCallRecord::of($request, $result, $costMicros, \max(0, $elapsed));
        $this->usage->record($costMicros, $result->promptTokens, $result->completionTokens);

        return $result;
    }

    /**
     * What the last {@see complete()} cost and how it went, or `null` before the first call.
     *
     * This is what the AI action hands to the execution log; see
     * {@see \Plugin\tnt_mcp\src\Interfaces\ProvidesExecutionDetail}.
     */
    public function lastRecord(): ?AiCallRecord
    {
        return $this->lastRecord;
    }

    /**
     * Reading the counters is skipped entirely when no limit is set — the overwhelmingly common
     * case, and the one where a database round trip per AI call would be pure cost.
     */
    private function refusal(): string
    {
        if (!$this->budget->hasSpendLimit() && !$this->budget->hasRunLimit()) {
            return '';
        }

        return $this->budget->refusalReason(
            $this->budget->hasSpendLimit() ? $this->usage->spentTodayMicros() : 0,
            $this->budget->hasRunLimit() ? $this->usage->runsThisHour() : 0,
        );
    }
}
