/**
 * @file Tests for the `ccam stage` / `ccam lanes` CLI subcommands: lane
 * resolution from the current directory, the stage round-trip through the HTTP
 * API, and the non-zero exit when the cwd belongs to no lane.
 * @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 fs = require("fs");
const http = require("http");
const { spawn } = require("child_process");

const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-cli-${Date.now()}-${process.pid}.db`);
const PIPELINE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-pipeline-fixture-"));
const LANES_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-managed-lanes-cli-"));
const SOURCE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-worktree-source-"));
const REMOTE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-worktree-remote-"));
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_PIPELINES_DIR = PIPELINE_DIR;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.LANES_ROOT = LANES_ROOT;

// Create a fixture pipeline template for testing custom pipeline selection
fs.writeFileSync(
  path.join(PIPELINE_DIR, "test-pipeline.json"),
  JSON.stringify({
    id: "test-pipeline",
    name: "Test Pipeline",
    nodes: [
      { id: "start", label: "start", icon: "🚀", gate: false, aliases: [] },
      { id: "end", label: "end", icon: "✓", gate: false, aliases: [] },
    ],
  })
);

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

const LANE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-cli-"));
const CLI = path.join(__dirname, "..", "..", "bin", "ccam.js");

let server;
let BASE;

// Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this
// very suite) sets in its own environment — those leak to every child
// process and override an explicit `cwd`, so without this a git command
// meant for this test's throwaway tmp repo silently operates on the real
// repo running the hook instead.
const GIT_ENV = { ...process.env };
delete GIT_ENV.GIT_DIR;
delete GIT_ENV.GIT_WORK_TREE;
delete GIT_ENV.GIT_INDEX_FILE;
delete GIT_ENV.GIT_COMMON_DIR;
delete GIT_ENV.GIT_OBJECT_DIRECTORY;
delete GIT_ENV.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete GIT_ENV.GIT_PREFIX;
delete GIT_ENV.GIT_NAMESPACE;
delete GIT_ENV.GIT_CONFIG_PARAMETERS;

function git(args, cwd) {
  return new Promise((resolve, reject) => {
    const child = spawn("git", args, { cwd, env: GIT_ENV });
    let stderr = "";
    child.stderr.on("data", (chunk) => (stderr += chunk));
    child.on("error", reject);
    child.on("close", (status) => {
      if (status === 0) return resolve();
      reject(new Error(`git ${args.join(" ")} failed (${status}): ${stderr}`));
    });
  });
}

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

function get(urlPath) {
  return new Promise((resolve, reject) => {
    const url = new URL(urlPath, BASE);
    const req = http.request(
      {
        hostname: url.hostname,
        port: url.port,
        path: `${url.pathname}${url.search}`,
        method: "GET",
      },
      (res) => {
        let d = "";
        res.on("data", (c) => (d += c));
        res.on("end", () => resolve({ status: res.statusCode, body: d ? JSON.parse(d) : null }));
      }
    );
    req.on("error", reject);
    req.end();
  });
}

// MUST be async: the test server runs in THIS process, so a blocking
// spawnSync would stall the event loop and the CLI child's request to
// 127.0.0.1 would never be served — a deadlock that looks like a network
// sandbox blocking loopback.
function cli(args, cwd) {
  return new Promise((resolve, reject) => {
    const child = spawn(process.execPath, [CLI, ...args], {
      cwd,
      env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(server.address().port) },
    });
    let stdout = "";
    let stderr = "";
    child.stdout.on("data", (c) => (stdout += c));
    child.stderr.on("data", (c) => (stderr += c));
    child.on("error", reject);
    child.on("close", (status) => resolve({ status, stdout, stderr }));
  });
}

before(async () => {
  await git(["init", "--bare"], REMOTE_REPO);
  await git(["init", "--initial-branch=main"], SOURCE_REPO);
  await git(["config", "user.email", "lanes-cli@example.test"], SOURCE_REPO);
  await git(["config", "user.name", "Lanes CLI Test"], SOURCE_REPO);
  fs.writeFileSync(path.join(SOURCE_REPO, "README.md"), "fixture\n");
  await git(["add", "README.md"], SOURCE_REPO);
  await git(["commit", "-m", "fixture"], SOURCE_REPO);
  await git(["remote", "add", "origin", REMOTE_REPO], SOURCE_REPO);
  await git(["push", "-u", "origin", "main"], SOURCE_REPO);

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

  // Poll for server readiness
  let ready = false;
  const deadline = Date.now() + 2000; // 2 second timeout
  while (!ready && Date.now() < deadline) {
    try {
      const res = await get("/api/health");
      if (res.status === 200) ready = true;
    } catch {
      /* not ready yet */
    }
    if (!ready) await new Promise((r) => setTimeout(r, 50));
  }
  if (!ready) throw new Error("Server failed to become ready within 2s");

  await post("/api/lanes", { cwd: LANE_DIR, title: "CLI lane" });
});

after(() => {
  try {
    if (server) server.close();
    fs.rmSync(LANE_DIR, { recursive: true, force: true });
    fs.rmSync(PIPELINE_DIR, { recursive: true, force: true });
    fs.rmSync(LANES_ROOT, { recursive: true, force: true });
    fs.rmSync(SOURCE_REPO, { recursive: true, force: true });
    fs.rmSync(REMOTE_REPO, { recursive: true, force: true });
  } finally {
    // Clean up TEST_DB and its WAL/SHM siblings (always runs, even if earlier cleanup fails)
    fs.rmSync(TEST_DB, { force: true });
    fs.rmSync(`${TEST_DB}-wal`, { force: true });
    fs.rmSync(`${TEST_DB}-shm`, { force: true });
  }
});

describe("ccam stage", () => {
  it("reports a stage for the lane owning the current directory", async () => {
    const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
    assert.equal(r.status, 0, r.stderr);
    assert.match(r.stdout, /review/);
    const list = await cli(["lanes"], LANE_DIR);
    assert.match(list.stdout, /review/);
  });

  it("exits non-zero when no lane owns the cwd", async () => {
    const r = await cli(["stage", "review"], os.tmpdir());
    assert.notEqual(r.status, 0);
    assert.match(`${r.stdout}${r.stderr}`, /no lane/i);
  });

  it("warns, but still records, a stage name matching no pipeline node", async () => {
    // setStage stores the string verbatim, so a typo'd stage is accepted and
    // then renders nowhere (phaseIdx -1, progress 0). Silent acceptance is how
    // a lane ends up looking unstarted for an entire pipeline run.
    const r = await cli(["stage", "revieww"], LANE_DIR);
    assert.equal(r.status, 0, r.stderr);
    assert.match(r.stdout, /revieww/);
    assert.match(r.stderr, /matches no node/i);
    assert.match(r.stderr, /revieww/);
  });

  it("does not warn for a stage declared by alias", async () => {
    const r = await cli(["stage", "planning"], LANE_DIR);
    assert.equal(r.status, 0, r.stderr);
    assert.doesNotMatch(r.stderr, /matches no node/i);
  });

  it("does not warn for a valid stage reported as failed", async () => {
    // --result fail paints the node `failed`, not `current`; the warning must
    // not read that as an unknown stage.
    const r = await cli(["stage", "review", "--result", "fail"], LANE_DIR);
    assert.equal(r.status, 0, r.stderr);
    assert.doesNotMatch(r.stderr, /matches no node/i);
  });
});

describe("ccam lanes add", () => {
  it("creates a lane and it appears in lanes list", async () => {
    const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-add-"));
    try {
      const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "CLI added"], addDir);
      assert.equal(r.status, 0, r.stderr);
      assert.match(r.stdout, /Created lane/i);

      const list = await cli(["lanes"], addDir);
      assert.equal(list.status, 0, list.stderr);
      assert.match(list.stdout, /CLI added/);
    } finally {
      fs.rmSync(addDir, { recursive: true, force: true });
    }
  });

  it("defaults to 'default' pipeline when --pipeline is omitted", async () => {
    const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-no-pipeline-"));
    try {
      const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir);
      assert.equal(r.status, 0, r.stderr);
      assert.match(r.stdout, /Created lane/i);

      const details = await get(`/api/lanes`);
      const lane = details.body.lanes.find((l) => l.title === "Default pipeline");
      assert.ok(lane, "lane not found");
      assert.equal(lane.pipeline, "default", "should use default pipeline when omitted");
    } finally {
      fs.rmSync(addDir, { recursive: true, force: true });
    }
  });

  it("accepts --pipeline flag and creates lane with custom pipeline", async () => {
    const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-custom-pipeline-"));
    try {
      const r = await cli(
        [
          "lanes",
          "add",
          "--cwd",
          addDir,
          "--title",
          "Custom pipeline",
          "--pipeline",
          "test-pipeline",
        ],
        addDir
      );
      assert.equal(r.status, 0, r.stderr);
      assert.match(r.stdout, /Created lane/i);

      const details = await get(`/api/lanes`);
      const lane = details.body.lanes.find((l) => l.title === "Custom pipeline");
      assert.ok(lane, "lane not found");
      assert.equal(lane.pipeline, "test-pipeline", "should use specified pipeline");
    } finally {
      fs.rmSync(addDir, { recursive: true, force: true });
    }
  });

  it("switches an existing lane's pipeline, and shows it when given no target", async () => {
    // `--pipeline` was creation-only, so every lane added from the dashboard
    // was pinned to `default`'s 8 nodes with no way to reach a longer template.
    const show = await cli(["lanes", "pipeline"], LANE_DIR);
    assert.equal(show.status, 0, show.stderr);
    assert.match(show.stdout, /default/);
    assert.match(show.stdout, /available:.*ship-feature/);

    const set = await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
    assert.equal(set.status, 0, set.stderr);
    assert.match(set.stdout, /ship-feature/);
    assert.match(set.stdout, /16 nodes/);

    const back = await cli(["lanes", "pipeline", "default"], LANE_DIR);
    assert.equal(back.status, 0, back.stderr);
    assert.match(back.stdout, /8 nodes/);
  });

  it("refuses an unknown pipeline id instead of silently falling back to default", async () => {
    // getPipeline() returns the default template for an unknown id, so without
    // a write-side check a typo would store, render `default`, and look fine.
    const r = await cli(["lanes", "pipeline", "no-such-pipeline"], LANE_DIR);
    assert.notEqual(r.status, 0);
    assert.match(`${r.stdout}${r.stderr}`, /unknown pipeline/i);

    const after = await cli(["lanes", "pipeline"], LANE_DIR);
    assert.match(after.stdout, /default/);
  });

  it("provisions a managed worktree lane and reports it ready", async () => {
    const r = await cli(
      ["lanes", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"],
      SOURCE_REPO
    );
    assert.equal(r.status, 0, r.stderr);
    assert.match(r.stdout, /Worktree lane #\d+ ready: CLI worktree/);

    const list = await get("/api/lanes");
    const lane = list.body.lanes.find((l) => l.title === "CLI worktree");
    assert.ok(lane, "managed lane not found");
    assert.equal(lane.kind, "managed");
    assert.equal(lane.status, "idle");
    assert.ok(fs.existsSync(lane.cwd), "provisioned worktree directory is missing");
  });

  it("reports the failure notes, not success, when provisioning fails", async () => {
    const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-cli-unborn-repo-"));
    try {
      await git(["init", "-b", "main"], emptyRepo);
      const r = await cli(
        ["lanes", "add", "--repo", emptyRepo, "--title", "CLI Unborn"],
        emptyRepo
      );
      assert.notEqual(r.status, 0);
      assert.match(`${r.stdout}${r.stderr}`, /Worktree lane #\d+ failed:/);
      assert.match(`${r.stdout}${r.stderr}`, /fatal:|ambiguous argument|unknown revision/i);
      assert.doesNotMatch(r.stdout, /ready/i);
    } finally {
      fs.rmSync(emptyRepo, { recursive: true, force: true });
    }
  });
});

describe("ccam lanes destructive lifecycle", () => {
  // Every test provisions its OWN lane. These tests used to share one mutable
  // `managedLane` across describe blocks, which meant they only ever exercised
  // whatever state the previous test happened to leave behind — the same
  // shared-fixture reuse that let removeWorktree's destroy guard ship unpinned.
  let laneSeq = 0;
  async function ownLane(title) {
    const slug = `cli-destructive-${(laneSeq += 1)}`;
    const added = await cli(
      ["lanes", "add", "--repo", SOURCE_REPO, "--title", title, "--slug", slug],
      SOURCE_REPO
    );
    assert.equal(added.status, 0, added.stderr);
    const list = await get("/api/lanes");
    const lane = list.body.lanes.find((l) => l.slug === slug);
    assert.ok(lane, `lane ${slug} not found`);
    assert.equal(lane.status, "idle");
    return lane;
  }

  it("prints reset preflight facts and changes nothing without --yes", async () => {
    const lane = await ownLane("CLI reset dry run");
    const dirtyFile = path.join(lane.cwd, "dirty.txt");
    fs.writeFileSync(dirtyFile, "must survive\n");

    const r = await cli(["lanes", "reset", String(lane.id)], SOURCE_REPO);
    assert.notEqual(r.status, 0);
    assert.match(r.stdout, /Preflight for reset lane #\d+:/);
    assert.match(r.stdout, /dirty\s+0/);
    assert.match(r.stdout, /untracked\s+1/);
    assert.match(`${r.stdout}${r.stderr}`, /without --yes/);
    assert.equal(fs.readFileSync(dirtyFile, "utf8"), "must survive\n");
  });

  it("resets a managed worktree with --yes and leaves it clean", async () => {
    const lane = await ownLane("CLI reset applied");
    fs.writeFileSync(path.join(lane.cwd, "dirty.txt"), "to be cleaned\n");

    const r = await cli(["lanes", "reset", String(lane.id), "--yes"], SOURCE_REPO);
    assert.equal(r.status, 0, r.stderr);
    assert.match(r.stdout, new RegExp(`Reset lane #${lane.id}`));
    assert.equal(fs.existsSync(path.join(lane.cwd, "dirty.txt")), false);

    const preflight = await get(`/api/lanes/${lane.id}/preflight?action=reset`);
    assert.equal(preflight.status, 200, JSON.stringify(preflight.body));
    assert.equal(preflight.body.dirty, 0);
    assert.equal(preflight.body.untracked, 0);
    assert.equal(preflight.body.unpushed, 0);
  });

  it("prints expected-versus-current preflight facts on 409 ESTALE", async () => {
    // Both CLI invocations read the same (clean) preflight before either takes
    // the per-lane lock. The untracked file makes each read a non-trivial
    // `untracked: 1`. Whichever process's POST acquires the lock first performs
    // the reset (cleaning the file); the lock forces the second POST to wait,
    // so by the time it re-checks preflight under the lock, the facts it
    // captured before starting are stale.
    const lane = await ownLane("CLI reset race");
    fs.writeFileSync(path.join(lane.cwd, "race.txt"), "one more untracked file\n");
    const [a, b] = await Promise.all([
      cli(["lanes", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
      cli(["lanes", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
    ]);
    const [winner, loser] = a.status === 0 ? [a, b] : [b, a];
    assert.equal(winner.status, 0, winner.stderr);
    assert.notEqual(loser.status, 0);
    assert.match(`${loser.stdout}${loser.stderr}`, /lane state changed since preflight/);
    assert.match(loser.stdout, /Expected preflight:/);
    assert.match(loser.stdout, /Current preflight:/);
  });

  it("removes a managed lane's worktree and directory with --yes", async () => {
    const lane = await ownLane("CLI remove managed");
    assert.equal(fs.existsSync(lane.cwd), true);
    const r = await cli(["lanes", "remove", String(lane.id), "--yes"], SOURCE_REPO);
    assert.equal(r.status, 0, r.stderr);
    assert.match(r.stdout, new RegExp(`Removed lane #${lane.id}`));
    assert.equal(fs.existsSync(lane.cwd), false);
    assert.equal((await get(`/api/lanes/${lane.id}`)).status, 404);
  });

  it("refuses to RESET an adopted lane but FORGETS it on remove, leaving the directory intact", async () => {
    const adoptedDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-adopted-remove-"));
    const preserved = path.join(adoptedDir, "preserve.txt");
    fs.writeFileSync(preserved, "do not delete\n");
    try {
      const created = await post("/api/lanes", {
        cwd: adoptedDir,
        title: "Adopted forget",
      });
      const id = created.body.lane.id;

      const reset = await cli(["lanes", "reset", String(id), "--yes"], adoptedDir);
      assert.notEqual(reset.status, 0);
      assert.match(`${reset.stdout}${reset.stderr}`, /points at a directory you own/i);

      // The server permits `remove` for an adopted lane — it drops the dashboard
      // record only — so the CLI must not refuse it, or the capability is
      // unreachable from the terminal.
      const removed = await cli(["lanes", "remove", String(id), "--yes"], adoptedDir);
      assert.equal(removed.status, 0, removed.stderr);
      assert.match(removed.stdout, new RegExp(`Removed lane #${id}`));
      assert.equal((await get(`/api/lanes/${id}`)).status, 404);
      assert.equal(fs.existsSync(adoptedDir), true);
      assert.equal(fs.readFileSync(preserved, "utf8"), "do not delete\n");
    } finally {
      fs.rmSync(adoptedDir, { recursive: true, force: true });
    }
  });

  it("reports purge counts that exactly match its preflight", async () => {
    const lane = await ownLane("CLI purge");
    const preflight = await get(`/api/lanes/${lane.id}/preflight?action=purge`);
    assert.equal(preflight.status, 200);
    const { sessions, events, tokenRows } = preflight.body;

    const r = await cli(["lanes", "purge", String(lane.id), "--yes"], SOURCE_REPO);
    assert.equal(r.status, 0, r.stderr);
    assert.match(
      r.stdout,
      new RegExp(
        `Purged lane #${lane.id}: ${sessions} sessions, ${events} events, ${tokenRows} token rows`
      )
    );
  });
});

describe("ccam lanes — inferred stage detection", () => {
  it("prints the inferred stage when detection leads the declared stage", async () => {
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-detect-lead-"));
    try {
      const created = await post("/api/lanes", { cwd: dir, title: "Detect Lead" });
      const id = created.body.lane.id;

      // Default declared stage is 'idle' (no pipeline node), so a Bash
      // test-run hook — which the default pipeline's `tests` node detects —
      // leads it. Real path: POST /api/hooks/event, not writing the column
      // directly.
      await post("/api/hooks/event", {
        hook_type: "PostToolUse",
        data: {
          session_id: "sess-detect-lead",
          cwd: dir,
          tool_name: "Bash",
          tool_input: { command: "npm run test:server" },
        },
      });

      const r = await cli(["lanes"], dir);
      assert.equal(r.status, 0, r.stderr);
      const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
      assert.ok(line, "lane row not found in ccam lanes output");
      assert.match(line, /⇢ detected:tests/);
    } finally {
      fs.rmSync(dir, { recursive: true, force: true });
    }
  });

  it("does not print an inferred stage when the declaration already leads", async () => {
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-detect-behind-"));
    try {
      const created = await post("/api/lanes", { cwd: dir, title: "Detect Behind" });
      const id = created.body.lane.id;

      // Declare 'ship' (past 'tests' in the default pipeline) first, so the
      // detection below is behind the declaration and recordDetection's
      // declared-wins guard refuses to write it.
      const staged = await cli(["stage", "ship", "--lane", String(id)], dir);
      assert.equal(staged.status, 0, staged.stderr);

      await post("/api/hooks/event", {
        hook_type: "PostToolUse",
        data: {
          session_id: "sess-detect-behind",
          cwd: dir,
          tool_name: "Bash",
          tool_input: { command: "npm run test:server" },
        },
      });

      const r = await cli(["lanes"], dir);
      assert.equal(r.status, 0, r.stderr);
      const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
      assert.ok(line, "lane row not found in ccam lanes output");
      assert.doesNotMatch(line, /detected:/);
    } finally {
      fs.rmSync(dir, { recursive: true, force: true });
    }
  });
});
