// Prompts journal: a durable, portable, append-only projection of every owner-typed
// turn, derived from ~/.claude/projects/<slug>/<session>.jsonl (+ its
// <session>.jsonl.live.jsonl crash-recovery sidecar written by live-transcript-journal.mjs).
// Reuses the shared classifier (prompt-classifier.mjs) — never reimplements it.
//
// Incremental by construction: a per-session cursor records a byte offset into each
// source file plus a short trailing dedup window, so re-running only reads newly
// appended bytes — never the whole transcript.

import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { classifyPromptRecord } from "./prompt-classifier.mjs";

const DEDUP_WINDOW_MS = 30_000;
const DEDUP_KEEP = 50;

export function stateRoot(env = process.env) {
  return (
    env.OVERDECK_PROMPT_STATE_ROOT ??
    path.join(env.HOME || os.homedir(), ".local", "state", "overdeck", "prompts")
  );
}

export function journalPath(env = process.env) {
  return path.join(stateRoot(env), "prompts.jsonl");
}

export function cursorDir(env = process.env) {
  return path.join(stateRoot(env), "cursors");
}

export function cursorPath(env, sessionId) {
  return path.join(cursorDir(env), `${sessionId}.json`);
}

export function projectsRoot(env = process.env) {
  return (
    env.OVERDECK_CLAUDE_PROJECTS_ROOT ??
    path.join(env.HOME || os.homedir(), ".claude", "projects")
  );
}

// Top-level *.jsonl files directly under each project dir only — nested directories
// hold subagent transcripts, not owner sessions, and are skipped.
export function discoverSessions(env = process.env) {
  const root = projectsRoot(env);
  let projectEntries;
  try {
    projectEntries = fs.readdirSync(root, { withFileTypes: true });
  } catch {
    return [];
  }
  const sessions = [];
  for (const projectEntry of projectEntries) {
    if (!projectEntry.isDirectory()) continue;
    const project = projectEntry.name;
    const projectDir = path.join(root, project);
    let files;
    try {
      files = fs.readdirSync(projectDir, { withFileTypes: true });
    } catch {
      continue;
    }
    const bases = new Set();
    for (const file of files) {
      if (!file.isFile()) continue;
      if (file.name.endsWith(".jsonl.live.jsonl")) {
        bases.add(file.name.slice(0, -".jsonl.live.jsonl".length));
      } else if (file.name.endsWith(".jsonl")) {
        bases.add(file.name.slice(0, -".jsonl".length));
      }
    }
    for (const sessionId of bases) {
      const mainPath = path.join(projectDir, `${sessionId}.jsonl`);
      const sidecarPath = path.join(projectDir, `${sessionId}.jsonl.live.jsonl`);
      sessions.push({
        project,
        sessionId,
        mainPath: fs.existsSync(mainPath) ? mainPath : null,
        sidecarPath: fs.existsSync(sidecarPath) ? sidecarPath : null,
      });
    }
  }
  return sessions;
}

function ensureDir(dir, mode) {
  fs.mkdirSync(dir, { recursive: true, mode });
}

function defaultCursor() {
  return { mainOffset: 0, sidecarOffset: 0, nextSeq: 0, recentDedup: [] };
}

export function loadCursor(cpath) {
  try {
    const parsed = JSON.parse(fs.readFileSync(cpath, "utf8"));
    return { ...defaultCursor(), ...parsed };
  } catch {
    return defaultCursor();
  }
}

export function saveCursor(cpath, state) {
  ensureDir(path.dirname(cpath), 0o700);
  const tmp = `${cpath}.${process.pid}.tmp`;
  fs.writeFileSync(tmp, `${JSON.stringify(state)}\n`, { encoding: "utf8", mode: 0o600 });
  fs.renameSync(tmp, cpath);
}

// Reads only the bytes appended since `fromOffset`, and only up through the last
// complete line — a partial trailing line (file still being written) is left for the
// next run. Returns the lines plus the byte offset to resume from.
export function readNewLines(filePath, fromOffset) {
  let size;
  try {
    size = fs.statSync(filePath).size;
  } catch {
    return { lines: [], nextOffset: fromOffset };
  }
  if (size <= fromOffset) return { lines: [], nextOffset: fromOffset };
  const fd = fs.openSync(filePath, "r");
  let text;
  try {
    const length = size - fromOffset;
    const buf = Buffer.alloc(length);
    fs.readSync(fd, buf, 0, length, fromOffset);
    text = buf.toString("utf8");
  } finally {
    fs.closeSync(fd);
  }
  const lastNewline = text.lastIndexOf("\n");
  if (lastNewline === -1) return { lines: [], nextOffset: fromOffset };
  const complete = text.slice(0, lastNewline + 1);
  const lines = complete.split("\n").filter((l) => l.length > 0);
  return { lines, nextOffset: fromOffset + Buffer.byteLength(complete, "utf8") };
}

function normalizeText(s) {
  return s.replace(/\s+/g, " ").trim().toLowerCase();
}

function tsMs(ts) {
  const parsed = Date.parse(ts);
  return Number.isFinite(parsed) ? parsed : null;
}

// True when an identical-text turn was already classified within DEDUP_WINDOW_MS —
// this is how main-transcript + live-transcript-journal-sidecar double writes for the
// SAME owner turn collapse to one journal record without merging two genuinely
// separate turns that happen to share text (e.g. two different "yes" replies).
function isDuplicate(recentDedup, normText, ms) {
  if (ms == null) return false;
  return recentDedup.some((e) => e.t === normText && Math.abs(e.ms - ms) <= DEDUP_WINDOW_MS);
}

function pushDedup(recentDedup, normText, ms) {
  if (ms == null) return recentDedup;
  const next = [...recentDedup, { t: normText, ms }];
  next.sort((a, b) => a.ms - b.ms);
  return next.slice(-DEDUP_KEEP);
}

// Projects newly-appended records for one session into journal records. Pure w.r.t.
// the filesystem beyond reading the two source files — callers persist state/journal.
export function projectSession(session, cursorState, deps = {}) {
  const now = deps.now ?? (() => new Date().toISOString());
  const state = { ...cursorState, recentDedup: [...cursorState.recentDedup] };

  const batch = [];
  let malformed = 0;
  for (const [filePath, offsetKey] of [
    [session.mainPath, "mainOffset"],
    [session.sidecarPath, "sidecarOffset"],
  ]) {
    if (!filePath) continue;
    const { lines, nextOffset } = readNewLines(filePath, state[offsetKey] ?? 0);
    for (const line of lines) {
      let rec;
      try {
        rec = JSON.parse(line);
      } catch {
        malformed += 1;
        continue;
      }
      if (!rec || typeof rec !== "object") {
        malformed += 1;
        continue;
      }
      batch.push(rec);
    }
    state[offsetKey] = nextOffset;
  }

  // Sort combined new lines from both files chronologically so the trailing dedup
  // window sees same-turn duplicates (main vs sidecar) in the right order.
  batch.sort((a, b) => String(a?.timestamp ?? "").localeCompare(String(b?.timestamp ?? "")));

  const journalRecords = [];
  for (const rec of batch) {
    const classified = classifyPromptRecord(rec);
    if (!classified) continue;

    const ts = typeof rec.timestamp === "string" ? rec.timestamp : now();
    const ms = tsMs(ts);
    const normText = normalizeText(classified.text);
    if (isDuplicate(state.recentDedup, normText, ms)) continue;
    state.recentDedup = pushDedup(state.recentDedup, normText, ms);

    const seq = state.nextSeq;
    state.nextSeq += 1;
    journalRecords.push({
      id: `${session.sessionId}:${seq}`,
      ts,
      session: session.sessionId,
      project: session.project,
      cwd: typeof rec.cwd === "string" ? rec.cwd : null,
      seq,
      text: classified.text,
      noise: classified.noise,
    });
  }

  return { state, journalRecords, malformed };
}

export function appendJournal(jpath, records) {
  if (!records.length) return;
  ensureDir(path.dirname(jpath), 0o700);
  const fd = fs.openSync(jpath, "a", 0o600);
  try {
    fs.chmodSync(jpath, 0o600);
    const text = records.map((r) => `${JSON.stringify(r)}\n`).join("");
    fs.writeSync(fd, text);
  } finally {
    fs.closeSync(fd);
  }
}

// Runs one incremental backfill pass across every discoverable session. Returns a
// summary; never throws on a single bad session/file — those are counted and skipped.
export function backfill(env = process.env, deps = {}) {
  const sessions = discoverSessions(env);
  const jpath = journalPath(env);
  let sessionsWithNewRecords = 0;
  let recordsAppended = 0;
  let malformedTotal = 0;

  for (const session of sessions) {
    const cpath = cursorPath(env, session.sessionId);
    const cursorState = loadCursor(cpath);
    let result;
    try {
      result = projectSession(session, cursorState, deps);
    } catch (error) {
      malformedTotal += 1;
      if (deps.onError) deps.onError(session, error);
      continue;
    }
    if (result.journalRecords.length) {
      appendJournal(jpath, result.journalRecords);
      recordsAppended += result.journalRecords.length;
      sessionsWithNewRecords += 1;
    }
    malformedTotal += result.malformed;
    saveCursor(cpath, result.state);
  }

  return {
    sessionsScanned: sessions.length,
    sessionsWithNewRecords,
    recordsAppended,
    malformed: malformedTotal,
  };
}
