<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Ai;

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

/**
 * Reads and writes the one {@see AiStatusRecord} row (`tnt_mcp_ai_status`).
 *
 * ## Every write is best-effort
 *
 * This class is called from the AI call path: a merchant's action must never fail *because
 * recording its health failed*. So every method swallows database errors — including "table does
 * not exist", which is the state of a shop between installing this version and running its
 * migration. A lost signal costs a stale banner and nothing else, and the next call replaces it.
 *
 * ## Why success and failure are separate methods
 *
 * `lastOkAt` and `lastErrorAt` are both kept forever, not collapsed into one "last result" column,
 * because the interesting question is not "what happened last" but "has this EVER worked recently"
 * — that is what separates a transient blip (Eingeschränkt) from a broken setup (Gestört). Keeping
 * both timestamps is the cheapest way to answer it. {@see AiStatus} does the comparing.
 */
final class AiStatusStore
{
    public const TABLE = 'tnt_mcp_ai_status';

    /** The one row. The table exists to hold a singleton, like `tnt_mcp_ai_config`. */
    private const ROW_ID = 1;

    /** The provider's message is a detail line in a banner, not a log — keep it to one. */
    private const MAX_MESSAGE_LENGTH = 255;

    private readonly DbInterface $db;

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

    /**
     * @param callable(): int|null $clock unix-timestamp source, injected by tests so a record can be
     *                                    aged without sleeping
     */
    public function __construct(?DbInterface $db = null, ?callable $clock = null)
    {
        $this->db    = $db ?? Shop::Container()->getDB();
        $this->clock = $clock ?? static fn (): int => \time();
    }

    /** Never throws; an unreadable or absent row reads as "nothing observed yet". */
    public function load(): AiStatusRecord
    {
        try {
            $row = $this->db->getSingleObject(
                'SELECT * FROM `' . self::TABLE . '` WHERE `id` = :id',
                ['id' => self::ROW_ID]
            );
        } catch (Throwable) {
            return new AiStatusRecord();
        }

        if ($row === null) {
            return new AiStatusRecord();
        }

        return new AiStatusRecord(
            lastOkAt: ($row->lastOkAt ?? null) === null ? null : (string)$row->lastOkAt,
            lastErrorAt: ($row->lastErrorAt ?? null) === null ? null : (string)$row->lastErrorAt,
            lastErrorKind: (string)($row->lastErrorKind ?? ''),
            lastErrorMessage: (string)($row->lastErrorMessage ?? ''),
            lastLatencyMs: (int)($row->lastLatencyMs ?? 0),
            lastSource: (string)($row->lastSource ?? ''),
            lastCheckedAt: ($row->lastCheckedAt ?? null) === null ? null : (string)$row->lastCheckedAt,
        );
    }

    /**
     * Records that a call succeeded. The previous error is kept: "it failed an hour ago but works
     * now" is exactly the Eingeschränkt case, and blanking the error would hide it.
     *
     * @param string $source one of {@see AiStatusRecord}'s SOURCE_* constants
     */
    public function recordSuccess(int $latencyMs = 0, string $source = AiStatusRecord::SOURCE_LIVE): void
    {
        $now = $this->now();
        $this->write([
            'lastOkAt'      => $now,
            'lastLatencyMs' => \max(0, $latencyMs),
            'lastSource'    => $source,
            'lastCheckedAt' => $now,
        ]);
    }

    /**
     * Records that a call failed.
     *
     * @param string $kind    one of {@see AiStatusRecord}'s KIND_* constants — use
     *                        {@see AiStatusRecord::classify()} rather than passing a literal
     * @param string $message the provider's own words, truncated. ⚠️ Callers must not pass anything
     *                        derived from the API key: this string is rendered in the admin.
     */
    public function recordFailure(
        string $kind,
        string $message = '',
        string $source = AiStatusRecord::SOURCE_LIVE,
    ): void {
        $now = $this->now();
        $this->write([
            'lastErrorAt'      => $now,
            'lastErrorKind'    => $kind,
            'lastErrorMessage' => \mb_substr($message, 0, self::MAX_MESSAGE_LENGTH),
            'lastSource'       => $source,
            'lastCheckedAt'    => $now,
        ]);
    }

    /** Translates a connection test straight into the matching record write. */
    public function recordConnectionResult(
        AiConnectionResult $result,
        int $latencyMs = 0,
        string $source = AiStatusRecord::SOURCE_TEST,
    ): void {
        if ($result->ok) {
            $this->recordSuccess($latencyMs, $source);

            return;
        }

        $this->recordFailure(AiStatusRecord::classify($result->status), $result->message, $source);
    }

    /** Forgets every signal — paired with {@see AiConfigurationStore::clear()}. */
    public function clear(): void
    {
        try {
            $this->db->queryPrepared(
                'DELETE FROM `' . self::TABLE . '` WHERE `id` = :id',
                ['id' => self::ROW_ID]
            );
        } catch (Throwable) {
            // best-effort; see the class doc block
        }
    }

    /**
     * Whether the record is older than $ttlSeconds, i.e. the tab should spend a cheap call to
     * refresh it. A record that has never been written is always stale.
     */
    public function isStale(AiStatusRecord $record, int $ttlSeconds): bool
    {
        if ($record->lastCheckedAt === null) {
            return true;
        }

        $checked = \strtotime($record->lastCheckedAt);

        return $checked === false || ($this->clock)() - $checked >= $ttlSeconds;
    }

    /**
     * UPDATEs the named columns, INSERTing the row first if it is missing.
     *
     * Written as insert-then-update rather than `REPLACE INTO` because a REPLACE would drop the
     * columns this call does not name — recording a failure would erase `lastOkAt`, and with it the
     * "worked recently" evidence the Eingeschränkt state is built on.
     *
     * @param array<string, scalar> $columns
     */
    private function write(array $columns): void
    {
        $assignments = [];
        foreach (\array_keys($columns) as $column) {
            $assignments[] = '`' . $column . '` = :' . $column;
        }

        try {
            $this->db->queryPrepared(
                'INSERT IGNORE INTO `' . self::TABLE . '` (`id`) VALUES (:id)',
                ['id' => self::ROW_ID]
            );
            $this->db->queryPrepared(
                'UPDATE `' . self::TABLE . '` SET ' . \implode(', ', $assignments) . ' WHERE `id` = :id',
                $columns + ['id' => self::ROW_ID]
            );
        } catch (Throwable) {
            // best-effort; see the class doc block
        }
    }

    private function now(): string
    {
        return \date('Y-m-d H:i:s', ($this->clock)());
    }
}
