<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Ai;

/**
 * What a completion costs, in US dollars per million tokens (epic 4.5).
 *
 * ## Why a table in the plugin at all
 *
 * No provider returns a price with the answer — the response carries token counts and nothing else.
 * A budget guard and a cost log therefore need a price from somewhere, and the only two candidates
 * are a table shipped with the plugin or a number the merchant types in. This class is both: the
 * table answers for the models most shops actually use, and {@see self::withOverride()} answers for
 * everything else, including a model released after this build.
 *
 * ## Unknown is a first-class answer, not zero
 *
 * {@see self::costMicros()} returns `null` for a model with no price, and every caller treats that
 * as "cost unknown" rather than "cost nothing". A model whose price silently read as 0 would let a
 * spend limit be exceeded without ever tripping — the runs-per-hour limit, which needs no price at
 * all, is what still bounds such a shop. The execution log records `costKnown: false` so the
 * merchant can see WHY a day's total looks too small.
 *
 * ## Micro-dollars
 *
 * Money is carried as an integer of millionths of a dollar. A day's spend is a SUM() in MySQL, and
 * a float column would accumulate rounding across thousands of rows; a `BIGINT` of micros holds
 * about nine trillion dollars, which is enough.
 *
 * ⚠️ Prices drift. These are the published list prices as of 2026-08-04 and are a DEFAULT, not a
 * promise — the provider's invoice is the truth, and a shop whose model is mispriced here sets the
 * override.
 */
final class AiPricing
{
    /** One dollar, in the integer unit this class counts in. */
    public const MICROS_PER_USD = 1_000_000;

    private const TOKENS_PER_UNIT = 1_000_000;

    /**
     * Model id (lower-cased) => [input USD per 1M tokens, output USD per 1M tokens].
     *
     * Matching is exact first, then LONGEST PREFIX, so a dated snapshot such as
     * `claude-haiku-4-5-20251001` resolves through its family entry without a row of its own. The
     * prefix order is therefore load-bearing only in that longer keys must win, which
     * {@see self::lookup()} enforces by length rather than by the order written here.
     *
     * @var array<string, array{float, float}>
     */
    private const PRICES = [
        // Anthropic
        'claude-fable-5'    => [10.00, 50.00],
        'claude-mythos-5'   => [10.00, 50.00],
        'claude-opus-5'     => [5.00, 25.00],
        'claude-opus-4'     => [5.00, 25.00],
        'claude-sonnet-5'   => [3.00, 15.00],
        'claude-sonnet-4'   => [3.00, 15.00],
        'claude-haiku-4-5'  => [1.00, 5.00],
        // OpenAI
        'gpt-4.1-nano'      => [0.10, 0.40],
        'gpt-4.1-mini'      => [0.40, 1.60],
        'gpt-4.1'           => [2.00, 8.00],
        'gpt-4o-mini'       => [0.15, 0.60],
        'gpt-4o'            => [2.50, 10.00],
    ];

    /**
     * @param float|null $inputPerMillion  merchant override, USD per 1M input tokens
     * @param float|null $outputPerMillion merchant override, USD per 1M output tokens
     */
    private function __construct(
        private readonly ?float $inputPerMillion = null,
        private readonly ?float $outputPerMillion = null,
    ) {
    }

    /** The shipped table alone. */
    public static function default(): self
    {
        return new self();
    }

    /**
     * The shipped table plus a fallback price for every model the table does not know.
     *
     * The override is deliberately a FALLBACK and not a replacement: a shop that types a price for
     * its exotic model must not thereby misprice the well-known model it uses for everything else.
     */
    public static function withOverride(float $inputPerMillion, float $outputPerMillion): self
    {
        return new self(\max(0.0, $inputPerMillion), \max(0.0, $outputPerMillion));
    }

    /**
     * The cost of one call in micro-dollars, or `null` when this model has no price here.
     *
     * Rounds up (`ceil`), so a budget is never overrun by accumulated rounding-down.
     */
    public function costMicros(string $model, int $promptTokens, int $completionTokens): ?int
    {
        $price = $this->lookup($model);
        if ($price === null) {
            return null;
        }

        $usd = ($price[0] * \max(0, $promptTokens) + $price[1] * \max(0, $completionTokens))
            / self::TOKENS_PER_UNIT;

        return (int)\ceil($usd * self::MICROS_PER_USD);
    }

    /** Whether a price is known for this model — the same question, without the token counts. */
    public function knows(string $model): bool
    {
        return $this->lookup($model) !== null;
    }

    /**
     * @return array{0: float, 1: float}|null input/output USD per 1M tokens
     */
    private function lookup(string $model): ?array
    {
        $model = \strtolower(\trim($model));
        if ($model === '') {
            return $this->override();
        }

        if (isset(self::PRICES[$model])) {
            return self::PRICES[$model];
        }

        $best   = null;
        $bestId = '';
        foreach (self::PRICES as $id => $price) {
            if (\str_starts_with($model, $id) && \strlen($id) > \strlen($bestId)) {
                $best   = $price;
                $bestId = $id;
            }
        }

        return $best ?? $this->override();
    }

    /**
     * @return array{0: float, 1: float}|null
     */
    private function override(): ?array
    {
        if ($this->inputPerMillion === null || $this->outputPerMillion === null) {
            return null;
        }

        return [$this->inputPerMillion, $this->outputPerMillion];
    }
}
