<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Admin;

/**
 * View model for the request-log tab.
 *
 * The log stores what the transport saw: an endpoint (`tools/call`, `initialize`, `auth_challenge`)
 * and a one-line parameter summary. For `tools/call` — which is nearly every row on a busy shop —
 * that made the Endpunkt column say `tools/call` two hundred times while the one thing a merchant
 * scans for, the tool name, sat mid-cell inside `tool=get_snippet args=[…]` (docs/UI_REVIEW.md U25).
 *
 * This class does the split, and nothing else: the summary format is written in exactly one place
 * ({@see \Plugin\tnt_mcp\src\Mcp\Http\McpHttpEndpoint::paramsSummary()}) and read in exactly one
 * place, here. A row whose summary does not match keeps its endpoint as the headline and its
 * summary verbatim — an unparsed row must still be readable, because the rows that do not parse are
 * the interesting ones.
 */
final class RequestLogView
{
    /**
     * The 401 that STARTS an OAuth handshake. Painting it red made the log open with what looked
     * like two failures, which teaches a merchant to ignore the colour that matters elsewhere
     * (docs/UI_REVIEW.md U27).
     */
    public const HANDSHAKE_ENDPOINT = 'auth_challenge';

    /**
     * The other half of the same handshake (docs/UI_REVIEW.md U36). A client that has not been
     * given credentials yet probes discovery FIRST and is answered 401, so every legacy session
     * opened the log with a red row before the connect that worked.
     *
     * ⚠️ Deliberately gated on auth mode `none` — a request that carried NO credentials. A 401 on
     * auth `invalid` means a token was presented and rejected, which is a real problem a merchant
     * must see in red; widening this to `invalid` would hide bad-token rows behind the same excuse.
     */
    private const HANDSHAKE_PROBE_ENDPOINTS = ['tools/list', 'initialize'];

    /**
     * @param iterable<object> $entries rows as {@see \Plugin\tnt_mcp\src\Mcp\Observability\RequestLogger::recent()}
     *        returns them
     * @return list<array{time:string,endpoint:string,tool:string,args:string,auth:string,status:int,duration:int,handshake:bool}>
     */
    public static function rows(iterable $entries): array
    {
        $out = [];
        foreach ($entries as $entry) {
            $endpoint = (string)($entry->cEndpoint ?? '');
            $summary  = (string)($entry->cParamsSummary ?? '');
            $auth     = (string)($entry->cAuthMode ?? '');
            $status   = (int)($entry->nStatus ?? 0);
            [$tool, $args] = self::split($summary);

            $out[] = [
                'time'      => (string)($entry->dCreated ?? ''),
                'endpoint'  => $endpoint,
                'tool'      => $tool,
                'args'      => $args,
                'auth'      => $auth,
                'status'    => $status,
                'duration'  => (int)($entry->nDurationMs ?? 0),
                'handshake' => self::isHandshake($endpoint, $auth, $status),
            ];
        }

        return $out;
    }

    private static function isHandshake(string $endpoint, string $auth, int $status): bool
    {
        if ($endpoint === self::HANDSHAKE_ENDPOINT) {
            return true;
        }

        return $status === 401
            && $auth === 'none'
            && \in_array($endpoint, self::HANDSHAKE_PROBE_ENDPOINTS, true);
    }

    /**
     * `tool=get_snippet args=[id,verbose]` → `['get_snippet', 'id,verbose']`.
     *
     * @return array{string, string} tool name (empty when this is not a tool call) and the argument
     *         list, which falls back to the whole summary so nothing is ever dropped
     */
    private static function split(string $summary): array
    {
        if (\preg_match('/^tool=(\S*)(?:\s+args=\[(.*)])?$/', \trim($summary), $m) !== 1) {
            return ['', $summary];
        }

        $tool = $m[1] === '?' ? '' : $m[1];

        return [$tool, $m[2] ?? ''];
    }
}
