<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Ai;

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

/**
 * The one-time links `begin_ai_key_entry` hands out, so a merchant can type their API key into the
 * shop admin instead of pasting it into a chat window.
 *
 * ## What the token is and is not
 *
 * It is NOT authentication. Opening the link still requires a logged-in admin session, exactly like
 * any other URL under `/admin`; the {@see \Plugin\tnt_mcp\adminmenu\Controller\Ai} tab checks that
 * before it looks at the token at all. What the token adds is provenance and a note: "your assistant
 * prepared this form", plus a reason to focus the key field. That is why a leaked token is not a
 * credential — it opens a page the holder could not open anyway without admin rights.
 *
 * It is stored HASHED all the same. The row is cheap to protect and the alternative — a plaintext
 * one-time value sitting in a table a `run_sql_read` call can reach — teaches the wrong habit about
 * every other secret in this plugin.
 *
 * ## Single use and 15 minutes
 *
 * Both are the same idea: the link exists for the length of one conversation. A merchant who takes
 * longer, or who wants a second try, asks the assistant for a new one — which costs one tool call
 * and leaves a second audit entry, which is exactly the trail this feature should leave.
 *
 * Expired and consumed rows are swept on issue rather than by a cron: the table only grows when
 * someone is actively setting AI up, so the write path is the only place that ever needs to clean it.
 *
 * Every method fails CLOSED and never throws — a broken table must not take the KI tab down, it must
 * only mean "this link does not work", which is a state the tab already renders.
 */
final class AiSetupTokenStore
{
    public const TABLE = 'tnt_mcp_ai_setup_token';

    /** Design §5: long enough for a merchant to switch windows, short enough to be one sitting. */
    public const TTL_SECONDS = 900;

    /** Rows older than this are swept whether used or not — they are audit noise, not evidence. */
    private const SWEEP_AFTER = 86400;

    private readonly DbInterface $db;

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

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

    /**
     * Mints a token and returns its PLAINTEXT — the only moment it exists in readable form.
     *
     * @param string $createdBy free-text origin for the audit trail, e.g. an OAuth client name
     *
     * @return array{token: string, expiresAt: string}|null null when the row could not be written,
     *         because a link that was never stored must not be handed to a merchant
     */
    public function issue(string $createdBy = ''): ?array
    {
        $now       = ($this->clock)();
        $token     = \bin2hex(\random_bytes(16));
        $expiresAt = \date('Y-m-d H:i:s', $now + self::TTL_SECONDS);

        try {
            $this->sweep($now);
            $this->db->queryPrepared(
                'INSERT INTO `' . self::TABLE . '` (`tokenHash`, `createdAt`, `expiresAt`, `createdBy`)
                 VALUES (:h, :c, :e, :b)',
                [
                    'h' => self::hash($token),
                    'c' => \date('Y-m-d H:i:s', $now),
                    'e' => $expiresAt,
                    'b' => \mb_substr($createdBy, 0, 64),
                ]
            );
        } catch (Throwable) {
            return null;
        }

        return ['token' => $token, 'expiresAt' => $expiresAt];
    }

    /**
     * Spends a token. Returns what the tab should say — and marks the row used in the same breath,
     * so a reload of the link shows nothing rather than the note a second time.
     */
    public function consume(string $token): AiSetupTokenStatus
    {
        if ($token === '') {
            return AiSetupTokenStatus::Unknown;
        }

        try {
            $row = $this->db->getSingleObject(
                'SELECT `id`, `expiresAt`, `usedAt` FROM `' . self::TABLE . '` WHERE `tokenHash` = :h',
                ['h' => self::hash($token)]
            );
        } catch (Throwable) {
            return AiSetupTokenStatus::Unknown;
        }

        if ($row === null) {
            return AiSetupTokenStatus::Unknown;
        }

        // A token that was already spent and one that ran out are the same message to the merchant:
        // ask your assistant for a new link. Distinguishing them would only tell them off.
        if (($row->usedAt ?? null) !== null) {
            return AiSetupTokenStatus::Expired;
        }

        $expires = \strtotime((string)($row->expiresAt ?? ''));
        if ($expires === false || $expires <= ($this->clock)()) {
            return AiSetupTokenStatus::Expired;
        }

        try {
            $this->db->queryPrepared(
                'UPDATE `' . self::TABLE . '` SET `usedAt` = :u WHERE `id` = :id AND `usedAt` IS NULL',
                ['u' => \date('Y-m-d H:i:s', ($this->clock)()), 'id' => (int)$row->id]
            );
        } catch (Throwable) {
            // Fail closed: a token that could not be marked spent must not be usable.
            return AiSetupTokenStatus::Unknown;
        }

        return AiSetupTokenStatus::Accepted;
    }

    /** SHA-256, not a password hash: the input is 128 bits of randomness, so there is nothing to slow down. */
    private static function hash(string $token): string
    {
        return \hash('sha256', $token);
    }

    private function sweep(int $now): void
    {
        try {
            $this->db->queryPrepared(
                'DELETE FROM `' . self::TABLE . '` WHERE `createdAt` < :cut',
                ['cut' => \date('Y-m-d H:i:s', $now - self::SWEEP_AFTER)]
            );
        } catch (Throwable) {
            // Housekeeping must never fail the thing it is housekeeping for.
        }
    }
}
