#!/usr/bin/env node
// SessionStart — re-inject distilled recovery signal after compaction (source=compact)
// and compact-proof structured Task* list from disk (compact/startup/resume).
// Fail-open: every path exits 0; on any error it injects nothing.

import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import {
  expandHome,
  isPureHarnessSignal,
  cleanText,
  isNoise,
  extractUserString,
} from "../lib/prompt-classifier.mjs";

const MAX_PROMPTS = 25;
const MAX_PROMPT_CHARS = 800;
const HEAD_PROMPTS = 3;
const HEAD_PROMPT_CHARS = 4000;
const MAX_DECISIONS = 10;
const DECISION_Q_CHARS = 400;
const DECISION_A_CHARS = 400;

const FALLBACK_HINT =
  "STRUCTURED TASK LIST: your TaskCreate/TaskUpdate tasks persist on disk across compaction — call TaskList to see current open tasks; do NOT assume they were lost.";
const HEADER_EXACT =
  "STRUCTURED TASK LIST (compact-proof, from disk — source of truth; TaskUpdate to change):";
const HEADER_FALLBACK_PREFIX =
  "STRUCTURED TASK LIST (compact-proof, recovered from ";
const HEADER_FALLBACK_SUFFIX = " — source of truth; TaskUpdate to change):";

const MAX_TASKS = 40;
const SUBJECT_MAX_BYTES = 160;
const DESC_MAX_BYTES = 300;
const BLOCK_MAX_BYTES = 4096;

function tasksRoot() {
  const root = process.env.CCR_TASKS_ROOT || path.join(os.homedir(), ".claude", "tasks");
  return expandHome(root);
}

function utf8ByteLen(s) {
  return Buffer.byteLength(s, "utf8");
}

function truncateUtf8Bytes(s, maxBytes) {
  if (utf8ByteLen(s) <= maxBytes) return s;
  const ellipsis = "…";
  const budget = maxBytes - utf8ByteLen(ellipsis);
  if (budget <= 0) return ellipsis.slice(0, maxBytes);
  let out = "";
  for (const ch of s) {
    const next = out + ch;
    if (utf8ByteLen(next) > budget) break;
    out = next;
  }
  return out + ellipsis;
}

function normalizeField(s) {
  return String(s ?? "")
    .replace(/[\x00-\x1f\x7f]/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

function extractAssistantText(rec) {
  if (!rec || rec.type !== "assistant") return null;
  const c = rec.message && rec.message.content;
  if (typeof c === "string") return c;
  if (Array.isArray(c)) {
    return c.filter((p) => p && p.type === "text" && typeof p.text === "string").map((p) => p.text).join("\n");
  }
  return null;
}

const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
const ECHO_MIN = 40;

function extractTodoWrite(rec) {
  const c = rec && rec.message && rec.message.content;
  if (!Array.isArray(c)) return null;
  for (const p of c) {
    if (p && p.type === "tool_use" && p.name === "TodoWrite" && p.input && Array.isArray(p.input.todos)) {
      return p.input.todos;
    }
  }
  return null;
}

function extractDecisions(rec) {
  if (!rec || !String(rec.version ?? "").startsWith("live-transcript-journal/")) return [];
  const c = rec.message && rec.message.content;
  if (!Array.isArray(c)) return [];
  const out = [];
  for (const p of c) {
    if (!p || p.type !== "tool_use" || p.name !== "AskUserQuestion") continue;
    const answers = p.input && p.input.answers;
    if (!answers || typeof answers !== "object") continue;
    for (const [q, a] of Object.entries(answers)) {
      const question = String(q).replace(/\s+/g, " ").trim();
      const answer = String(a).replace(/\s+/g, " ").trim();
      if (!question || !answer) continue;
      out.push({ question, answer });
    }
  }
  return out;
}

function isOpenTaskStatus(status) {
  return status === "in_progress" || status === "pending";
}

function isValidTaskId(id) {
  return Number.isInteger(id) && id >= 0;
}

function parseTaskFile(filePath, mtimeMs) {
  let raw;
  try {
    raw = fs.readFileSync(filePath, "utf8");
  } catch {
    return { malformed: true };
  }
  let task;
  try {
    task = JSON.parse(raw);
  } catch {
    return { malformed: true };
  }
  if (!task || typeof task !== "object") return { malformed: true };
  const status = task.status;
  if (!isOpenTaskStatus(status)) return { task: null };
  const id = Number(task.id);
  if (!isValidTaskId(id)) return { malformed: true };
  const blockedBy = Array.isArray(task.blockedBy)
    ? task.blockedBy.map(Number).filter(isValidTaskId)
    : [];
  return {
    task: {
      id,
      subject: normalizeField(task.subject || task.activeForm || ""),
      description: normalizeField(task.description || ""),
      status,
      blockedBy,
      mtimeMs,
    },
  };
}

function readTaskDir(sessionId) {
  const dir = path.join(tasksRoot(), sessionId);
  let entries;
  try {
    entries = fs.readdirSync(dir);
  } catch {
    return { exists: false, open: [], malformed: false };
  }
  const open = [];
  let malformed = false;
  for (const name of entries) {
    if (!/^\d+$/.test(name)) continue;
    const filePath = path.join(dir, name);
    let st;
    try {
      st = fs.statSync(filePath);
    } catch {
      malformed = true;
      continue;
    }
    if (!st.isFile()) continue;
    const parsed = parseTaskFile(filePath, st.mtimeMs);
    if (parsed.malformed) {
      malformed = true;
      continue;
    }
    if (parsed.task) open.push(parsed.task);
  }
  return { exists: true, open, malformed };
}

function findFallbackSession(sessionId, transcriptPath, hookStartMs) {
  const tp = expandHome(transcriptPath);
  if (!tp) return { kind: "none" };
  const parent = path.dirname(tp);
  let parentEntries;
  try {
    parentEntries = fs.readdirSync(parent);
  } catch {
    return { kind: "none" };
  }
  let upperBound = hookStartMs;
  try {
    upperBound = fs.statSync(tp).mtimeMs;
  } catch {
    // current transcript absent — hook start time is upper bound
  }
  const candidates = [];
  for (const name of parentEntries) {
    if (!name.endsWith(".jsonl")) continue;
    if (name.endsWith(".live.jsonl")) continue;
    const base = name.slice(0, -".jsonl".length);
    if (!base || base === sessionId) continue;
    const transcriptFile = path.join(parent, name);
    let st;
    try {
      st = fs.statSync(transcriptFile);
    } catch {
      continue;
    }
    if (st.mtimeMs >= upperBound) continue;
    const dirResult = readTaskDir(base);
    if (!dirResult.exists || dirResult.open.length === 0) continue;
    candidates.push({ sessionId: base, mtimeMs: st.mtimeMs });
  }
  if (candidates.length === 1) return { kind: "one", sessionId: candidates[0].sessionId };
  if (candidates.length > 1) return { kind: "ambiguous" };
  return { kind: "none" };
}

function renderHeader(sourceSessionId) {
  if (sourceSessionId) {
    return `${HEADER_FALLBACK_PREFIX}${sourceSessionId}${HEADER_FALLBACK_SUFFIX}`;
  }
  return HEADER_EXACT;
}

function taskLine(task) {
  const subject = truncateUtf8Bytes(task.subject || "(untitled)", SUBJECT_MAX_BYTES);
  let line = `#${task.id} [${task.status}] ${subject}`;
  if (task.blockedBy.length) {
    line += ` (blocked by ${task.blockedBy.map((b) => `#${b}`).join(",")})`;
  }
  return line;
}

function taskBlockBytes(header, tasks, omitted) {
  const lines = [header, ...tasks.flatMap((t) => {
    const out = [taskLine(t)];
    if (t.status === "in_progress" && t.description) {
      out.push(`  description: ${truncateUtf8Bytes(t.description, DESC_MAX_BYTES)}`);
    }
    return out;
  })];
  if (omitted > 0) lines.push(`…${omitted} more: call TaskList`);
  return utf8ByteLen(lines.join("\n"));
}

function renderOrder(tasks) {
  return [...tasks].sort((a, b) => {
    if (a.status !== b.status) return a.status === "in_progress" ? -1 : 1;
    return a.id - b.id;
  });
}

function selectPriority(tasks) {
  const inProg = tasks.filter((t) => t.status === "in_progress").sort((a, b) => b.mtimeMs - a.mtimeMs);
  const pending = tasks.filter((t) => t.status === "pending").sort((a, b) => b.mtimeMs - a.mtimeMs);
  return [...inProg, ...pending];
}

function selectTasks(openTasks, header) {
  const priority = selectPriority(openTasks);
  const selected = [];

  for (const task of priority) {
    if (selected.length >= MAX_TASKS) break;
    const candidate = [...selected, task];
    const ordered = renderOrder(candidate);
    const totalOmitted = openTasks.length - ordered.length;
    if (taskBlockBytes(header, ordered, totalOmitted) > BLOCK_MAX_BYTES) continue;
    selected.push(task);
  }

  return {
    selected: renderOrder(selected),
    omitted: openTasks.length - selected.length,
  };
}

function renderTaskBlock(openTasks, sourceSessionId) {
  if (!openTasks.length) return null;
  const header = renderHeader(sourceSessionId);
  const { selected, omitted } = selectTasks(openTasks, header);
  const lines = [header];
  for (const task of selected) {
    lines.push(taskLine(task));
    if (task.status === "in_progress" && task.description) {
      lines.push(`  description: ${truncateUtf8Bytes(task.description, DESC_MAX_BYTES)}`);
    }
  }
  if (omitted > 0) lines.push(`…${omitted} more: call TaskList`);
  return lines.join("\n");
}

function resolveTasks(sessionId, transcriptPath, hookStartMs) {
  const exact = readTaskDir(sessionId);
  if (exact.exists) {
    if (exact.open.length > 0) {
      return { kind: "block", block: renderTaskBlock(exact.open, null) };
    }
    if (exact.malformed) return { kind: "hint" };
    return { kind: "empty" };
  }
  const fallback = findFallbackSession(sessionId, transcriptPath, hookStartMs);
  if (fallback.kind === "one") {
    const recovered = readTaskDir(fallback.sessionId);
    if (recovered.open.length > 0) {
      return { kind: "block", block: renderTaskBlock(recovered.open, fallback.sessionId) };
    }
  }
  if (fallback.kind === "ambiguous") return { kind: "hint" };
  return { kind: "hint" };
}

function taskInjection(taskResult) {
  if (taskResult.kind === "block" && taskResult.block) return taskResult.block;
  if (taskResult.kind === "hint") return FALLBACK_HINT;
  return null;
}

function buildCompactRecovery(transcriptPath) {
  const tp = expandHome(transcriptPath);
  if (!tp || !fs.existsSync(tp)) return null;

  const records = [];
  let merged = false;
  for (const file of [tp, `${tp}.live.jsonl`]) {
    let text;
    try {
      text = fs.readFileSync(file, "utf8");
    } catch {
      continue;
    }
    if (file !== tp) merged = true;
    for (const line of text.split("\n")) {
      if (!line) continue;
      try {
        records.push(JSON.parse(line));
      } catch {
        continue;
      }
    }
  }
  if (!records.length) return null;
  if (merged) {
    records.sort((a, b) => String(a?.timestamp ?? "").localeCompare(String(b?.timestamp ?? "")));
  }

  const prompts = [];
  const seen = new Set();
  const asstTexts = [];
  const decisions = [];
  const seenDecisions = new Set();
  let todos = null;

  for (const rec of records) {
    const t = extractTodoWrite(rec);
    if (t) todos = t;

    for (const d of extractDecisions(rec)) {
      const k = norm(d.question) + "\0" + norm(d.answer);
      if (seenDecisions.has(k)) continue;
      seenDecisions.add(k);
      decisions.push(d);
    }

    const at = extractAssistantText(rec);
    if (at) asstTexts.push(norm(at));

    const raw = extractUserString(rec);
    if (raw == null) continue;
    if (isPureHarnessSignal(raw)) continue;
    const cleaned = cleanText(raw);
    if (!cleaned || isNoise(cleaned)) continue;

    const nc = norm(cleaned);
    const sig = nc.slice(0, Math.min(nc.length, 60));
    if (nc.length >= ECHO_MIN && asstTexts.some((a) => a.includes(sig))) continue;
    if (seen.has(nc)) continue;
    seen.add(nc);
    prompts.push(cleaned);
  }

  const head = prompts.slice(0, HEAD_PROMPTS);
  const tailStart = Math.max(head.length, prompts.length - MAX_PROMPTS);
  const selected = [
    ...head.map((text, i) => ({ text, index: i + 1 })),
    ...prompts.slice(tailStart).map((text, i) => ({ text, index: tailStart + i + 1 })),
  ];
  const omitted = tailStart - head.length;
  const openTodos = (todos || []).filter((td) => td && td.status !== "completed");
  const keptDecisions = decisions.slice(-MAX_DECISIONS);

  if (selected.length === 0 && openTodos.length === 0 && keptDecisions.length === 0) return null;

  const parts = [];
  parts.push(
    "Context was just compacted. The summary may have flattened specifics. " +
      "Ground yourself in these recovered signals before acting."
  );

  if (selected.length) {
    parts.push(
      omitted > 0
        ? `\nUSER-TYPED TURNS — opening ${head.length} + most recent ${selected.length - head.length} of ${prompts.length} (harness/ack noise filtered, oldest→newest):`
        : "\nUSER-TYPED TURNS (harness/ack noise filtered, oldest→newest):"
    );
    let gapShown = false;
    for (const { text, index } of selected) {
      if (!gapShown && omitted > 0 && index > head.length) {
        parts.push(`… ${omitted} middle turn${omitted === 1 ? "" : "s"} omitted — read the journal below for them …`);
        gapShown = true;
      }
      const cap = index <= head.length ? HEAD_PROMPT_CHARS : MAX_PROMPT_CHARS;
      const one = text.replace(/\s+/g, " ").trim();
      const clipped = one.length > cap ? one.slice(0, cap) + "…" : one;
      parts.push(`${index}. ${clipped}`);
    }
  }

  if (keptDecisions.length) {
    parts.push(
      decisions.length > keptDecisions.length
        ? `\nUSER DECISIONS — most recent ${keptDecisions.length} of ${decisions.length} (already settled, do NOT re-ask):`
        : "\nUSER DECISIONS (already settled, do NOT re-ask):"
    );
    const clip = (s, n) => (s.length > n ? s.slice(0, n) + "…" : s);
    for (const { question, answer } of keptDecisions) {
      parts.push(`- Q: ${clip(question, DECISION_Q_CHARS)}`);
      parts.push(`  A: ${clip(answer, DECISION_A_CHARS)}`);
    }
  }

  if (openTodos.length) {
    parts.push("\nOPEN TODOs (last TodoWrite snapshot):");
    for (const td of openTodos) {
      const label = td.content || td.activeForm || "(untitled)";
      const mark = td.status === "in_progress" ? "~" : " ";
      parts.push(`- [${mark}] ${String(label).replace(/\s+/g, " ").trim()}`);
    }
  }

  parts.push(`\nVERBATIM JOURNAL (every prompt + tool decision, for exact recall): ${tp}`);
  return parts.join("\n");
}

function emit(context) {
  if (!context) return;
  process.stdout.write(
    JSON.stringify({
      hookSpecificOutput: {
        hookEventName: "SessionStart",
        additionalContext: context,
      },
    })
  );
}

function main() {
  let input;
  try {
    input = JSON.parse(fs.readFileSync(0, "utf8"));
  } catch {
    return;
  }
  if (input.hook_event_name !== "SessionStart") return;

  const source = input.source;
  if (source === "clear" || (source !== "compact" && source !== "startup" && source !== "resume")) return;

  const hookStartMs = Date.now();
  const sessionId = typeof input.session_id === "string" ? input.session_id : "";
  const taskResult = sessionId ? resolveTasks(sessionId, input.transcript_path, hookStartMs) : { kind: "hint" };
  const taskPart = taskInjection(taskResult);

  if (source === "compact") {
    const compactPart = buildCompactRecovery(input.transcript_path);
    const parts = [];
    if (compactPart) parts.push(compactPart);
    if (taskPart) parts.push(taskPart);
    emit(parts.join("\n"));
    return;
  }

  emit(taskPart);
}

try {
  main();
} catch {
  // fail-open: never break session start
}
