<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Observability;

use JTL\DB\DbInterface;
use stdClass;
use Throwable;

/**
 * Records every MCP HTTP request (endpoint/tool, a non-sensitive params summary, auth mode, HTTP
 * status, duration) into tnt_mcp_request_log, for the admin "Anfrage-Protokoll" tab. Hooked in ONE
 * place — the dispatch choke point in {@see \Plugin\tnt_mcp\src\Mcp\Http\McpHttpEndpoint} — so it
 * covers success, error and denied outcomes alike.
 *
 * Never blocks the request: a failed write is swallowed. Never logs a secret: only the method /
 * tool name and the argument KEY names are summarised, never the bearer token or argument VALUES.
 * Constructed with a null DB in tests / before the route is wired, where it is a no-op. The log is
 * kept bounded: {@see prune()} caps the table at {@see MAX_ROWS}, invoked probabilistically so the
 * cost is amortised across requests rather than paid on every insert.
 */
final class RequestLogger
{
    /** Hard cap on retained rows; older rows are pruned so the log cannot grow unbounded. */
    public const MAX_ROWS = 2000;

    /** 1-in-N chance a given insert also runs a prune pass (keeps the amortised cost tiny). */
    private const PRUNE_EVERY = 25;

    public function __construct(private readonly ?DbInterface $db = null)
    {
    }

    public function record(
        string $endpoint,
        string $paramsSummary,
        string $authMode,
        int $status,
        float $durationMs,
    ): void {
        if ($this->db === null) {
            return;
        }

        try {
            $row                 = new stdClass();
            $row->cEndpoint      = mb_substr($endpoint, 0, 191);
            $row->cParamsSummary = mb_substr($paramsSummary, 0, 500);
            $row->cAuthMode      = mb_substr($authMode, 0, 32);
            $row->nStatus        = max(0, $status);
            $row->nDurationMs    = max(0, (int)round($durationMs));
            $row->dCreated       = date('Y-m-d H:i:s');
            $this->db->insertRow('tnt_mcp_request_log', $row);

            if (mt_rand(1, self::PRUNE_EVERY) === 1) {
                $this->prune();
            }
        } catch (Throwable) {
            // Logging is best-effort; a failure must never break the request.
        }
    }

    /**
     * Most-recent-first slice of the log for the admin viewer. Best-effort: returns [] on any error
     * (e.g. table not yet migrated) so the tab still renders.
     *
     * @return list<stdClass>
     */
    public function recent(int $limit = 200): array
    {
        if ($this->db === null) {
            return [];
        }

        try {
            $limit = max(1, min($limit, self::MAX_ROWS));

            // getObjects() is typed array<stdClass>; a fetch-all result is always sequential, and
            // array_values() makes that guarantee explicit for the list<> contract above.
            return \array_values($this->db->getObjects(
                'SELECT kLog, cEndpoint, cParamsSummary, cAuthMode, nStatus, nDurationMs, dCreated
                   FROM tnt_mcp_request_log
                  ORDER BY kLog DESC
                  LIMIT ' . $limit,
            ));
        } catch (Throwable) {
            return [];
        }
    }

    /** Delete rows older than the newest {@see MAX_ROWS}. Idempotent; best-effort. */
    public function prune(): void
    {
        if ($this->db === null) {
            return;
        }

        try {
            $cutoff = $this->db->getSingleInt(
                'SELECT COALESCE(MAX(kLog), 0) AS c FROM tnt_mcp_request_log',
                'c',
            ) - self::MAX_ROWS;
            if ($cutoff > 0) {
                $this->db->queryPrepared(
                    'DELETE FROM tnt_mcp_request_log WHERE kLog <= :cutoff',
                    ['cutoff' => $cutoff],
                );
            }
        } catch (Throwable) {
            // Best-effort pruning.
        }
    }
}
