/**
 * "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 { posthog, DISTINCT_ID } from "./posthog";
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";
import { appendFeedback, latestRatingByKey, type FeedbackRating } from "./thread-feedback";
import { appendAction, type WeightSnapshot } from "./action-log";
import { loadDiscoveredThreads } from "./discovered-threads";

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 every resolution from RESOLUTIONS_FILE, newest first.
 *  No date cutoff — the UI shows all of them. */
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) {
      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(),
      });
    }
    // Append-only file: entries lower in the file are newer. Reverse then
    // stable-sort by date so newest is first.
    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;
  /** ISO date string — set for manual tasks only. Used by computeWeight()
   *  to apply the age-decay penalty after MANUAL_DECAY_AFTER_DAYS. */
  createdAt?: string;
  /** EXPERIMENT(weighting) Phase 3 — per-item rule stack. Each entry says
   *  which rule contributed how much. Surfaced in the UI as a tooltip on
   *  the weight badge so Pavel can see why an item scored what it did. */
  rules?: Array<{ rule: string; delta: number }>;
  /** Task classification for the three-queue model:
   *    decide — Pavel's input is the blocker (sign-off, kick-off, scope)
   *    do     — actionable item with execution path
   *    chase  — Pavel is waiting on someone else (48h+ rule applies) */
  taskClass?: "decide" | "do" | "chase";
};

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;
/** Linear's @-mention syntax. Highest-value signal — someone explicitly
 *  asked Pavel for input. Gated on source=Linear so generic uses of
 *  "@pavel" in briefs / notes don't trip it. */
const LINEAR_MENTION_RE = /@pavel\b/i;

/** Days after which a manual task's weight starts decaying. */
const MANUAL_DECAY_AFTER_DAYS = 28;
/** Lifetime postpone count at which manual task weight halves. */
const MANUAL_POSTPONE_DECAY_THRESHOLD = 3;

/** Dev-task title patterns from Linear. Items starting with these prefixes
 *  are engineering execution work — see `docs/activity-algorithm-feedback.md`
 *  for the 5/5 noise rating that drove this rule. */
const DEV_TASK_PREFIX_RE = /\b(BE|FE|FS|QA|DEVOPS|INFRA|API|UI|DB)\s*[:/]/i;
const DEV_TASK_KEYWORD_RE = /\b(backfill|migration|script|implementation|hotfix|patch|cleanup|sync.?back|reconciliation)\b/i;

/** Decide-bucket signals: items where Pavel's input/decision is the blocker. */
const DECIDE_KEYWORD_RE = /\b(decide|decision|approve|approval|sign.?off|kick.?off|reset target|prioritize|trade.?off|scope|gate|sign off|go\/no.?go)\b/i;

/** Chase-bucket signals: Pavel is waiting on someone else's action. */
const CHASE_PATTERN_RE = /\b(waiting on|awaiting (?:from|response)|asked .+ to|chased|followed up with|pinged|owe me|reply from)\b/i;

/** EXPERIMENT(weighting) Phase 3 — compute a weight + rule trace per item.
 *
 *  Returns BOTH the final weight AND a list of `{rule, delta}` entries
 *  describing every rule that fired. The UI surfaces this trace as a
 *  tooltip on the weight badge so the algorithm is debuggable in-place.
 *
 *  Active signals:
 *    - urgency-overdue:      −daysOff for overdue items (linear)
 *    - urgency-soon:         5·exp(−daysOff/7) for due-within-14d (continuous)
 *    - pavel-mention, status-keywords, verdict-escalate (existing attention)
 *    - manual-boost:         +3 for source=Manual
 *    - source-claude-output: −2 (100% noise in thumbs to date)
 *    - sprint-active-dev:    −8 for Linear dev-task patterns not assigned to Pavel
 *    - recency:              +2/(1+daysSinceTouch) — fresher = higher
 *    - habituation:          −2/day after 3 days running on the dashboard
 *    - thumbs-noise:         −10  (suppresses on next scan)
 *    - thumbs-signal:        +5   (promotes on next scan)
 *
 *  Decays still apply at the end (×0.5 each) for manual tasks:
 *    - manual-age-decay:     manual + age > MANUAL_DECAY_AFTER_DAYS
 *    - manual-snooze-decay:  manual + postponed ≥ MANUAL_POSTPONE_DECAY_THRESHOLD
 */
function computeWeight(
  it: NowItem,
  latestCheck?: { verdict?: string },
  ctx?: {
    postponeCount: number;
    todayMs: number;
    feedbackRating?: FeedbackRating;
    firstSeenAt?: string;
    lastSeenAt?: string;
  },
): { weight: number; rules: Array<{ rule: string; delta: number }> } {
  const rules: Array<{ rule: string; delta: number }> = [];
  const push = (rule: string, delta: number) => {
    if (Math.abs(delta) < 0.05) return;
    rules.push({ rule, delta: Math.round(delta * 10) / 10 });
  };

  // ── Urgency: linear for overdue, continuous exp-decay for due-soon ──
  const daysOff = it.daysOff ?? 0;
  let urgency = 0;
  if (daysOff < 0) {
    urgency = -daysOff;
    push(`overdue (${Math.abs(daysOff)}d late)`, urgency);
  } else if (daysOff >= 0 && daysOff <= 14) {
    urgency = 5 * Math.exp(-daysOff / 7);
    push(`urgency-soon (in ${daysOff}d)`, urgency);
  }

  // ── Attention signals ──
  const pavelMention = PAVEL_RE.test(it.text) ? 1 : 0;
  if (pavelMention) push("pavel-mention", 1);

  // Linear @-mention comment — strongest signal. Co-fires with pavel-mention
  // (additive) so the trace shows both, but this one carries the real weight.
  const isLinearSrc = /linear/i.test(it.source);
  const linearCommentMention = isLinearSrc && LINEAR_MENTION_RE.test(it.text) ? 1 : 0;
  if (linearCommentMention) push("linear-comment-mention", 10);

  const statusKeywords = countStatusKeywords(it.text);
  if (statusKeywords) push(`status-keywords (${statusKeywords})`, statusKeywords);

  const escalate = latestCheck?.verdict === "ESCALATE" ? 1 : 0;
  if (escalate) push("verdict-escalate", 1);

  // ── Source-level adjustments ──
  const isManual = it.source === "Manual";
  if (isManual) push("manual-boost", 3);

  let sourcePenalty = 0;
  if (/claude-output|claude_output|^files$/i.test(it.source)) {
    sourcePenalty = -2;
    push("source-claude-output (100% noise)", sourcePenalty);
  }

  // ── Sprint-active dev-task suppression (5/5 noise in feedback log) ──
  const isLinear = /linear/i.test(it.source);
  const isDevTask = DEV_TASK_PREFIX_RE.test(it.text) || DEV_TASK_KEYWORD_RE.test(it.text);
  const isPavelOwned = /\bpavel\b.{0,40}\b(owner|assigned|assignee)\b/i.test(it.text)
    || /\b(owner|assigned|assignee)\b.{0,40}\bpavel\b/i.test(it.text);
  let sprintPenalty = 0;
  if (isLinear && isDevTask && !isPavelOwned) {
    sprintPenalty = -8;
    push("sprint-active-dev", sprintPenalty);
  }

  // ── Recency boost ──
  let recencyBoost = 0;
  const touchedMs = it.mtime
    || (ctx?.lastSeenAt ? Date.parse(ctx.lastSeenAt) : NaN);
  if (isFinite(touchedMs) && ctx?.todayMs) {
    const daysSinceTouch = Math.max(0, (ctx.todayMs - touchedMs) / DAY);
    recencyBoost = 2 / (1 + daysSinceTouch);
    push(`recency (${daysSinceTouch.toFixed(1)}d ago)`, recencyBoost);
  }

  // ── Habituation penalty ──
  let habituationPenalty = 0;
  if (ctx?.firstSeenAt && ctx.todayMs) {
    const daysShown = Math.floor((ctx.todayMs - Date.parse(ctx.firstSeenAt)) / DAY);
    if (daysShown > 3) {
      habituationPenalty = -2 * (daysShown - 3);
      push(`habituation (${daysShown}d on dashboard)`, habituationPenalty);
    }
  }

  // ── Thumbs feedback — strongest signal once given ──
  let feedbackDelta = 0;
  if (ctx?.feedbackRating === "noise") {
    feedbackDelta = -10;
    push("thumbs-noise", feedbackDelta);
  } else if (ctx?.feedbackRating === "signal") {
    feedbackDelta = 5;
    push("thumbs-signal", feedbackDelta);
  }

  let weight = urgency + pavelMention + statusKeywords + escalate
    + (isManual ? 3 : 0) + sourcePenalty + sprintPenalty
    + recencyBoost + habituationPenalty + feedbackDelta;

  // ── Manual decays (multiplicative; apply last) ──
  if (isManual && ctx) {
    if (it.createdAt) {
      const ageDays = (ctx.todayMs - Date.parse(it.createdAt)) / DAY;
      if (ageDays > MANUAL_DECAY_AFTER_DAYS) {
        weight *= 0.5;
        push("manual-age-decay ×0.5", 0);
      }
    }
    if (ctx.postponeCount >= MANUAL_POSTPONE_DECAY_THRESHOLD) {
      weight *= 0.5;
      push("manual-postpone-decay ×0.5", 0);
    }
  }

  return { weight: Math.round(weight * 10) / 10, rules };
}

/** Three-queue classification — Decide / Do / Chase.
 *  Rule-based; deliberately conservative. The intent is to NOT mis-bucket
 *  rather than to catch every Decide item — Do is a safe default. */
function classifyTaskType(it: NowItem, latestCheck?: { verdict?: string }): "decide" | "do" | "chase" {
  // Chase: explicit waiting-on language
  if (CHASE_PATTERN_RE.test(it.text)) return "chase";

  // Decide: explicit decision keywords, OR Discovery-phase items where target is past
  if (DECIDE_KEYWORD_RE.test(it.text)) return "decide";
  if (/\bdiscovery\b/i.test(it.text) && (it.kind === "overdue" || it.kind === "today")) {
    return "decide";
  }
  // Pavel-owned + ESCALATE verdict → decide
  if (latestCheck?.verdict === "ESCALATE" && /\bpavel\b/i.test(it.text)) {
    const isDevTask = DEV_TASK_PREFIX_RE.test(it.text) || DEV_TASK_KEYWORD_RE.test(it.text);
    if (!isDevTask) return "decide";
  }

  return "do";
}
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,
        createdAt: t.createdAt,
      };
    });

    // 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,
        firstSeenAt: d.firstSeenAt,
        ...(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 3 — compute per-item weight + rule trace.
    //
    // New signals wired in this pass:
    //   - thumbs feedback (latestRatingByKey)         → -10 noise / +5 signal
    //   - recency (mtime / lastSeenAt)                → fresher = higher
    //   - habituation (firstSeenAt on discovered)     → -2/day after 3 days
    //   - sprint-active-dev pattern (Linear text)     → -8 penalty
    //   - claude-output source penalty                → -2
    //   - continuous urgency for due-soon items       → 5·exp(-daysOff/7)
    //
    // The rule trace is attached as `rules: [{rule, delta}]` so the UI
    // can show why an item scored what it did.
    const postponeCountByKey = new Map<string, number>();
    for (const p of loadPostpones()) {
      const untilMs = Date.parse(p.until);
      const snoozedMs = Date.parse(p.snoozedAt);
      if (!isFinite(snoozedMs) || !isFinite(untilMs) || untilMs <= snoozedMs) continue; // skip unsnoozes
      const k = postponeKey(p);
      postponeCountByKey.set(k, (postponeCountByKey.get(k) ?? 0) + 1);
    }
    const feedbackByKey = latestRatingByKey();
    // For habituation we need firstSeenAt — discovered items have it cached,
    // memory/manual items don't, so we treat them as "always fresh".
    const discoveredMap = loadDiscoveredThreads();
    const weighted = unique.map((it) => {
      const k = itemKey(it.file, it.line, it.text);
      const latestCheck = checkHistoryForFilter[k]?.[0];
      const pKey = postponeKey({ file: it.file, line: it.line, itemText: it.text } as any);
      const postponeCount = postponeCountByKey.get(pKey) ?? 0;
      const feedbackRating = feedbackByKey[k];
      const discovered = discoveredMap[k];
      const { weight, rules } = computeWeight(it, latestCheck, {
        postponeCount,
        todayMs,
        feedbackRating,
        firstSeenAt: discovered?.firstSeenAt,
        lastSeenAt: discovered?.lastSeenAt,
      });
      const taskClass = classifyTaskType(it, latestCheck);
      return { ...it, weight, rules, taskClass };
    });

    // 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,
      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;
      weightSnapshot?: WeightSnapshot;
    }>();
    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");
      appendAction({
        itemKey: itemKey(body.file, body.line, body.itemText),
        file: body.file, line: body.line, text: body.itemText,
        source: body.source, kind: body.kind,
        action: "resolve", at: new Date().toISOString(),
        weightSnapshot: body.weightSnapshot,
      });
      posthog.capture({
        distinctId: DISTINCT_ID,
        event: "activity item resolved",
        properties: {
          source: body.source,
          kind: body.kind,
          weight: body.weightSnapshot?.weight,
        },
      });
      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);
    posthog.capture({
      distinctId: DISTINCT_ID,
      event: "manual task created",
      properties: {
        has_due_date: !!due,
      },
    });
    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);
    posthog.capture({
      distinctId: DISTINCT_ID,
      event: "manual task deleted",
      properties: { task_id: id },
    });
    return c.json({ ok: true });
  });

  // ─── Thread surfacing feedback (thumbs up / down) ────────────────
  app.post("/api/now/feedback", async (c) => {
    const body = await c.req.json<{
      file: string; line: number; source: string; kind: string;
      itemText: string; rating: FeedbackRating;
      checkSnapshot?: { verdict: string | null; rationale: string; suggested: string; checkedAt: string };
    }>();
    if (body.rating !== "signal" && body.rating !== "noise") {
      return c.json({ ok: false, error: "rating must be 'signal' or 'noise'" }, 400);
    }
    const key = itemKey(body.file, body.line, body.itemText);
    appendFeedback({
      itemKey: key,
      file: body.file,
      line: body.line,
      text: body.itemText,
      source: body.source,
      kind: body.kind,
      rating: body.rating,
      at: new Date().toISOString(),
      checkSnapshot: body.checkSnapshot,
    });
    posthog.capture({
      distinctId: DISTINCT_ID,
      event: "thread feedback submitted",
      properties: {
        rating: body.rating,
        source: body.source,
        kind: body.kind,
        has_check_snapshot: !!body.checkSnapshot,
        check_verdict: body.checkSnapshot?.verdict ?? null,
      },
    });
    return c.json({ ok: true });
  });

  // ─── Force-trigger the background check tick ────────────────────
  app.post("/api/now/scan-now", async (c) => {
    posthog.capture({
      distinctId: DISTINCT_ID,
      event: "scan triggered",
      properties: { trigger: "manual" },
    });
    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();
    if (aborted) {
      posthog.capture({
        distinctId: DISTINCT_ID,
        event: "scan aborted",
        properties: {},
      });
    }
    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;
      weightSnapshot?: WeightSnapshot;
    }>();
    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) {
        posthog.capture({
          distinctId: DISTINCT_ID,
          event: "work on started",
          properties: {
            mode: "resume",
            source: body.source,
            kind: body.kind,
            force_new: false,
            has_prior_check: !!latest,
          },
        });
        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");

    appendAction({
      itemKey: body.threadKey,
      file: body.file, line: body.line, text: body.threadText,
      source: body.source, kind: body.kind,
      action: "work-on", at: new Date().toISOString(),
      weightSnapshot: body.weightSnapshot,
    });
    posthog.capture({
      distinctId: DISTINCT_ID,
      event: "work on started",
      properties: {
        mode: "new",
        source: body.source,
        kind: body.kind,
        force_new: !!body.forceNew,
        has_prior_check: !!latest,
      },
    });
    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;
      weightSnapshot?: WeightSnapshot;
    }>();
    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);
    if (body.days > 0) {
      appendAction({
        itemKey: itemKey(body.file, body.line, body.itemText),
        file: body.file, line: body.line, text: body.itemText,
        source: body.source, kind: body.kind,
        action: "postpone", at: new Date().toISOString(),
        postponeDays: body.days,
        weightSnapshot: body.weightSnapshot,
      });
      posthog.capture({
        distinctId: DISTINCT_ID,
        event: "activity item postponed",
        properties: {
          source: body.source,
          kind: body.kind,
          days: body.days,
          weight: body.weightSnapshot?.weight,
        },
      });
    }
    return c.json({ ok: true, until: entry.until });
  });
}
