/**
 * "What needs your attention" dashboard.
 *
 * Three storage layers:
 *   - MEMORY_DIR/MEMORY.md + project_*.md — read-only source of truth (Pavel maintains)
 *   - RESOLUTIONS_FILE (.md, append-only) — log of how items were resolved
 *   - POSTPONES_FILE (.json, replace-by-key) — active snoozes
 *
 * Endpoints:
 *   GET  /api/now             — scan + filter + bucket items, return dashboard payload
 *   POST /api/now/resolve     — append a resolution; matching items get filtered next /api/now
 *   POST /api/now/postpone    — save a snooze entry; with days < 0 = unsnooze
 */
import type { Hono } from "hono";
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync } from "node:fs";
import { appendFile } from "node:fs/promises";
import { join } from "node:path";
import { MEMORY_DIR, RESOLUTIONS_FILE, POSTPONES_FILE } from "./paths";
import { loadCheckHistory, itemKey, type CachedCheck } from "./check-cache";
import { loadThreadChats, findLatestActiveChatForThread, type ThreadChat } from "./thread-chats";
import { loadManualTasks, saveManualTasks, classifyTask, type ManualTask } from "./manual-tasks";
import { runCheckSchedulerTick, abortScan } from "./check-scheduler";
import { getScanState } from "./scan-state";
import { listDiscovered, STALE_AFTER_DAYS as DISCOVERY_STALE_AFTER_DAYS } from "./discovered-threads";
import { mergeThreads } from "./thread-merge";

const DAY = 86_400_000;

// ─── postpones (JSON) ────────────────────────────────────────────────

export type PostponeEntry = {
  file: string;
  line: number;
  source: string;
  kind: string;
  itemText: string;
  snoozedAt: string;
  until: string;
  note: string;
};

export function loadPostpones(): PostponeEntry[] {
  if (!existsSync(POSTPONES_FILE)) return [];
  try {
    const data = JSON.parse(readFileSync(POSTPONES_FILE, "utf8"));
    return Array.isArray(data) ? data : [];
  } catch { return []; }
}

function savePostpones(list: PostponeEntry[]) {
  writeFileSync(POSTPONES_FILE, JSON.stringify(list, null, 2));
}

const postponeKey = (p: PostponeEntry) =>
  `${p.file}:${p.line}|${p.itemText.slice(0, 80)}`;

/** Items whose LATEST postpone event has `until` today or in the future.
 *  Schema changed in EXPERIMENT(weighting) Phase 1a from "1 entry per
 *  key, overwrite on write" to "append-per-event"; we now find the
 *  most recent entry per key before deciding active-status. */
function activePostponedKeys(): Set<string> {
  const today = new Date(); today.setHours(0, 0, 0, 0);
  const latest = new Map<string, PostponeEntry>();
  for (const p of loadPostpones()) {
    const k = postponeKey(p);
    const existing = latest.get(k);
    if (!existing || p.snoozedAt > existing.snoozedAt) latest.set(k, p);
  }
  const set = new Set<string>();
  for (const [k, p] of latest) {
    const until = new Date(p.until); until.setHours(0, 0, 0, 0);
    if (until.getTime() >= today.getTime()) set.add(k);
  }
  return set;
}

/** EXPERIMENT(weighting) Phase 1a — snooze-fatigue suppression.
 *  Items the user has actively snoozed K+ times in the last D days
 *  signal "stop showing me this for a while". Unsnooze events (the
 *  `days = -1` convention where `until <= snoozedAt`) don't count;
 *  only forward-dated snoozes do. */
function fatiguedSnoozeKeys(thresholdK: number, windowDays: number): Set<string> {
  const sinceMs = Date.now() - windowDays * DAY;
  const counts = new Map<string, number>();
  for (const p of loadPostpones()) {
    const snoozedMs = Date.parse(p.snoozedAt);
    const untilMs = Date.parse(p.until);
    if (!isFinite(snoozedMs) || snoozedMs < sinceMs) continue;
    if (!isFinite(untilMs) || untilMs <= snoozedMs) continue; // skip unsnoozes
    const k = postponeKey(p);
    counts.set(k, (counts.get(k) || 0) + 1);
  }
  const set = new Set<string>();
  for (const [k, n] of counts) if (n >= thresholdK) set.add(k);
  return set;
}

// ─── resolutions (Markdown, append-only) ─────────────────────────────

/**
 * Parse RESOLUTIONS_FILE and return the set of {file:line|item-text} keys
 * we've already resolved, so the dashboard can filter them out.
 *
 * Regex allows any content (e.g. **Source:**) between the date header
 * and **Item:**.
 */
function loadResolvedKeys(): Set<string> {
  const set = new Set<string>();
  if (!existsSync(RESOLUTIONS_FILE)) return set;
  try {
    const text = readFileSync(RESOLUTIONS_FILE, "utf8");
    const re = /^### \d{4}-\d{2}-\d{2}\s+(\S+):(\d+)[\s\S]*?\*\*Item:\*\*\s+(.+?)$/gm;
    let m;
    while ((m = re.exec(text)) !== null) {
      set.add(`${m[1]}:${m[2]}|${m[3].slice(0, 80)}`);
    }
  } catch {}
  return set;
}

/** Parse the last-14-days of resolutions for the dashboard "Recently resolved" section. */
function recentResolved() {
  const recent: Array<{ date: string; file: string; line: number; source: string; kind: string; itemText: string; resolution: string }> = [];
  if (!existsSync(RESOLUTIONS_FILE)) return recent;
  try {
    const text = readFileSync(RESOLUTIONS_FILE, "utf8");
    const re = /^### (\d{4}-\d{2}-\d{2})\s+(\S+):(\d+)\s*\n\*\*Source:\*\*\s+(.+?)\s+·\s+\*\*Kind:\*\*\s+(\S+)\s*\n\*\*Item:\*\*\s+(.+?)\n\n([\s\S]*?)\n\n---/gm;
    let m;
    while ((m = re.exec(text)) !== null) {
      const dt = new Date(m[1]).getTime();
      if (Date.now() - dt < 14 * DAY) {
        recent.push({
          date: m[1],
          file: m[2],
          line: parseInt(m[3], 10),
          source: m[4].trim(),
          kind: m[5].trim(),
          itemText: m[6].trim(),
          resolution: m[7].trim(),
        });
      }
    }
    // Resolutions log is append-only: entries lower in the file are newer.
    // Reverse before sort so that within the same date, the most recently
    // appended (= most recently resolved) is first. Array.sort is stable
    // in modern JS, so the reverse order survives the date sort.
    recent.reverse();
    recent.sort((a, b) => b.date.localeCompare(a.date));
  } catch {}
  return recent;
}

// ─── workspace scanner ───────────────────────────────────────────────

export type NowItem = {
  kind: "overdue" | "today" | "upcoming" | "stalled" | "open";
  text: string;
  source: string;
  file: string;
  line: number;
  date?: string;
  daysOff?: number;
  mtime?: number;
  /** Set on discovered-cache items not seen in the last N runs. The frontend
   *  dims them; resolve/postpone still works. */
  stale?: boolean;
  /** EXPERIMENT(weighting) Phase 2 — computed score used to sort
   *  intra-kind. Phantom field at scan time, populated at /api/now
   *  decoration time using uniform coefficients (all = 1.0). See
   *  scan-discovery.ts top-of-file experiment block. */
  weight?: number;
};

const STATUS_RE = /\b(due|by|deadline|awaiting|expecting|expected|blocked|blocker|decision needed|open question|tbd|to be decided|follow.?up|need.s? to|must|owe|shipping|ships?|launch)\b/i;

/** EXPERIMENT(weighting) Phase 2 — case-insensitive set of distinct
 *  status keywords found in the item text. Returns a count (not the
 *  matches themselves) so the weight calc stays cheap. */
const STATUS_KEYWORDS_G = /\b(due|by|deadline|awaiting|expecting|expected|blocked|blocker|decision|tbd|follow.?up|need.s? to|must|owe|shipping|launch|escalate|stalled|pending)\b/gi;
function countStatusKeywords(text: string): number {
  const seen = new Set<string>();
  let m: RegExpExecArray | null;
  STATUS_KEYWORDS_G.lastIndex = 0;
  while ((m = STATUS_KEYWORDS_G.exec(text)) !== null) {
    seen.add(m[0].toLowerCase());
  }
  return seen.size;
}
const PAVEL_RE = /\bpavel\b/i;

/** EXPERIMENT(weighting) Phase 2 — compute a weight per item using
 *  the schema described in scan-discovery.ts (Group 2 urgency × source
 *  coefficient + Group 3 attention sum). All coefficients are 1.0 at
 *  Phase 2 by design — calibration happens in Phase 4 after the
 *  2-week observation window captures real outcome data.
 *
 *  Signals currently fed in:
 *    - days_overdue:    max(0, -daysOff)                       (urgency)
 *    - pavel_mention:   binary, /\bpavel\b/i on item.text      (attention)
 *    - status_keywords: count of distinct status-words present (attention)
 *    - verdict_escalate:binary, latest auto-check = ESCALATE   (attention)
 *
 *  Deferred:
 *    - recency_factor:  needs unified daysSinceTouch across the three
 *                       item types (scanned/manual/discovered). The
 *                       kind hierarchy already does heavy lifting here.
 *    - similar_resolved penalty: requires text-similarity per item
 *                       against the resolution log; quadratic, defer
 *                       until calibration. */
function computeWeight(it: NowItem, latestCheck?: { verdict?: string }): number {
  const daysOverdue = Math.max(0, -(it.daysOff ?? 0));
  const pavelMention = PAVEL_RE.test(it.text) ? 1 : 0;
  const statusKeywords = countStatusKeywords(it.text);
  const escalate = latestCheck?.verdict === "ESCALATE" ? 1 : 0;
  // Source coefficient is 1.0 uniformly at Phase 2; the function
  // exists so calibration can swap in per-source values without
  // changing the call site.
  const sourceCoef = 1.0;
  const urgency = daysOverdue;
  const attention = pavelMention + statusKeywords + escalate;
  return urgency * sourceCoef + attention;
}
const DATE_RES = [
  /\b(\d{4}-\d{2}-\d{2})\b/,                                                                                           // ISO
  /\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+(\d{1,2})(?:st|nd|rd|th)?(?:,?\s+(\d{4}))?\b/i,         // "May 5, 2026"
  /\b(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*(?:\s+(\d{4}))?\b/i,                           // "5 May 2026"
];

function parseDateFromLine(line: string): { date: Date; raw: string } | null {
  for (const re of DATE_RES) {
    const m = line.match(re);
    if (!m) continue;
    const t = Date.parse(m[0]);
    if (!isNaN(t)) return { date: new Date(t), raw: m[0] };
  }
  return null;
}

/** Walks MEMORY_DIR (MEMORY.md + project_*.md + decision_*.md), produces NowItems. */
export function scanWorkspace(): NowItem[] {
  const today = new Date(); today.setHours(0, 0, 0, 0);
  const todayMs = today.getTime();
  const items: NowItem[] = [];

  let files: string[] = [];
  try {
    files = readdirSync(MEMORY_DIR).filter(
      (f) => f === "MEMORY.md" || f.startsWith("project_") || f.startsWith("decision_")
    );
  } catch { return items; }

  for (const f of files) {
    const fp = join(MEMORY_DIR, f);
    let text = "";
    let mtime = 0;
    try {
      text = readFileSync(fp, "utf8");
      mtime = Math.floor(statSync(fp).mtimeMs);
    } catch { continue; }
    const ageDays = Math.floor((Date.now() - mtime) / DAY);

    // Source label = first H1 if present, else filename humanized
    const m1 = text.match(/^# (.+)$/m);
    const source = m1 ? m1[1].trim() : f.replace(/^project_/, "").replace(/\.md$/, "").replace(/_/g, " ");

    const lines = text.split("\n");
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i].trim();
      if (!line) continue;
      if (/^#{1,6}\s/.test(line)) continue; // skip headers

      const hasStatus = STATUS_RE.test(line);
      const dateP = parseDateFromLine(line);

      if (dateP) {
        const daysOff = Math.round((dateP.date.getTime() - todayMs) / DAY);
        if (daysOff < 0 && hasStatus) {
          items.push({ kind: "overdue", text: line, source, file: f, line: i + 1, date: dateP.raw, daysOff });
        } else if (daysOff === 0 && hasStatus) {
          items.push({ kind: "today", text: line, source, file: f, line: i + 1, date: dateP.raw, daysOff });
        } else if (daysOff > 0 && daysOff <= 14 && hasStatus) {
          items.push({ kind: "upcoming", text: line, source, file: f, line: i + 1, date: dateP.raw, daysOff });
        }
        continue;
      }

      // Open loop: status keyword without a parseable date
      if (
        hasStatus &&
        line.length > 12 &&
        line.length < 280 &&
        !/^[-*]\s*\[[a-zA-Z]/.test(line) && // skip memory's link-list index
        !line.startsWith("- [")
      ) {
        if (/^[-*•]\s/.test(line) || /:\s/.test(line) || hasStatus) {
          items.push({ kind: "open", text: line, source, file: f, line: i + 1 });
        }
      }
    }

    // Stalled: project file untouched 14+ days
    if (f.startsWith("project_") && ageDays >= 14) {
      const desc = lines.find((l) => l.trim() && !l.startsWith("#") && !l.startsWith("---"))?.trim() || source;
      items.push({ kind: "stalled", text: desc.slice(0, 200), source, file: f, line: 1, mtime, daysOff: -ageDays });
    }
  }

  return items;
}

// ─── routes ──────────────────────────────────────────────────────────

export function registerNowRoutes(app: Hono) {
  app.get("/api/now", (c) => {
    const resolvedKeys = loadResolvedKeys();
    const postponedKeys = activePostponedKeys();
    const today = new Date(); today.setHours(0, 0, 0, 0);
    const todayMs = today.getTime();

    const scanned = scanWorkspace();

    // Manual tasks live in MANUAL_TASKS_FILE — same item-key shape as scanned
    // items so resolve/postpone match. file = "manual_tasks.json", line = ix+1.
    const manualTasks = loadManualTasks();
    const manualItems: NowItem[] = manualTasks.map((t) => {
      const kind = classifyTask(t, todayMs);
      const daysOff = t.due ? Math.round((new Date(t.due).setHours(0,0,0,0) - todayMs) / 86_400_000) : undefined;
      // Stable item-key derived from the task's UUID so resolve/postpone keys
      // survive deletions and reorders of other tasks.
      const line = parseInt(t.id.replace(/-/g, "").slice(0, 8), 16) || 1;
      return {
        kind,
        text: t.text,
        source: "Manual",
        file: "manual_tasks.json",
        line,
        date: t.due,
        daysOff,
      };
    });

    // Discovered threads from past scan-now runs. Persisted in
    // discovered_threads.json so a flaky agent run doesn't wipe Activity.
    // Items not seen in N runs are flagged stale=true and dimmed by the
    // frontend; never auto-deleted (resolve/postpone is the only delete).
    const discoveredCache = listDiscovered();
    const staleCutoff = Date.now() - DISCOVERY_STALE_AFTER_DAYS * 86_400_000;
    const discoveredItems: NowItem[] = discoveredCache.map((d) => {
      const lastSeen = Date.parse(d.lastSeenAt);
      const stale = !isNaN(lastSeen) && lastSeen < staleCutoff;
      return {
        kind: d.kind,
        text: d.text,
        source: d.source,
        file: d.file,
        line: d.line,
        date: d.date,
        daysOff: d.daysOff,
        ...(stale ? { stale: true } : {}),
      } as NowItem;
    });

    const items = [...scanned, ...manualItems, ...discoveredItems];

    // EXPERIMENT(weighting) — Phase 0 / TODO(gating) first ship.
    // See server/lib/scan-discovery.ts top-of-file experiment block for
    // the full plan + research doc reference.
    //
    // CLOSE-verdict suppression: any item whose latest auto-check
    // returned CLOSE within the TTL window is hidden from Activity.
    // Expires naturally — once the latest CLOSE is older than
    // CLOSE_VERDICT_TTL_DAYS the item becomes re-eligible (so a stale
    // CLOSE doesn't permanently bury a thread that's resurfaced).
    const CLOSE_VERDICT_TTL_DAYS = 7;
    const closeCutoffMs = Date.now() - CLOSE_VERDICT_TTL_DAYS * DAY;
    const closeSuppressKeys = new Set<string>();
    const checkHistoryForFilter = loadCheckHistory();
    for (const [tk, checks] of Object.entries(checkHistoryForFilter)) {
      const latest = checks[0];
      if (latest?.verdict === "CLOSE" && Date.parse(latest.checkedAt) >= closeCutoffMs) {
        closeSuppressKeys.add(tk);
      }
    }

    // EXPERIMENT(weighting) Phase 1a — snooze-fatigue: items snoozed
    // 2+ times in the last 30 days are hidden. The active-postpone
    // filter only catches "currently snoozed"; this rule extends the
    // suppression so the same item doesn't re-appear the day after
    // each short snooze expires when the user has clearly signalled
    // disinterest. Default K=2, D=30 (research doc §Schema, §Recs).
    const SNOOZE_FATIGUE_K = 2;
    const SNOOZE_FATIGUE_WINDOW_DAYS = 30;
    const fatiguedKeys = fatiguedSnoozeKeys(SNOOZE_FATIGUE_K, SNOOZE_FATIGUE_WINDOW_DAYS);

    // Filter resolved + actively-postponed + recently-CLOSE'd + snooze-fatigued
    const filtered = items.filter((it) => {
      const k = `${it.file}:${it.line}|${it.text.slice(0, 80)}`;
      return !resolvedKeys.has(k)
        && !postponedKeys.has(k)
        && !closeSuppressKeys.has(k)
        && !fatiguedKeys.has(k);
    });

    // Cross-source dedup: same itemKey OR same Linear/Notion ref appearing
    // in MEMORY + a discovered counterpart. Pass MEMORY items first so
    // their annotation context wins.
    const unique = mergeThreads(filtered);

    // EXPERIMENT(weighting) Phase 2 — compute per-item weight using
    // uniform coefficients. Reuses the check history already loaded
    // for the Phase 0 filter. Sort within each kind by weight
    // descending so the highest-signal items survive the per-kind cap.
    const weighted = unique.map((it) => {
      const k = itemKey(it.file, it.line, it.text);
      const latestCheck = checkHistoryForFilter[k]?.[0];
      return { ...it, weight: computeWeight(it, latestCheck) };
    });

    // Caps tuned after discovery layer landed — discovery can surface up to
    // 30 items per scan plus the persistent cache, so the old MEMORY-only
    // budgets were silently dropping real items.
    const byKindSorted = (kind: string, n: number) =>
      weighted
        .filter((i) => i.kind === kind)
        .sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
        .slice(0, n);
    const out = [
      ...byKindSorted("overdue", 20),
      ...byKindSorted("today", 16),
      ...byKindSorted("upcoming", 16),
      ...byKindSorted("stalled", 16),
      ...byKindSorted("open", 30),
    ];

    // Decorate each item with check history (newest first) + linked chats.
    // Reuse the load already done for the CLOSE-verdict filter above.
    const checkHistory = checkHistoryForFilter;
    const threadChats = loadThreadChats();
    const decorated = out.map((it) => {
      const k = itemKey(it.file, it.line, it.text);
      const history = checkHistory[k] || [];
      const chats = threadChats[k] || [];
      const next: NowItem & { check?: CachedCheck; checkHistory?: CachedCheck[]; linkedChats?: ThreadChat[]; threadKey: string } = {
        ...it,
        threadKey: k,
      };
      if (history.length) {
        next.check = history[0];
        next.checkHistory = history;
      }
      if (chats.length) next.linkedChats = chats;
      return next;
    });

    const recent = recentResolved();
    // Snoozed-section list: dedup to the latest event per key (schema
    // change in Phase 1a allows multiple events per key now), then
    // filter to active ones, sort by `until` ascending.
    const t = new Date(); t.setHours(0, 0, 0, 0);
    const latestByKey = new Map<string, PostponeEntry>();
    for (const p of loadPostpones()) {
      const k = postponeKey(p);
      const existing = latestByKey.get(k);
      if (!existing || p.snoozedAt > existing.snoozedAt) latestByKey.set(k, p);
    }
    const postponedActive = [...latestByKey.values()]
      .filter((p) => {
        const u = new Date(p.until); u.setHours(0, 0, 0, 0);
        return u.getTime() >= t.getTime();
      })
      .sort((a, b) => a.until.localeCompare(b.until));

    return c.json({
      today: today.toISOString().slice(0, 10),
      items: decorated,
      counts: {
        overdue: weighted.filter((i) => i.kind === "overdue").length,
        today: weighted.filter((i) => i.kind === "today").length,
        upcoming: weighted.filter((i) => i.kind === "upcoming").length,
        stalled: weighted.filter((i) => i.kind === "stalled").length,
        open: weighted.filter((i) => i.kind === "open").length,
        resolvedTotal: resolvedKeys.size,
        resolvedRecent: recent.length,
        postponed: postponedKeys.size,
      },
      recentResolved: recent.slice(0, 8),
      postponed: postponedActive,
    });
  });

  app.post("/api/now/resolve", async (c) => {
    const body = await c.req.json<{
      file: string; line: number; source: string; kind: string;
      itemText: string; resolution: string;
    }>();
    const today = new Date().toISOString().slice(0, 10);
    const block = [
      "",
      `### ${today}  ${body.file}:${body.line}`,
      `**Source:** ${body.source}  ·  **Kind:** ${body.kind}`,
      `**Item:** ${body.itemText.replace(/\n+/g, " ").trim()}`,
      "",
      body.resolution.trim(),
      "",
      "---",
    ].join("\n");

    const header = !existsSync(RESOLUTIONS_FILE)
      ? "# Resolutions\n\nLog of how items surfaced on the 'What needs your attention' dashboard were resolved. Newest at bottom.\n\n"
      : "";

    try {
      await appendFile(RESOLUTIONS_FILE, header + block + "\n");
      return c.json({ ok: true, file: RESOLUTIONS_FILE });
    } catch (err: any) {
      return c.json({ ok: false, error: err?.message || String(err) }, 500);
    }
  });

  // ─── Manual tasks ───────────────────────────────────────────────
  app.post("/api/now/tasks", async (c) => {
    const body = await c.req.json<{ text: string; due?: string }>();
    const text = (body.text || "").trim();
    if (!text) return c.json({ ok: false, error: "text required" }, 400);
    if (text.length > 600) return c.json({ ok: false, error: "text too long" }, 400);
    const due = (body.due || "").trim() || undefined;
    if (due && !/^\d{4}-\d{2}-\d{2}$/.test(due)) {
      return c.json({ ok: false, error: "due must be YYYY-MM-DD" }, 400);
    }
    const list = loadManualTasks();
    const task: ManualTask = {
      id: crypto.randomUUID(),
      text,
      due,
      createdAt: new Date().toISOString().slice(0, 10),
    };
    list.push(task);
    saveManualTasks(list);
    return c.json({ ok: true, task });
  });

  app.delete("/api/now/tasks/:id", async (c) => {
    const id = c.req.param("id");
    const list = loadManualTasks().filter((t) => t.id !== id);
    saveManualTasks(list);
    return c.json({ ok: true });
  });

  // ─── Force-trigger the background check tick ────────────────────
  app.post("/api/now/scan-now", async (c) => {
    const result = await runCheckSchedulerTick();
    return c.json(result);
  });

  // ─── Scan-now run state — used by the dashboard to rehydrate the
  //     "Scanning…" indicator after a page reload. The Claude SDK call
  //     keeps running on the server; the client just needs to know.
  app.get("/api/now/scan-status", (c) => c.json(getScanState()));

  // ─── Abort an in-flight scan (user pressed Stop). ────────────────
  app.post("/api/now/scan-abort", (c) => {
    const aborted = abortScan();
    return c.json({ ok: true, aborted });
  });

  // ─── "Work on this" — resume the latest linked chat, or seed a new one ────
  //
  // Default behavior: if this thread already has a non-archived linked chat,
  // return its sessionId so the frontend resumes that conversation (full
  // memory preserved). Otherwise, return a fresh seed prompt for a new chat.
  //
  // `forceNew: true` skips the resume check — used by the "+ Start fresh"
  // affordance in the chat header.
  //
  // Both response shapes include `contextCard` — a structured snapshot of
  // the thread state — which the frontend renders as a collapsible "things
  // to know" card above the conversation (replaces the wall of context
  // formerly dumped as a user-message bubble).
  app.post("/api/now/work-on", async (c) => {
    const body = await c.req.json<{
      threadKey: string;
      threadText: string;
      file: string;
      line: number;
      source: string;
      kind: string;
      daysOff?: number;
      includeLatestCheck?: boolean;
      forceNew?: boolean;
    }>();
    if (!body.threadKey || !body.threadText) {
      return c.json({ ok: false, error: "missing threadKey or threadText" }, 400);
    }

    // Build the structured context card (always sent — frontend decides
    // how to present it for resume vs new).
    const history = loadCheckHistory();
    const latest = (body.includeLatestCheck !== false ? history[body.threadKey]?.[0] : null) ?? null;
    const contextCard = {
      threadText: body.threadText,
      source: body.source,
      sourceLocation: `${body.file}:${body.line}`,
      kind: body.kind,
      daysOff: body.daysOff,
      lastCheck: latest ? {
        ageHours: Math.round((Date.now() - Date.parse(latest.checkedAt)) / 3600000),
        verdict: latest.verdict,
        rationale: latest.rationale,
        evidence: latest.evidence,
        suggested: latest.suggested,
      } : null,
    };
    const threadLabel = `Working on: ${body.threadText.slice(0, 80)}`;

    // Resume path: if a non-archived chat exists and forceNew wasn't set.
    if (!body.forceNew) {
      const existing = findLatestActiveChatForThread(body.threadKey);
      if (existing) {
        return c.json({
          ok: true,
          mode: "resume" as const,
          threadKey: body.threadKey,
          threadLabel,
          contextCard,
          existingSessionId: existing.sessionId,
          existingProjectRaw: existing.projectRaw,
          existingCwd: existing.cwd,
        });
      }
    }

    // New-chat path: build the full prompt for the SDK and a brief
    // user-visible bubble. The full prompt still includes the context
    // (so Claude has it on turn 1) but the user bubble shows just the ask.
    //
    // Framing notes (see commit msg for rationale):
    //  - The auto-check's output is presented as a HYPOTHESIS, not a
    //    brief. The previous prompt let Claude over-anchor on the
    //    "suggested next" and invent deadlines / message templates.
    //  - Turn 1 must be either a focused question OR a one-paragraph
    //    take — never a multi-step action plan or drafted artifacts.
    let latestCheckBlock = "";
    if (latest) {
      latestCheckBlock = [
        ``,
        `An auto-check agent already analyzed this ${contextCard.lastCheck!.ageHours}h ago. Treat its output as a *hypothesis*, not a brief:`,
        ``,
        `**Auto-check verdict:** ${latest.verdict ?? "no verdict"}`,
        latest.rationale ? `**Rationale:** ${latest.rationale}` : "",
        latest.evidence.length
          ? `**Evidence cited:**\n${latest.evidence.map((e) => `- ${e}`).join("\n")}`
          : "",
        latest.suggested ? `**Suggested next (the auto-check's guess):** ${latest.suggested}` : "",
      ].filter(Boolean).join("\n");
    }
    const prompt = [
      `This is an open thread from my dashboard that I need to move forward.`,
      ``,
      `**Thread:** ${body.threadText.slice(0, 1500)}`,
      `**Source:** ${body.file}:${body.line} (${body.source})`,
      `**Status:** ${body.kind}${body.daysOff ? ` (${Math.abs(body.daysOff)}d ${body.daysOff < 0 ? "late" : "off"})` : ""}`,
      latestCheckBlock,
      ``,
      `Before suggesting actions:`,
      `- If the verdict or suggested-next assumes facts you can't verify from the evidence, name the assumption out loud.`,
      `- If you need information from me to give a useful answer, ask ONE focused question. Don't multi-step.`,
      `- Don't draft messages, deadlines, dates, names, or templates unless I explicitly ask for them.`,
      `- If you have enough context already, give me a one-paragraph "what I'd do next" — not a plan, not a checklist, not a section breakdown.`,
    ].filter(Boolean).join("\n");

    return c.json({
      ok: true,
      mode: "new" as const,
      threadKey: body.threadKey,
      threadLabel,
      contextCard,
      prompt,
      userText: "Help me make progress on this.",
    });
  });

  // ─── Look up a thread's contextCard by threadKey ─────────────────
  // Used by HistoryPanel.resume(): when a chat carries a threadKey,
  // we need to rebuild the ContextCard for the chat surface without
  // the dashboard item being in scope. Walks discovered_threads to
  // find the entry whose itemKey matches, then enriches with the
  // latest auto-check verdict from check-history.
  app.get("/api/now/thread-context", (c) => {
    const threadKey = c.req.query("threadKey");
    if (!threadKey) return c.json({ ok: false, error: "missing threadKey" }, 400);

    // Find the discovered thread whose itemKey matches.
    let match: { file: string; line: number; text: string; source: string; kind: string; daysOff?: number } | null = null;
    for (const d of listDiscovered()) {
      if (itemKey(d.file, d.line, d.text) === threadKey) {
        match = { file: d.file, line: d.line, text: d.text, source: d.source, kind: d.kind, daysOff: d.daysOff };
        break;
      }
    }
    // Fallback: thread_chats has a label like "Working on: <text-prefix>"
    // which is enough to draw a minimal card if the discovered_threads
    // file has been pruned but the chat still references the threadKey.
    if (!match) {
      const map = loadThreadChats();
      const chats = map[threadKey];
      if (chats && chats.length > 0 && chats[0].label) {
        const text = chats[0].label.replace(/^Working on:\s*/, "");
        match = { file: "(unknown)", line: 0, text, source: "(unknown)", kind: "(unknown)" };
      }
    }
    if (!match) return c.json({ ok: false, error: "not found" }, 404);

    const history = loadCheckHistory();
    const latest = history[threadKey]?.[0] ?? null;
    return c.json({
      ok: true,
      contextCard: {
        threadText: match.text,
        source: match.source,
        sourceLocation: `${match.file}:${match.line}`,
        kind: match.kind,
        daysOff: match.daysOff,
        lastCheck: latest ? {
          ageHours: Math.round((Date.now() - Date.parse(latest.checkedAt)) / 3600000),
          verdict: latest.verdict,
          rationale: latest.rationale,
          evidence: latest.evidence,
          suggested: latest.suggested,
        } : null,
      },
    });
  });

  app.post("/api/now/postpone", async (c) => {
    const body = await c.req.json<{
      file: string; line: number; source: string; kind: string;
      itemText: string; days: number; note?: string;
    }>();
    const today = new Date();
    const until = new Date(today.getTime() + body.days * DAY);
    const entry: PostponeEntry = {
      file: body.file,
      line: body.line,
      source: body.source,
      kind: body.kind,
      itemText: body.itemText,
      snoozedAt: today.toISOString().slice(0, 10),
      until: until.toISOString().slice(0, 10),
      note: (body.note || "").slice(0, 1000),
    };
    // Append-per-event (EXPERIMENT(weighting) Phase 1a schema change).
    // Previously this overwrote any earlier postpone for the same key,
    // which destroyed the history needed for snooze-fatigue counting.
    // Backward compatible — old files have at most one entry per key
    // and continue to work identically.
    const list = loadPostpones();
    list.push(entry);
    savePostpones(list);
    return c.json({ ok: true, until: entry.until });
  });
}
