#!/usr/bin/env bash
# Regression tests for od-requests: add (create, confident-duplicate report, --force,
# weak-match advisory), list, and show, against a mocked collector — no real collector
# is contacted.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CLI="$ROOT/bin/od-requests"
PASS=0; FAIL=0
ok()  { PASS=$((PASS+1)); printf 'PASS %s\n' "$1"; }
bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n     %s\n' "$1" "$2"; }

TMP=$(mktemp -d "${TMPDIR:-/tmp}/od-requests-XXXX")
trap 'rm -rf "$TMP"; [[ -n "${SERVER_PID:-}" ]] && kill "$SERVER_PID" 2>/dev/null' EXIT

TOKEN="test-token-abc"

cat >"$TMP/mock-server.mjs" <<'EOF'
const token = process.env.MOCK_TOKEN;
const shipped = {
  id: "req-shipped", title: "Ship the diff viewer", project: "overdeck", state: "shipped",
  priority: "NORMAL", origin: "owner", asked_at: "2026-08-09T00:00:00.000Z",
  updated_at: "2026-08-09T00:00:00.000Z", worker: null, detail: null,
  proof_url: "https://proof.example/diff-viewer", plan_ref: null,
};
const rows = [shipped];

const server = Bun.serve({
  port: 0,
  async fetch(req) {
    const auth = req.headers.get("authorization");
    if (auth !== `Bearer ${token}`) return new Response("unauthorized", { status: 401 });
    const url = new URL(req.url);
    if (url.pathname === "/requests" && req.method === "GET") {
      return Response.json({ requests: rows });
    }
    if (url.pathname === "/requests/fire" && req.method === "POST") {
      const body = await req.json().catch(() => ({}));
      const id = `fire-${body.signature}-${body.project}`;
      const existing = rows.find((r) => r.id === id);
      if (existing && existing.state !== "shipped") {
        return Response.json({ request: existing, claimed: false }, { status: 409 });
      }
      const row = { ...shipped, id, title: body.title || body.signature, project: body.project, origin: "agent-incident", state: "asked", worker: body.worker ?? null, detail: body.detail ?? null, proof_url: null, plan_ref: null };
      const idx = rows.findIndex((r) => r.id === id);
      if (idx >= 0) rows[idx] = row; else rows.push(row);
      return Response.json({ request: row, claimed: true }, { status: 201 });
    }
    const claimMatch = /^\/requests\/([^/]+)$/.exec(url.pathname);
    if (claimMatch && req.method === "POST") {
      const id = decodeURIComponent(claimMatch[1]);
      const idx = rows.findIndex((r) => r.id === id || r.plan_ref === id);
      if (idx < 0) return Response.json({ error: "not-found" }, { status: 404 });
      const body = await req.json().catch(() => ({}));
      rows[idx] = { ...rows[idx], ...body };
      return Response.json({ request: rows[idx] }, { status: 200 });
    }
    if (url.pathname === "/requests" && req.method === "POST") {
      const body = await req.json().catch(() => ({}));
      const normalized = String(body.title || "").toLowerCase().trim();
      const dup = !body.skipDedup && rows.find((r) => r.project === body.project && r.title.toLowerCase().trim() === normalized);
      if (dup) return Response.json({ error: "duplicate", match: { request: dup, confidence: "confident" } }, { status: 409 });
      const row = { ...shipped, worker: null, detail: null, proof_url: null, plan_ref: null, ...body, state: body.state ?? "asked" };
      rows.push(row);
      const weakMatch = !body.skipDedup && body.title === "fix login bug middleware flow"
        ? { request: rows[1] ?? row, confidence: "weak" }
        : undefined;
      return Response.json({ request: row, ...(weakMatch ? { match: weakMatch } : {}) }, { status: 201 });
    }
    return new Response("not found", { status: 404 });
  },
});
console.log(server.port);
await new Promise(() => {});
EOF

MOCK_TOKEN="$TOKEN" bun "$TMP/mock-server.mjs" >"$TMP/server.out" 2>"$TMP/server.err" &
SERVER_PID=$!

for _ in $(seq 1 50); do
  [[ -s "$TMP/server.out" ]] && break
  sleep 0.1
done
PORT=$(cat "$TMP/server.out" 2>/dev/null)
if [[ -z "$PORT" ]]; then
  bad "mock collector server starts" "no port printed; stderr=$(cat "$TMP/server.err")"
  echo; echo "PASS=$PASS FAIL=$FAIL"; exit 1
else
  ok "mock collector server starts"
fi

export COLLECTOR_URL="http://127.0.0.1:$PORT"
export COLLECTOR_TOKEN="$TOKEN"

run() { bun "$CLI" "$@" >"$TMP/out" 2>"$TMP/err"; echo $?; }

# --- add: fresh title creates a row ---
rc=$(run add --project overdeck "Wire the new gateway retry")
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == created\ #* ]] \
  && ok "add creates a fresh row" \
  || bad "add creates a fresh row" "rc=$rc out=$(cat "$TMP/out") err=$(cat "$TMP/err")"

# --- add: confident duplicate of a shipped row reports status, does not create ---
rc=$(run add --project overdeck "Ship the diff viewer")
[[ "$rc" -eq 1 && "$(cat "$TMP/out")" == *"already tracked"* && "$(cat "$TMP/out")" == *"proof https://proof.example/diff-viewer"* ]] \
  && ok "add on a shipped duplicate reports status, not a new row" \
  || bad "add on a shipped duplicate reports status, not a new row" "rc=$rc out=$(cat "$TMP/out")"

# --- add --force bypasses dedup ---
rc=$(run add --project overdeck --force "Ship the diff viewer")
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == created\ #* ]] \
  && ok "add --force bypasses dedup" \
  || bad "add --force bypasses dedup" "rc=$rc out=$(cat "$TMP/out")"

# --- add: weak match is advisory, row still created ---
rc=$(run add --project overdeck "fix login bug middleware flow")
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == *"possible twin"* ]] \
  && ok "add on a weak match creates the row and names the twin" \
  || bad "add on a weak match creates the row and names the twin" "rc=$rc out=$(cat "$TMP/out")"

# --- add --work-key passes through to the plan_ref field (the exact-match dedup key) ---
rc=$(run add --project overdeck --work-key "wt-proof-slug" "Work key passthrough row")
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == created\ #* ]] \
  && ok "add --work-key creates a row" \
  || bad "add --work-key creates a row" "rc=$rc out=$(cat "$TMP/out") err=$(cat "$TMP/err")"
WORK_KEY_ID=$(sed -n 's/^created #//p' "$TMP/out")
rc=$(run show "$WORK_KEY_ID")
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == *'"plan_ref": "wt-proof-slug"'* ]] \
  && ok "add --work-key value lands in plan_ref (the extended, not duplicated, key seam)" \
  || bad "add --work-key value lands in plan_ref" "rc=$rc out=$(cat "$TMP/out")"
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == *'"original_body": "Work key passthrough row"'* && "$(cat "$TMP/out")" == *'"original_body_format": "plain_text"'* && "$(cat "$TMP/out")" == *'"intake_source": "od-requests-cli"'* ]] \
  && ok "add preserves immutable CLI intake evidence" \
  || bad "add preserves immutable CLI intake evidence" "rc=$rc out=$(cat "$TMP/out")"

# --- add: optional session attribution passes through when supplied ---
rc=$(run add --project overdeck --session-name "memory-organizer-fluttering-owl" --session-id "session-full-id" "Attributed request")
ATTRIBUTED_ID=$(sed -n 's/^created #//p' "$TMP/out")
rc=$(run show "$ATTRIBUTED_ID")
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == *'"session_name": "memory-organizer-fluttering-owl"'* && "$(cat "$TMP/out")" == *'"session_id": "session-full-id"'* ]] \
  && ok "add passes optional session attribution" \
  || bad "add passes optional session attribution" "rc=$rc out=$(cat "$TMP/out")"

# --- add requires a title ---
rc=$(run add --project overdeck)
[[ "$rc" -eq 2 ]] \
  && ok "add without a title exits 2" \
  || bad "add without a title exits 2" "rc=$rc err=$(cat "$TMP/err")"

# --- list ---
rc=$(run list)
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == *"req-shipped"* ]] \
  && ok "list prints known rows" \
  || bad "list prints known rows" "rc=$rc out=$(cat "$TMP/out")"

# --- list --json: raw rows, for scripts like od-worktree's association step ---
rc=$(run list --json)
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == \[*\"req-shipped\"* ]] \
  && ok "list --json prints a raw JSON array" \
  || bad "list --json prints a raw JSON array" "rc=$rc out=$(cat "$TMP/out")"

# --- show ---
rc=$(run show req-shipped)
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == *'"state": "shipped"'* ]] \
  && ok "show prints the full record" \
  || bad "show prints the full record" "rc=$rc out=$(cat "$TMP/out")"

# --- show missing id ---
rc=$(run show does-not-exist)
[[ "$rc" -eq 1 && "$(cat "$TMP/err")" == *"not found"* ]] \
  && ok "show on a missing id exits 1" \
  || bad "show on a missing id exits 1" "rc=$rc err=$(cat "$TMP/err")"

# --- claim: sets in_flight and a short owner-readable worker label, never a raw session id ---
rc=$(run claim req-shipped --session abcdefgh12345678)
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == claimed\ #req-shipped\ as\ abcdefgh@* ]] \
  && ok "claim sets a short worker label, not a raw session id" \
  || bad "claim sets a short worker label, not a raw session id" "rc=$rc out=$(cat "$TMP/out") err=$(cat "$TMP/err")"

# --- claim: optional session attribution updates the row ---
run claim req-shipped --session-name "memory-organizer-fluttering-owl" --session-id "session-full-id" >/dev/null
rc=$(run show req-shipped)
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == *'"session_name": "memory-organizer-fluttering-owl"'* && "$(cat "$TMP/out")" == *'"session_id": "session-full-id"'* ]] \
  && ok "claim passes optional session attribution" \
  || bad "claim passes optional session attribution" "rc=$rc out=$(cat "$TMP/out")"

# --- claim: --worker overrides the derived label ---
rc=$(run claim req-shipped --worker "lane-b@debian2")
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == "claimed #req-shipped as lane-b@debian2" ]] \
  && ok "claim --worker overrides the derived label" \
  || bad "claim --worker overrides the derived label" "rc=$rc out=$(cat "$TMP/out")"

# --- claim: missing id ---
rc=$(run claim does-not-exist)
[[ "$rc" -eq 1 && "$(cat "$TMP/err")" == *"not found"* ]] \
  && ok "claim on a missing id exits 1" \
  || bad "claim on a missing id exits 1" "rc=$rc err=$(cat "$TMP/err")"

# --- claim: requires an id ---
rc=$(run claim)
[[ "$rc" -eq 2 ]] \
  && ok "claim without an id exits 2" \
  || bad "claim without an id exits 2" "rc=$rc err=$(cat "$TMP/err")"

# --- block/unblock: work-key resolution, required owner-language reason, and state verbs ---
rc=$(run add --project overdeck --work-key lifecycle-cli "Lifecycle CLI row")
LIFECYCLE_ID=$(sed -n 's/^created #//p' "$TMP/out")
rc=$(run block lifecycle-cli --worker "lane-a@buildbox" --reason "Please provide the registry token.")
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == "blocked #$LIFECYCLE_ID: Please provide the registry token." ]] \
  && ok "block resolves a work key and sends the required reason" \
  || bad "block resolves a work key and sends the required reason" "rc=$rc out=$(cat "$TMP/out") err=$(cat "$TMP/err")"
rc=$(run show "$LIFECYCLE_ID")
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == *'"state": "blocked_needs_owner"'* && "$(cat "$TMP/out")" == *'"reason": "Please provide the registry token."'* ]] \
  && ok "block persists blocked state and reason" \
  || bad "block persists blocked state and reason" "rc=$rc out=$(cat "$TMP/out")"
rc=$(run unblock lifecycle-cli --worker owner)
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == "unblocked #$LIFECYCLE_ID" ]] \
  && ok "unblock resolves a work key and returns it to in_flight" \
  || bad "unblock resolves a work key and returns it to in_flight" "rc=$rc out=$(cat "$TMP/out") err=$(cat "$TMP/err")"
rc=$(run block lifecycle-cli)
[[ "$rc" -eq 2 && "$(cat "$TMP/err")" == *"block requires --reason"* ]] \
  && ok "block requires a non-empty reason" \
  || bad "block requires a non-empty reason" "rc=$rc err=$(cat "$TMP/err")"
rc=$(run cancel lifecycle-cli --worker owner --reason "The request was superseded by the deployed path.")
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == "canceled #$LIFECYCLE_ID: The request was superseded by the deployed path." ]] \
  && ok "cancel records an explicit terminal disposition" \
  || bad "cancel records an explicit terminal disposition" "rc=$rc out=$(cat "$TMP/out") err=$(cat "$TMP/err")"
rc=$(run show "$LIFECYCLE_ID")
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == *'"state": "canceled"'* && "$(cat "$TMP/out")" == *'"reason": "The request was superseded by the deployed path."'* ]] \
  && ok "cancel persists terminal state and reason" \
  || bad "cancel persists terminal state and reason" "rc=$rc out=$(cat "$TMP/out")"
rc=$(run cancel req-shipped)
[[ "$rc" -eq 2 && "$(cat "$TMP/err")" == *"cancel requires --reason"* ]] \
  && ok "cancel requires a non-empty reason" \
  || bad "cancel requires a non-empty reason" "rc=$rc err=$(cat "$TMP/err")"

# --- fire consumer write-back: a note preserves asked, ship records the final proof ---
rc=$(run add --project overdeck "Fire consumer write-back row")
WRITEBACK_ID=$(sed -n 's/^created #//p' "$TMP/out")
rc=$(run note "$WRITEBACK_ID" --detail "Escalated: exact unit is missing.")
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == "noted #$WRITEBACK_ID: Escalated: exact unit is missing." ]] \
  && ok "note records an escalation without changing state" \
  || bad "note records an escalation without changing state" "rc=$rc out=$(cat "$TMP/out") err=$(cat "$TMP/err")"
rc=$(run ship "$WRITEBACK_ID" --worker fire-consumer --detail "Runner is online." --proof-url "gh api: online")
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == "shipped #$WRITEBACK_ID: Runner is online." ]] \
  && ok "ship records the final proof through lifecycle transitions" \
  || bad "ship records the final proof through lifecycle transitions" "rc=$rc out=$(cat "$TMP/out") err=$(cat "$TMP/err")"

# --- fire: a fresh signature claims ---
rc=$(run fire --project overdeck "deploy-local:actions-gateway-config-missing")
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == claimed\ #* ]] \
  && ok "fire claims a fresh signature" \
  || bad "fire claims a fresh signature" "rc=$rc out=$(cat "$TMP/out") err=$(cat "$TMP/err")"

# --- fire: a second lane on the same signature is told it is already claimed, not given a new row ---
rc=$(run fire --project overdeck "deploy-local:actions-gateway-config-missing")
[[ "$rc" -eq 1 && "$(cat "$TMP/out")" == *"already claimed"* ]] \
  && ok "fire on an open duplicate reports the existing claim" \
  || bad "fire on an open duplicate reports the existing claim" "rc=$rc out=$(cat "$TMP/out")"

# --- fire: requires a signature ---
rc=$(run fire --project overdeck)
[[ "$rc" -eq 2 ]] \
  && ok "fire without a signature exits 2" \
  || bad "fire without a signature exits 2" "rc=$rc err=$(cat "$TMP/err")"

# --- fire: fails open when the collector is unreachable ---
rc=$(COLLECTOR_URL="http://127.0.0.1:1" run fire --project overdeck "unreachable-signature")
[[ "$rc" -eq 0 && "$(cat "$TMP/out")" == *"unavailable"* ]] \
  && ok "fire fails open when the collector is unreachable" \
  || bad "fire fails open when the collector is unreachable" "rc=$rc out=$(cat "$TMP/out")"

# --- restart window: a collector that is not up YET is waited for, not reported down ---
# Every deploy restarts overdeck-collector.service and it takes ~34-57s to answer, so
# without this the board is unreachable several times a day for no good reason.
PORT=39731
( sleep 3; exec bun -e '
  const port = Number(process.argv[1]);
  Bun.serve({ port, hostname: "127.0.0.1", fetch: () => Response.json({ requests: [] }) });
  setTimeout(() => process.exit(0), 30_000);
' "$PORT" >/dev/null 2>&1 ) &
LATE=$!
start=$(date +%s)
rc=$(COLLECTOR_URL="http://127.0.0.1:$PORT" OD_REQUESTS_RETRY_MS=20000 run list --project overdeck)
waited=$(( $(date +%s) - start ))
kill "$LATE" 2>/dev/null; wait "$LATE" 2>/dev/null
[[ "$rc" -eq 0 && "$waited" -ge 2 ]] \
  && ok "a collector that comes up late is waited for, not called unreachable" \
  || bad "a collector that comes up late is waited for, not called unreachable" "rc=$rc waited=${waited}s out=$(cat "$TMP/out")"

# --- and the wait is BOUNDED: a genuinely dead collector still fails, promptly ---
start=$(date +%s)
rc=$(COLLECTOR_URL="http://127.0.0.1:1" OD_REQUESTS_RETRY_MS=3000 run list --project overdeck)
waited=$(( $(date +%s) - start ))
[[ "$rc" -ne 0 && "$waited" -lt 15 ]] \
  && ok "a dead collector still fails, within its bound" \
  || bad "a dead collector still fails, within its bound" "rc=$rc waited=${waited}s err=$(cat "$TMP/err")"

echo
echo "PASS=$PASS FAIL=$FAIL"
[[ "$FAIL" -eq 0 ]]
