/**
 * @file Tests for the `ccam` umbrella CLI (bin/ccam.js). Spawns the real CLI
 * as a subprocess against a live in-test dashboard server and asserts each
 * command's output shape across the whole surface: monitoring (health, stats,
 * kanban), data browsing (sessions, session detail, agents, events), insights
 * (analytics, workflows, runs, cost), alerts/rules/webhooks, pricing CRUD,
 * import, administration (doctor, info, export, cleanup, clear-data guard),
 * plus help and error paths.
 *
 * The CLI must be spawned ASYNCHRONOUSLY (child_process.spawn, not
 * spawnSync): the dashboard server lives in THIS process, so a synchronous
 * spawn would block the event loop and deadlock every request the child
 * makes. Port targeting uses the DASHBOARD_PORT env override, which takes
 * precedence over ~/.claude/.agent-dashboard.json discovery.
 * @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 fs = require("fs");
const os = require("os");
const http = require("http");
const { spawn } = require("child_process");

const STAMP = `ccam-cli-${Date.now()}-${process.pid}`;
const TMP = path.join(os.tmpdir(), STAMP);
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "home");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
// Every spawn below inherits `process.env` — a shell that forces color
// (FORCE_COLOR/CCAM_COLOR set in the environment running this test) would
// leak into the spawned CLI child and defeat its own "colors off when piped"
// behavior, which every plain-text assertion in this file assumes.
delete process.env.FORCE_COLOR;
delete process.env.CCAM_COLOR;

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

const CLI = path.resolve(__dirname, "..", "..", "bin", "ccam.js");

let server;
let PORT;

/**
 * Run the CLI asynchronously against the test server. Async is load-bearing:
 * the server runs in this same process, so a blocking spawnSync would starve
 * its event loop and every CLI request would hang until timeout.
 */
function ccam(...args) {
  return new Promise((resolve) => {
    const child = spawn(process.execPath, [CLI, ...args], {
      env: { ...process.env, DASHBOARD_PORT: String(PORT) },
    });
    let out = "";
    let err = "";
    child.stdout.on("data", (d) => (out += d));
    child.stderr.on("data", (d) => (err += d));
    const killer = setTimeout(() => child.kill("SIGKILL"), 20_000);
    child.on("close", (code) => {
      clearTimeout(killer);
      resolve({ code, out, err });
    });
  });
}

function post(urlPath, body) {
  return new Promise((resolve, reject) => {
    const payload = JSON.stringify(body);
    const req = http.request(
      {
        hostname: "127.0.0.1",
        port: PORT,
        path: urlPath,
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Content-Length": Buffer.byteLength(payload),
        },
      },
      (res) => {
        res.resume();
        res.on("end", () => resolve(res.statusCode));
      }
    );
    req.on("error", reject);
    req.write(payload);
    req.end();
  });
}

before(async () => {
  const app = createApp();
  server = await startServer(app, 0);
  PORT = server.address().port;
  // Seed one session via the real hook path so list commands have a row.
  const code = await post("/api/hooks/event", {
    hook_type: "Stop",
    data: { session_id: "cli-test-session-0001", cwd: "/tmp/ccam-cli" },
  });
  assert.equal(code, 200);
});

after(() => {
  if (server) server.close();
  if (db) db.close();
  try {
    fs.rmSync(TMP, { recursive: true, force: true });
  } catch {
    /* ignore */
  }
});

describe("ccam CLI — monitoring", () => {
  it("health reports the dashboard as up", async () => {
    const { code, out } = await ccam("health");
    assert.equal(code, 0);
    assert.match(out, /Dashboard/);
    assert.match(out, /up/);
    assert.match(out, /v\d+\.\d+\.\d+/);
    assert.match(out, new RegExp(String(PORT)));
  });

  it("stats prints totals including the seeded session", async () => {
    const { code, out } = await ccam("stats");
    assert.equal(code, 0);
    assert.match(out, /Total sessions/);
    assert.match(out, /Events today/);
    assert.match(out, /Sessions by status/);
  });

  it("kanban groups sessions and agents into status columns", async () => {
    const { code, out } = await ccam("kanban");
    assert.equal(code, 0);
    assert.match(out, /Sessions/);
    assert.match(out, /Agents/);
    assert.match(out, /active \(1\)/);
    assert.match(out, /waiting \(1\)/); // the main agent lands in waiting after Stop
  });
});

describe("ccam CLI — data browsing", () => {
  it("sessions lists the seeded session", async () => {
    const { code, out } = await ccam("sessions", "--limit", "5");
    assert.equal(code, 0);
    assert.match(out, /cli-test/);
    assert.match(out, /of 1 session/);
  });

  it("sessions --status filters correctly", async () => {
    const { code, out } = await ccam("sessions", "--status", "completed");
    assert.equal(code, 0);
    assert.match(out, /of 0 session/);
  });

  it("session <id> shows detail with agents and recent events", async () => {
    const { code, out } = await ccam("session", "cli-test-session-0001");
    assert.equal(code, 0);
    assert.match(out, /cli-test-session-0001/);
    assert.match(out, /Agents/);
    assert.match(out, /Recent events/);
  });

  it("session without an id exits 1 with usage", async () => {
    const { code, err } = await ccam("session");
    assert.equal(code, 1);
    assert.match(err, /Usage: ccam session/);
  });

  it("agents lists the auto-created main agent", async () => {
    const { code, out } = await ccam("agents", "--session", "cli-test-session-0001");
    assert.equal(code, 0);
    assert.match(out, /main/);
  });

  it("events lists the seeded Stop event", async () => {
    const { code, out } = await ccam("events", "--session", "cli-test-session-0001");
    assert.equal(code, 0);
    assert.match(out, /Stop/);
  });
});

describe("ccam CLI — insights", () => {
  it("analytics prints token totals and averages", async () => {
    const { code, out } = await ccam("analytics");
    assert.equal(code, 0);
    assert.match(out, /Tokens/);
    assert.match(out, /Input/);
  });

  it("workflows prints intelligence stats", async () => {
    const { code, out } = await ccam("workflows");
    assert.equal(code, 0);
    assert.match(out, /Workflow intelligence/);
    assert.match(out, /Sessions analyzed/);
  });

  it("runs lists dynamic workflow runs (empty is fine)", async () => {
    const { code, out } = await ccam("runs");
    assert.equal(code, 0);
    assert.match(out, /Run/);
    assert.match(out, /Status/);
  });

  it("cost prints a total", async () => {
    const { code, out } = await ccam("cost");
    assert.equal(code, 0);
    assert.match(out, /Total estimated cost: \$/);
  });

  it("cost --session scopes the total to one session", async () => {
    const { code, out } = await ccam("cost", "--session", "cli-test-session-0001");
    assert.equal(code, 0);
    assert.match(out, /Session cost/);
    assert.match(out, /cli-test-session-0001/);
    assert.match(out, /Total estimated cost: \$/);
  });

  it("cost surfaces server-tool surcharges when web search was billed", async () => {
    // Feature surcharges accrue independent of per-model pricing rules, so a
    // web_search_requests count alone drives the line ($10 / 1k searches).
    db.prepare(
      "INSERT INTO token_usage (session_id, model, web_search_requests) VALUES (?, ?, ?)"
    ).run("cli-test-session-0001", "claude-sonnet-5", 200);
    try {
      const { code, out } = await ccam("cost", "--session", "cli-test-session-0001");
      assert.equal(code, 0);
      assert.match(out, /Server-tool surcharges/);
      assert.match(out, /web search \$/);
    } finally {
      db.prepare(
        "DELETE FROM token_usage WHERE session_id = 'cli-test-session-0001' AND model = 'claude-sonnet-5'"
      ).run();
    }
  });

  it("cost warns about models with usage but no pricing rule", async () => {
    // Usage on a model no default pattern matches: the API prices it at $0
    // and reports it via unpriced_models; the CLI must surface that.
    db.prepare(
      "INSERT INTO token_usage (session_id, model, input_tokens, output_tokens) VALUES (?, ?, ?, ?)"
    ).run("cli-test-session-0001", "ccam-mystery-model-9", 1200, 300);
    try {
      const { code, out } = await ccam("cost");
      assert.equal(code, 0);
      assert.match(out, /no pricing rule/);
      assert.match(out, /ccam-mystery-model-9/);
      assert.match(out, /ccam pricing set/);
    } finally {
      db.prepare("DELETE FROM token_usage WHERE model = 'ccam-mystery-model-9'").run();
    }
  });
});

describe("ccam CLI — alerts, rules, webhooks", () => {
  it("alerts lists the (empty) fired-alert feed", async () => {
    const { code, out } = await ccam("alerts");
    assert.equal(code, 0);
    assert.match(out, /unacknowledged of/);
  });

  it("alerts ack-all succeeds on an empty feed", async () => {
    const { code, out } = await ccam("alerts", "ack-all");
    assert.equal(code, 0);
    assert.match(out, /Acknowledged/);
  });

  it("rules lists alert rules", async () => {
    const { code, out } = await ccam("rules");
    assert.equal(code, 0);
    assert.match(out, /Enabled/);
  });

  it("webhooks lists targets", async () => {
    const { code, out } = await ccam("webhooks");
    assert.equal(code, 0);
    assert.match(out, /Provider/);
  });
});

describe("ccam CLI — pricing", () => {
  it("pricing lists the default rules", async () => {
    const { code, out } = await ccam("pricing");
    assert.equal(code, 0);
    assert.match(out, /Pattern/);
    assert.match(out, /claude/);
  });

  it("pricing set / delete round-trips a custom rule", async () => {
    const set = await ccam(
      "pricing",
      "set",
      "ccam-test-model%",
      "--input",
      "1",
      "--output",
      "2",
      "--cache-read",
      "0.1",
      "--cache-write",
      "1.25"
    );
    assert.equal(set.code, 0, `stderr: ${set.err} stdout: ${set.out}`);
    assert.match(set.out, /saved/);
    const list = await ccam("pricing");
    assert.match(list.out, /ccam-test-model%/);
    const del = await ccam("pricing", "delete", "ccam-test-model%");
    assert.equal(del.code, 0);
    assert.match(del.out, /deleted/);
  });

  it("pricing set persists fast-mode and intro rates via flags", async () => {
    const set = await ccam(
      "pricing",
      "set",
      "ccam-fast-model%",
      "--input",
      "5",
      "--output",
      "25",
      "--fast-input",
      "10",
      "--fast-output",
      "50",
      "--intro-input",
      "2",
      "--intro-output",
      "10",
      "--intro-until",
      "2099-01-01"
    );
    assert.equal(set.code, 0, `stderr: ${set.err} stdout: ${set.out}`);
    const list = await ccam("pricing");
    assert.match(list.out, /ccam-fast-model%/);
    assert.match(list.out, /\$10\/\$50/); // fast in/out column
    assert.match(list.out, /\$2\/\$10/); // intro in/out column
    assert.match(list.out, /2099-01-01/);
    // A plain rate edit without intro flags must preserve the promo (the
    // intro block is only sent when an --intro-* flag is present).
    const edit = await ccam("pricing", "set", "ccam-fast-model%", "--input", "6", "--output", "30");
    assert.equal(edit.code, 0);
    const after = await ccam("pricing");
    assert.match(after.out, /\$6/);
    assert.match(after.out, /2099-01-01/);
    await ccam("pricing", "delete", "ccam-fast-model%");
  });
});

describe("ccam CLI — import & administration", () => {
  it("import rescan runs against the (empty) default projects dir", async () => {
    const { code, out } = await ccam("import", "rescan");
    assert.equal(code, 0);
    assert.match(out, /imported \d+/);
  });

  it("import without a subcommand exits 1 with usage", async () => {
    const { code, err } = await ccam("import");
    assert.equal(code, 1);
    assert.match(err, /Usage: ccam import/);
  });

  it("doctor reports API, hooks, database, remotes, and uptime lines", async () => {
    const { code, out } = await ccam("doctor");
    // Exit 1 when hooks are missing (common in the test harness) — still prints a full report.
    assert.ok(code === 0 || code === 1, `unexpected exit ${code}`);
    assert.match(out, /API reachable/);
    assert.match(out, /Claude Code hooks/);
    assert.match(out, /Database/);
    assert.match(out, /Server uptime/);
    assert.match(out, /Remote sources/);
  });

  it("info dumps system info JSON", async () => {
    const { code, out } = await ccam("info");
    assert.equal(code, 0);
    const parsed = JSON.parse(out);
    assert.ok(parsed.db);
    assert.ok(parsed.server);
  });

  it("export writes a JSON file containing the seeded session", async () => {
    const file = path.join(TMP, "export.json");
    const { code, out } = await ccam("export", file);
    assert.equal(code, 0);
    assert.match(out, /Exported to/);
    const data = JSON.parse(fs.readFileSync(file, "utf8"));
    assert.ok(JSON.stringify(data).includes("cli-test-session-0001"));
  });

  it("update-check reports the checkout's update status", async () => {
    // The test process runs inside the real repo clone, so the route always
    // reports git_repo: true; the CLI exits 0 whether the checkout is behind,
    // current, or the remote is unreachable (fetch errors are informational).
    const { code, out } = await ccam("update-check");
    assert.equal(code, 0);
    assert.match(out, /Dashboard updates/);
    assert.match(out, /checkout|commit|remote|update/i);
  });

  it("cleanup without flags exits 1 with usage", async () => {
    const { code, err } = await ccam("cleanup");
    assert.equal(code, 1);
    assert.match(err, /Usage: ccam cleanup/);
  });

  it("cleanup with flags runs", async () => {
    const { code, out } = await ccam("cleanup", "--days", "3650");
    assert.equal(code, 0);
    assert.match(out, /Cleanup done/);
  });

  it("clear-data REFUSES without --yes", async () => {
    const { code, err } = await ccam("clear-data");
    assert.equal(code, 1);
    assert.match(err, /--yes/);
    // Data must be intact.
    const { out } = await ccam("sessions");
    assert.match(out, /of 1 session/);
  });

  it("clear-data --yes wipes rows (runs last)", async () => {
    const { code, out } = await ccam("clear-data", "--yes");
    assert.equal(code, 0);
    assert.match(out, /cleared/);
    const { out: after } = await ccam("sessions");
    assert.match(after, /of 0 session/);
  });
});

describe("ccam CLI — offline mode (server down, DB read directly)", () => {
  // The online admin suite ends with clear-data, so re-seed one session here
  // (through the live server) for the offline reads to find.
  before(async () => {
    const code = await post("/api/hooks/event", {
      hook_type: "Stop",
      data: { session_id: "cli-test-offline-0002", cwd: "/tmp/ccam-cli-off" },
    });
    assert.equal(code, 200);
  });

  /** Run the CLI with an unreachable port so it falls back to offline reads
   *  of the SAME database file the in-test server uses (WAL second reader). */
  function offline(...args) {
    return new Promise((resolve) => {
      const child = spawn(process.execPath, [CLI, ...args], {
        env: { ...process.env, DASHBOARD_PORT: "1" },
      });
      let out = "";
      let err = "";
      child.stdout.on("data", (d) => (out += d));
      child.stderr.on("data", (d) => (err += d));
      const killer = setTimeout(() => child.kill("SIGKILL"), 20_000);
      child.on("close", (code) => {
        clearTimeout(killer);
        resolve({ code, out, err });
      });
    });
  }

  it("sessions falls back to offline reads with the banner", async () => {
    const { code, out } = await offline("sessions", "--limit", "5");
    assert.equal(code, 0);
    assert.match(out, /Offline mode/);
    assert.match(out, /ccam start/);
    assert.match(out, /cli-test/);
    assert.match(out, /of 1 session/);
  });

  it("stats works offline", async () => {
    const { code, out } = await offline("stats");
    assert.equal(code, 0);
    assert.match(out, /Total sessions/);
    assert.match(out, /offline/);
  });

  it("session <id> works offline and flags cost as server-only", async () => {
    const { code, out } = await offline("session", "cli-test-offline-0002");
    assert.equal(code, 0);
    assert.match(out, /Agents/);
    assert.match(out, /Cost\s+requires the server/);
  });

  it("kanban works offline", async () => {
    const { code, out } = await offline("kanban");
    assert.equal(code, 0);
    assert.match(out, /Sessions/);
    assert.match(out, /Agents/);
  });

  it("pricing list works offline; pricing set is refused with a reason", async () => {
    const list = await offline("pricing");
    assert.equal(list.code, 0);
    assert.match(list.out, /claude/);
    const set = await offline("pricing", "set", "x%", "--input", "1", "--output", "1");
    assert.equal(set.code, 1);
    assert.match(set.err, /pricing changes must go through the server/);
  });

  it("rules and alerts list offline", async () => {
    const rules = await offline("rules");
    assert.equal(rules.code, 0);
    const alerts = await offline("alerts");
    assert.equal(alerts.code, 0);
    assert.match(alerts.out, /unacknowledged of/);
  });

  it("export works offline and marks the payload", async () => {
    const file = path.join(TMP, "offline-export.json");
    const { code } = await offline("export", file);
    assert.equal(code, 0);
    const data = JSON.parse(fs.readFileSync(file, "utf8"));
    assert.equal(data.exported_offline, true);
    assert.ok(JSON.stringify(data.sessions).includes("cli-test-offline-0002"));
  });

  it("doctor works offline and reports the server as down", async () => {
    const { code, out } = await offline("doctor");
    assert.equal(code, 1);
    assert.match(out, /NOT running/);
    assert.match(out, /Database/);
    assert.match(out, /rows: sessions/);
    assert.match(out, /Remote sources/);
  });

  it("cost refuses offline with the server-side-math reason", async () => {
    const { code, err } = await offline("cost");
    assert.equal(code, 1);
    assert.match(err, /cost math .* runs server-side/);
  });

  it("clear-data --yes refuses offline — data stays intact", async () => {
    const { code, err } = await offline("clear-data", "--yes");
    assert.equal(code, 1);
    assert.match(err, /data wipes must go through the server/);
    const { out } = await offline("sessions");
    assert.match(out, /of 1 session/);
  });

  it("prints the staleness caveat when the liveness probe is unavailable", async () => {
    // The suite env sets DASHBOARD_LIVENESS_PROBE=0, so the probe reports
    // unavailable and offline output must carry the "statuses as stored"
    // caveat instead of silently showing possibly-stale active rows.
    const { code, out } = await offline("sessions");
    assert.equal(code, 0);
    assert.match(out, /Statuses are as stored/);
  });

  it("corrects dead active sessions display-side when the probe can answer", async (t) => {
    // Only meaningful where the real probe works (macOS/Linux, not container).
    const { spawnSync } = require("child_process");
    const avail = spawnSync(
      process.execPath,
      [
        "-e",
        "const p=require(process.argv[1]).probeLiveCwds();console.log(p.available)",
        path.resolve(__dirname, "..", "lib", "session-liveness.js"),
      ],
      { encoding: "utf8", env: { ...process.env, DASHBOARD_LIVENESS_PROBE: "" } }
    ).stdout.trim();
    if (avail !== "true") {
      t.skip("liveness probe unavailable on this platform");
      return;
    }
    // Run offline WITHOUT the probe kill-switch: the seeded session's cwd
    // (/tmp/ccam-cli-off) has no running claude process, so its stored
    // "active" status must be DISPLAYED as completed, with the footnote —
    // and the database itself must keep the stored status.
    const r = await new Promise((resolve) => {
      const child = spawn(process.execPath, [CLI, "sessions"], {
        env: { ...process.env, DASHBOARD_PORT: "1", DASHBOARD_LIVENESS_PROBE: "" },
      });
      let out = "";
      child.stdout.on("data", (d) => (out += d));
      child.on("close", (code) => resolve({ code, out }));
    });
    assert.equal(r.code, 0);
    assert.match(r.out, /displayed as completed by the process-liveness probe/);
    // DB unchanged: the stored status is still whatever the server left there.
    const stored = db
      .prepare("SELECT status FROM sessions WHERE id = 'cli-test-offline-0002'")
      .get();
    assert.equal(stored.status, "active");
  });

  it("analytics/workflows/tail refuse offline with reasons", async () => {
    for (const cmd of ["analytics", "workflows", "tail"]) {
      const { code, err } = await offline(cmd);
      assert.equal(code, 1, `${cmd} should refuse offline`);
      assert.match(err, /No offline fallback/);
    }
  });
});

describe("ccam CLI — help & errors", () => {
  it("help lists every command group", async () => {
    const { code, out } = await ccam("help");
    assert.equal(code, 0);
    for (const word of [
      "status",
      "start",
      "health",
      "stats",
      "kanban",
      "tail",
      "sessions",
      "session <id>",
      "agents",
      "events",
      "analytics",
      "workflows",
      "runs",
      "cost",
      "alerts",
      "rules",
      "webhooks",
      "pricing",
      "import",
      "doctor",
      "info",
      "export",
      "cleanup",
      "reinstall-hooks",
      "update-check",
      "clear-data",
      "open",
    ]) {
      assert.ok(out.includes(word), `help should mention ${word}`);
    }
  });

  it("no arguments prints help and exits 0", async () => {
    const { code, out } = await ccam();
    assert.equal(code, 0);
    assert.match(out, /Usage: ccam <command>/);
  });

  it("version prints the package version", async () => {
    const pkg = JSON.parse(
      fs.readFileSync(path.resolve(__dirname, "..", "..", "package.json"), "utf8")
    );
    const { code, out } = await ccam("version");
    assert.equal(code, 0);
    assert.equal(out.trim(), `ccam ${pkg.version}`);
    const flag = await ccam("--version");
    assert.equal(flag.out.trim(), `ccam ${pkg.version}`);
  });

  it("--no-color is accepted anywhere on the command line", async () => {
    const before = await ccam("--no-color", "health");
    assert.equal(before.code, 0);
    assert.match(before.out, /Dashboard/);
    const after = await ccam("sessions", "--no-color");
    assert.equal(after.code, 0);
    assert.match(after.out, /of 1 session/);
  });

  it("piped output contains no ANSI escape codes (colors off when not a TTY)", async () => {
    const { out } = await ccam("stats");
    assert.ok(!out.includes("\x1b["), "piped output must be plain text");
  });

  it("tables render with box-drawing borders and an Updated column", async () => {
    const { out } = await ccam("sessions");
    assert.ok(out.includes("╭"), "table should have a top border");
    assert.ok(out.includes("│"), "table should have column separators");
    assert.match(out, /Updated/);
  });

  it("unknown command exits 1 with an error", async () => {
    const { code, err } = await ccam("frobnicate");
    assert.equal(code, 1);
    assert.match(err, /Unknown command/);
  });

  it("unreachable server exits 1 with the not-running indicator + start hint", async () => {
    const r = await new Promise((resolve) => {
      const child = spawn(process.execPath, [CLI, "health"], {
        env: { ...process.env, DASHBOARD_PORT: "1" }, // nothing listens on port 1
      });
      let err = "";
      child.stderr.on("data", (d) => (err += d));
      child.on("close", (code) => resolve({ code, err }));
    });
    assert.equal(r.code, 1);
    assert.match(r.err, /Dashboard server is NOT running/);
    assert.match(r.err, /ccam start/);
    assert.match(r.err, /npm run dev/);
  });

  it("status reports running when the server is up", async () => {
    const { code, out } = await ccam("status");
    assert.equal(code, 0);
    assert.match(out, /running/);
    assert.match(out, new RegExp(String(PORT)));
  });

  it("status exits 1 with the down indicator when the server is down", async () => {
    const r = await new Promise((resolve) => {
      const child = spawn(process.execPath, [CLI, "status"], {
        env: { ...process.env, DASHBOARD_PORT: "1" },
      });
      let out = "";
      child.stdout.on("data", (d) => (out += d));
      child.on("close", (code) => resolve({ code, out }));
    });
    assert.equal(r.code, 1);
    assert.match(r.out, /NOT running/);
    assert.match(r.out, /ccam start/);
  });

  it("start no-ops with a pointer when a server is already running", async () => {
    const { code, out } = await ccam("start");
    assert.equal(code, 0);
    assert.match(out, /already running/);
  });
});

describe("ccam CLI — interactive REPL", () => {
  /**
   * Drive `ccam repl` with piped stdin (non-TTY). Each typed line is executed
   * as a child `ccam` process, so grandchildren reach the in-test server via
   * the DASHBOARD_PORT override; async spawn keeps the event loop free.
   */
  function repl(input, port = PORT) {
    return new Promise((resolve) => {
      const child = spawn(process.execPath, [CLI, "repl"], {
        env: { ...process.env, DASHBOARD_PORT: String(port) },
      });
      let out = "";
      let err = "";
      child.stdout.on("data", (d) => (out += d));
      child.stderr.on("data", (d) => (err += d));
      const killer = setTimeout(() => child.kill("SIGKILL"), 20_000);
      child.on("close", (code) => {
        clearTimeout(killer);
        resolve({ code, out, err });
      });
      child.stdin.write(input);
      child.stdin.end();
    });
  }

  it("runs piped commands in order and exits at EOF", async () => {
    const { code, out } = await repl("version\nstats\nexit\n");
    assert.equal(code, 0);
    // version output precedes the stats table → sequential execution.
    const vIdx = out.indexOf("ccam ");
    const sIdx = out.indexOf("Total sessions");
    assert.ok(vIdx >= 0 && sIdx >= 0 && vIdx < sIdx, `order: v=${vIdx} s=${sIdx}`);
  });

  it("dispatches real commands (sessions) through child processes", async () => {
    const { code, out } = await repl("sessions --limit 5\nexit\n");
    assert.equal(code, 0);
    assert.match(out, /of \d+ session/);
  });

  it("the `commands` built-in lists every command", async () => {
    const { out } = await repl("commands\nexit\n");
    assert.match(out, /sessions/);
    assert.match(out, /kanban/);
    assert.match(out, /repl/);
  });

  it("the `help` built-in shows shell built-ins and the grouped catalog", async () => {
    const { out } = await repl("help\nexit\n");
    assert.match(out, /built-ins/i);
    assert.match(out, /Server/); // catalog group headers are present
    assert.match(out, /Administration/);
    assert.match(out, /watch/); // the new built-in is documented
  });

  it("`help <command>` shows that command's details", async () => {
    const { out } = await repl("help sessions\nexit\n");
    assert.match(out, /sessions/);
    assert.match(out, /--status/); // the args hint / description is shown
  });

  it("`commands` groups every command under its category", async () => {
    const { out } = await repl("commands\nexit\n");
    assert.match(out, /Server/);
    assert.match(out, /Insights/);
    assert.match(out, /sessions/);
  });

  it("an unknown command does not kill the shell — later commands still run", async () => {
    const { code, out, err } = await repl("frobnicate\nstats\nexit\n");
    assert.equal(code, 0);
    assert.match(err, /Unknown command/); // the refusal lands on stderr
    assert.match(out, /Total sessions/); // the shell survived and ran stats
  });

  it("a server-only refusal (offline) does not kill the shell", async () => {
    // Point the shell at a dead port: `cost` refuses, but the shell survives
    // to run `exit` and close cleanly.
    const { code, out, err } = await repl("cost\nexit\n", 1);
    assert.equal(code, 0);
    assert.match(err + out, /runs server-side|NOT running/);
  });
});
