// ---------------------------------------------------------------------------
// 0836 US-002 — X-Studio-Token gate tests (RED → GREEN).
//
// The eval-server's router currently allows any localhost Origin via
// `LOCALHOST_ORIGIN_RE`. After the fix:
//   - every /api/* request requires `X-Studio-Token: <token>`
//   - missing/wrong → 401, empty body, no token in log
//   - OPTIONS preflight bypasses the gate
//   - static files bypass the gate
//   - length-mismatch is rejected before timingSafeEqual
//   - the token is rotated per process via `_resetStudioTokenForTests`
// ---------------------------------------------------------------------------

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Router } from "../router.js";
import {
  _resetStudioTokenForTests,
  getStudioToken,
  tokenGate,
  tokensEqual,
} from "../router.js";
import type { IncomingMessage, ServerResponse } from "node:http";

interface FakeReqInit {
  url?: string;
  method?: string;
  headers?: Record<string, string>;
}

function fakeReq(init: FakeReqInit = {}): IncomingMessage {
  return {
    method: init.method ?? "GET",
    // Default to a token-gated endpoint. /api/health is intentionally exempt
    // from tokenGate (Codex C#1 fix — sidecar boot polls health BEFORE it has
    // the studio_token from stdout), so a default of "/api/health" would mask
    // every gate-rejection test. Use /api/skills instead — a normal protected
    // route — so missing-token assertions actually fire.
    url: init.url ?? "/api/skills",
    headers: { host: "localhost", ...(init.headers ?? {}) },
  } as unknown as IncomingMessage;
}

interface FakeResState {
  status?: number;
  body?: string;
  headers?: Record<string, string | number>;
  ended: boolean;
}

function fakeRes(): { res: ServerResponse; state: FakeResState } {
  const state: FakeResState = { ended: false };
  const res = {
    headersSent: false,
    writeHead: vi.fn((status: number, headers?: Record<string, string | number>) => {
      state.status = status;
      state.headers = headers;
      (res as unknown as { headersSent: boolean }).headersSent = true;
      return res;
    }),
    end: vi.fn((body?: string) => {
      state.body = body;
      state.ended = true;
    }),
    setHeader: vi.fn(),
  } as unknown as ServerResponse;
  return { res, state };
}

describe("getStudioToken (US-002 AC-US2-01, AC-US2-08)", () => {
  beforeEach(() => {
    _resetStudioTokenForTests();
  });

  it("AC-US2-01: returns a base64url string of length 43 (256 bits)", () => {
    const t = getStudioToken();
    expect(typeof t).toBe("string");
    // base64url-encoded 32 bytes = 43 chars (no padding).
    expect(t).toHaveLength(43);
    expect(t).toMatch(/^[A-Za-z0-9_-]+$/);
  });

  it("AC-US2-01: same process returns the same token across calls", () => {
    const a = getStudioToken();
    const b = getStudioToken();
    expect(a).toBe(b);
  });

  it("AC-US2-08: rotates after _resetStudioTokenForTests", () => {
    const a = getStudioToken();
    _resetStudioTokenForTests();
    const b = getStudioToken();
    expect(a).not.toBe(b);
  });
});

describe("tokensEqual constant-time compare (US-002 AC-US2-06)", () => {
  it("returns true for matching tokens", () => {
    expect(tokensEqual("abc123", "abc123")).toBe(true);
  });

  it("returns false for length mismatch (rejected before timingSafeEqual)", () => {
    expect(tokensEqual("short", "much-longer")).toBe(false);
  });

  it("returns false for same-length but different content", () => {
    expect(tokensEqual("abcdef", "abcXXX")).toBe(false);
  });

  it("returns false when supplied is undefined or non-string", () => {
    expect(tokensEqual(undefined, "abc")).toBe(false);
    expect(tokensEqual(null as unknown as string, "abc")).toBe(false);
    expect(tokensEqual(123 as unknown as string, "abc")).toBe(false);
  });

  it("returns false for empty string supplied", () => {
    expect(tokensEqual("", "abc")).toBe(false);
  });
});

describe("tokenGate (US-002 AC-US2-03, AC-US2-04, AC-US2-05)", () => {
  beforeEach(() => {
    _resetStudioTokenForTests();
  });

  it("AC-US2-05: /api/* without X-Studio-Token returns 401, empty body", () => {
    const req = fakeReq({ url: "/api/skills" });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(false);
    expect(state.status).toBe(401);
    expect(state.body ?? "").toBe("");
  });

  it("AC-US2-05: /api/* with correct X-Studio-Token allows the request", () => {
    const token = getStudioToken();
    const req = fakeReq({
      url: "/api/skills",
      headers: { "x-studio-token": token },
    });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(true);
    expect(state.ended).toBe(false);
  });

  it("AC-US2-03: /api/* with wrong same-length X-Studio-Token returns 401", () => {
    const token = getStudioToken();
    const wrong = "X".repeat(token.length);
    const req = fakeReq({
      url: "/api/skills",
      headers: { "x-studio-token": wrong },
    });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(false);
    expect(state.status).toBe(401);
  });

  it("AC-US2-06: a wrong-length token returns 401 (length-mismatch fast path)", () => {
    const req = fakeReq({
      url: "/api/skills",
      headers: { "x-studio-token": "too-short" },
    });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(false);
    expect(state.status).toBe(401);
  });

  it("AC-US2-03: a wrong supplied token does NOT appear in any log line", () => {
    const wrong = "supersekret-attacker-value-XYZ-000-aaa-bbb";
    const logs: string[] = [];
    const origLog = console.log;
    const origWarn = console.warn;
    console.log = (...args: unknown[]) => logs.push(args.map(String).join(" "));
    console.warn = (...args: unknown[]) => logs.push(args.map(String).join(" "));
    try {
      const req = fakeReq({
        url: "/api/skills",
        headers: { "x-studio-token": wrong },
      });
      const { res } = fakeRes();
      tokenGate(req, res);
    } finally {
      console.log = origLog;
      console.warn = origWarn;
    }
    for (const line of logs) {
      expect(line).not.toContain(wrong);
    }
  });

  it("AC-US2-03: OPTIONS bypasses the gate", () => {
    const req = fakeReq({ url: "/api/skills", method: "OPTIONS" });
    const { res } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(true);
  });

  it("non-/api paths bypass the gate (static files)", () => {
    const req = fakeReq({ url: "/index.html" });
    const { res } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(true);
  });

  it("AC-US2-08: a token from a prior process (after reset) returns 401", () => {
    const stale = getStudioToken();
    _resetStudioTokenForTests();
    // The fresh process generates a different token on next request.
    const req = fakeReq({
      url: "/api/skills",
      headers: { "x-studio-token": stale },
    });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(false);
    expect(state.status).toBe(401);
  });

  // ─── Codex 2026-05-10 follow-up regressions ────────────────────────────
  // These guard the two fixes from the adversarial review:
  //   C#1: /api/health must pass without a token (desktop boot deadlock).
  //   H#4: absolute-form request targets must gate on parsed pathname,
  //        not on the raw req.url string.
  it("Codex C#1: /api/health is exempt from tokenGate (desktop sidecar boot)", () => {
    const req = fakeReq({ url: "/api/health" });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(true);
    expect(state.status).toBeUndefined();
    expect(state.ended).toBe(false);
  });

  it("desktop GitHub OAuth completion is exempt from tokenGate (browser form POST)", () => {
    const req = fakeReq({ url: "/api/oauth/github/desktop-complete", method: "POST" });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(true);
    expect(state.status).toBeUndefined();
    expect(state.ended).toBe(false);
  });

  it("Codex H#4: absolute-form URL is gated by parsed pathname, not raw string", () => {
    // Some HTTP clients (curl, Rust reqwest, certain proxies) emit the
    // request target in absolute form. The raw req.url then contains the
    // scheme+host prefix and `startsWith('/api/')` returns false on it,
    // even though the route dispatcher correctly resolves to /api/skills.
    const req = fakeReq({
      url: "http://127.0.0.1:3077/api/skills",
      // intentionally no x-studio-token
    });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(false);
    expect(state.status).toBe(401);
  });

  it("Codex H#4: absolute-form URL with valid token still passes", () => {
    const valid = getStudioToken();
    const req = fakeReq({
      url: "http://127.0.0.1:3077/api/skills",
      headers: { "x-studio-token": valid },
    });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(true);
    expect(state.status).toBeUndefined();
  });
});

// ---------------------------------------------------------------------------
// 0855 US-001 / US-002 — query-param studioToken exemption for EventSource
// streams. The browser `EventSource` API cannot set request headers, so the
// notification stream (`GET /api/v1/skills/stream`) was being 401'd by the
// 0836 header-only gate — killing live toasts. The fix: accept the token as
// a `?studioToken=<t>` query param on known GET stream paths, validated with
// the SAME timing-safe compare. The token must be stripped from req.url
// before the request is proxied upstream, and must never be logged.
// ---------------------------------------------------------------------------
describe("tokenGate EventSource query-param exemption (0855 US-001/US-002)", () => {
  beforeEach(() => {
    _resetStudioTokenForTests();
  });

  it("AC-US1-01: GET /api/v1/skills/stream?studioToken=<valid> passes the gate", () => {
    const token = getStudioToken();
    const req = fakeReq({
      url: `/api/v1/skills/stream?skills=a,b&studioToken=${token}`,
    });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(true);
    expect(state.ended).toBe(false);
    expect(state.status).toBeUndefined();
  });

  it("AC-US2-04: the studioToken param is stripped from req.url before proxy", () => {
    const token = getStudioToken();
    const req = fakeReq({
      url: `/api/v1/skills/stream?skills=a,b&studioToken=${token}`,
    });
    const { res } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(true);
    // The proxy reads req.url AFTER the gate returns. The token must be gone,
    // but the legitimate ?skills= filter must survive.
    expect(req.url).not.toContain("studioToken");
    expect(req.url).not.toContain(token);
    expect(req.url).toContain("skills=a%2Cb");
    expect(req.url?.startsWith("/api/v1/skills/stream")).toBe(true);
  });

  it("AC-US2-01: GET /api/v1/skills/stream with NO token is still 401", () => {
    const req = fakeReq({ url: "/api/v1/skills/stream?skills=a,b" });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(false);
    expect(state.status).toBe(401);
  });

  it("AC-US2-02: GET /api/v1/skills/stream?studioToken=<wrong-same-length> is 401", () => {
    const token = getStudioToken();
    const wrong = "X".repeat(token.length);
    const req = fakeReq({
      url: `/api/v1/skills/stream?studioToken=${wrong}`,
    });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(false);
    expect(state.status).toBe(401);
  });

  it("AC-US2-02: GET /api/v1/skills/stream?studioToken=<wrong-length> is 401", () => {
    const req = fakeReq({
      url: "/api/v1/skills/stream?studioToken=too-short",
    });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(false);
    expect(state.status).toBe(401);
  });

  it("AC-US2-05: the query token NEVER appears in any log line on rejection", () => {
    const token = getStudioToken();
    const wrong = "wrongtoken-leak-canary-aaa-bbb-ccc-ddd-eee".slice(
      0,
      token.length,
    );
    const logs: string[] = [];
    const origLog = console.log;
    const origWarn = console.warn;
    console.log = (...a: unknown[]) => logs.push(a.map(String).join(" "));
    console.warn = (...a: unknown[]) => logs.push(a.map(String).join(" "));
    try {
      const req = fakeReq({
        url: `/api/v1/skills/stream?studioToken=${wrong}`,
      });
      const { res } = fakeRes();
      tokenGate(req, res);
    } finally {
      console.log = origLog;
      console.warn = origWarn;
    }
    for (const line of logs) {
      expect(line).not.toContain(wrong);
    }
  });

  it("AC-US2-05: even a VALID query token never appears in any log line", () => {
    const token = getStudioToken();
    const logs: string[] = [];
    const origLog = console.log;
    const origWarn = console.warn;
    console.log = (...a: unknown[]) => logs.push(a.map(String).join(" "));
    console.warn = (...a: unknown[]) => logs.push(a.map(String).join(" "));
    try {
      const req = fakeReq({
        url: `/api/v1/skills/stream?studioToken=${token}`,
      });
      const { res } = fakeRes();
      tokenGate(req, res);
    } finally {
      console.log = origLog;
      console.warn = origWarn;
    }
    for (const line of logs) {
      expect(line).not.toContain(token);
    }
  });

  it("AC-US2-06: a POST with ?studioToken= is NOT exempted by the query mechanism", () => {
    const token = getStudioToken();
    const req = fakeReq({
      method: "POST",
      url: `/api/v1/skills/stream?studioToken=${token}`,
    });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    // POST is outside the EventSource exemption; with no header it must 401.
    expect(passed).toBe(false);
    expect(state.status).toBe(401);
  });

  it("AC-US2-06: a non-stream /api path with ?studioToken= is NOT exempted", () => {
    const token = getStudioToken();
    const req = fakeReq({
      url: `/api/skills?studioToken=${token}`,
    });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(false);
    expect(state.status).toBe(401);
  });

  it("local GET stream paths (/api/events) also accept the query token", () => {
    const token = getStudioToken();
    const req = fakeReq({ url: `/api/events?studioToken=${token}` });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(true);
    expect(state.status).toBeUndefined();
    expect(req.url).not.toContain("studioToken");
  });

  it("a valid X-Studio-Token header still works on the stream path (no regression)", () => {
    const token = getStudioToken();
    const req = fakeReq({
      url: "/api/v1/skills/stream?skills=a",
      headers: { "x-studio-token": token },
    });
    const { res, state } = fakeRes();
    const passed = tokenGate(req, res);
    expect(passed).toBe(true);
    expect(state.status).toBeUndefined();
  });
});

describe("Router.handle integrated with tokenGate (US-002 AC-US2-04)", () => {
  beforeEach(() => {
    _resetStudioTokenForTests();
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("AC-US2-04/05: /api/* without token never reaches the route handler", async () => {
    const router = new Router();
    const handler = vi.fn((_req, res) => {
      (res as ServerResponse).writeHead(200);
      (res as ServerResponse).end("{}");
    });
    router.get("/api/health", handler);

    const req = fakeReq({ url: "/api/skills" });
    const { res, state } = fakeRes();
    const matched = await router.handle(req, res);
    // Either the gate produces 401 (and matched=true since the response is
    // owned by the router), OR the gate is missing (legacy path) and the
    // handler runs. The strict assertion: handler MUST NOT have been called.
    expect(handler).not.toHaveBeenCalled();
    // Some implementations short-circuit before route matching → matched=true
    // with status=401. Accept either as long as the response is 401.
    expect(matched).toBeTruthy();
    expect(state.status).toBe(401);
  });

  it("AC-US2-05: /api/* with valid token reaches the route handler", async () => {
    const token = getStudioToken();
    const router = new Router();
    const handler = vi.fn((_req, res) => {
      (res as ServerResponse).writeHead(200);
      (res as ServerResponse).end(JSON.stringify({ ok: true }));
    });
    // Register on /api/skills (not /api/health, which is now exempt from
    // tokenGate per Codex C#1 — using a non-exempt path proves the gate
    // both lets the request through AND the route runs).
    router.get("/api/skills", handler);

    const req = fakeReq({
      url: "/api/skills",
      headers: { "x-studio-token": token },
    });
    const { res, state } = fakeRes();
    await router.handle(req, res);
    expect(handler).toHaveBeenCalledTimes(1);
    expect(state.status).toBe(200);
  });
});

// ---------------------------------------------------------------------------
// 0836 US-002 (F-017 follow-up) — OPTIONS preflight Access-Control-Allow-Headers
// MUST advertise X-Studio-Token, otherwise browser-initiated cross-origin
// fetches fail at preflight before the actual request leaves the renderer.
//
// The OPTIONS handler lives in api-routes.ts (registerRoutes attaches it as
// `router.options`). The contract under test: when a same-machine cross-origin
// caller (e.g. http://localhost:9999) issues a CORS preflight, the response
// must list both `Content-Type` and `X-Studio-Token` in the allowed-headers
// list. Without `X-Studio-Token`, the browser refuses to send the actual
// authenticated request and AC-US2-05 silently fails in the cross-origin case.
// ---------------------------------------------------------------------------

describe("OPTIONS preflight allow-headers (US-002 AC-US2-05 cross-origin path)", () => {
  it("F-017: preflight echoes X-Studio-Token in Access-Control-Allow-Headers", async () => {
    const { registerRoutes } = await import("../api-routes.js");
    const router = new Router();
    // registerRoutes() attaches `router.options` as a side effect of wiring
    // the API surface. We don't need a real workspace root for this assertion.
    registerRoutes(router, "/tmp/router-token-gate-test-fixture");
    expect(router.options).toBeTypeOf("function");

    const req = fakeReq({
      method: "OPTIONS",
      url: "/api/skills",
      headers: {
        origin: "http://localhost:9999",
        "access-control-request-method": "GET",
        "access-control-request-headers": "x-studio-token, content-type",
      },
    });
    const { res, state } = fakeRes();
    router.options!(req, res);

    expect(state.status).toBe(204);
    expect(state.ended).toBe(true);
    // Header keys are case-sensitive in the fake, so we read the exact one
    // the handler writes.
    const allowHeaders = state.headers?.["Access-Control-Allow-Headers"];
    expect(typeof allowHeaders).toBe("string");
    expect(String(allowHeaders).toLowerCase()).toContain("x-studio-token");
    expect(String(allowHeaders).toLowerCase()).toContain("content-type");
  });
});
