/**
 * @file HTTP tests for /api/lanes: CRUD, stage reporting, the aggregate
 * counters the header badges read, and the last-event age that drives liveness.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const http = require("http");
const { WebSocket } = require("ws");

const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-api-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";

const { createApp, startServer } = require("../index");

let server;
let BASE;

function request(method, urlPath, body, headers = {}) {
  return new Promise((resolve, reject) => {
    const url = new URL(urlPath, BASE);
    const payload = body ? JSON.stringify(body) : null;
    const req = http.request(
      {
        hostname: url.hostname,
        port: url.port,
        path: url.pathname + url.search,
        method,
        headers: {
          ...headers,
          ...(payload
            ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
            : {}),
        },
      },
      (res) => {
        let data = "";
        res.on("data", (c) => (data += c));
        res.on("end", () => {
          let parsed = null;
          try {
            parsed = JSON.parse(data);
          } catch {
            /* non-JSON body */
          }
          resolve({ status: res.statusCode, body: parsed });
        });
      }
    );
    req.on("error", reject);
    if (payload) req.write(payload);
    req.end();
  });
}

before(async () => {
  const app = createApp();
  server = await startServer(app, 0);
  BASE = `http://127.0.0.1:${server.address().port}`;
});

after(() => server && server.close());

describe("/api/lanes", () => {
  let laneId;

  it("creates a lane", async () => {
    const r = await request("POST", "/api/lanes", { title: "Lane A", cwd: "/tmp/lane-api-a" });
    assert.equal(r.status, 201);
    assert.equal(r.body.lane.title, "Lane A");
    assert.equal(r.body.lane.stage, "idle");
    laneId = r.body.lane.id;
  });

  it("rejects a relative cwd", async () => {
    const r = await request("POST", "/api/lanes", { cwd: "relative/path" });
    assert.equal(r.status, 400);
  });

  it("rejects a duplicate cwd", async () => {
    const r = await request("POST", "/api/lanes", { cwd: "/tmp/lane-api-a" });
    assert.equal(r.status, 409);
  });

  it("reports a stage and returns node states", async () => {
    const r = await request("POST", `/api/lanes/${laneId}/stage`, {
      stage: "review",
      status: "running",
      evidence: null,
    });
    assert.equal(r.status, 200);
    const review = r.body.lane.pipeline_nodes.find((n) => n.id === "review");
    assert.equal(review.state, "current");
    assert.ok(r.body.lane.progress > 0);
  });

  it("404s on an unknown lane", async () => {
    const r = await request("POST", "/api/lanes/99999/stage", { stage: "plan" });
    assert.equal(r.status, 404);
  });

  it("lists lanes with counters", async () => {
    const r = await request("GET", "/api/lanes");
    assert.equal(r.status, 200);
    assert.ok(r.body.lanes.length >= 1);
    assert.equal(r.body.counts.total, r.body.lanes.length);
    assert.equal(typeof r.body.counts.running, "number");
    assert.equal(typeof r.body.counts.needs_you, "number");
  });

  it("exposes pipeline templates", async () => {
    const r = await request("GET", "/api/lanes/pipelines");
    assert.equal(r.status, 200);
    assert.ok(r.body.pipelines.some((p) => p.id === "default"));
  });

  it("patches and deletes", async () => {
    const p = await request("PATCH", `/api/lanes/${laneId}`, { ci_status: "green" });
    assert.equal(p.body.lane.ci_status, "green");
    const d = await request("DELETE", `/api/lanes/${laneId}`);
    assert.equal(d.status, 200);
    assert.equal((await request("GET", `/api/lanes/${laneId}`)).status, 404);
  });

  it("rejects cross-origin lane deletion", async () => {
    const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-delete" });
    const id = created.body.lane.id;
    const rejected = await request("DELETE", `/api/lanes/${id}`, undefined, {
      Origin: "https://attacker.example",
    });
    assert.equal(rejected.status, 403);
    assert.equal(rejected.body.error.code, "EBADORIGIN");
    assert.equal((await request("GET", `/api/lanes/${id}`)).status, 200);
    assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
  });

  it("rejects cross-origin lane patching", async () => {
    const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-patch" });
    const id = created.body.lane.id;
    const rejected = await request(
      "PATCH",
      `/api/lanes/${id}`,
      { run_id: "attacker-run" },
      { Origin: "https://attacker.example" }
    );
    assert.equal(rejected.status, 403);
    assert.equal(rejected.body.error.code, "EBADORIGIN");
    assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.run_id, null);
    assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
  });

  it("rejects cross-origin lane creation", async () => {
    const rejected = await request(
      "POST",
      "/api/lanes",
      { cwd: "/tmp/lane-cross-origin-create" },
      { Origin: "https://attacker.example" }
    );
    assert.equal(rejected.status, 403);
    assert.equal(rejected.body.error.code, "EBADORIGIN");
  });

  it("rejects cross-origin stage reporting", async () => {
    const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-stage" });
    const id = created.body.lane.id;
    const rejected = await request(
      "POST",
      `/api/lanes/${id}/stage`,
      { stage: "implement" },
      { Origin: "https://attacker.example" }
    );
    assert.equal(rejected.status, 403);
    assert.equal(rejected.body.error.code, "EBADORIGIN");
    assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.stage, "idle");
    assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
  });

  it("broadcasts lane_update on create", async () => {
    const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`;
    const ws = new WebSocket(wsUrl);
    const messages = [];
    const timeout = new Promise((_, reject) => {
      setTimeout(() => reject(new Error("ws test timeout")), 5000);
    });

    await new Promise((resolve, reject) => {
      ws.on("open", resolve);
      ws.on("error", reject);
      Promise.race([timeout]).catch(reject);
    });

    ws.on("message", (msg) => {
      try {
        messages.push(JSON.parse(msg));
      } catch {
        /* parse error */
      }
    });

    const createResp = await request("POST", "/api/lanes", {
      title: "WS Test Lane",
      cwd: "/tmp/ws-test-lane",
    });
    assert.equal(createResp.status, 201);
    const newLaneId = createResp.body.lane.id;

    await new Promise((resolve) => {
      setTimeout(resolve, 100);
    });

    const laneUpdateMsg = messages.find(
      (m) => m.type === "lane_update" && m.data.lane?.id === newLaneId
    );
    assert.ok(laneUpdateMsg, "create should broadcast lane_update");
    assert.equal(laneUpdateMsg.data.lane.id, newLaneId);
    assert.equal(laneUpdateMsg.data.lane.title, "WS Test Lane");

    // Test delete broadcast
    messages.length = 0;
    const deleteResp = await request("DELETE", `/api/lanes/${newLaneId}`);
    assert.equal(deleteResp.status, 200);

    await new Promise((resolve) => {
      setTimeout(resolve, 100);
    });

    const deleteMsg = messages.find(
      (m) => m.type === "lane_update" && m.data.removed === newLaneId
    );
    assert.ok(deleteMsg, "delete should broadcast lane_update with removed");
    assert.equal(deleteMsg.data.removed, newLaneId);

    ws.close();
  });
});

describe("hook → lane binding", () => {
  it("binds a session to the lane owning its cwd and flags/clears needs_action", async () => {
    const created = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-hook-a",
      title: "Hooked",
    });
    const id = created.body.lane.id;

    await request("POST", "/api/hooks/event", {
      hook_type: "SessionStart",
      data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a/sub/dir" },
    });
    assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-lane-1");

    await request("POST", "/api/hooks/event", {
      hook_type: "Notification",
      data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", message: "needs permission" },
    });
    assert.equal(
      (await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
      "needs permission"
    );

    await request("POST", "/api/hooks/event", {
      hook_type: "PostToolUse",
      data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", tool_name: "Read" },
    });
    assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);

    await request("DELETE", `/api/lanes/${id}`);
  });

  it("ignores a hook whose cwd is under no lane", async () => {
    const before = (await request("GET", "/api/lanes")).body.lanes.length;
    await request("POST", "/api/hooks/event", {
      hook_type: "SessionStart",
      data: { session_id: "sess-lane-orphan", cwd: "/tmp/not-a-lane" },
    });
    assert.equal((await request("GET", "/api/lanes")).body.lanes.length, before);
  });

  it("clears needs_action only from the session that raised it, not from cross-session rebinds", async () => {
    const created = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-cross-session",
      title: "Cross-Session",
    });
    const id = created.body.lane.id;

    // Session A sets needs_action
    await request("POST", "/api/hooks/event", {
      hook_type: "SessionStart",
      data: { session_id: "sess-a", cwd: "/tmp/lane-cross-session" },
    });
    await request("POST", "/api/hooks/event", {
      hook_type: "Notification",
      data: { session_id: "sess-a", cwd: "/tmp/lane-cross-session", message: "blocked" },
    });
    assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
    assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-a");

    // Session B PostToolUse: rebinds lane to B, but does NOT clear A's flag
    await request("POST", "/api/hooks/event", {
      hook_type: "PostToolUse",
      data: { session_id: "sess-b", cwd: "/tmp/lane-cross-session", tool_name: "Read" },
    });
    assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-b");
    assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");

    // Session B's second PostToolUse clears the flag (B is now bound)
    await request("POST", "/api/hooks/event", {
      hook_type: "PostToolUse",
      data: { session_id: "sess-b", cwd: "/tmp/lane-cross-session", tool_name: "Read" },
    });
    assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);

    await request("DELETE", `/api/lanes/${id}`);
  });

  it("raises needs_action with default 'needs you' when Notification has no message", async () => {
    const created = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-default-msg",
      title: "Default Msg",
    });
    const id = created.body.lane.id;

    await request("POST", "/api/hooks/event", {
      hook_type: "SessionStart",
      data: { session_id: "sess-c", cwd: "/tmp/lane-default-msg" },
    });
    await request("POST", "/api/hooks/event", {
      hook_type: "Notification",
      data: { session_id: "sess-c", cwd: "/tmp/lane-default-msg" },
    });
    assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "needs you");

    await request("DELETE", `/api/lanes/${id}`);
  });

  it("the bare idle nudge never raises needs_action and clears a stale one", async () => {
    const created = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-idle-nudge",
      title: "Idle Nudge",
    });
    const id = created.body.lane.id;

    await request("POST", "/api/hooks/event", {
      hook_type: "SessionStart",
      data: { session_id: "sess-nudge", cwd: "/tmp/lane-idle-nudge" },
    });
    await request("POST", "/api/hooks/event", {
      hook_type: "Notification",
      data: {
        session_id: "sess-nudge",
        cwd: "/tmp/lane-idle-nudge",
        message: "Claude needs your permission to use Bash",
      },
    });
    assert.equal(
      (await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
      "Claude needs your permission to use Bash"
    );

    // The 60s idle nudge fires after Stop: it proves the CLI is parked at an
    // idle prompt, so it must clear rather than pin the banner.
    await request("POST", "/api/hooks/event", {
      hook_type: "Notification",
      data: {
        session_id: "sess-nudge",
        cwd: "/tmp/lane-idle-nudge",
        message: "Claude is waiting for your input",
      },
    });
    const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
    assert.equal(lane.needs_action, null);
    assert.equal(lane.status, "idle");

    await request("DELETE", `/api/lanes/${id}`);
  });

  it("mirrors CLI turn state onto lane.status for a lane the dashboard did not launch", async () => {
    const created = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-turn-status",
      title: "Turn Status",
    });
    const id = created.body.lane.id;
    assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle");

    await request("POST", "/api/hooks/event", {
      hook_type: "UserPromptSubmit",
      data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
    });
    assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running");

    // A subagent finishing is not the end of the turn.
    await request("POST", "/api/hooks/event", {
      hook_type: "SubagentStop",
      data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
    });
    assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running");

    await request("POST", "/api/hooks/event", {
      hook_type: "Stop",
      data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
    });
    assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle");

    await request("DELETE", `/api/lanes/${id}`);
  });
});

describe("hook → stage detection", () => {
  it("a Bash test-run hook sets detected_stage to tests, a following Read leaves it unchanged", async () => {
    const created = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-detect-a",
      title: "Detect",
    });
    const id = created.body.lane.id;

    await request("POST", "/api/hooks/event", {
      hook_type: "PostToolUse",
      data: {
        session_id: "sess-detect-1",
        cwd: "/tmp/lane-detect-a",
        tool_name: "Bash",
        tool_input: { command: "npm run test:server" },
      },
    });
    let lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
    assert.equal(lane.detected_stage, "tests");
    assert.ok(lane.detected_signal);

    await request("POST", "/api/hooks/event", {
      hook_type: "PostToolUse",
      data: {
        session_id: "sess-detect-1",
        cwd: "/tmp/lane-detect-a",
        tool_name: "Read",
        tool_input: { file_path: "/tmp/lane-detect-a/foo.js" },
      },
    });
    lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
    assert.equal(lane.detected_stage, "tests");

    await request("DELETE", `/api/lanes/${id}`);
  });

  it("a hook whose cwd is under no lane changes nothing", async () => {
    const beforeLanes = (await request("GET", "/api/lanes")).body.lanes.length;
    await request("POST", "/api/hooks/event", {
      hook_type: "PostToolUse",
      data: {
        session_id: "sess-detect-orphan",
        cwd: "/tmp/not-a-lane-detect",
        tool_name: "Bash",
        tool_input: { command: "npm run test:server" },
      },
    });
    assert.equal((await request("GET", "/api/lanes")).body.lanes.length, beforeLanes);
  });

  it("a lane already declared at ship ignores an implement detection", async () => {
    const created = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-detect-declared",
      title: "Declared",
    });
    const id = created.body.lane.id;
    await request("POST", `/api/lanes/${id}/stage`, { stage: "ship", status: "running" });

    await request("POST", "/api/hooks/event", {
      hook_type: "PostToolUse",
      data: {
        session_id: "sess-detect-2",
        cwd: "/tmp/lane-detect-declared",
        tool_name: "Edit",
        tool_input: { file_path: "/tmp/lane-detect-declared/foo.js" },
      },
    });
    const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
    assert.equal(lane.detected_stage, null);

    await request("DELETE", `/api/lanes/${id}`);
  });

  it("a malformed hook payload still returns 200 and leaves the lane untouched", async () => {
    const created = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-detect-malformed",
      title: "Malformed",
    });
    const id = created.body.lane.id;

    const r1 = await request("POST", "/api/hooks/event", {
      hook_type: "PostToolUse",
      data: {
        session_id: "sess-detect-3",
        cwd: "/tmp/lane-detect-malformed",
        tool_input: "just a string, no tool_name",
      },
    });
    assert.equal(r1.status, 200);

    const r2 = await request("POST", "/api/hooks/event", {
      hook_type: "PostToolUse",
      data: { session_id: "sess-detect-4", cwd: "/tmp/lane-detect-malformed" },
    });
    assert.equal(r2.status, 200);

    const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
    assert.equal(lane.detected_stage, null);

    await request("DELETE", `/api/lanes/${id}`);
  });

  it("detection is visible in GET /api/lanes/:id with a detected node, and no node is 'done'", async () => {
    const created = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-detect-visible",
      title: "Visible",
    });
    const id = created.body.lane.id;

    await request("POST", "/api/hooks/event", {
      hook_type: "PostToolUse",
      data: {
        session_id: "sess-detect-5",
        cwd: "/tmp/lane-detect-visible",
        tool_name: "Bash",
        tool_input: { command: "npm run test:server" },
      },
    });

    const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
    assert.equal(lane.detected_stage, "tests");
    assert.ok(lane.detected_signal);
    const testsNode = lane.pipeline_nodes.find((n) => n.id === "tests");
    assert.equal(testsNode.detected, true);
    assert.ok(!lane.pipeline_nodes.some((n) => n.state === "done"));

    await request("DELETE", `/api/lanes/${id}`);
  });

  it("a throwing recordDetection does not cost the lane bookkeeping in the same hook", async () => {
    const created = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-detect-throws",
      title: "Throws",
    });
    const id = created.body.lane.id;

    // Bind the session and raise needs_action, exactly as a permission prompt does.
    await request("POST", "/api/hooks/event", {
      hook_type: "SessionStart",
      data: { session_id: "sess-detect-throw", cwd: "/tmp/lane-detect-throws" },
    });
    await request("POST", "/api/hooks/event", {
      hook_type: "Notification",
      data: {
        session_id: "sess-detect-throw",
        cwd: "/tmp/lane-detect-throws",
        message: "needs permission",
      },
    });
    assert.equal(
      (await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
      "needs permission"
    );

    // Stand in for the real failures recordDetection can raise mid-hook: ENOLANE
    // when the lane is deleted between resolveLaneByCwd and its lookup, or
    // SQLITE_BUSY from another process on the same database.
    const lanesLib = require("../lib/lanes");
    const realRecordDetection = lanesLib.recordDetection;
    lanesLib.recordDetection = () => {
      throw Object.assign(new Error("no lane 999"), { code: "ENOLANE" });
    };
    try {
      const r = await request("POST", "/api/hooks/event", {
        hook_type: "PostToolUse",
        data: {
          session_id: "sess-detect-throw",
          cwd: "/tmp/lane-detect-throws",
          tool_name: "Edit",
          tool_input: { file_path: "/tmp/lane-detect-throws/foo.js" },
        },
      });
      assert.equal(r.status, 200);
    } finally {
      lanesLib.recordDetection = realRecordDetection;
    }

    const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
    assert.equal(lane.needs_action, null, "bookkeeping must survive a failed detection");
    assert.equal(lane.detected_stage, null);

    await request("DELETE", `/api/lanes/${id}`);
  });
});

describe("lane actions", () => {
  it("requires confirmation, then removes an adopted lane row without its directory", async () => {
    const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-a" });
    const id = c.body.lane.id;
    assert.equal((await request("POST", `/api/lanes/${id}/remove`, {})).status, 400);
    const preflight = await request("GET", `/api/lanes/${id}/preflight?action=remove`);
    const removed = await request("POST", `/api/lanes/${id}/remove`, {
      confirm: true,
      expect: {
        head: preflight.body.head,
        dirty: preflight.body.dirty,
        untracked: preflight.body.untracked,
        unpushed: preflight.body.unpushed,
      },
    });
    assert.equal(removed.status, 200);
    assert.equal((await request("GET", `/api/lanes/${id}`)).status, 404);
  });

  it("rejects an unknown action", async () => {
    const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-b" });
    const id = c.body.lane.id;
    const before = await request("GET", `/api/lanes/${id}`);
    const r = await request("POST", `/api/lanes/${id}/frobnicate`, {});
    assert.equal(r.status, 400);
    const after = await request("GET", `/api/lanes/${id}`);
    assert.equal(after.body.lane.stage, before.body.lane.stage);
    assert.equal(after.body.lane.status, before.body.lane.status);
    await request("DELETE", `/api/lanes/${id}`);
  });

  it("clear resets stage state but keeps the lane", async () => {
    const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-c", title: "Keep me" });
    const id = c.body.lane.id;
    await request("POST", `/api/lanes/${id}/stage`, { stage: "review", status: "running" });
    const r = await request("POST", `/api/lanes/${id}/clear`, {});
    assert.equal(r.status, 200);
    assert.equal(r.body.lane.stage, "idle");
    assert.equal(r.body.lane.title, "Keep me");
    assert.deepEqual(r.body.lane.stages, {});
    await request("DELETE", `/api/lanes/${id}`);
  });

  it("stop on a lane with no run is a no-op, not a 500", async () => {
    const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-d" });
    const r = await request("POST", `/api/lanes/${c.body.lane.id}/stop`, {});
    assert.equal(r.status, 200);
    assert.equal(r.body.lane.status, "idle");
    await request("DELETE", `/api/lanes/${c.body.lane.id}`);
  });

  it("message on a lane is no longer supported via REST", async () => {
    const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-e" });
    const id = c.body.lane.id;
    const r = await request("POST", `/api/lanes/${id}/message`, { text: "hello" });
    assert.equal(r.status, 400);
    assert.equal(r.body.error.code, "EUNSUPPORTED");
    await request("DELETE", `/api/lanes/${id}`);
  });
});

describe("GET /api/lanes/:id/git", () => {
  const fs = require("node:fs");
  const { execFileSync } = require("node:child_process");
  const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-api-git-"));
  const g = (cwd, ...args) => {
    const env = { ...process.env };
    for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) delete env[k];
    env.GIT_TERMINAL_PROMPT = "0";
    return execFileSync("git", args, { cwd, encoding: "utf8", env });
  };

  after(() => fs.rmSync(ROOT, { recursive: true, force: true }));

  it("reports the working-copy facts for a lane pointing at a real repo", async () => {
    const dir = path.join(ROOT, "repo");
    fs.mkdirSync(dir, { recursive: true });
    g(dir, "init", "-b", "feat/api-facts");
    g(dir, "config", "user.email", "t@example.com");
    g(dir, "config", "user.name", "Test");
    fs.writeFileSync(path.join(dir, "a.txt"), "one\n");
    g(dir, "add", "-A");
    g(dir, "commit", "-m", "api facts fixture");
    fs.writeFileSync(path.join(dir, "a.txt"), "two\n");
    fs.writeFileSync(path.join(dir, "untracked.txt"), "u\n");

    const created = await request("POST", "/api/lanes", { cwd: dir, title: "git facts" });
    const id = created.body.lane.id;
    const r = await request("GET", `/api/lanes/${id}/git`);
    assert.equal(r.status, 200);
    assert.equal(r.body.available, true);
    assert.equal(r.body.branch, "feat/api-facts");
    assert.equal(r.body.subject, "api facts fixture");
    assert.equal(r.body.head, g(dir, "rev-parse", "--short", "HEAD").trim());
    assert.equal(r.body.dirty, 1);
    assert.equal(r.body.untracked, 1);
    await request("DELETE", `/api/lanes/${id}`);
  });

  it("reports available:false for a plain directory, not an error", async () => {
    const dir = path.join(ROOT, "plain");
    fs.mkdirSync(dir, { recursive: true });
    const created = await request("POST", "/api/lanes", { cwd: dir, title: "plain dir" });
    const id = created.body.lane.id;
    const r = await request("GET", `/api/lanes/${id}/git`);
    assert.equal(r.status, 200);
    assert.deepEqual(r.body, { available: false });
    await request("DELETE", `/api/lanes/${id}`);
  });

  it("reports available:false when the lane's directory is gone", async () => {
    const dir = path.join(ROOT, "vanishes");
    fs.mkdirSync(dir, { recursive: true });
    const created = await request("POST", "/api/lanes", { cwd: dir, title: "vanishing" });
    const id = created.body.lane.id;
    fs.rmSync(dir, { recursive: true, force: true });
    const r = await request("GET", `/api/lanes/${id}/git`);
    assert.equal(r.status, 200);
    assert.deepEqual(r.body, { available: false });
    await request("DELETE", `/api/lanes/${id}`);
  });

  it("404s for a lane that does not exist", async () => {
    const r = await request("GET", "/api/lanes/999999/git");
    assert.equal(r.status, 404);
    assert.equal(r.body.error.code, "ENOLANE");
  });

  it("resolves to the facts route, not an action error", async () => {
    // The `/:id/:action` catch-all is a POST and cannot shadow this GET, so
    // this pins the response SHAPE rather than any registration order.
    const dir = path.join(ROOT, "ordering");
    fs.mkdirSync(dir, { recursive: true });
    const created = await request("POST", "/api/lanes", { cwd: dir, title: "ordering" });
    const id = created.body.lane.id;
    const r = await request("GET", `/api/lanes/${id}/git`);
    assert.equal(r.status, 200);
    assert.ok("available" in r.body, JSON.stringify(r.body));
    assert.equal(r.body.error, undefined);
    await request("DELETE", `/api/lanes/${id}`);
  });
});

describe("GET /api/lanes/branches", () => {
  const fs = require("node:fs");
  const { execFileSync } = require("node:child_process");
  const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-api-branches-"));
  const g = (cwd, ...args) => {
    const env = { ...process.env };
    for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) delete env[k];
    env.GIT_TERMINAL_PROMPT = "0";
    return execFileSync("git", args, { cwd, encoding: "utf8", env });
  };

  after(() => fs.rmSync(ROOT, { recursive: true, force: true }));

  it("lists the repo's local branches and the current one", async () => {
    const dir = path.join(ROOT, "repo");
    fs.mkdirSync(dir, { recursive: true });
    g(dir, "init", "-b", "main");
    g(dir, "config", "user.email", "t@example.com");
    g(dir, "config", "user.name", "Test");
    fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
    g(dir, "add", "-A");
    g(dir, "commit", "-m", "init");
    g(dir, "branch", "feat/x");

    const r = await request("GET", `/api/lanes/branches?repo=${encodeURIComponent(dir)}`);
    assert.equal(r.status, 200);
    assert.deepEqual([...r.body.branches].sort(), ["feat/x", "main"]);
    assert.equal(r.body.current, "main");
  });

  it("400s for a relative path", async () => {
    const r = await request("GET", "/api/lanes/branches?repo=relative/path");
    assert.equal(r.status, 400);
    assert.equal(r.body.error.code, "EBADSOURCEREPO");
  });

  it("400s for a path that does not exist", async () => {
    const r = await request(
      "GET",
      `/api/lanes/branches?repo=${encodeURIComponent(path.join(ROOT, "nope"))}`
    );
    assert.equal(r.status, 400);
    assert.equal(r.body.error.code, "EBADSOURCEREPO");
  });

  it("400s for a directory that is not a git repository", async () => {
    const dir = path.join(ROOT, "plain");
    fs.mkdirSync(dir, { recursive: true });
    const r = await request("GET", `/api/lanes/branches?repo=${encodeURIComponent(dir)}`);
    assert.equal(r.status, 400);
    assert.equal(r.body.error.code, "EBADSOURCEREPO");
  });

  it("is not swallowed by the /:id catch-all", async () => {
    // "/branches" would otherwise be read as a lane id and 404 with ENOLANE.
    const r = await request("GET", "/api/lanes/branches?repo=%2Fdoes%2Fnot%2Fmatter");
    assert.notEqual(r.body?.error?.code, "ENOLANE");
  });
});

describe("detection attributes work to the lane the work touched", () => {
  // Measured on a real session: 325 of 400 hook events carried the SESSION's
  // cwd, while the actual edits and test runs happened inside a different
  // repo reached with `cd <other> && ...`. The lane doing the work detected
  // nothing; the lane the terminal happened to start in absorbed all of it.
  it("credits a Bash command that cd's into another lane to THAT lane", async () => {
    const session = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-attr-session",
      title: "where the shell started",
    });
    const worked = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-attr-worked",
      title: "where the work happened",
    });
    const sessionId = session.body.lane.id;
    const workedId = worked.body.lane.id;

    await request("POST", "/api/hooks/event", {
      hook_type: "PostToolUse",
      data: {
        session_id: "sess-attr-1",
        cwd: "/tmp/lane-attr-session",
        tool_name: "Bash",
        tool_input: { command: "cd /tmp/lane-attr-worked && npm run test:server" },
      },
    });

    assert.equal(
      (await request("GET", `/api/lanes/${workedId}`)).body.lane.detected_stage,
      "tests"
    );
    assert.equal(
      (await request("GET", `/api/lanes/${sessionId}`)).body.lane.detected_stage,
      null,
      "the session's own lane did no work and must not be credited"
    );

    await request("DELETE", `/api/lanes/${workedId}`);
    await request("DELETE", `/api/lanes/${sessionId}`);
  });

  it("credits an Edit to the lane owning the edited file", async () => {
    const session = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-edit-session",
      title: "session",
    });
    const worked = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-edit-worked",
      title: "worked",
    });

    await request("POST", "/api/hooks/event", {
      hook_type: "PostToolUse",
      data: {
        session_id: "sess-attr-2",
        cwd: "/tmp/lane-edit-session",
        tool_name: "Edit",
        tool_input: { file_path: "/tmp/lane-edit-worked/server/lib/x.js" },
      },
    });

    assert.equal(
      (await request("GET", `/api/lanes/${worked.body.lane.id}`)).body.lane.detected_stage,
      "implement"
    );
    assert.equal(
      (await request("GET", `/api/lanes/${session.body.lane.id}`)).body.lane.detected_stage,
      null
    );

    await request("DELETE", `/api/lanes/${worked.body.lane.id}`);
    await request("DELETE", `/api/lanes/${session.body.lane.id}`);
  });

  it("falls back to the session's lane when the tool names no other lane's path", async () => {
    const created = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-attr-fallback",
      title: "fallback",
    });
    const id = created.body.lane.id;

    await request("POST", "/api/hooks/event", {
      hook_type: "PostToolUse",
      data: {
        session_id: "sess-attr-3",
        cwd: "/tmp/lane-attr-fallback",
        tool_name: "Bash",
        tool_input: { command: "npm run test:server" },
      },
    });

    assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.detected_stage, "tests");
    await request("DELETE", `/api/lanes/${id}`);
  });

  it("keeps session bookkeeping on the session's lane, not the worked-in lane", async () => {
    // session_id / needs_action ARE session-scoped facts: the session really is
    // bound to the directory it started in. Only the stage inference follows
    // the work.
    const session = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-book-session",
      title: "session",
    });
    const worked = await request("POST", "/api/lanes", {
      cwd: "/tmp/lane-book-worked",
      title: "worked",
    });

    await request("POST", "/api/hooks/event", {
      hook_type: "PostToolUse",
      data: {
        session_id: "sess-attr-4",
        cwd: "/tmp/lane-book-session",
        tool_name: "Bash",
        tool_input: { command: "cd /tmp/lane-book-worked && npm test" },
      },
    });

    assert.equal(
      (await request("GET", `/api/lanes/${session.body.lane.id}`)).body.lane.session_id,
      "sess-attr-4"
    );
    assert.equal(
      (await request("GET", `/api/lanes/${worked.body.lane.id}`)).body.lane.session_id,
      null
    );

    await request("DELETE", `/api/lanes/${worked.body.lane.id}`);
    await request("DELETE", `/api/lanes/${session.body.lane.id}`);
  });
});
