#!/usr/bin/env bun
// od-requests — CLI over the collector's /requests store (owner intake, agent-incident
// writers, and `/request`). See docs/plans/2026-08-14-request-intake.md.
//
//   od-requests add <title...> [--project P] [--priority P] [--origin owner|agent-incident|agent-judgement]
//                                [--work-key KEY] [--plan-ref REF] [--session-name NAME] [--session-id ID] [--force]
//       Creates a row. Runs dedup server-side first: a confident match against ANY existing
//       state (including shipped) is reported and NOTHING is created, unless --force.
//       `--work-key` is the stable-identity dedup key (plan slug, worktree slug, ticket,
//       adw_id) — an exact match against another row's key ALWAYS wins over title similarity
//       (see requests-dedup.ts's findMatch). It is the same underlying field as `--plan-ref`
//       (an external-id seam that already existed and already ran exact-match-first dedup —
//       extended rather than duplicated with a parallel column); pass either name, `--work-key`
//       is preferred going forward. `--plan-ref` wins if both are given.
//   od-requests fire <signature...> [--project P] [--title T] [--worker W] [--detail D]
//       S3 of the plan: atomic incident claim by exact signature (e.g.
//       "deploy-local:actions-gateway-config-missing"), never by prose similarity. An open
//       incident with the same signature+project is reported as already claimed, never
//       duplicated. FAILS OPEN: a collector/network failure prints a warning and exits 0 —
//       the caller's own repair must never be blocked by incident registration.
//   od-requests claim <id> [--session S] [--worker W] [--session-name NAME] [--session-id ID]
//       S4 of the plan: agent association. Sets state=in_flight and worker to a short,
//       owner-readable label — NEVER a raw session UUID (`deriveWorkerLabel`: short session id
//       + host from the containment ledger when dispatched to a buildbox, "laptop" otherwise).
//       --worker overrides the derived label outright (e.g. a human-assigned name).
//   od-requests block <work_key-or-id> --reason 'What the owner must provide' [--worker W] [--session-name NAME] [--session-id ID]
//       Blocks directly from a fresh asked row; do not create a fake claim first.
//   od-requests cancel <work_key-or-id> --reason 'Why this request is no longer active' [--worker W]
//   od-requests unblock <work_key-or-id> [--worker W]
//   od-requests note <id> --detail 'Escalation or other owner-visible context'
//   od-requests ship <id> --detail 'Outcome' --proof-url 'Proof' [--worker W]
//   od-requests list [--project P] [--state S] [--json]
//   od-requests show <id>
//
// Base URL / auth mirror od-incidents: COLLECTOR_URL env > port key in
// ${OVERDECK_CONFIG_DIR:-~/.config/overdeck}/config.toml > http://127.0.0.1:4980. Token:
// COLLECTOR_TOKEN env > ${OVERDECK_CONFIG_DIR:-~/.config/overdeck}/token.

import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, resolve, sep } from "node:path";
import { fingerprint } from "../hooks/lib/task-store.mjs";
import { deriveWorkerLabel } from "../hooks/lib/worker-identity.mjs";

const DEFAULT_COLLECTOR_URL = "http://127.0.0.1:4980";

function configDir() {
  return process.env.OVERDECK_CONFIG_DIR ?? join(homedir(), ".config", "overdeck");
}

function collectorBaseUrl() {
  if (process.env.COLLECTOR_URL) return process.env.COLLECTOR_URL;
  try {
    const raw = readFileSync(join(configDir(), "config.toml"), "utf8");
    const config = Bun.TOML.parse(raw);
    if (config.port) return `http://${config.bindHost || "127.0.0.1"}:${config.port}`;
  } catch {
    // no config.toml (or unreadable/unparsable) — fall through to the code default
  }
  return DEFAULT_COLLECTOR_URL;
}

function resolveToken() {
  if (process.env.COLLECTOR_TOKEN) return process.env.COLLECTOR_TOKEN;
  try {
    return readFileSync(join(configDir(), "token"), "utf8").trim();
  } catch {
    return "";
  }
}

// Bounded, so a genuinely dead collector still fails — just not within one restart.
// MEASURED 2026-08-16, do not lower without re-measuring: the collector takes ~34s from
// systemd "Started" to answering on its port, and longer under load — a 45s window still
// lost the race. 120s clears it with margin and still ends every call.
const RETRY_WINDOW_MS = Number(process.env.OD_REQUESTS_RETRY_MS ?? 120_000);
const RETRY_INTERVAL_MS = 1_000;

class CollectorRequestError extends Error {
  constructor(status, body) {
    super(`collector request failed: ${status}`);
    this.status = status;
    this.body = body;
  }
}

async function collectorFetch(path, init = {}) {
  const url = new URL(path, collectorBaseUrl());
  const headers = new Headers(init.headers ?? {});
  headers.set("authorization", `Bearer ${resolveToken()}`);
  if (init.body !== undefined) headers.set("content-type", "application/json");
  // Every deploy restarts overdeck-collector.service (see packaging/deploy-local.sh's
  // restart step), so the board is briefly unreachable several times a day. Failing hard
  // on that window is what made the board look unreliable — measured 2026-08-16: four
  // clean stop/start cycles in one day, oom_kill 0, so this was never a crash. Ride out
  // the restart instead of reporting the board as down.
  const deadline = Date.now() + RETRY_WINDOW_MS;
  let res;
  let lastCause;
  for (;;) {
    try {
      res = await fetch(url, { ...init, headers });
      break;
    } catch (cause) {
      lastCause = cause;
      if (Date.now() >= deadline) {
        throw new Error(
          `could not reach collector at ${url.origin} after ${Math.round(RETRY_WINDOW_MS / 1000)}s of retries: ${cause.message ?? cause}`,
        );
      }
      await new Promise((r) => setTimeout(r, RETRY_INTERVAL_MS));
    }
  }
  void lastCause;
  const text = await res.text();
  if (res.status !== 200 && res.status !== 201 && res.status !== 409) {
    throw new CollectorRequestError(res.status, text);
  }
  const body = text.length ? JSON.parse(text) : null;
  return { status: res.status, body };
}

function usage(message) {
  const err = new Error(
    message ??
      "usage: od-requests <add <title...> [--project P] [--priority P] [--origin owner|agent-incident|agent-judgement] [--work-key KEY] [--plan-ref REF] [--session-name NAME] [--session-id ID] [--force]|fire <signature...> [--project P] [--title T] [--worker W] [--detail D]|claim <id> [--session S] [--worker W] [--session-name NAME] [--session-id ID]|block <work_key-or-id> --reason TEXT [--worker W] [--session-name NAME] [--session-id ID]|unblock <work_key-or-id> [--worker W] [--session-name NAME] [--session-id ID]|cancel <work_key-or-id> --reason TEXT [--worker W]|note <id> --detail TEXT|ship <id> --detail TEXT --proof-url TEXT [--worker W]|list [--project P] [--state S]|show <id>>"
  );
  err.usage = true;
  throw err;
}

function parseFlags(args, valueFlags, boolFlags = new Set()) {
  const flags = {};
  const positional = [];
  for (let i = 0; i < args.length; i++) {
    const arg = args[i];
    if (arg.startsWith("--")) {
      const name = arg.slice(2);
      if (boolFlags.has(name)) {
        flags[name] = true;
        continue;
      }
      if (!valueFlags.has(name)) usage(`unknown flag --${name}`);
      const value = args[++i];
      if (value === undefined) usage(`--${name} requires a value`);
      flags[name] = value;
    } else {
      positional.push(arg);
    }
  }
  return { flags, positional };
}

function printTable(rows, columns) {
  const widths = columns.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? "").length)));
  const line = (cells) => cells.map((c, i) => String(c).padEnd(widths[i])).join("  ");
  console.log(line(columns));
  for (const row of rows) console.log(line(columns.map((c) => row[c] ?? "")));
}

function deriveProject(cwd) {
  const original = resolve(String(cwd || process.cwd()));
  let dir = original;
  let root = original;
  while (true) {
    if (existsSync(join(dir, ".git"))) {
      root = dir;
      break;
    }
    const parent = dirname(dir);
    if (parent === dir) break;
    dir = parent;
  }
  const marker = `${sep}.worktrees${sep}`;
  const markerIndex = root.indexOf(marker);
  if (markerIndex >= 0) root = root.slice(0, markerIndex);
  return basename(root) || "unknown";
}

function statusLine(request) {
  const asked = new Date(request.asked_at).toISOString().slice(0, 10);
  const worker = request.worker ? `, worker ${request.worker}` : "";
  const proof = request.proof_url ? `, proof ${request.proof_url}` : "";
  return `#${request.id} — asked ${asked}, state ${request.state}${worker}${proof}`;
}

async function cmdAdd(args) {
  const { flags, positional } = parseFlags(
    args,
    new Set(["project", "priority", "origin", "plan-ref", "work-key", "session-name", "session-id"]),
    new Set(["force"])
  );
  const title = positional.join(" ").trim();
  if (!title) usage("add requires a title");
  const origin = flags.origin ?? "owner";
  if (origin !== "owner" && origin !== "agent-incident" && origin !== "agent-judgement") {
    usage("--origin must be owner, agent-incident, or agent-judgement");
  }
  const project = flags.project ?? deriveProject(process.cwd());
  const now = new Date().toISOString();
  const requestId = `manual-${fingerprint(`${project}\n${title}`)}`;
  const body = {
    id: requestId,
    title,
    project,
    state: "asked",
    priority: flags.priority ?? "NORMAL",
    origin,
    asked_at: now,
    original_body: title,
    original_body_format: "plain_text",
    intake_source: "od-requests-cli",
    intake_source_event_id: requestId,
    updated_at: now,
    detail: null,
    // --work-key is the preferred name going forward; --plan-ref is the pre-existing field
    // both write (same column, same exact-match-first dedup — see cmdAdd's usage comment).
    plan_ref: flags["plan-ref"] ?? flags["work-key"] ?? null,
    session_name: flags["session-name"] ?? null,
    session_id: flags["session-id"] ?? null,
    ...(flags.force ? { skipDedup: true } : {}),
  };
  const { status, body: result } = await collectorFetch("/requests", { method: "POST", body: JSON.stringify(body) });
  if (status === 409) {
    if (result?.error === "duplicate" && result.match?.request) {
      console.log(`already tracked: ${statusLine(result.match.request)}`);
      console.log("use --force to create a new row anyway");
      process.exitCode = 1;
      return;
    }
    throw new CollectorRequestError(409, JSON.stringify(result));
  }
  const request = result.request;
  if (result.match?.confidence === "weak") {
    console.log(`created #${request.id}; possible twin: ${statusLine(result.match.request)}`);
  } else {
    console.log(`created #${request.id}`);
  }
}

async function cmdFire(args) {
  const { flags, positional } = parseFlags(args, new Set(["project", "title", "worker", "detail"]));
  const signature = positional.join(" ").trim();
  if (!signature) usage("fire requires a signature, e.g. deploy-local:actions-gateway-config-missing");
  const project = flags.project ?? deriveProject(process.cwd());
  const body = {
    signature,
    project,
    ...(flags.title ? { title: flags.title } : {}),
    ...(flags.worker ? { worker: flags.worker } : {}),
    ...(flags.detail ? { detail: flags.detail } : {}),
  };
  // FAIL OPEN: incident registration must never block the caller's own recovery. A network
  // failure or a non-{201,409} collector response is reported and swallowed, never thrown.
  let status, result;
  try {
    ({ status, body: result } = await collectorFetch("/requests/fire", { method: "POST", body: JSON.stringify(body) }));
  } catch (err) {
    console.log(`fire registration unavailable (${err.message ?? err}) — proceeding unclaimed`);
    return;
  }
  const request = result?.request;
  if (status === 201 && request) {
    console.log(`claimed #${request.id} (${signature})`);
    return;
  }
  if (status === 409 && request) {
    console.log(`already claimed: ${statusLine(request)}`);
    process.exitCode = 1;
    return;
  }
  console.log(`fire registration returned unexpected status ${status} — proceeding unclaimed`);
}

async function cmdClaim(args) {
  const { flags, positional } = parseFlags(args, new Set(["session", "worker", "session-name", "session-id"]));
  const [id] = positional;
  if (!id) usage("claim requires <id>");
  const worker = flags.worker ?? deriveWorkerLabel({ sessionId: flags.session ?? process.env.CLAUDE_SESSION_ID });
  let status, result;
  try {
    ({ status, body: result } = await collectorFetch(`/requests/${encodeURIComponent(id)}`, {
      method: "POST",
      body: JSON.stringify({ state: "in_flight", worker, actor: worker, ...(flags["session-name"] ? { session_name: flags["session-name"] } : {}), ...(flags["session-id"] ? { session_id: flags["session-id"] } : {}) }),
    }));
  } catch (err) {
    if (err instanceof CollectorRequestError && err.status === 404) {
      console.error(`not found: ${id}`);
      process.exitCode = 1;
      return;
    }
    throw err;
  }
  if (status !== 200) throw new CollectorRequestError(status, JSON.stringify(result));
  console.log(`claimed #${id} as ${worker}`);
}

async function cmdLifecycle(args, state) {
  const { flags, positional } = parseFlags(args, new Set(["reason", "worker", "session-name", "session-id"]));
  const [target] = positional;
  const command = state === "blocked_needs_owner" ? "block" : state === "canceled" ? "cancel" : "unblock";
  if (!target) usage(`${command} requires <work_key-or-id>`);
  if (positional.length > 1) usage("lifecycle target must be one work key or id");
  const reason = String(flags.reason ?? "").trim();
  if (state === "blocked_needs_owner" && !reason) usage("block requires --reason with what the owner must provide");
  if (state === "canceled" && !reason) usage("cancel requires --reason");
  if (state === "in_flight" && flags.reason !== undefined) usage("unblock does not accept --reason");
  const actor = flags.worker ?? deriveWorkerLabel({ sessionId: process.env.CLAUDE_SESSION_ID });
  const { status, body } = await collectorFetch(`/requests/${encodeURIComponent(target)}`, {
    method: "POST",
    body: JSON.stringify({ state, actor, ...(reason ? { reason } : {}), ...(flags["session-name"] ? { session_name: flags["session-name"] } : {}), ...(flags["session-id"] ? { session_id: flags["session-id"] } : {}) }),
  });
  if (status === 409) {
    console.error(`invalid transition for ${target}: current state is ${body?.current_state ?? "unknown"}`);
    process.exitCode = 1;
    return;
  }
  const verb = state === "blocked_needs_owner" ? "blocked" : state === "canceled" ? "canceled" : "unblocked";
  console.log(`${verb} #${body.request.id}${reason ? `: ${reason}` : ""}`);
}

async function cmdNote(args) {
  const { flags, positional } = parseFlags(args, new Set(["detail"]));
  const [id] = positional;
  const detail = String(flags.detail ?? "").trim();
  if (!id || positional.length !== 1) usage("note requires one <id>");
  if (!detail) usage("note requires --detail");
  const { body } = await collectorFetch(`/requests/${encodeURIComponent(id)}`, { method: "POST", body: JSON.stringify({ detail }) });
  console.log(`noted #${body.request.id}: ${detail}`);
}

async function cmdShip(args) {
  const { flags, positional } = parseFlags(args, new Set(["detail", "proof-url", "worker"]));
  const [id] = positional;
  const detail = String(flags.detail ?? "").trim();
  const proof_url = String(flags["proof-url"] ?? "").trim();
  if (!id || positional.length !== 1) usage("ship requires one <id>");
  if (!detail || !proof_url) usage("ship requires --detail and --proof-url");
  const actor = flags.worker ?? deriveWorkerLabel({ sessionId: process.env.CLAUDE_SESSION_ID });
  const transition = async (state) => collectorFetch(`/requests/${encodeURIComponent(id)}`, { method: "POST", body: JSON.stringify({ state, actor, worker: actor }) });
  await transition("in_flight");
  await transition("shipped");
  const { body } = await collectorFetch(`/requests/${encodeURIComponent(id)}`, { method: "POST", body: JSON.stringify({ detail, proof_url }) });
  console.log(`shipped #${body.request.id}: ${detail}`);
}

async function cmdList(args) {
  const { flags } = parseFlags(args, new Set(["project", "state"]), new Set(["json"]));
  const { body } = await collectorFetch("/requests");
  let rows = body.requests ?? [];
  if (flags.project) rows = rows.filter((r) => r.project === flags.project);
  if (flags.state) rows = rows.filter((r) => r.state === flags.state);
  if (flags.json) {
    console.log(JSON.stringify(rows));
    return;
  }
  printTable(
    rows.map((r) => ({ id: r.id, state: r.state, project: r.project, priority: r.priority, origin: r.origin, title: r.title })),
    ["id", "state", "project", "priority", "origin", "title"]
  );
}

async function cmdShow(args) {
  const [id] = args;
  if (!id) usage("show requires <id>");
  const { body } = await collectorFetch("/requests");
  const request = (body.requests ?? []).find((r) => r.id === id);
  if (!request) {
    console.error(`not found: ${id}`);
    process.exitCode = 1;
    return;
  }
  console.log(JSON.stringify(request, null, 2));
}

async function main() {
  const [cmd, ...rest] = process.argv.slice(2);
  if (!cmd) usage();
  switch (cmd) {
    case "add":
      return cmdAdd(rest);
    case "fire":
      return cmdFire(rest);
    case "claim":
      return cmdClaim(rest);
    case "block":
      return cmdLifecycle(rest, "blocked_needs_owner");
    case "unblock":
      return cmdLifecycle(rest, "in_flight");
    case "cancel":
      return cmdLifecycle(rest, "canceled");
    case "note":
      return cmdNote(rest);
    case "ship":
      return cmdShip(rest);
    case "list":
      return cmdList(rest);
    case "show":
      return cmdShow(rest);
    default:
      usage(`unknown subcommand: ${cmd}`);
  }
}

main().catch((err) => {
  if (err instanceof CollectorRequestError) {
    console.error(`collector error: HTTP ${err.status}`);
    console.error(err.body);
    process.exit(1);
  }
  console.error(err.message ?? String(err));
  process.exit(err.exitCode ?? (err.usage ? 2 : 1));
});
