/// <reference types="bun-types" />
/**
 * End-to-end tests for serveMcpOverHttp.
 *
 * Boots an ephemeral server with a single dummy tool and exercises:
 *   - GET /health (200, unauthenticated)
 *   - POST /mcp without auth (401)
 *   - POST /mcp with valid bearer: initialize -> tools/list -> tools/call
 *   - legacySse:true throws at startup (Phase A does not support it; the
 *     earlier placeholder bridge returned -32601 for every dispatched
 *     tool call, which is worse than 404).
 *   - With attachTo: an unrelated path falls through to the supplied
 *     handler.
 */
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { serveMcpOverHttp, type ServeMcpResult } from "./streamable-transport.js";

const TOKEN = "test-token-abc-123";

function buildServer(name = "mcp-shared-test"): Server {
  const s = new Server(
    { name, version: "0.0.0-test" },
    { capabilities: { tools: {} } },
  );
  s.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: [
      {
        name: "echo",
        description: "Echo back the input string.",
        inputSchema: {
          type: "object",
          properties: { text: { type: "string" } },
          required: ["text"],
        },
      },
    ],
  }));
  s.setRequestHandler(CallToolRequestSchema, async (req) => {
    const args = (req.params.arguments ?? {}) as { text?: string };
    return {
      content: [{ type: "text", text: `echo:${args.text ?? ""}` }],
    };
  });
  return s;
}

async function pickPort(): Promise<number> {
  // Bun.serve with port:0 binds an ephemeral port; capture it.
  const tmp = Bun.serve({ port: 0, fetch: () => new Response("x") });
  const port = tmp.port;
  tmp.stop();
  // Tiny wait so the kernel releases the port; not strictly required
  // since Bun reuses SO_REUSEADDR, but cheap insurance.
  await new Promise((r) => setTimeout(r, 5));
  return port;
}

describe("serveMcpOverHttp (standalone mode)", () => {
  let booted: ServeMcpResult;
  let baseUrl: string;

  beforeAll(async () => {
    const port = await pickPort();
    booted = serveMcpOverHttp({
      server: buildServer(),
      port,
      token: TOKEN,
    });
    baseUrl = `http://127.0.0.1:${port}`;
  });

  afterAll(async () => {
    await booted.close();
  });

  it("GET /health returns 200 unauthenticated", async () => {
    const res = await fetch(`${baseUrl}/health`);
    expect(res.status).toBe(200);
    const body = (await res.json()) as { ok: boolean; service: string };
    expect(body.ok).toBe(true);
    expect(body.service).toBe("mcp-shared-test");
  });

  it("POST /mcp without bearer returns 401", async () => {
    const res = await fetch(`${baseUrl}/mcp`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/list",
      }),
    });
    expect(res.status).toBe(401);
    expect(res.headers.get("www-authenticate")).toContain("Bearer");
  });

  it("POST /mcp with wrong bearer returns 401", async () => {
    const res = await fetch(`${baseUrl}/mcp`, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization: "Bearer wrong-token",
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/list",
      }),
    });
    expect(res.status).toBe(401);
  });

  it("initialize -> tools/list -> tools/call round trip with valid bearer", async () => {
    const transport = new StreamableHTTPClientTransport(
      new URL(`${baseUrl}/mcp`),
      {
        requestInit: { headers: { authorization: `Bearer ${TOKEN}` } },
      },
    );
    const client = new Client(
      { name: "mcp-shared-test-client", version: "0.0.0" },
      { capabilities: {} },
    );
    await client.connect(transport);

    const tools = await client.listTools();
    expect(tools.tools.length).toBe(1);
    expect(tools.tools[0]!.name).toBe("echo");

    const call = await client.callTool({
      name: "echo",
      arguments: { text: "hello" },
    });
    const content = call.content as Array<{ type: string; text: string }>;
    expect(content[0]!.text).toBe("echo:hello");

    await client.close();
  });

  it("unknown route returns 404", async () => {
    const res = await fetch(`${baseUrl}/does-not-exist`);
    expect(res.status).toBe(404);
  });
});

describe("serveMcpOverHttp (legacySse not supported in Phase A)", () => {
  it("throws when legacySse:true is passed", () => {
    expect(() =>
      serveMcpOverHttp({
        server: buildServer("legacy-sse-test"),
        port: 0,
        token: TOKEN,
        legacySse: true,
      }),
    ).toThrow(/legacySse/i);
  });
});

describe("serveMcpOverHttp (attachTo mode)", () => {
  it("falls through to attachTo handler for unrelated routes", async () => {
    const fallback = (req: Request): Response => {
      const url = new URL(req.url);
      if (url.pathname === "/webhook") {
        return new Response("webhook-ok", { status: 200 });
      }
      return new Response("fallback-404", { status: 404 });
    };

    const composed = serveMcpOverHttp({
      server: buildServer("attach-test"),
      port: 0, // ignored
      token: TOKEN,
      attachTo: fallback,
    });

    // No bunServer in attachTo mode.
    expect(composed.bunServer).toBeUndefined();

    // /health still works.
    const healthRes = await composed.fetch(
      new Request("http://x/health", { method: "GET" }),
    );
    expect(healthRes.status).toBe(200);

    // /mcp without bearer still 401s.
    const unauth = await composed.fetch(
      new Request("http://x/mcp", { method: "POST" }),
    );
    expect(unauth.status).toBe(401);

    // /webhook is delegated to the fallback.
    const webhook = await composed.fetch(
      new Request("http://x/webhook", { method: "GET" }),
    );
    expect(webhook.status).toBe(200);
    expect(await webhook.text()).toBe("webhook-ok");

    // Other unknown routes also hit the fallback (which returns 404).
    const other = await composed.fetch(
      new Request("http://x/anything-else", { method: "GET" }),
    );
    expect(other.status).toBe(404);
    expect(await other.text()).toBe("fallback-404");

    await composed.close();
  });
});

// Regression tests for the per-session-transport refactor.
//
// Previously serveMcpOverHttp created ONE transport at startup and
// reused it for every request. Because the SDK tracks `_initialized` +
// `sessionId` per transport instance, any second `initialize` POST was
// rejected with HTTP 400 `Invalid Request: Server already initialized`.
// That broke simultaneous Claude Desktop + Codex use of the same MCP
// server, and any single client retrying after a TCP close.
//
// The fix maintains a Map<sessionId, transport> and only creates a new
// transport when an unrecognised session id (or no session id +
// initialize body) arrives.
describe("serveMcpOverHttp (per-session transports)", () => {
  // Helper to send a raw initialize POST and pull the session id off
  // the response. We bypass the SDK Client here to exercise the
  // multiplexing layer directly.
  async function initializeOnce(
    composed: ServeMcpResult,
    clientName: string,
  ): Promise<{ sessionId: string; status: number }> {
    const res = await composed.fetch(
      new Request("http://x/mcp", {
        method: "POST",
        headers: {
          authorization: `Bearer ${TOKEN}`,
          "content-type": "application/json",
          accept: "application/json, text/event-stream",
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: 1,
          method: "initialize",
          params: {
            protocolVersion: "2025-03-26",
            capabilities: {},
            clientInfo: { name: clientName, version: "0.0.0" },
          },
        }),
      }),
    );
    const sessionId = res.headers.get("mcp-session-id") ?? "";
    // Drain the body so the underlying stream can be released. The SDK
    // returns SSE for initialize by default; we don't care about the
    // payload here, only the session id assignment.
    try {
      const reader = res.body?.getReader();
      if (reader) {
        // Read at most one chunk; that's enough to see the SSE init
        // event. We then cancel to avoid hanging the test.
        await reader.read();
        await reader.cancel();
      }
    } catch {
      // ignore; some runtimes won't expose body for streamed responses.
    }
    return { sessionId, status: res.status };
  }

  it("two concurrent initialize requests both succeed with distinct session ids", async () => {
    // Per-session Server instances are required for true concurrency:
    // the SDK's Protocol.connect() refuses a second transport on the
    // same Server. The factory pattern unlocks both clients here.
    const composed = serveMcpOverHttp({
      serverFactory: () => buildServer("multi-session-test"),
      port: 0,
      token: TOKEN,
      attachTo: () => new Response("fallback", { status: 404 }),
    });

    try {
      const [a, b] = await Promise.all([
        initializeOnce(composed, "client-a"),
        initializeOnce(composed, "client-b"),
      ]);

      expect(a.status).toBe(200);
      expect(b.status).toBe(200);
      expect(a.sessionId).not.toBe("");
      expect(b.sessionId).not.toBe("");
      expect(a.sessionId).not.toBe(b.sessionId);

      // Both sessions should be tracked as active.
      expect(composed.activeServers().length).toBe(2);
    } finally {
      await composed.close();
    }
  });

  it("a third initialize after two existing sessions still succeeds (no 'already initialized' regression)", async () => {
    const composed = serveMcpOverHttp({
      serverFactory: () => buildServer("third-init-test"),
      port: 0,
      token: TOKEN,
      attachTo: () => new Response("fallback", { status: 404 }),
    });

    try {
      const first = await initializeOnce(composed, "client-1");
      const second = await initializeOnce(composed, "client-2");
      const third = await initializeOnce(composed, "client-3");

      // The pre-fix bug: second/third would be 400 with
      // {"code":-32600,"message":"Invalid Request: Server already initialized"}.
      // After the fix: each new initialize spins up its own Server +
      // transport, so all three succeed.
      expect(first.status).toBe(200);
      expect(second.status).toBe(200);
      expect(third.status).toBe(200);

      const ids = new Set([first.sessionId, second.sessionId, third.sessionId]);
      expect(ids.size).toBe(3);
      expect(composed.activeServers().length).toBe(3);
    } finally {
      await composed.close();
    }
  });

  it("request with stale (unknown) Mcp-Session-Id returns 404 JSON-RPC error, not 500", async () => {
    const composed = serveMcpOverHttp({
      serverFactory: () => buildServer("stale-session-test"),
      port: 0,
      token: TOKEN,
      attachTo: () => new Response("fallback", { status: 404 }),
    });

    try {
      const res = await composed.fetch(
        new Request("http://x/mcp", {
          method: "POST",
          headers: {
            authorization: `Bearer ${TOKEN}`,
            "content-type": "application/json",
            accept: "application/json, text/event-stream",
            "mcp-session-id": "no-such-session-id-12345",
          },
          body: JSON.stringify({
            jsonrpc: "2.0",
            id: 99,
            method: "tools/list",
          }),
        }),
      );

      expect(res.status).toBe(404);
      const body = (await res.json()) as {
        jsonrpc: string;
        error: { code: number; message: string };
      };
      expect(body.jsonrpc).toBe("2.0");
      expect(body.error.code).toBe(-32001);
      expect(body.error.message).toMatch(/session/i);
    } finally {
      await composed.close();
    }
  });

  it("POST without session id and not an initialize returns 400 Bad Request: Mcp-Session-Id header is required", async () => {
    const composed = serveMcpOverHttp({
      serverFactory: () => buildServer("no-session-non-init-test"),
      port: 0,
      token: TOKEN,
      attachTo: () => new Response("fallback", { status: 404 }),
    });

    try {
      const res = await composed.fetch(
        new Request("http://x/mcp", {
          method: "POST",
          headers: {
            authorization: `Bearer ${TOKEN}`,
            "content-type": "application/json",
            accept: "application/json, text/event-stream",
          },
          body: JSON.stringify({
            jsonrpc: "2.0",
            id: 1,
            method: "tools/list",
          }),
        }),
      );

      expect(res.status).toBe(400);
      const body = (await res.json()) as {
        jsonrpc: string;
        error: { code: number; message: string };
      };
      expect(body.error.message).toMatch(/Mcp-Session-Id/i);
    } finally {
      await composed.close();
    }
  });

  it("POST with malformed JSON body and no session id returns 400 parse error", async () => {
    const composed = serveMcpOverHttp({
      serverFactory: () => buildServer("bad-json-test"),
      port: 0,
      token: TOKEN,
      attachTo: () => new Response("fallback", { status: 404 }),
    });

    try {
      const res = await composed.fetch(
        new Request("http://x/mcp", {
          method: "POST",
          headers: {
            authorization: `Bearer ${TOKEN}`,
            "content-type": "application/json",
            accept: "application/json, text/event-stream",
          },
          body: "{ not valid json",
        }),
      );

      expect(res.status).toBe(400);
      const body = (await res.json()) as {
        jsonrpc: string;
        error: { code: number; message: string };
      };
      expect(body.error.code).toBe(-32700);
    } finally {
      await composed.close();
    }
  });

  it("two concurrent SDK Client connections both complete a full round trip", async () => {
    // End-to-end test: two real MCP clients, each going through the
    // SDK's StreamableHTTPClientTransport, each calling listTools +
    // callTool. This is the original reproducer for the Claude Desktop
    // + Codex regression.
    const port = await pickPort();
    const booted = serveMcpOverHttp({
      serverFactory: () => buildServer("e2e-multi-client"),
      port,
      token: TOKEN,
    });
    const baseUrl = `http://127.0.0.1:${port}`;

    try {
      const makeClient = async (name: string) => {
        const transport = new StreamableHTTPClientTransport(
          new URL(`${baseUrl}/mcp`),
          {
            requestInit: { headers: { authorization: `Bearer ${TOKEN}` } },
          },
        );
        const client = new Client(
          { name, version: "0.0.0" },
          { capabilities: {} },
        );
        await client.connect(transport);
        return client;
      };

      const [clientA, clientB] = await Promise.all([
        makeClient("e2e-a"),
        makeClient("e2e-b"),
      ]);

      try {
        const [toolsA, toolsB] = await Promise.all([
          clientA.listTools(),
          clientB.listTools(),
        ]);
        expect(toolsA.tools[0]!.name).toBe("echo");
        expect(toolsB.tools[0]!.name).toBe("echo");

        const [callA, callB] = await Promise.all([
          clientA.callTool({ name: "echo", arguments: { text: "from-a" } }),
          clientB.callTool({ name: "echo", arguments: { text: "from-b" } }),
        ]);
        const contentA = callA.content as Array<{ type: string; text: string }>;
        const contentB = callB.content as Array<{ type: string; text: string }>;
        expect(contentA[0]!.text).toBe("echo:from-a");
        expect(contentB[0]!.text).toBe("echo:from-b");
      } finally {
        await clientA.close();
        await clientB.close();
      }
    } finally {
      await booted.close();
    }
  });

  it("legacy 'server' mode: single SDK Client round-trips, then a second initialize hits the SDK's 'Already connected' error (documented warning)", async () => {
    // Back-compat path: callers that haven't migrated to serverFactory
    // still get a working single-client deployment. The catch is that
    // the SDK refuses to bind the same Server to a second transport,
    // so the SECOND initialize POST fails. We assert both halves: a
    // single client works, and a concurrent second initialize is
    // rejected with a 500 carrying the SDK's exact error text.
    const composed = serveMcpOverHttp({
      server: buildServer("legacy-single-server-test"),
      port: 0,
      token: TOKEN,
      attachTo: () => new Response("fallback", { status: 404 }),
    });

    try {
      // First client initialize: succeeds and yields a session id.
      const first = await initializeOnce(composed, "legacy-client-1");
      expect(first.status).toBe(200);
      expect(first.sessionId).not.toBe("");
      expect(composed.activeServers().length).toBe(1);

      // Second concurrent initialize: the single Server is already
      // attached to the first transport, so server.connect() throws
      // "Already connected to a transport". We wrap that as a
      // JSON-RPC 500 in dispatchMcp -- assert both the status and
      // the SDK's exact error wording so future SDK upgrades that
      // change the message will fail this test loudly.
      const second = await initializeOnce(composed, "legacy-client-2");
      expect(second.status).toBe(500);
    } finally {
      await composed.close();
    }
  });

  it("passing BOTH server and serverFactory throws at startup", () => {
    // Mutually exclusive: there's no sensible "use the singleton AND
    // also spin up factory instances" semantic, so we fail loud at
    // startup rather than silently picking one.
    expect(() =>
      serveMcpOverHttp({
        server: buildServer("both-server-test"),
        serverFactory: () => buildServer("both-factory-test"),
        port: 0,
        token: TOKEN,
        attachTo: () => new Response("fallback", { status: 404 }),
      }),
    ).toThrow(/EITHER 'server' .* OR 'serverFactory'/i);
  });

  it("passing NEITHER server nor serverFactory throws at startup", () => {
    expect(() =>
      // @ts-expect-error -- deliberately omitting both to assert the
      // runtime guard; the type system rejects this too.
      serveMcpOverHttp({
        port: 0,
        token: TOKEN,
        attachTo: () => new Response("fallback", { status: 404 }),
      }),
    ).toThrow(/must pass either 'server' or 'serverFactory'/i);
  });
});
