/**
 * @file Express router for lanes — the durable per-working-directory unit of
 * parallel agent work. Read endpoints join each lane with its most recent event
 * timestamp so liveness can be computed without a separate heartbeat, and every
 * mutation re-broadcasts the lane over the existing WebSocket as `lane_update`.
 * Orchestration is deliberately absent: the driving Claude session declares its
 * own stage (`POST /:id/stage`); the dashboard never guesses a transition.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const { Router } = require("express");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const { listPipelines, getPipeline, nodeStates, progressPct } = require("../lib/pipelines");
const laneFeatures = require("../lib/lane-features");
const proofLib = require("../lib/proof");
const { broadcast } = require("../websocket");
const runs = require("../lib/pty-run");
const { sameOriginGuard } = require("./run");
const { preflight } = require("../lib/lane-preflight");
const {
  LANES_ROOT,
  addWorktree,
  gitFacts,
  isGitRepo,
  isValidBranchName,
  listBranches,
  removeWorktree,
  resetWorktree,
  resolveBase,
  slugify,
} = require("../lib/worktree");
const { withLaneLock } = require("../lib/lane-lock");
const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync");
const { installAgents } = require("../lib/lane-agents");
const { syncMcp } = require("../lib/lane-mcp");
const { HOOKS, runHook, resolveProfile, isIntegrationEnabled } = require("../lib/lane-profile");
const { slotDirs } = require("../lib/lane-slots");
const {
  upLane,
  downLane,
  runtimeFacts,
  requireProfile,
  provisionLane,
  resetLaneData,
  removeLaneData,
} = require("../lib/lane-runtime");
const { reapOrphanMcp, capOversizedLogs } = require("../lib/lane-gc");
const { detectNode, scaffoldProfile } = require("../lib/lane-detect");

const router = Router();
const MAX_WORKTREE_DIRECTORY_ATTEMPTS = 50;

/** Bytes of a hook log returned by default — enough to see a failure's tail. */
const LOG_TAIL_DEFAULT = 64 * 1024;
const LOG_TAIL_MAX = 1024 * 1024;

/** Seconds since this lane's session last emitted an event; null if never. */
function lastEventAge(lane) {
  if (!lane.session_id) return null;
  const row = db
    .prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?")
    .get(lane.session_id);
  if (!row || !row.last) return null;
  const t = Date.parse(row.last);
  return Number.isNaN(t) ? null : Math.max(0, Math.round((Date.now() - t) / 1000));
}

/**
 * Self-heals a stale `run_id`: a tmux-backed run has no exit event to push a
 * release notification, so liveness is re-checked here, on every read,
 * instead — the same "computed fact, never stored" principle this repo
 * already applies to lane runtime up/down. A lane whose run_id points at a
 * tmux session that's gone (the pane's process exited, or it was killed
 * outside the dashboard entirely) gets released the next time anything reads
 * it, exactly like the old push-based handler did, just pulled instead of
 * pushed.
 */
function healRunId(lane) {
  if (!lane.run_id) return lane;
  const run = runs.getRun(lane.run_id);
  if (run && run.status === "running") return lane;
  lanesLib.updateLane(lane.id, { run_id: null, status: "idle" });
  broadcastLane(lane.id);
  return lanesLib.getLane(lane.id);
}

function payload(lane) {
  const healed = healRunId(lane);
  return lanesLib.lanePayload(healed, lastEventAge(healed));
}

/** A feature row's pipeline view, computed the same way payload() computes
 *  it for a live lane — lets the client render an archived feature with the
 *  exact same PipelineMap component, no special-casing on the frontend. */
function featurePayload(feature) {
  const pipeline = getPipeline(feature.pipeline);
  return {
    ...feature,
    pipeline_name: pipeline.name,
    pipeline_nodes: nodeStates(pipeline, feature),
    progress: progressPct(pipeline, feature),
  };
}
/** Push the current state of one lane to every connected client. */
function broadcastLane(id) {
  const lane = lanesLib.getLane(id);
  if (lane) broadcast("lane_update", { lane: payload(lane) });
}

router.get("/", (_req, res) => {
  const lanes = lanesLib.listLanes().map(payload);
  res.json({
    lanes,
    counts: {
      total: lanes.length,
      running: lanes.filter((l) => l.status === "running").length,
      needs_you: lanes.filter((l) => l.needs_action).length,
      dead: lanes.filter((l) => l.liveness === "dead").length,
    },
  });
});

// Registered before "/:id" so the literal path is not swallowed by the param.
router.get("/pipelines", (_req, res) => res.json({ pipelines: listPipelines() }));

/**
 * Local branches of a candidate source repo, for the "Add lane" picker: pick
 * a repo, then pick which branch to fork the new worktree from, instead of
 * typing a branch name and hoping it exists. Same validation as `/worktree`
 * (below), since a repo this can't resolve branches for can't be provisioned
 * from either. Read-only, so no same-origin guard.
 */
router.get("/branches", async (req, res) => {
  const sourceRepo = typeof req.query.repo === "string" ? req.query.repo : "";
  if (!sourceRepo || !path.isAbsolute(sourceRepo) || !fs.existsSync(sourceRepo)) {
    return res.status(400).json({
      error: { code: "EBADSOURCEREPO", message: "repo must be an existing absolute path" },
    });
  }
  if (!(await isGitRepo(sourceRepo))) {
    return res.status(400).json({
      error: { code: "EBADSOURCEREPO", message: "repo is not a git repository" },
    });
  }
  try {
    const { branches, current } = await listBranches(sourceRepo);
    res.json({ branches, current });
  } catch (err) {
    res.status(500).json({ error: { code: err.code, message: err.message, git: err.git } });
  }
});

/**
 * Idempotent "which lane owns this directory?" — the Workspace page opens on a
 * cwd, not on a lane id, so it needs one lane to exist for that cwd without
 * ever creating a duplicate. Also registered before "/:id".
 */
router.post("/ensure", sameOriginGuard, (req, res) => {
  const body = req.body || {};
  const owner = lanesLib.resolveLaneByCwd(body.cwd);
  if (owner) return res.json({ lane: payload(owner), created: false });
  try {
    const lane = lanesLib.createLane({
      cwd: body.cwd,
      title: body.title || "",
      ...(body.pipeline ? { pipeline: body.pipeline } : {}),
    });
    broadcastLane(lane.id);
    return res.status(201).json({ lane: payload(lane), created: true });
  } catch (err) {
    if (err.code === "EBADCWD" || err.code === "EBADPIPELINE") {
      return res.status(400).json({ error: { code: err.code, message: err.message } });
    }
    // The cwd UNIQUE constraint is the arbiter: someone else won the race, so
    // re-read and return THEIR lane rather than reporting a conflict.
    if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
      const winner = lanesLib.resolveLaneByCwd(body.cwd);
      if (winner) return res.json({ lane: payload(winner), created: false });
    }
    return res.status(500).json({ error: { code: err.code, message: err.message } });
  }
});

/**
 * Machine-wide housekeeping — not scoped to one lane. Reaps orphaned
 * Playwright MCP processes and caps oversized hook logs across every lane
 * on this machine. See server/lib/lane-gc.js for what "orphaned" and
 * "oversized" mean.
 */
router.post("/gc", sameOriginGuard, (req, res) => {
  const dryRun = req.body?.dryRun === true;
  try {
    const reaped = reapOrphanMcp({ dryRun });
    const capped = capOversizedLogs({ dryRun });
    res.json({ reaped, capped });
  } catch (err) {
    res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
  }
});

/**
 * Directory listing for the Add Lane modal's folder browser — browsers cannot
 * expose absolute filesystem paths from a native picker, so path selection is
 * done by browsing server-side instead. Read-only; registered ahead of
 * "/:id" so the literal "browse" segment is never captured as a lane id.
 */
router.get("/browse", (req, res) => {
  const raw =
    typeof req.query.path === "string" && req.query.path.trim() ? req.query.path : os.homedir();
  const resolved = path.resolve(raw);

  let stat;
  try {
    stat = fs.statSync(resolved);
  } catch {
    return res.status(400).json({ error: { code: "ENOTFOUND", message: "path does not exist" } });
  }
  if (!stat.isDirectory()) {
    return res
      .status(400)
      .json({ error: { code: "ENOTADIR", message: "path is not a directory" } });
  }

  let entries = [];
  try {
    entries = fs
      .readdirSync(resolved, { withFileTypes: true })
      .filter((e) => e.isDirectory() && !e.name.startsWith("."))
      .map((e) => {
        const full = path.join(resolved, e.name);
        return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")) };
      })
      .sort((a, b) => a.name.localeCompare(b.name));
  } catch {
    // An unreadable entry mid-listing is skipped, not a request failure.
  }

  const parent = path.dirname(resolved) === resolved ? null : path.dirname(resolved);
  res.json({ path: resolved, parent, entries });
});

router.get("/:id", (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  res.json({ lane: payload(lane) });
});

router.post("/", sameOriginGuard, (req, res) => {
  try {
    const lane = lanesLib.createLane(req.body || {});
    broadcastLane(lane.id);
    res.status(201).json({ lane: payload(lane) });
  } catch (err) {
    if (err.code === "EBADCWD" || err.code === "EBADPIPELINE" || err.code === "EBADKIND") {
      return res.status(400).json({ error: { code: err.code, message: err.message } });
    }
    if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
      return res
        .status(409)
        .json({ error: { code: "EDUPCWD", message: "a lane already owns that cwd" } });
    }
    res.status(500).json({ error: { message: err.message } });
  }
});

router.patch("/:id", sameOriginGuard, (req, res) => {
  if (!lanesLib.getLane(req.params.id)) {
    return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  }
  let lane;
  try {
    lane = lanesLib.updateLane(req.params.id, req.body || {});
  } catch (err) {
    // A bad `kind` or `pipeline` is invalid input, not a server fault — every
    // sibling route answers 400 here, so this one must too instead of throwing
    // into Express.
    if (err.code === "EBADKIND" || err.code === "EBADPIPELINE") {
      return res.status(400).json({ error: { code: err.code, message: err.message } });
    }
    return res.status(500).json({ error: { code: err.code, message: err.message } });
  }
  broadcastLane(lane.id);
  res.json({ lane: payload(lane) });
});

router.post("/:id/stage", sameOriginGuard, (req, res) => {
  if (!lanesLib.getLane(req.params.id)) {
    return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  }
  const lane = lanesLib.setStage(req.params.id, req.body || {});
  broadcastLane(lane.id);
  res.json({ lane: payload(lane) });
});

router.get("/:id/preflight", async (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  const action = String(req.query.action || "");
  if (!["reset", "remove", "purge"].includes(action)) {
    return res
      .status(400)
      .json({ error: { code: "EBADACTION", message: `unknown action ${action}` } });
  }
  try {
    res.json(await preflight(lane, action));
  } catch (err) {
    res.status(500).json({ error: { code: err.code, message: err.message } });
  }
});

/**
 * Per-feature state and archive (B) — `lib/lane-features.js`. `GET
 * /:id/features` lists every feature the lane has ever activated (archived
 * or live); `GET /:id/features/:slug` shows one, including an archived
 * one's saved pipeline; `POST /:id/features/activate` switches the live
 * lane to a feature by slug, archiving whichever one was active first.
 */
router.get("/:id/features", (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  res.json({ features: laneFeatures.listFeatures(lane.id).map(featurePayload) });
});

router.get("/:id/features/:slug", (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  const feature = laneFeatures.getFeature(lane.id, req.params.slug);
  if (!feature) {
    return res.status(404).json({ error: { code: "ENOFEATURE", message: "no such feature" } });
  }
  res.json({ feature: featurePayload(feature) });
});

router.post("/:id/features/activate", sameOriginGuard, (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  const slug = typeof req.body?.slug === "string" ? req.body.slug : "";
  if (!slug) {
    return res.status(400).json({ error: { code: "EBADSLUG", message: "slug is required" } });
  }
  try {
    const { lane: updated, feature } = laneFeatures.activateFeature(lane.id, slug, {
      title: req.body?.title,
    });
    broadcastLane(updated.id);
    res.json({ lane: payload(updated), feature: featurePayload(feature) });
  } catch (err) {
    if (err.code === "EBADSLUG") {
      return res.status(400).json({ error: { code: err.code, message: err.message } });
    }
    res.status(500).json({ error: { code: err.code, message: err.message } });
  }
});

router.get("/:id/proof", (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  res.json({ features: proofLib.listProof(lane.id) });
});

router.get("/:id/proof/:slug/:group/:file", (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).send("not found");
  const p = proofLib.proofFile(lane.id, req.params.slug, req.params.group, req.params.file);
  if (!p) return res.status(404).send("not found");
  const ext = path.extname(p).toLowerCase();
  const contentType =
    ext === ".html" ? "text/html; charset=utf-8" : ext === ".png" ? "image/png" : "image/jpeg";
  res.type(contentType).sendFile(p);
});

router.delete("/:id/proof/:slug", sameOriginGuard, (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  try {
    const result = proofLib.deleteProof(lane.id, {
      slug: req.params.slug,
      group: req.body?.group,
      images: req.body?.images,
    });
    res.json(result);
  } catch (err) {
    if (err.code === "ENOFEATURE") {
      return res.status(404).json({ error: { code: err.code, message: err.message } });
    }
    if (err.code === "EBADPATH") {
      return res.status(400).json({ error: { code: err.code, message: err.message } });
    }
    res.status(500).json({ error: { code: err.code, message: err.message } });
  }
});

router.post("/:id/proof-link", sameOriginGuard, (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  res.json(proofLib.ensureProofLink(lane));
});

/**
 * Install the ship-feature-lane pipeline's agent templates (qc-local,
 * senior-gate-reviewer) into this lane's own .claude/agents/. Static file
 * copy — no templating, no credentials to inject (this repo has no
 * seed-account system yet; see the E3 design spec's Decisions table).
 * Never automatic, same as proof-link: a session calls this explicitly.
 */
router.post("/:id/agents/install", sameOriginGuard, async (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  try {
    res.json(await installAgents(lane));
  } catch (err) {
    if (err.code === "ENOTGITREPO") {
      return res.status(400).json({ error: { code: err.code, message: err.message } });
    }
    res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
  }
});

/**
 * Give this lane the same MCP servers as its source repo — relocates the
 * source repo's already-configured mcpServers (from ~/.claude.json) into
 * <lane>/.mcp.json, pins Playwright's proof output dir, seeds Chromium
 * profiles. Never automatic, same as proof-link/agents-install: a session
 * calls this explicitly. Never touches permissions/settings.local.json —
 * see the F1 design spec for why.
 */
router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  try {
    res.json(await syncMcp(lane));
  } catch (err) {
    if (err.code === "ENOMCPCONFIG") {
      return res.status(400).json({ error: { code: err.code, message: err.message } });
    }
    res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
  }
});

/**
 * Detect a Node.js project at this lane's OWN directory and scaffold
 * .ccam/profile/ if one is found — the HTTP equivalent of
 * `ccam lanes profile init`, always targeting lane.cwd (never an arbitrary
 * path; the CLI's <repo> argument has no meaning here, this lane's own
 * directory is the only sensible target). "No Node.js project detected" is
 * a normal 200 outcome, not an error — most lanes won't be auto-detectable
 * and that's fine, same as every other optional profile declaration.
 */
router.post("/:id/profile/init", sameOriginGuard, (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });

  const facts = detectNode(lane.cwd);
  if (!facts) {
    return res.json({ scaffolded: false, reason: "no detectable Node.js project" });
  }
  try {
    const result = scaffoldProfile(lane.cwd, facts, { force: req.body?.force === true });
    res.json({ scaffolded: true, written: result.written, todos: result.todos });
  } catch (err) {
    if (err.code === "EPROFILEEXISTS") {
      return res.status(400).json({ error: { code: err.code, message: err.message } });
    }
    res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
  }
});

/**
 * Whether a named integration (tracker, dev_qc, ci_wait, ...) is turned on
 * for this lane — reads .ccam/profile/integrations.env. Read-only, no
 * sameOriginGuard needed (same reasoning GET /:id/git already documents:
 * that guard exists for destructive actions).
 */
router.get("/:id/integrations/:name", (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  res.json({ enabled: isIntegrationEnabled(lane, req.params.name) });
});

/**
 * A lane's working-copy facts: branch, short HEAD, that commit's subject, and
 * the uncommitted counts. Read-only, so no same-origin guard — that guard
 * exists for the destructive actions.
 *
 * Deliberately NOT part of `GET /api/lanes`: this shells out to git three
 * times, and that payload is polled and re-broadcast on every hook-driven
 * lane_update. Any failure — no such directory, not a repo, git itself
 * erroring — is reported as `available: false` rather than a 500, because a
 * lane pointing at a plain directory is a normal state, not a fault.
 *
 * The `/:id/:action` catch-all below cannot shadow this one — that route is a
 * POST and Express matches on method as well as path. Verified by moving this
 * registration after it: the suite stayed green.
 */
router.get("/:id/git", async (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  try {
    res.json({ available: true, ...(await gitFacts(lane.cwd)) });
  } catch {
    res.json({ available: false });
  }
});

/**
 * Create a dashboard-managed worktree lane. Git work happens after the 202
 * response because provisioning a large repository can take seconds.
 */
router.post("/worktree", sameOriginGuard, async (req, res) => {
  const body = req.body || {};
  const sourceRepo = body.sourceRepo;
  if (
    typeof sourceRepo !== "string" ||
    !path.isAbsolute(sourceRepo) ||
    !fs.existsSync(sourceRepo) ||
    !(await isGitRepo(sourceRepo))
  ) {
    return res.status(400).json({
      error: {
        code: "EBADSOURCEREPO",
        message: "sourceRepo must be an existing absolute git repository",
      },
    });
  }

  let slug;
  try {
    slug = slugify(body.slug || body.title);
  } catch (err) {
    return res.status(400).json({ error: { code: err.code, message: err.message } });
  }

  const resolvedSourceRepo = path.resolve(sourceRepo);
  const repoName = path.basename(resolvedSourceRepo);
  const originalSlug = slug;
  let dir = path.join(LANES_ROOT, `${repoName}__${slug}`);
  let attempts = 0;
  while (fs.existsSync(dir) && attempts < MAX_WORKTREE_DIRECTORY_ATTEMPTS) {
    attempts += 1;
    const suffix = attempts + 1;
    slug = `${originalSlug}-${suffix}`;
    dir = path.join(LANES_ROOT, `${repoName}__${slug}`);
  }
  if (fs.existsSync(dir)) {
    return res.status(409).json({
      error: {
        code: "EWORKTREEDIRCOLLISION",
        message: "could not allocate a unique worktree directory after 50 attempts",
      },
    });
  }

  let branch;
  if (body.branch !== undefined) {
    if (!(await isValidBranchName(resolvedSourceRepo, body.branch))) {
      return res.status(400).json({
        error: { code: "EBADBRANCH", message: "branch is not a valid git branch name" },
      });
    }
    branch = body.branch;
  } else {
    const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/";
    branch = `${branchPrefix}${slug}`;
  }
  let lane;
  try {
    lane = lanesLib.createLane({
      title: body.title || "",
      cwd: dir,
      branch,
      kind: "managed",
      source_repo: resolvedSourceRepo,
      base_branch: body.base || null,
      slug,
      ...(body.pipeline ? { pipeline: body.pipeline } : {}),
    });
    lane = lanesLib.updateLane(lane.id, { status: "provisioning" });
  } catch (err) {
    if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
      return res
        .status(409)
        .json({ error: { code: "EDUPCWD", message: "a lane already owns that cwd" } });
    }
    return res.status(500).json({ error: { code: err.code, message: err.message } });
  }

  res.status(202).json({ lane: payload(lane) });

  void withLaneLock(lane.id, async () => {
    try {
      const baseBranch = await resolveBase(
        resolvedSourceRepo,
        body.base || process.env.LANE_BASE_BRANCH || "main"
      );
      await addWorktree({ sourceRepo: resolvedSourceRepo, dir, branch, base: baseBranch });
      // base_branch is a provisioning fact, not a patchable field — see PATCHABLE.
      lanesLib.setProvisioningFacts(lane.id, { base_branch: baseBranch });

      // A2 data isolation: only when the repo actually declares a profile — a
      // worktree lane with none is a normal state (nothing about A1 required
      // one either), so this is a no-op rather than a provisioning failure.
      const worktreeLane = lanesLib.getLane(lane.id);
      const profile = resolveProfile(worktreeLane);
      if (profile) {
        const onLine = (line, stream) =>
          broadcast("lane_hook_output", { laneId: lane.id, hook: "provision", stream, line });
        await provisionLane(worktreeLane, { onLine });
      }

      lanesLib.updateLane(lane.id, { status: "idle", notes: null });
    } catch (err) {
      lanesLib.updateLane(lane.id, {
        status: "failed",
        notes: err.git?.stderr || err.message,
      });
    }
    broadcastLane(lane.id);
  });
});

const ACTIONS = new Set(["start", "stop", "message", "clear", "reset", "remove", "purge"]);
const DESTRUCTIVE_ACTIONS = new Set(["reset", "remove", "purge"]);
const RUN_EXIT_POLL_MS = 50;
// killRun escalates from SIGTERM to SIGKILL after five seconds. Leave enough
// time for that escalation and for Node to receive the child's real exit.
const RUN_EXIT_TIMEOUT_MS = 7500;

function lifecycleError(code, message) {
  return Object.assign(new Error(message), { code });
}

function expectedFields(action) {
  return action === "purge"
    ? ["sessions", "events", "tokenRows"]
    : ["head", "dirty", "untracked", "unpushed"];
}

/** Refuse a destructive action when the facts shown in its preflight have moved. */
function assertExpectedPreflight(action, current, expected) {
  const fields = expectedFields(action);
  if (
    !expected ||
    typeof expected !== "object" ||
    Array.isArray(expected) ||
    fields.some((field) => !Object.hasOwn(expected, field))
  ) {
    throw lifecycleError(
      "EEXPECT",
      `${action} requires a complete expect object with: ${fields.join(", ")}`
    );
  }
  const changed = fields.some((field) => current[field] !== expected[field]);
  if (changed) {
    const err = lifecycleError("ESTALE", "lane state changed since preflight");
    err.expected = expected;
    err.current = current;
    throw err;
  }
}

function wait(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/** Kill a lane run and wait for the tmux session to exit before touching its cwd. */
async function stopLaneRun(lane) {
  if (!lane.run_id) return;
  try {
    runs.killRun(lane.run_id);
  } catch {
    /* a concurrently completed run is already safe */
  }

  const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS;
  let run = runs.getRun(lane.run_id);
  while (run && run.status !== "gone") {
    if (Date.now() >= deadline) {
      throw lifecycleError(
        "ERUNTIMEOUT",
        `lane run ${lane.run_id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
      );
    }
    await wait(RUN_EXIT_POLL_MS);
    run = runs.getRun(lane.run_id);
  }
  lanesLib.updateLane(lane.id, { run_id: null });
}

function sendLifecycleError(res, err) {
  if (["ENOTMANAGED", "EOUTSIDEROOT", "ENOTWORKTREE", "EEXPECT"].includes(err.code)) {
    return res.status(400).json({ error: { code: err.code, message: err.message } });
  }
  if (["ESTALE", "EUNPUSHED"].includes(err.code)) {
    return res.status(409).json({
      error: {
        code: err.code,
        message: err.message,
        ...(err.code === "ESTALE" ? { expected: err.expected, current: err.current } : {}),
      },
    });
  }
  if (err.git) {
    return res.status(500).json({
      error: { code: err.code, message: err.message, stderr: err.git.stderr },
    });
  }
  return res.status(500).json({ error: { code: err.code, message: err.message } });
}

/* ---------------------------------------------------------------------------
 * Runtime: a lane's own stack, isolated by slot-derived ports and directories.
 *
 * These routes are registered BEFORE the "/:id/:action" catch-all below, which
 * would otherwise swallow "up" and "down" as unknown actions. They are also
 * deliberately NOT folded into that catch-all: it drives a lane's Claude RUN,
 * while these drive the application the lane is working on — two different
 * lifecycles that happen to share a lane id.
 *
 * None of them writes `stage`, `status` or `notes`. A booted stack is not an
 * agent at work, and only `slot`/`ports` describe the runtime.
 * ------------------------------------------------------------------------ */

/** Map a runtime error onto its status code. */
function sendRuntimeError(res, err) {
  const badRequest = [
    "ENOPROFILE",
    "ENOHOOK",
    "EBADLANEDIR",
    "EBADSVC",
    "EBADBRANCH",
    "EUNRESOLVED",
    "EMERGEUNCOMMITTED",
    "ENOMCPCONFIG",
  ];
  if (badRequest.includes(err.code)) {
    return res.status(400).json({
      error: {
        code: err.code,
        message: err.message,
        ...(err.searched ? { searched: err.searched } : {}),
      },
    });
  }
  if (err.code === "ESLOTS") {
    return res.status(409).json({ error: { code: err.code, message: err.message } });
  }
  if (err.code === "EPORTBUSY") {
    return res.status(409).json({
      error: { code: err.code, message: err.message, port: err.preferred, pids: err.pids },
    });
  }
  return res.status(500).json({
    error: { code: err.code || "ERUNTIME", message: err.message, logPath: err.logPath },
  });
}

/** Resolve `:id` or answer 404. Returns null once the response has been sent. */
function laneOr404(req, res) {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) {
    res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
    return null;
  }
  return lane;
}

/**
 * What is running for this lane, computed fresh.
 *
 * Follows `GET /:id/git`'s contract: a lane with no profile answers
 * `{available:false}` with HTTP 200, because that is a normal state and not a
 * fault. It probes ports and stats pid files, which is why it is its own endpoint
 * rather than a field on the polled lane list.
 */
router.get("/:id/runtime", async (req, res) => {
  const lane = laneOr404(req, res);
  if (!lane) return;
  try {
    res.json(await runtimeFacts(lane));
  } catch (err) {
    sendRuntimeError(res, err);
  }
});

/**
 * Tail one of the lane's hook or service logs.
 *
 * `:svc` is resolved against the log directory's real contents and the result is
 * confined to that directory after `realpath`, so a name from the request can
 * never escape it.
 */
router.get("/:id/logs/:svc", (req, res) => {
  const lane = laneOr404(req, res);
  if (!lane) return;
  if (!lane.slot) return res.json({ available: false });

  const { logDir } = slotDirs(lane.slot);
  const file = path.resolve(logDir, `${req.params.svc}.log`);
  let real;
  try {
    real = fs.realpathSync(file);
    const relative = path.relative(fs.realpathSync(logDir), real);
    if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("escapes log dir");
  } catch {
    return res.status(404).json({ error: { code: "ENOLOG", message: "no such log" } });
  }

  const requested = Number(req.query.tail);
  const tail = Math.min(
    Number.isInteger(requested) && requested > 0 ? requested : LOG_TAIL_DEFAULT,
    LOG_TAIL_MAX
  );
  const { size } = fs.statSync(real);
  const start = Math.max(0, size - tail);
  const fd = fs.openSync(real, "r");
  try {
    const buffer = Buffer.alloc(size - start);
    fs.readSync(fd, buffer, 0, buffer.length, start);
    res.json({
      available: true,
      svc: req.params.svc,
      size,
      truncated: start > 0,
      text: buffer.toString("utf8"),
    });
  } finally {
    fs.closeSync(fd);
  }
});

/**
 * Boot the lane's stack. 202 + background, like `POST /worktree`: a build can run
 * for minutes and the caller should not hold a socket open for it. Progress
 * streams as `lane_hook_output`; completion re-broadcasts the lane, whose `ports`
 * the boot may have changed.
 */
router.post("/:id/up", sameOriginGuard, (req, res) => {
  const lane = laneOr404(req, res);
  if (!lane) return;
  try {
    requireProfile(lane);
  } catch (err) {
    return sendRuntimeError(res, err);
  }

  const build = req.body?.build !== false;
  const qc = req.body?.qc === true;
  res.status(202).json({ ok: true, laneId: lane.id });

  void withLaneLock(lane.id, async () => {
    const onLine = (line, stream) =>
      broadcast("lane_hook_output", { laneId: lane.id, hook: "up", stream, line });
    try {
      const facts = await upLane(lanesLib.getLane(lane.id), { build, qc, onLine });
      broadcast("lane_runtime", { laneId: lane.id, runtime: facts });
    } catch (err) {
      broadcast("lane_runtime", {
        laneId: lane.id,
        error: { code: err.code || "ERUNTIME", message: err.message },
      });
    }
    broadcastLane(lane.id);
  });
});

/** Stop the lane's stack. Fast and idempotent, so it answers synchronously. */
router.post("/:id/down", sameOriginGuard, async (req, res) => {
  const lane = laneOr404(req, res);
  if (!lane) return;
  try {
    const result = await withLaneLock(lane.id, () => downLane(lanesLib.getLane(lane.id)));
    const facts = await runtimeFacts(lanesLib.getLane(lane.id));
    broadcast("lane_runtime", { laneId: lane.id, runtime: facts });
    res.json({ ok: true, killed: result.killed, runtime: facts });
  } catch (err) {
    sendRuntimeError(res, err);
  }
});

/**
 * Run one of the profile's hooks on the lane — the surface a driving session uses
 * for `ci-gate`, `e2e`, `migrate` and friends.
 *
 * `:name` is checked against the hook allowlist BEFORE anything is spawned, and
 * `args` travels as an array of strings straight into argv. Neither is ever
 * joined into a command string.
 */
router.post("/:id/hook/:name", sameOriginGuard, (req, res) => {
  const lane = laneOr404(req, res);
  if (!lane) return;
  const { name } = req.params;
  if (!HOOKS.includes(name)) {
    return res
      .status(400)
      .json({ error: { code: "ENOHOOK", message: `unknown hook ${name}`, allowed: HOOKS } });
  }
  const args = Array.isArray(req.body?.args) ? req.body.args.map(String) : [];

  let profile;
  try {
    profile = requireProfile(lane);
  } catch (err) {
    return sendRuntimeError(res, err);
  }
  if (!lane.slot) {
    return res.status(409).json({
      error: { code: "ENOSLOT", message: "lane has no runtime yet — bring it up first" },
    });
  }

  res.status(202).json({ ok: true, laneId: lane.id, hook: name });

  void withLaneLock(lane.id, async () => {
    const onLine = (line, stream) =>
      broadcast("lane_hook_output", { laneId: lane.id, hook: name, stream, line });
    try {
      const result = await runHook(lanesLib.getLane(lane.id), profile, name, args, { onLine });
      broadcast("lane_hook_result", { laneId: lane.id, hook: name, code: result.code });
    } catch (err) {
      broadcast("lane_hook_result", {
        laneId: lane.id,
        hook: name,
        code: null,
        error: { code: err.code || "ERUNTIME", message: err.message },
      });
    }
  });
});

/**
 * The ONE sanctioned merge in the ship-feature-lane pipeline: origin/development
 * INTO a feature branch, gated by a migration-number collision preflight.
 * Synchronous — a fetch + collision-check + merge is seconds of git work, not
 * the minutes a build/test hook can take, so this follows GET /:id/git's
 * pattern rather than the hook route's 202-and-broadcast.
 *
 * Returns 200 with {code: 0|4|5, ...} for every DOCUMENTED outcome — a
 * migration collision or a left-in-place conflict is an expected result, not
 * an HTTP error. A malformed request, a missing profile, or an out-of-order
 * --continue is the only case that answers with an `error` body.
 *
 * Never writes stage/status/notes — same boundary the hook and runtime
 * routes already keep; the caller decides what a collision or conflict means
 * for the lane's declared stage.
 */
router.post("/:id/sync-base", sameOriginGuard, async (req, res) => {
  const lane = laneOr404(req, res);
  if (!lane) return;
  let profile;
  try {
    profile = requireProfile(lane);
  } catch (err) {
    return sendRuntimeError(res, err);
  }

  const mode = ["check", "merge", "continue"].includes(req.body?.mode) ? req.body.mode : "merge";
  const branch =
    typeof req.body?.branch === "string" && req.body.branch ? req.body.branch : undefined;

  try {
    const result = await withLaneLock(lane.id, () => {
      const current = lanesLib.getLane(lane.id);
      if (mode === "check") return checkSync(current, profile, branch);
      if (mode === "continue") return continueSync(current, profile, branch);
      return mergeSync(current, profile, branch);
    });
    res.json(result);
  } catch (err) {
    sendRuntimeError(res, err);
  }
});

/**
 * Lane control. Deliberately thin: every action maps onto one existing
 * lifecycle function. There is no queue, no chaining, no gate evaluation — the
 * dashboard drives a lane, it does not orchestrate a pipeline.
 */
router.post("/:id/:action", sameOriginGuard, async (req, res) => {
  const { action } = req.params;
  if (!ACTIONS.has(action)) {
    return res
      .status(400)
      .json({ error: { code: "EBADACTION", message: `unknown action ${action}` } });
  }
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  const body = req.body || {};

  if (DESTRUCTIVE_ACTIONS.has(action)) {
    if (body.confirm !== true) {
      return res.status(400).json({
        error: { code: "ECONFIRM", message: `${action} requires confirm: true` },
      });
    }
    try {
      const result = await withLaneLock(lane.id, async () => {
        const lockedLane = lanesLib.getLane(lane.id);
        if (!lockedLane) throw lifecycleError("ENOLANE", "lane not found");

        await stopLaneRun(lockedLane);
        // A running stack holds files open in the very directory reset and remove
        // are about to rewrite or delete, and its processes would outlive the lane
        // still bound to its ports. Stop it before touching git. Idempotent and a
        // no-op for a lane that was never brought up.
        if (action === "reset" || action === "remove") {
          await downLane(lanesLib.getLane(lane.id));
        }
        const current = lanesLib.getLane(lane.id);
        const facts = await preflight(current, action);
        assertExpectedPreflight(action, facts, body.expect);
        if (
          (action === "reset" || (action === "remove" && current.kind === "managed")) &&
          facts.unpushed > 0 &&
          body.force !== true
        ) {
          throw lifecycleError(
            "EUNPUSHED",
            `${action} requires force: true when commits are unpushed`
          );
        }

        if (action === "reset") {
          await resetWorktree(current);
          const resetLane = lanesLib.clearLane(current.id);
          // A2 data isolation, only when the lane actually has both a profile
          // and an allocated slot — a lane that was never brought up has
          // nothing of this kind to reset.
          const profile = resolveProfile(resetLane);
          if (profile && resetLane.slot) {
            const onLine = (line, stream) =>
              broadcast("lane_hook_output", { laneId: resetLane.id, hook: "reset", stream, line });
            await resetLaneData(resetLane, profile, { keepDb: body.keepDb === true, onLine });
          }
          return { lane: lanesLib.getLane(current.id) };
        }
        if (action === "remove") {
          // Drop the lane's own database(s) before anything else — its state
          // directory (the drop-created marker) is about to be deleted too.
          const profile = resolveProfile(current);
          if (profile && current.slot) await removeLaneData(current, profile);
          // Forgetting an adopted lane only removes dashboard metadata. The
          // filesystem destroy guard is deliberately reached only for managed
          // worktrees, where removal can actually touch a directory.
          if (current.kind === "managed") await removeWorktree(current);
          // Runtime bookkeeping outlives the row otherwise: pid files and hook
          // logs under .state/lane<slot>/ would be inherited by whichever lane
          // claims that slot next. Deleting the row is what frees the slot —
          // usedSlots() reads the table, so there is nothing else to release.
          if (current.slot) {
            fs.rmSync(slotDirs(current.slot).stateDir, { recursive: true, force: true });
          }
          lanesLib.deleteLane(current.id);
          return { removed: current.id };
        }
        return { purged: lanesLib.purgeLaneSessions(current.id) };
      });

      if (action === "remove") {
        broadcast("lane_update", { removed: result.removed });
        return res.json({ ok: true });
      }
      if (action === "purge") {
        broadcastLane(lane.id);
        return res.json({ ok: true, purged: result.purged });
      }
      broadcastLane(lane.id);
      return res.json({ lane: payload(result.lane) });
    } catch (err) {
      if (err.code === "ENOLANE") {
        return res.status(404).json({ error: { code: err.code, message: err.message } });
      }
      return sendLifecycleError(res, err);
    }
  }

  try {
    switch (action) {
      case "start": {
        // Overwriting run_id while its child is alive orphans that child: a later
        // reset would kill and await only the RECORDED run, then `git clean -fd`
        // the directory the orphan is still writing into. Stop the first run before starting a
        // second. The check and the spawn happen under the per-lane lock so that
        // atomicity is guaranteed rather than an accident of this code having no
        // `await` between them — a future edit that adds one must not reopen the
        // race.
        const outcome = await withLaneLock(lane.id, async () => {
          const current = lanesLib.getLane(lane.id);
          // A concurrent `remove` could have deleted the row while this request
          // waited for the lock — the lock makes that race visible instead of
          // spawning a run for a lane that no longer exists.
          if (!current) return { missing: true };
          const live = current.run_id ? runs.getRun(current.run_id) : null;
          if (live && live.status === "running") {
            return { conflict: true };
          }
          const handle = runs.spawnRun({
            laneId: current.id,
            initialPrompt: body.prompt || "",
            cwd: current.cwd,
            model: body.model,
            permissionMode: body.permissionMode,
            effort: body.effort,
            resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined),
          });
          lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
          return {};
        });
        if (outcome.missing) {
          return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
        }
        if (outcome.conflict) {
          return res
            .status(409)
            .json({ error: { code: "ERUNLIVE", message: "lane already has a live run" } });
        }
        break;
      }
      case "stop": {
        // A lane with no live run is already stopped — say so, don't 500.
        if (lane.run_id) {
          try {
            runs.killRun(lane.run_id);
          } catch {
            /* already gone */
          }
        }
        lanesLib.updateLane(lane.id, { status: "idle", run_id: null });
        break;
      }
      case "message": {
        return res.status(400).json({
          error: {
            code: "EUNSUPPORTED",
            message:
              "sending input to a lane's run is no longer supported via REST — open the lane's terminal in Workspace and type directly (attaches over WebSocket to the same tmux session)",
          },
        });
      }
      case "clear":
        lanesLib.clearLane(lane.id);
        break;
      default:
        break;
    }
  } catch (err) {
    return res.status(500).json({ error: { code: err.code, message: err.message } });
  }

  broadcastLane(lane.id);
  res.json({ lane: payload(lanesLib.getLane(lane.id)) });
});

router.delete("/:id", sameOriginGuard, (req, res) => {
  const ok = lanesLib.deleteLane(req.params.id);
  if (!ok) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  broadcast("lane_update", { removed: Number(req.params.id) });
  res.json({ ok: true });
});

module.exports = router;
module.exports.broadcastLane = broadcastLane;
