/**
 * Tests for src/lib/mcp.ts (router) + src/lib/mcp-tools.ts (tools).
 *
 * The DB-touching tool runs require live Postgres; we focus on:
 *   - JSON-RPC envelope shape (initialize / tools/list / unknown method)
 *   - Notification handling (no `id` → no response)
 *   - tools/call validation (missing name, unknown tool)
 *   - Tool manifest shape (every default tool has a name + inputSchema)
 *   - Pure helper edge cases (argString / argNumber)
 *   - The HTTP route's discovery GET + JSON-RPC POST shape
 */

import { describe, it, expect } from "bun:test";
import {
  routeMcpRequest,
  MCP_PROTOCOL_VERSION,
  MCP_SERVER_NAME,
  ERR_METHOD_NOT_FOUND,
  ERR_INVALID_REQUEST,
  ERR_INVALID_PARAMS,
  McpError,
} from "../lib/mcp";
import { defaultTools, __test } from "../lib/mcp-tools";
import app from "../app";

const tools = defaultTools();
const ctx = { userId: null };

describe("routeMcpRequest — initialize", () => {
  it("returns the protocol version + server info", async () => {
    const r = await routeMcpRequest(
      { jsonrpc: "2.0", id: 1, method: "initialize" },
      { ctx, tools }
    );
    expect(r).not.toBeNull();
    if (!r || "error" in r) throw new Error("expected success");
    const result = r.result as any;
    expect(result.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
    expect(result.serverInfo.name).toBe(MCP_SERVER_NAME);
    expect(result.capabilities.tools).toBeDefined();
  });
});

describe("routeMcpRequest — invalid input", () => {
  it("rejects a non-JSON-RPC envelope", async () => {
    const r = await routeMcpRequest({ method: "x" } as any, { ctx, tools });
    if (!r || "result" in r) throw new Error("expected error");
    expect(r.error.code).toBe(ERR_INVALID_REQUEST);
  });

  it("rejects an unknown method", async () => {
    const r = await routeMcpRequest(
      { jsonrpc: "2.0", id: 1, method: "no_such_method" },
      { ctx, tools }
    );
    if (!r || "result" in r) throw new Error("expected error");
    expect(r.error.code).toBe(ERR_METHOD_NOT_FOUND);
  });

  it("returns null for a notification (no `id`)", async () => {
    const r = await routeMcpRequest(
      { jsonrpc: "2.0", method: "notifications/initialized" },
      { ctx, tools }
    );
    expect(r).toBeNull();
  });
});

describe("routeMcpRequest — tools/list", () => {
  it("returns the full tool manifest", async () => {
    const r = await routeMcpRequest(
      { jsonrpc: "2.0", id: 2, method: "tools/list" },
      { ctx, tools }
    );
    if (!r || "error" in r) throw new Error("expected success");
    const result = r.result as { tools: Array<{ name: string; inputSchema: any }> };
    expect(Array.isArray(result.tools)).toBe(true);
    expect(result.tools.length).toBeGreaterThanOrEqual(4);
    for (const t of result.tools) {
      expect(typeof t.name).toBe("string");
      expect(t.name).toMatch(/^gluecron_/);
      expect(t.inputSchema.type).toBe("object");
    }
  });
});

describe("routeMcpRequest — tools/call validation", () => {
  it("rejects calls without a name", async () => {
    const r = await routeMcpRequest(
      { jsonrpc: "2.0", id: 3, method: "tools/call", params: {} },
      { ctx, tools }
    );
    if (!r || "result" in r) throw new Error("expected error");
    expect(r.error.code).toBe(ERR_INVALID_PARAMS);
  });

  it("rejects calls with an unknown tool name", async () => {
    const r = await routeMcpRequest(
      {
        jsonrpc: "2.0",
        id: 4,
        method: "tools/call",
        params: { name: "no_such_tool", arguments: {} },
      },
      { ctx, tools }
    );
    if (!r || "result" in r) throw new Error("expected error");
    expect(r.error.code).toBe(ERR_METHOD_NOT_FOUND);
  });
});

describe("argString / argNumber pure helpers", () => {
  it("returns the trimmed string when present", () => {
    expect(__test.argString({ x: "  hi  " }, "x")).toBe("hi");
  });

  it("falls back when missing", () => {
    expect(__test.argString({}, "x", "default")).toBe("default");
  });

  it("throws McpError when missing without fallback", () => {
    expect(() => __test.argString({}, "x")).toThrow(McpError);
  });

  it("argNumber accepts numeric strings", () => {
    expect(__test.argNumber({ n: "42" }, "n")).toBe(42);
  });

  it("argNumber falls back on non-numeric input", () => {
    expect(__test.argNumber({ n: "abc" }, "n", 7)).toBe(7);
  });
});

describe("tool manifest shape", () => {
  for (const handler of Object.values(tools)) {
    it(`${handler.tool.name} — required fields populated`, () => {
      expect(handler.tool.name).toMatch(/^gluecron_/);
      expect(typeof handler.tool.description).toBe("string");
      expect(handler.tool.description.length).toBeGreaterThan(10);
      expect(handler.tool.inputSchema.type).toBe("object");
      expect(typeof handler.tool.inputSchema.properties).toBe("object");
    });
  }
});

describe("tool annotations (Anthropic connector-directory requirement)", () => {
  // Every tool must carry MCP ToolAnnotations with an explicit boolean
  // readOnlyHint (and destructiveHint) so directory review can classify it.
  for (const handler of Object.values(tools)) {
    it(`${handler.tool.name} — has annotations with boolean readOnlyHint`, () => {
      expect(handler.tool.annotations).toBeDefined();
      expect(typeof handler.tool.annotations?.readOnlyHint).toBe("boolean");
      expect(typeof handler.tool.annotations?.destructiveHint).toBe("boolean");
    });
  }

  it("gluecron_repo_search is annotated read-only", () => {
    const t = tools["gluecron_repo_search"].tool;
    expect(t.annotations?.readOnlyHint).toBe(true);
  });

  it("gluecron_delete_repo is annotated destructive", () => {
    const t = tools["gluecron_delete_repo"].tool;
    expect(t.annotations?.readOnlyHint).toBe(false);
    expect(t.annotations?.destructiveHint).toBe(true);
  });

  it("tools/list passes annotations through to the wire", async () => {
    const r = await routeMcpRequest(
      { jsonrpc: "2.0", id: 8, method: "tools/list" },
      { ctx, tools }
    );
    if (!r || "error" in r) throw new Error("expected success");
    const result = r.result as { tools: Array<{ name: string; annotations?: any }> };
    const search = result.tools.find((t) => t.name === "gluecron_repo_search");
    expect(search?.annotations?.readOnlyHint).toBe(true);
  });
});

describe("HTTP route — GET /mcp discovery", () => {
  it("returns server info + tool count", async () => {
    const res = await app.request("/mcp");
    expect(res.status).toBe(200);
    const body = (await res.json()) as any;
    expect(body.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
    expect(body.serverInfo.name).toBe(MCP_SERVER_NAME);
    expect(body.toolCount).toBeGreaterThanOrEqual(4);
  });
});

describe("HTTP route — POST /mcp JSON-RPC", () => {
  it("answers initialize over the wire", async () => {
    const res = await app.request("/mcp", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "initialize",
      }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as any;
    expect(body.jsonrpc).toBe("2.0");
    expect(body.id).toBe(1);
    expect(body.result.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
  });

  it("returns 400 on invalid JSON", async () => {
    const res = await app.request("/mcp", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: "not json",
    });
    expect(res.status).toBe(400);
  });

  it("returns 204 for a single notification (no `id`)", async () => {
    const res = await app.request("/mcp", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        jsonrpc: "2.0",
        method: "notifications/initialized",
      }),
    });
    expect(res.status).toBe(204);
  });

  it("supports a batched request", async () => {
    const res = await app.request("/mcp", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify([
        { jsonrpc: "2.0", id: 1, method: "initialize" },
        { jsonrpc: "2.0", id: 2, method: "tools/list" },
      ]),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as any[];
    expect(body.length).toBe(2);
    expect(body[0].id).toBe(1);
    expect(body[1].id).toBe(2);
    expect(body[1].result.tools.length).toBeGreaterThanOrEqual(4);
  });
});

describe("HTTP route — POST /mcp OAuth challenge", () => {
  // Anonymous tool calls must return the RFC 9728 challenge so remote
  // connectors (claude.ai, Cursor, …) know to start the OAuth flow. The
  // handshake stays open (covered by the tests above), so this is the one
  // gate that triggers sign-in.
  it("challenges an anonymous tools/call with 401 + WWW-Authenticate", async () => {
    const res = await app.request("/mcp", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 7,
        method: "tools/call",
        params: { name: "gluecron_repo_search", arguments: { q: "x" } },
      }),
    });
    expect(res.status).toBe(401);
    const wwwAuth = res.headers.get("www-authenticate") || "";
    expect(wwwAuth).toContain("Bearer");
    expect(wwwAuth).toContain("resource_metadata=");
    expect(wwwAuth).toContain("/.well-known/oauth-protected-resource");
    // RFC 6750: the ANONYMOUS challenge must NOT carry `error=` — its absence
    // is what tells the client "no credentials, start first-time auth" rather
    // than "your token died, refresh it".
    expect(wwwAuth).not.toContain("error=");
    const body = (await res.json()) as any;
    expect(body.error.code).toBe(-32001);
    expect(body.id).toBe(7); // echoes the request id
  });

  it("challenges when a batch contains a tools/call while anonymous", async () => {
    const res = await app.request("/mcp", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify([
        { jsonrpc: "2.0", id: 1, method: "initialize" },
        {
          jsonrpc: "2.0",
          id: 2,
          method: "tools/call",
          params: { name: "gluecron_repo_search", arguments: { q: "x" } },
        },
      ]),
    });
    expect(res.status).toBe(401);
    expect(res.headers.get("www-authenticate") || "").toContain("resource_metadata=");
  });

  it("still allows the anonymous handshake (no challenge on tools/list)", async () => {
    const res = await app.request("/mcp", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
    });
    expect(res.status).toBe(200);
  });
});

describe("HTTP route — POST /mcp invalid bearer challenge (RFC 6750)", () => {
  // A presented-but-dead bearer (expired / revoked / unknown) must fail
  // LOUDLY on every method — including the handshake — with
  // `error="invalid_token"`, so clients run the refresh grant or re-auth
  // instead of holding a false-healthy connection. Regression guard for the
  // 2026-07-14 "token expired" reconnect loop.
  const rpc = (method: string, bearer: string, id = 9) =>
    app.request("/mcp", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization: `Bearer ${bearer}`,
      },
      body: JSON.stringify({ jsonrpc: "2.0", id, method, params: {} }),
    });

  const expectInvalidToken = async (res: Response) => {
    expect(res.status).toBe(401);
    const wwwAuth = res.headers.get("www-authenticate") || "";
    expect(wwwAuth).toContain('error="invalid_token"');
    expect(wwwAuth).toContain("resource_metadata=");
    const body = (await res.json()) as any;
    expect(body.error.code).toBe(-32001);
    expect(String(body.error.message)).toMatch(/expired|invalid/i);
  };

  it("challenges an unknown glct_ token on tools/call", async () => {
    await expectInvalidToken(await rpc("tools/call", "glct_deadbeef"));
  });

  it("challenges an unknown glct_ token on initialize (breaks the reconnect loop)", async () => {
    await expectInvalidToken(await rpc("initialize", "glct_deadbeef"));
  });

  it("challenges an unknown glct_ token on tools/list", async () => {
    await expectInvalidToken(await rpc("tools/list", "glct_deadbeef"));
  });

  it("challenges an unknown glc_ PAT on initialize", async () => {
    await expectInvalidToken(await rpc("initialize", "glc_deadbeef"));
  });

  it("echoes the request id in the invalid_token error", async () => {
    const res = await rpc("initialize", "glct_deadbeef", 42);
    const body = (await res.json()) as any;
    expect(body.id).toBe(42);
  });

  it("challenges GET /mcp discovery with a dead bearer, plain-JSON body", async () => {
    const res = await app.request("/mcp", {
      headers: { authorization: "Bearer glct_deadbeef" },
    });
    expect(res.status).toBe(401);
    expect(res.headers.get("www-authenticate") || "").toContain(
      'error="invalid_token"'
    );
    const body = (await res.json()) as any;
    expect(body.error).toBe("invalid_token");
  });

  it("a non-gluecron bearer scheme is ignored (anonymous handshake works)", async () => {
    // e.g. an unrelated `Bearer xyz` (no glct_/glc_ prefix) must not trip the
    // invalid-token gate — softAuth only flags gluecron-shaped tokens.
    const res = await rpc("tools/list", "some-other-token");
    expect(res.status).toBe(200);
  });
});
