<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Ai;

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

/**
 * The spend and call counters the budget guard reads and writes (`tnt_mcp_ai_usage`, epic 4.5).
 *
 * One row per time bucket, not one row per call — see the migration for why this is not a second
 * log. Two buckets are touched per call: `d:<date>` for the daily spend limit and `h:<date>T<hour>`
 * for the hourly rate limit.
 *
 * ## Failure policy: reads fail OPEN, writes fail silent
 *
 * A read that fails ({@see self::spentTodayMicros()}, {@see self::runsThisHour()}) reports ZERO,
 * which lets the call through. That is deliberate and is the opposite of this plugin's usual
 * fail-closed stance: the most common cause of a failing read is a shop that has upgraded the
 * plugin but not yet run the migration, and refusing every AI call on such a shop would break
 * workflows that never asked for a budget in the first place. A budget is an opt-in ceiling on
 * spending, not a security boundary — the security boundary is the API key, which lives elsewhere.
 *
 * A write that fails is swallowed, exactly as {@see \Plugin\tnt_mcp\src\Services\ExecutionLogService}
 * swallows its own: recording that a call happened must never be the reason the call is reported as
 * failed.
 *
 * ## Wall clock
 *
 * Buckets are named in the shop's own timezone, because "spend per day" is a question a merchant
 * asks about their day. The clock is injectable so the tests can hold it still — a test that named
 * its bucket from `date()` would fail once an hour, at the boundary.
 */
final class AiUsageRepository
{
    public const TABLE = 'tnt_mcp_ai_usage';

    /** Buckets older than this are dropped on write. Long enough for a monthly view, no longer. */
    private const KEEP_DAYS = 40;

    private readonly DbInterface $db;

    /** @var callable(): DateTimeImmutable */
    private $clock;

    /**
     * @param callable(): DateTimeImmutable|null $clock injected by tests
     */
    public function __construct(?DbInterface $db = null, ?callable $clock = null)
    {
        $this->db    = $db ?? Shop::Container()->getDB();
        $this->clock = $clock ?? static fn (): DateTimeImmutable => new DateTimeImmutable();
    }

    /**
     * Counts one completed call against both the day and the hour bucket.
     *
     * ⚠️ Called for FAILED calls too. A provider that answers 500 to every request still consumes
     * the shop's rate limit and can still be billed for the input tokens it read; a runs-per-hour
     * ceiling that only counted successes would not bound a workflow stuck in a retry loop, which
     * is the exact situation the ceiling exists for.
     *
     * @param int|null $costMicros the call's cost, or `null` when the model has no known price
     */
    public function record(?int $costMicros, int $promptTokens, int $completionTokens): void
    {
        $now  = ($this->clock)();
        $cost = \max(0, $costMicros ?? 0);

        try {
            foreach ([$this->dayBucket($now), $this->hourBucket($now)] as $bucket) {
                $this->db->queryPrepared(
                    'INSERT INTO `' . self::TABLE . '`
                        (`bucket`, `runs`, `costKnownRuns`, `costMicros`, `promptTokens`,
                         `completionTokens`, `updated`)
                     VALUES (:bucket, 1, :known, :cost, :prompt, :completion, :updated)
                     ON DUPLICATE KEY UPDATE
                        `runs`             = `runs` + 1,
                        `costKnownRuns`    = `costKnownRuns` + VALUES(`costKnownRuns`),
                        `costMicros`       = `costMicros` + VALUES(`costMicros`),
                        `promptTokens`     = `promptTokens` + VALUES(`promptTokens`),
                        `completionTokens` = `completionTokens` + VALUES(`completionTokens`),
                        `updated`          = VALUES(`updated`)',
                    [
                        'bucket'     => $bucket,
                        'known'      => $costMicros === null ? 0 : 1,
                        'cost'       => $cost,
                        'prompt'     => \max(0, $promptTokens),
                        'completion' => \max(0, $completionTokens),
                        'updated'    => $now->format('Y-m-d H:i:s'),
                    ]
                );
            }

            $this->prune($now);
        } catch (Throwable) {
            // See the class doc block: a counter must not be able to fail a workflow.
        }
    }

    /** Micro-dollars spent since midnight, shop time. 0 when unknown — see the class doc block. */
    public function spentTodayMicros(): int
    {
        return (int)($this->bucketRow($this->dayBucket(($this->clock)()))->costMicros ?? 0);
    }

    /** Calls started in the current clock hour. 0 when unknown — see the class doc block. */
    public function runsThisHour(): int
    {
        return (int)($this->bucketRow($this->hourBucket(($this->clock)()))->runs ?? 0);
    }

    /**
     * Everything recorded for one day, for the admin surface.
     *
     * `runs > costKnownRuns` means the day's `costMicros` UNDERSTATES what the provider will bill,
     * because some call used a model this build has no price for. Anything rendering this must say
     * so rather than present the total as complete.
     *
     * @return array{runs: int, costKnownRuns: int, costMicros: int, promptTokens: int, completionTokens: int}
     */
    public function day(?DateTimeImmutable $day = null): array
    {
        $row = $this->bucketRow($this->dayBucket($day ?? ($this->clock)()));

        return [
            'runs'             => (int)($row->runs ?? 0),
            'costKnownRuns'    => (int)($row->costKnownRuns ?? 0),
            'costMicros'       => (int)($row->costMicros ?? 0),
            'promptTokens'     => (int)($row->promptTokens ?? 0),
            'completionTokens' => (int)($row->completionTokens ?? 0),
        ];
    }

    private function bucketRow(string $bucket): \stdClass
    {
        try {
            $row = $this->db->getSingleObject(
                'SELECT * FROM `' . self::TABLE . '` WHERE `bucket` = :bucket',
                ['bucket' => $bucket]
            );
        } catch (Throwable) {
            $row = null;
        }

        return $row ?? new \stdClass();
    }

    private function prune(DateTimeImmutable $now): void
    {
        $this->db->queryPrepared(
            'DELETE FROM `' . self::TABLE . '` WHERE `updated` < :cutoff',
            ['cutoff' => $now->modify('-' . self::KEEP_DAYS . ' days')->format('Y-m-d H:i:s')]
        );
    }

    private function dayBucket(DateTimeImmutable $now): string
    {
        return 'd:' . $now->format('Y-m-d');
    }

    private function hourBucket(DateTimeImmutable $now): string
    {
        return 'h:' . $now->format('Y-m-d\TH');
    }
}
