<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\OAuth;

/**
 * Single-use, session-bound nonce for the OAuth consent screen.
 *
 * Why this exists: the consent decision used to be a GET parameter (`?approve=yes`) on a route the
 * browser reaches with its ambient backend cookie. JTL's admin cookie is SameSite=Lax
 * (includes/src/Session/CookieConfig.php), which IS sent on a top-level GET navigation — so any page
 * could link a logged-in admin straight past the consent screen and mint an admin-bound MCP token
 * against an attacker-registered client. Registration is unauthenticated by design (RFC 7591), so the
 * attacker needs nothing but the link.
 *
 * The fix is the classic pair: the decision only counts when it arrives as a POST body, and the body
 * has to carry a nonce this server minted while rendering the consent HTML for *this* browser
 * session. A cross-site form can forge the POST but cannot read the nonce.
 *
 * Storage is the frontend `$_SESSION`, written unconditionally rather than behind a
 * `session_status()` check: on the authorize route the session is always live, and gating the write
 * would silently disable the protection anywhere it is not — exactly the wrong failure direction.
 * The frontend session is the right home for it: by the time the consent screen renders,
 * {@see JtlAdminSession::isAuthenticated()} has already restored it (see its `finally` block), so it
 * is the only session reliably ours at that point. Losing the nonce (session expiry, cookie-less
 * client) fails closed: the decision is refused and the admin re-renders the consent screen.
 */
final class ConsentNonce
{
    public const FIELD = 'tnt_mcp_consent';

    private const SESSION_KEY = 'tnt_mcp_consent_nonces';

    /** Nonces to keep. Enough for a few parallel consent tabs, small enough to bound the session. */
    private const KEEP = 5;

    /** Mints a nonce, remembers it in the session and returns it. */
    public static function issue(): string
    {
        $nonce = \bin2hex(\random_bytes(32));
        $kept  = self::stored();
        $kept[] = $nonce;
        if (\count($kept) > self::KEEP) {
            $kept = \array_slice($kept, -self::KEEP);
        }
        $_SESSION[self::SESSION_KEY] = $kept;

        return $nonce;
    }

    /**
     * Consumes a nonce. True only if it was issued to this session; a nonce never verifies twice, so
     * a replayed approval re-renders the consent screen instead of minting a second token.
     */
    public static function consume(?string $candidate): bool
    {
        if (!\is_string($candidate) || $candidate === '') {
            return false;
        }

        $kept  = self::stored();
        $found = false;
        $rest  = [];
        foreach ($kept as $nonce) {
            if (!$found && \hash_equals($nonce, $candidate)) {
                $found = true;
                continue;
            }
            $rest[] = $nonce;
        }
        if ($found) {
            $_SESSION[self::SESSION_KEY] = $rest;
        }

        return $found;
    }

    /** @return list<string> */
    private static function stored(): array
    {
        $kept = $_SESSION[self::SESSION_KEY] ?? null;
        if (!\is_array($kept)) {
            return [];
        }

        return \array_values(\array_filter($kept, static fn ($n): bool => \is_string($n) && $n !== ''));
    }
}
