/**
 * Model Context Protocol HTTP transport.
 *
 *   POST /mcp        — JSON-RPC 2.0 requests; body is a single request
 *                       or an array (batch). Response shape mirrors.
 *   GET  /mcp        — Lightweight discovery: returns server info +
 *                       protocol version + tool count.
 *
 * Auth: softAuth — `userId` in the McpContext is the cookie/PAT/OAuth
 * user when present, null otherwise. v1 tools are read-only and public-
 * only, so anonymous works; write tools (v2) will require requireAuth +
 * write-access on the target repo.
 *
 * Streamable-HTTP-mode is the recommended MCP transport for stateless
 * cloud servers. We don't emit server-sent notifications yet, so the
 * route is plain JSON in / JSON out.
 */

import { Hono } from "hono";
import type { Context } from "hono";
import { softAuth } from "../middleware/auth";
import type { AuthEnv } from "../middleware/auth";
import {
  routeMcpRequest,
  MCP_PROTOCOL_VERSION,
  MCP_SERVER_NAME,
  MCP_SERVER_VERSION,
} from "../lib/mcp";
import { defaultTools } from "../lib/mcp-tools";
import { config } from "../lib/config";

const mcp = new Hono<AuthEnv>();

mcp.use("*", softAuth);

/**
 * MCP authorization challenge (MCP auth spec + RFC 9728).
 *
 * When an anonymous client attempts to CALL a tool, respond 401 with a
 * `WWW-Authenticate: Bearer resource_metadata="…"` header pointing at our
 * protected-resource metadata. This is the signal a remote connector
 * (claude.ai, Cursor, Copilot, …) follows to start the OAuth flow — so adding
 * `<host>/mcp` as a connector and invoking a tool prompts sign-in, then works
 * end to end.
 *
 * The handshake (initialize / tools/list / ping / notifications) stays open so
 * any client can introspect the server unauthenticated — only `tools/call`
 * requires an identity. Session cookie / PAT (`glc_`) / OAuth bearer (`glct_`)
 * all satisfy it, so token clients (.mcp.json, CLI, VS Code extension) are
 * unaffected. GET /mcp discovery also stays open.
 */
function authChallenge(
  c: Context<AuthEnv>,
  id: unknown,
  opts?: { invalidToken?: boolean }
) {
  const rm = `${config.appBaseUrl}/.well-known/oauth-protected-resource`;
  if (opts?.invalidToken) {
    // RFC 6750 §3.1: `error="invalid_token"` tells the client its token is
    // expired/revoked, so it runs the refresh_token grant (or re-authorizes)
    // instead of replaying the dead token forever. The anonymous challenge
    // below deliberately omits `error=` — its absence means "no credentials
    // at all, start first-time auth".
    c.header(
      "WWW-Authenticate",
      `Bearer error="invalid_token", error_description="The access token is expired or invalid", resource_metadata="${rm}"`
    );
    return c.json(
      {
        jsonrpc: "2.0",
        id: id ?? null,
        error: {
          code: -32001,
          message: "Invalid or expired access token — refresh or re-authorize",
        },
      },
      401
    );
  }
  c.header("WWW-Authenticate", `Bearer resource_metadata="${rm}"`);
  return c.json(
    {
      jsonrpc: "2.0",
      id: id ?? null,
      error: { code: -32001, message: "Authentication required" },
    },
    401
  );
}

/** Extract the JSON-RPC id from a single (non-batch) envelope, else null. */
function idOf(body: unknown): unknown {
  return !Array.isArray(body) && body && typeof body === "object"
    ? (body as { id?: unknown }).id ?? null
    : null;
}

/** True when the JSON-RPC envelope (single or batch) invokes a tool. */
function invokesTool(body: unknown): boolean {
  const isCall = (e: unknown): boolean =>
    !!e &&
    typeof e === "object" &&
    (e as { method?: unknown }).method === "tools/call";
  if (Array.isArray(body)) return body.some(isCall);
  return isCall(body);
}

mcp.get("/mcp", (c) => {
  // A dead bearer on discovery gets the invalid_token signal too (plain JSON
  // — this endpoint is not JSON-RPC). Anonymous discovery stays open.
  if (c.get("bearerInvalid")) {
    const rm = `${config.appBaseUrl}/.well-known/oauth-protected-resource`;
    c.header(
      "WWW-Authenticate",
      `Bearer error="invalid_token", error_description="The access token is expired or invalid", resource_metadata="${rm}"`
    );
    return c.json(
      { error: "invalid_token", message: "Access token expired or invalid — refresh or re-authorize" },
      401
    );
  }
  const tools = defaultTools();
  return c.json({
    protocolVersion: MCP_PROTOCOL_VERSION,
    serverInfo: { name: MCP_SERVER_NAME, version: MCP_SERVER_VERSION },
    transport: "http",
    toolCount: Object.keys(tools).length,
    docs:
      "POST /mcp with a JSON-RPC 2.0 envelope to call. See https://spec.modelcontextprotocol.io/",
  });
});

mcp.post("/mcp", async (c) => {
  const user = c.get("user") ?? null;
  // Scope derivation mirrors the API-auth middleware:
  //   - OAuth bearer (glct_) → oauthScopes from middleware
  //   - PAT (glc_) → oauthScopes from middleware
  //   - Session cookie → full ["repo","user","admin"]
  //   - Anonymous → []
  const oauthScopes = c.get("oauthScopes");
  let scopes: string[] = [];
  if (Array.isArray(oauthScopes)) {
    scopes = oauthScopes;
  } else if (user) {
    scopes = ["repo", "user", "admin"];
  }
  const ctx = { userId: user?.id ?? null, scopes };
  const tools = defaultTools();

  let body: unknown;
  try {
    body = await c.req.json();
  } catch {
    return c.json(
      {
        jsonrpc: "2.0",
        id: null,
        error: { code: -32700, message: "Parse error" },
      },
      400
    );
  }

  // A bearer token was presented but rejected (expired/revoked/unknown).
  // Challenge EVERY method — including initialize — with invalid_token, so a
  // client reconnecting on a dead token fails loudly and refreshes or
  // re-authorizes, instead of getting a false-healthy handshake and then
  // looping on tools/call. Truly-anonymous handshake stays open below.
  if (c.get("bearerInvalid")) {
    return authChallenge(c, idOf(body), { invalidToken: true });
  }

  // Anonymous tool calls trigger the OAuth discovery challenge so connectors
  // sign in. The handshake (initialize/tools-list/ping/notifications) is left
  // open above so clients can introspect without a token.
  if (!user && invokesTool(body)) {
    return authChallenge(c, idOf(body));
  }

  if (Array.isArray(body)) {
    // Batched request — pass each through, drop nulls (notifications).
    const out = await Promise.all(
      body.map((entry) => routeMcpRequest(entry, { ctx, tools }))
    );
    const filtered = out.filter((r): r is NonNullable<typeof r> => r !== null);
    if (filtered.length === 0) return c.body(null, 204);
    return c.json(filtered);
  }

  const result = await routeMcpRequest(body, { ctx, tools });
  if (result === null) {
    // Notification — no response body, 204.
    return c.body(null, 204);
  }
  return c.json(result);
});

export default mcp;
