<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Ai;

use JTL\DB\DbInterface;
use JTL\Shop;
use Throwable;

/**
 * The ceilings a shop puts on its own LLM spending, and the prices used to measure it (epic 4.5).
 *
 * Three merchant settings, all shipped OFF (`0` = no limit):
 *
 * | Setting | Bounds |
 * |---|---|
 * | `tnt_mcp_ai_max_spend_day` | US dollars per calendar day, shop timezone |
 * | `tnt_mcp_ai_max_runs_hour` | calls per clock hour |
 * | `tnt_mcp_ai_price_override` | `input/output` USD per 1M tokens, for models {@see AiPricing} has no price for |
 *
 * ## Why two different limits
 *
 * They fail differently. A spend limit is the bill the merchant is willing to pay and answers the
 * question they actually have; it cannot bound a model whose price is unknown, and it cannot stop a
 * runaway loop quickly, because a cheap model can loop for hours inside a generous daily budget. A
 * runs-per-hour limit needs no price at all and stops a loop within the hour, but says nothing
 * about money. Neither one substitutes for the other, so both exist and a shop may set either.
 *
 * ## Ships off
 *
 * Unset means unlimited, not "some default number we picked". A limit that appeared without the
 * merchant choosing it would eventually refuse a workflow at 3am for a reason nobody configured,
 * and the failure would look like a broken plugin rather than a policy. The AI action already
 * refuses to run at all until a provider is configured, so a shop that never touches these settings
 * is in exactly the position it was in before this phase existed.
 */
final class AiBudget
{
    public const SETTING_MAX_SPEND_DAY = 'tnt_mcp_ai_max_spend_day';

    public const SETTING_MAX_RUNS_HOUR = 'tnt_mcp_ai_max_runs_hour';

    public const SETTING_PRICE_OVERRIDE = 'tnt_mcp_ai_price_override';

    /** Stable string PluginID (info.xml <PluginID>). */
    private const PLUGIN_ID = 'tnt_mcp';

    public readonly AiPricing $pricing;

    /**
     * @param int            $maxSpendPerDayMicros micro-dollars per day; 0 = no limit
     * @param int            $maxRunsPerHour       calls per clock hour; 0 = no limit
     * @param AiPricing|null $pricing              null = the shipped table with no override
     */
    public function __construct(
        public readonly int $maxSpendPerDayMicros = 0,
        public readonly int $maxRunsPerHour = 0,
        ?AiPricing $pricing = null,
    ) {
        $this->pricing = $pricing ?? AiPricing::default();
    }

    /**
     * Reads the three settings. Never throws — an unreadable setting reads as "no limit", for the
     * same reason {@see AiUsageRepository} fails open: a budget is a ceiling the merchant asked
     * for, and a shop that cannot read one has not asked for one.
     */
    public static function fromSettings(?DbInterface $db = null): self
    {
        $values = self::rawValues($db);

        return new self(
            maxSpendPerDayMicros: self::toMicros($values[self::SETTING_MAX_SPEND_DAY] ?? ''),
            maxRunsPerHour: \max(0, (int)($values[self::SETTING_MAX_RUNS_HOUR] ?? '0')),
            pricing: self::pricing($values[self::SETTING_PRICE_OVERRIDE] ?? ''),
        );
    }

    public function hasSpendLimit(): bool
    {
        return $this->maxSpendPerDayMicros > 0;
    }

    public function hasRunLimit(): bool
    {
        return $this->maxRunsPerHour > 0;
    }

    /**
     * Why this call must not be made, or `''` when it may.
     *
     * The sentence names the limit and its value, because it is read by a merchant in the execution
     * log hours later and "budget exceeded" would not tell them which setting to change.
     */
    public function refusalReason(int $spentTodayMicros, int $runsThisHour): string
    {
        if ($this->hasRunLimit() && $runsThisHour >= $this->maxRunsPerHour) {
            return \sprintf(
                'The AI rate limit of %d call(s) per hour is reached (%d so far this hour). '
                . 'Change it under Plugins → JTL Shop - AI Connector → KI.',
                $this->maxRunsPerHour,
                $runsThisHour
            );
        }

        if ($this->hasSpendLimit() && $spentTodayMicros >= $this->maxSpendPerDayMicros) {
            return \sprintf(
                'The AI spending limit of %s USD per day is reached (%s USD so far today). '
                . 'Change it under Plugins → JTL Shop - AI Connector → KI.',
                self::formatUsd($this->maxSpendPerDayMicros),
                self::formatUsd($spentTodayMicros)
            );
        }

        return '';
    }

    /** Micro-dollars as a plain decimal string — never a locale-formatted one, it goes in a log. */
    public static function formatUsd(int $micros): string
    {
        return \number_format($micros / AiPricing::MICROS_PER_USD, 4, '.', '');
    }

    /**
     * Whether a spend/runs field the merchant typed is storable — empty and `0` both mean "no
     * limit", so only a non-numeric or negative entry is refused. Public because the KI tab
     * rejects the entry BEFORE writing it, rather than silently reading it back as 0.
     */
    public static function isValidAmount(string $value): bool
    {
        $value = \trim($value);

        return $value === '' || (\is_numeric($value) && (float)$value >= 0.0);
    }

    /** Empty, or `input/output` with two non-negative numbers — the format {@see pricing()} reads. */
    public static function isValidPriceOverride(string $value): bool
    {
        $value = \trim($value);
        if ($value === '') {
            return true;
        }

        $parts = \explode('/', $value, 2);
        if (\count($parts) !== 2) {
            return false;
        }

        foreach ($parts as $part) {
            $part = \trim($part);
            if (!\is_numeric($part) || (float)$part < 0.0) {
                return false;
            }
        }

        return true;
    }

    private static function toMicros(string $usd): int
    {
        $usd = \trim($usd);
        if ($usd === '' || !\is_numeric($usd)) {
            return 0;
        }

        return \max(0, (int)\round((float)$usd * AiPricing::MICROS_PER_USD));
    }

    /** `"3.00/15.00"` — input price, slash, output price, both USD per 1M tokens. */
    private static function pricing(string $override): AiPricing
    {
        $parts = \explode('/', \trim($override), 2);
        if (\count($parts) !== 2 || !\is_numeric(\trim($parts[0])) || !\is_numeric(\trim($parts[1]))) {
            return AiPricing::default();
        }

        return AiPricing::withOverride((float)\trim($parts[0]), (float)\trim($parts[1]));
    }

    /**
     * The three settings as the merchant typed them — what the KI tab's form shows, as opposed to
     * the parsed ceilings {@see fromSettings()} builds from them.
     *
     * @return array<string, string>
     */
    public static function rawValues(?DbInterface $db = null): array
    {
        try {
            $db ??= Shop::Container()->getDB();
            /** @var array<int, \stdClass> $rows */
            $rows = $db->getObjects(
                'SELECT e.cName AS n, e.cWert AS v
                   FROM tplugineinstellungen e
                   INNER JOIN tplugin p ON p.kPlugin = e.kPlugin
                  WHERE p.cPluginID = :pid
                    AND e.cName IN (:a, :b, :c)',
                [
                    'pid' => self::PLUGIN_ID,
                    'a'   => self::SETTING_MAX_SPEND_DAY,
                    'b'   => self::SETTING_MAX_RUNS_HOUR,
                    'c'   => self::SETTING_PRICE_OVERRIDE,
                ]
            );
        } catch (Throwable) {
            return [];
        }

        $values = [];
        foreach ($rows as $row) {
            $values[(string)($row->n ?? '')] = (string)($row->v ?? '');
        }

        return $values;
    }
}
