<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Http;

use InvalidArgumentException;
use JsonException;
use JTL\DB\DbInterface;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\Diactoros\Response\JsonResponse;
use Plugin\tnt_mcp\src\Mcp\Audit\AuditLogger;
use Plugin\tnt_mcp\src\Mcp\Auth\TokenGuard;
use Plugin\tnt_mcp\src\Mcp\Mode\ModeSetting;
use Plugin\tnt_mcp\src\Mcp\Observability\RequestLogger;
use Plugin\tnt_mcp\src\Mcp\OAuth\DiscoveryMetadata;
use Plugin\tnt_mcp\src\Mcp\OAuth\OAuthTokenValidator;
use Plugin\tnt_mcp\src\Mcp\Server;
use Plugin\tnt_mcp\src\Mcp\ToolFactory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RuntimeException;
use Throwable;

/**
 * Streamable-HTTP (JSON) MCP endpoint. Handles a single JSON-RPC 2.0 POST and returns an
 * `application/json` response — the non-SSE happy path of the MCP Streamable-HTTP transport, which
 * modern connectors (Claude / ChatGPT) accept for request/response tool calls. Server-initiated
 * streaming (SSE upgrade) is a deliberate follow-up; nothing here needs a long-lived process or a
 * shared session store, so it slots cleanly into JTL's stateless per-request route lifecycle.
 *
 * Protocol handled: `initialize`, `ping`, `notifications/*` (acknowledged), `tools/list`,
 * `tools/call`. Auth is deny-by-default bearer-token ({@see TokenGuard}); `initialize`/`ping` are
 * open (handshake/liveness reveal nothing sensitive), everything touching tools requires a token.
 * Every tool call is scope-checked and audited by {@see Server::dispatch()}.
 */
final class McpHttpEndpoint
{
    private const PROTOCOL_VERSION = '2025-06-18';
    private const SERVER_NAME      = 'tnt_mcp';
    private const SERVER_VERSION   = '0.1.0';

    private readonly RequestLogger $requestLog;

    /** Per-request observability context, captured during {@see process()} for {@see handle()}. */
    private string $logEndpoint      = 'unknown';
    private string $logParamsSummary = '';
    private string $logAuthMode      = 'none';

    /**
     * @param list<\Plugin\tnt_mcp\src\Mcp\Contracts\Tool>|null $tools the served tool set; null (the
     *        only value production uses) resolves it per request from {@see ToolFactory}. Injected
     *        by tests that need a tool with a specific failure mode.
     */
    public function __construct(
        private readonly DbInterface $db,
        ?RequestLogger $requestLog = null,
        private readonly ?array $tools = null,
    ) {
        $this->requestLog = $requestLog ?? new RequestLogger($db);
    }

    /**
     * Single request choke point: times the whole request, delegates to {@see process()}, then logs
     * the outcome (endpoint/tool, params summary, auth mode, HTTP status, duration) exactly once —
     * covering the success, error and denied paths alike — before returning the response unchanged.
     */
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $start                  = microtime(true);
        $this->logEndpoint      = 'unknown';
        $this->logParamsSummary = '';
        $this->logAuthMode      = 'none';

        $response = $this->process($request);

        $this->requestLog->record(
            $this->logEndpoint,
            $this->logParamsSummary,
            $this->logAuthMode,
            $response->getStatusCode(),
            (microtime(true) - $start) * 1000.0,
        );

        return $response;
    }

    private function process(ServerRequestInterface $request): ResponseInterface
    {
        try {
            $message = json_decode((string)$request->getBody(), true, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            // ChatGPT's connector validates the endpoint by POSTing an empty, unauthenticated body
            // and expects the MCP-spec 401 + WWW-Authenticate challenge (so it can (re)discover the
            // protected-resource metadata), NOT a JSON parse error. Only answer 400 when a client
            // that actually presented a bearer sent malformed JSON.
            if ($this->bearerToken($request) === null) {
                $this->logEndpoint = 'auth_challenge';

                return $this->challenge($request, null, 'Authentication required');
            }
            $this->logEndpoint = 'parse_error';

            return $this->rpcError(null, -32700, 'Parse error', 400);
        }

        if (!\is_array($message) || array_is_list($message)) {
            // A JSON-RPC request/notification is a single object; batches are not supported in v1.
            $this->logEndpoint = 'invalid_request';

            return $this->rpcError(null, -32600, 'Invalid Request', 400);
        }

        $id     = $message['id'] ?? null;
        $method = \is_string($message['method'] ?? null) ? $message['method'] : '';
        $params = \is_array($message['params'] ?? null) ? $message['params'] : [];

        $this->logEndpoint      = $method !== '' ? $method : 'unknown';
        $this->logParamsSummary = $this->summariseParams($method, $params);

        if ($method === 'initialize') {
            return $this->ok($id, $this->initializeResult());
        }
        if ($method === 'ping') {
            return $this->ok($id, new \stdClass());
        }
        // Notifications carry no id and expect no response body (e.g. notifications/initialized).
        if (!\array_key_exists('id', $message)) {
            return new EmptyResponse(202);
        }

        $bearer = $this->bearerToken($request);
        if ($bearer === null) {
            return $this->challenge($request, $id, 'Missing bearer token');
        }

        $guard = $this->authenticate($bearer, $request);
        if ($guard === null) {
            $this->logAuthMode = 'invalid';

            return $this->challenge($request, $id, 'Invalid or revoked token');
        }

        $server = new Server(
            $this->tools ?? ToolFactory::enabledTools(),
            $guard,
            new AuditLogger($this->db),
            ModeSetting::fromDb($this->db),
        );

        return match ($method) {
            'tools/list' => $this->ok($id, ['tools' => $server->toolDefinitions()]),
            'tools/call' => $this->toolCall($server, $guard, $id, $params),
            default      => $this->rpcError($id, -32601, 'Method not found: ' . $method, 200),
        };
    }

    /**
     * @param array<string,mixed> $params
     */
    private function toolCall(Server $server, TokenGuard $guard, mixed $id, array $params): ResponseInterface
    {
        $name = \is_string($params['name'] ?? null) ? $params['name'] : '';
        $args = \is_array($params['arguments'] ?? null) ? $params['arguments'] : [];

        try {
            $result = $server->dispatch($name, $args, $guard->tokenId());
        } catch (RuntimeException | InvalidArgumentException $e) {
            // Tool-level failure is reported inside the MCP result (isError), NOT as a JSON-RPC
            // protocol error — that is what MCP clients expect for a tool that ran but refused.
            // `InvalidArgumentException` belongs in the same channel: tools throw it for a missing
            // or malformed argument, and its message is written FOR the client ("kind weekly needs
            // daysOfWeek, e.g. [1,3,5]"). Letting it escape hands the connector the shop's HTML
            // error page instead of an answer it can act on.
            return $this->toolError($id, $e->getMessage());
        } catch (Throwable $e) {
            // Anything else is a defect, not a refusal. It still must not leave this method as an
            // exception — an MCP client that asked for one tool must get a JSON-RPC response, not
            // a 404 page — but the message is NOT passed on, because an unexpected throwable's
            // text is internal (paths, SQL) and the client can do nothing with it.
            \error_log('tnt_mcp: tool "' . $name . '" failed: ' . $e->getMessage());

            return $this->toolError($id, 'The tool failed unexpectedly on this shop. The error was '
                . 'logged; nothing was changed by this call.');
        }

        return $this->ok($id, [
            'content' => [[
                'type' => 'text',
                // NOT pretty-printed: the reader is a model, not a person, and indentation is pure
                // context cost. Measured on this shop: `describe_shop_api` 44 756 B pretty vs
                // 19 132 B compact (57 % whitespace), `describe_workflow_capabilities` 48 595 vs
                // 30 729 (37 %). Same bytes of information either way, and every tool pays it.
                // UNESCAPED_SLASHES/UNICODE stay: they too are fewer bytes AND easier to read back.
                'text' => (string)json_encode(
                    $result,
                    JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
                ),
            ]],
            'isError' => false,
        ]);
    }

    /** A tool that ran and failed: a normal JSON-RPC result carrying `isError`. */
    private function toolError(mixed $id, string $message): ResponseInterface
    {
        return $this->ok($id, [
            'content' => [['type' => 'text', 'text' => $message]],
            'isError' => true,
        ]);
    }

    /**
     * Resolve a presented bearer against BOTH auth schemes, deny-by-default. The cheap legacy
     * sha256 lookup is tried first (preserves every existing static token unchanged); if that does
     * not match, the bearer is verified as an OAuth 2.1 access-token JWT. Either path yields a
     * {@see TokenGuard} carrying the token's scopes; null means neither scheme accepted it.
     *
     * @param non-empty-string $bearer
     */
    private function authenticate(string $bearer, ServerRequestInterface $request): ?TokenGuard
    {
        try {
            $guard             = TokenGuard::fromRequest($bearer, $this->db);
            $this->logAuthMode = 'legacy';

            return $guard;
        } catch (Throwable) {
            // Not a legacy token — fall through to OAuth.
        }

        $guard = OAuthTokenValidator::default($this->db)->validate($request);
        if ($guard !== null) {
            $this->logAuthMode = 'oauth';
        }

        return $guard;
    }

    /**
     * Build a short, NON-SENSITIVE summary of the request for the log: the tool name (for
     * tools/call) and the argument KEY names only — never argument values, and never the token.
     *
     * @param array<string,mixed> $params
     */
    private function summariseParams(string $method, array $params): string
    {
        if ($method === 'tools/call') {
            $name = \is_string($params['name'] ?? null) ? $params['name'] : '';
            $args = \is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
            $keys = array_map(static fn (mixed $k): string => (string)$k, array_keys($args));

            return 'tool=' . ($name !== '' ? $name : '?') . ' args=[' . implode(',', $keys) . ']';
        }

        if ($params === []) {
            return '';
        }

        $keys = array_map(static fn (mixed $k): string => (string)$k, array_keys($params));

        return 'params=[' . implode(',', $keys) . ']';
    }

    /**
     * 401 with the MCP-spec `WWW-Authenticate: Bearer resource_metadata=...` challenge, so an
     * unauthenticated connector can discover the protected-resource metadata and start OAuth.
     */
    private function challenge(ServerRequestInterface $request, mixed $id, string $message): ResponseInterface
    {
        // Public origin the connector reached us on (prefers trusted-proxy X-Forwarded-Host —
        // see {@see ForwardedRequest}) so the discovery URL it is handed is actually callable.
        $base = ForwardedRequest::baseUrl($request);

        return $this->rpcError($id, -32001, $message, 401)->withHeader(
            'WWW-Authenticate',
            DiscoveryMetadata::challenge($base . '/.well-known/oauth-protected-resource'),
        );
    }

    /** @return non-empty-string|null the `\S+` capture is non-empty by construction */
    private function bearerToken(ServerRequestInterface $request): ?string
    {
        if (preg_match('/^Bearer\s+(\S+)/i', $request->getHeaderLine('Authorization'), $m) !== 1) {
            return null;
        }

        return $m[1];
    }

    /**
     * The handshake, plus the one paragraph of orientation every client reads before it decides
     * anything.
     *
     * `instructions` is the MCP spec's own field for this and it used to be absent, so the whole
     * response was a name and a version — 144 bytes that told the model nothing. What goes in it is
     * what a client cannot work out from `tools/list`: where to start, that discovery is
     * incremental, and that a refusal explains itself.
     *
     * ⚠️ Deliberately STATIC. `initialize` is answered before any bearer is looked at, so naming the
     * server's mode or the token's scopes here would report shop state to an unauthenticated caller.
     * Both are already on the wire where they belong: `tools/list` shows only what the token may
     * call, and a refusal names the scope or the mode that would have allowed it.
     *
     * @return array<string,mixed>
     */
    private function initializeResult(): array
    {
        return [
            'protocolVersion' => self::PROTOCOL_VERSION,
            'capabilities'    => ['tools' => new \stdClass()],
            'serverInfo'      => ['name' => self::SERVER_NAME, 'version' => self::SERVER_VERSION],
            'instructions'    => 'These tools operate one live JTL shop. Start with shop_health, '
                . 'then get_shop_info. Discovery is incremental: call describe_workflow_capabilities '
                . '(triggers and actions), describe_shop_api (the PHP facade a snippet may call), '
                . 'list_admin_actions or get_guide with no arguments — or "index" — for an overview, '
                . 'then ask again for the entries you picked. tools/list already shows only what '
                . 'this token may call, and a refused call names the scope, the mode or the argument '
                . 'that would have allowed it, so read the refusal instead of guessing. Writes are '
                . 'explicit: use dryRun or validate_* first where a tool offers it, and batch to run '
                . 'several calls in one request.',
        ];
    }

    private function ok(mixed $id, mixed $result): ResponseInterface
    {
        return new JsonResponse(['jsonrpc' => '2.0', 'id' => $id, 'result' => $result]);
    }

    private function rpcError(mixed $id, int $code, string $message, int $httpStatus): ResponseInterface
    {
        return new JsonResponse(
            ['jsonrpc' => '2.0', 'id' => $id, 'error' => ['code' => $code, 'message' => $message]],
            $httpStatus,
        );
    }
}
