// src/engine/reflection/snapshot.ts
//
// Writes per-job snapshots under data/users/{userId}/.reflection-history/:
//
//   {ts}-{originalJobId}/
//     meta.json          — outcome, reasoning, modelUsed, tokens, ratingAtTime, rejections
//     memory.before/     — only files that were changed (their state BEFORE the reflection)
//     memory.after/      — same filenames, their state AFTER the reflection
//     piece.before.yaml  — only if a piece was edited
//     piece.after.yaml
//     diff.txt           — human-readable summary for the UI
//     llm-raw.json       — full LLM response (only when storeLlmRaw=true)
//
// All mutations are serialised under withUserLock, including the index.jsonl append.
//
// Exported functions:
//   writeSnapshot   — capture + index-append (called by the reflection runner)
//   revertSnapshot  — idempotent restore of memory + piece files
//   listSnapshots   — paged read of index.jsonl (Phase 7.2 history API)
//   readSnapshot    — full detail of one snapshot (Phase 7.2 detail API)

import {
  existsSync,
  mkdirSync,
  readFileSync,
  writeFileSync,
  readdirSync,
  copyFileSync,
  unlinkSync,
  appendFileSync,
} from 'fs';
import { join, basename } from 'path';
import { logger } from '../../logger.js';
import { withUserLock } from './user-lock.js';

// ── Types ─────────────────────────────────────────────────────────────────────

/** Files that changed in this reflection run.  Map of relative filename → content. */
export type FileSnapshot = Record<string, string>;

export interface SnapshotMeta {
  snapshotId: string;         // "{ts}-{originalJobId}"
  ts: string;                 // ISO 8601 UTC, e.g. "2026-05-11T10:23:00Z"
  originalJobId: string;
  userId: string;
  pieceName: string;
  outcome: string;            // ReflectionOutcome
  reasoning: string;
  modelUsed?: string;
  tokensIn?: number;
  tokensOut?: number;
  ratingAtTime?: 'good' | 'bad' | null;
  memoryChanges: number;
  pieceEdited: boolean;
  rejections?: Array<{ code: string; name?: string }>;
}

export interface WriteSnapshotMeta {
  originalJobId: string;
  userId: string;
  pieceName: string;
  outcome: string;
  reasoning: string;
  modelUsed?: string;
  tokensIn?: number;
  tokensOut?: number;
  ratingAtTime?: 'good' | 'bad' | null;
  memoryChanges: number;
  pieceEdited: boolean;
  rejections?: Array<{ code: string; name?: string }>;
  llmRaw?: unknown;           // stored only when storeLlmRaw=true
}

export interface SnapshotDeps {
  /** Root of the user data directory; snapshot lives at {dataDir}/{userId}/.reflection-history/ */
  dataDir: string;
  /** Root of the live memory directory used for revert: {dataDir}/{userId}/memory/ */
  memoryDataDir?: string;     // defaults to dataDir
  storeLlmRaw?: boolean;      // default false
}

export interface WriteSnapshotResult {
  snapshotId: string;
  dir: string;
}

export interface SnapshotIndexEntry {
  ts: string;
  snapshotId: string;
  jobId: string;
  pieceName: string;
  memoryChanges: number;
  pieceEdited: boolean;
  reverted: boolean;
}

export interface SnapshotDetail extends SnapshotMeta {
  beforeFiles: FileSnapshot;
  afterFiles: FileSnapshot;
  pieceBeforeYaml?: string;
  pieceAfterYaml?: string;
  diff?: string;
}

// ── Internal helpers ──────────────────────────────────────────────────────────

/** Formats a Date as YYYYMMDDTHHmmssZ (UTC, no separators, suffix Z). */
function formatSnapshotTs(d: Date = new Date()): string {
  const pad2 = (n: number) => String(n).padStart(2, '0');
  return (
    `${d.getUTCFullYear()}` +
    `${pad2(d.getUTCMonth() + 1)}` +
    `${pad2(d.getUTCDate())}` +
    `T${pad2(d.getUTCHours())}` +
    `${pad2(d.getUTCMinutes())}` +
    `${pad2(d.getUTCSeconds())}` +
    `Z`
  );
}

/** ISO 8601 UTC representation of a Date. */
function toIsoUtc(d: Date = new Date()): string {
  return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
}

function historyDir(dataDir: string, userId: string): string {
  return join(dataDir, userId, '.reflection-history');
}

function snapshotDir(dataDir: string, userId: string, snapshotId: string): string {
  return join(historyDir(dataDir, userId), snapshotId);
}

/** Write every key in `files` into `dir/{key}` (creating dir if needed). */
function writeFileSet(dir: string, files: FileSnapshot): void {
  mkdirSync(dir, { recursive: true });
  for (const [name, content] of Object.entries(files)) {
    writeFileSync(join(dir, name), content, 'utf-8');
  }
}

/**
 * Build a human-readable diff.txt summary.
 * Lists added, modified, and removed file names; no content diffing in v1.
 */
function buildDiffTxt(
  beforeFiles: FileSnapshot,
  afterFiles: FileSnapshot,
  pieceEdited: boolean,
  pieceName: string,
): string {
  const lines: string[] = [];
  const beforeKeys = new Set(Object.keys(beforeFiles));
  const afterKeys = new Set(Object.keys(afterFiles));

  const added = [...afterKeys].filter((k) => !beforeKeys.has(k));
  const removed = [...beforeKeys].filter((k) => !afterKeys.has(k));
  const modified = [...afterKeys].filter(
    (k) => beforeKeys.has(k) && beforeFiles[k] !== afterFiles[k],
  );

  if (added.length) lines.push(`Added memory entries: ${added.join(', ')}`);
  if (modified.length) lines.push(`Updated memory entries: ${modified.join(', ')}`);
  if (removed.length) lines.push(`Removed memory entries: ${removed.join(', ')}`);
  if (pieceEdited) lines.push(`Piece edited: ${pieceName}`);
  if (!lines.length) lines.push('No changes recorded.');

  return lines.join('\n') + '\n';
}

/**
 * Parse a single index.jsonl line.  Returns null for blank lines or parse errors.
 */
function parseIndexLine(line: string): SnapshotIndexEntry | null {
  const trimmed = line.trim();
  if (!trimmed) return null;
  try {
    return JSON.parse(trimmed) as SnapshotIndexEntry;
  } catch {
    return null;
  }
}

/** Read all lines of index.jsonl, skipping blanks + corrupt lines. */
function readIndexLines(indexPath: string): SnapshotIndexEntry[] {
  if (!existsSync(indexPath)) return [];
  const raw = readFileSync(indexPath, 'utf-8');
  return raw
    .split('\n')
    .map(parseIndexLine)
    .filter((e): e is SnapshotIndexEntry => e !== null);
}

// ── Public API ────────────────────────────────────────────────────────────────

/**
 * Write a snapshot for one reflection run.
 *
 * Must be called INSIDE the caller's withUserLock critical section (the runner
 * acquires the lock, applies changes, then calls writeSnapshot — all while
 * holding the lock).  writeSnapshot does NOT re-acquire the lock.
 *
 * @param deps       — dataDir (root of user data tree), storeLlmRaw flag
 * @param beforeFiles — map of filename → content for memory entries that
 *                      existed BEFORE the reflection (only changed entries)
 * @param afterFiles  — map of filename → content AFTER the reflection
 *                      (only changed entries; may include new files not in before)
 * @param meta        — outcome, reasoning, model, tokens, etc.
 * @param pieceBeforeYaml — only pass when a piece was actually edited
 * @param pieceAfterYaml  — only pass when a piece was actually edited
 * @param now         — override the timestamp (for deterministic tests)
 */
export async function writeSnapshot(
  deps: SnapshotDeps,
  beforeFiles: FileSnapshot,
  afterFiles: FileSnapshot,
  meta: WriteSnapshotMeta,
  pieceBeforeYaml?: string,
  pieceAfterYaml?: string,
  now: Date = new Date(),
): Promise<WriteSnapshotResult> {
  const ts = formatSnapshotTs(now);
  const isoTs = toIsoUtc(now);
  const snapshotId = `${ts}-${meta.originalJobId}`;
  const dir = snapshotDir(deps.dataDir, meta.userId, snapshotId);

  mkdirSync(dir, { recursive: true });

  // memory.before/ and memory.after/
  if (Object.keys(beforeFiles).length > 0) {
    writeFileSet(join(dir, 'memory.before'), beforeFiles);
  }
  if (Object.keys(afterFiles).length > 0) {
    writeFileSet(join(dir, 'memory.after'), afterFiles);
  }

  // Piece files (only when a piece was edited)
  if (meta.pieceEdited && pieceBeforeYaml !== undefined) {
    writeFileSync(join(dir, 'piece.before.yaml'), pieceBeforeYaml, 'utf-8');
  }
  if (meta.pieceEdited && pieceAfterYaml !== undefined) {
    writeFileSync(join(dir, 'piece.after.yaml'), pieceAfterYaml, 'utf-8');
  }

  // diff.txt
  const diffTxt = buildDiffTxt(beforeFiles, afterFiles, meta.pieceEdited, meta.pieceName);
  writeFileSync(join(dir, 'diff.txt'), diffTxt, 'utf-8');

  // meta.json
  const metaObj: SnapshotMeta = {
    snapshotId,
    ts: isoTs,
    originalJobId: meta.originalJobId,
    userId: meta.userId,
    pieceName: meta.pieceName,
    outcome: meta.outcome,
    reasoning: meta.reasoning,
    modelUsed: meta.modelUsed,
    tokensIn: meta.tokensIn,
    tokensOut: meta.tokensOut,
    ratingAtTime: meta.ratingAtTime,
    memoryChanges: meta.memoryChanges,
    pieceEdited: meta.pieceEdited,
    rejections: meta.rejections,
  };
  writeFileSync(join(dir, 'meta.json'), JSON.stringify(metaObj, null, 2), 'utf-8');

  // llm-raw.json — only when storeLlmRaw=true
  if (deps.storeLlmRaw && meta.llmRaw !== undefined) {
    writeFileSync(
      join(dir, 'llm-raw.json'),
      JSON.stringify(meta.llmRaw, null, 2),
      'utf-8',
    );
  }

  // Append to index.jsonl (atomic enough for append — race-safe only inside lock)
  const indexPath = join(historyDir(deps.dataDir, meta.userId), 'index.jsonl');
  const indexRow: SnapshotIndexEntry = {
    ts: isoTs,
    snapshotId,
    jobId: meta.originalJobId,
    pieceName: meta.pieceName,
    memoryChanges: meta.memoryChanges,
    pieceEdited: meta.pieceEdited,
    reverted: false,
  };
  appendFileSync(indexPath, JSON.stringify(indexRow) + '\n', 'utf-8');

  logger.info(
    `[reflection/snapshot] wrote snapshotId=${snapshotId} ` +
    `userId=${meta.userId} memoryChanges=${meta.memoryChanges} ` +
    `pieceEdited=${meta.pieceEdited} dir=${dir}`,
  );

  return { snapshotId, dir };
}

/**
 * Revert a snapshot — idempotent.
 *
 * Acquires the per-user lock before doing any I/O.  If a row with
 * `reverted:true, refersTo:snapshotId` already exists in index.jsonl,
 * returns `{ reverted: false }` without touching any files.
 *
 * Revert logic:
 *   1. Copy every file in memory.before/ → {memoryDir}/{file}
 *   2. Delete any file that is ONLY in memory.after/ (was ADDED by the reflection)
 *   3. Copy piece.before.yaml → {piecesDir}/{pieceName}.yaml (if present)
 *   4. Append { reverted:true, refersTo:snapshotId, ts:now } to index.jsonl
 */
export async function revertSnapshot(
  deps: SnapshotDeps,
  snapshotId: string,
): Promise<{ reverted: boolean }> {
  // Parse userId + originalJobId from snapshotId = "{ts}-{originalJobId}"
  // The ts portion is 16 chars: YYYYMMDDTHHmmssZ (fixed width)
  const TS_LEN = 16; // "20260511T102300Z"
  const userId = await _extractUserIdFromSnapshot(deps, snapshotId);
  if (!userId) {
    throw new Error(`revertSnapshot: cannot find snapshot "${snapshotId}" under any user`);
  }

  return withUserLock(deps.dataDir, userId, async () => {
    return _doRevert(deps, userId, snapshotId, TS_LEN);
  });
}

/** Internal revert (called inside the lock). */
function _doRevert(
  deps: SnapshotDeps,
  userId: string,
  snapshotId: string,
  _tsLen: number,
): { reverted: boolean } {
  const histDir = historyDir(deps.dataDir, userId);
  const indexPath = join(histDir, 'index.jsonl');
  const sDir = snapshotDir(deps.dataDir, userId, snapshotId);

  if (!existsSync(sDir)) {
    throw new Error(`revertSnapshot: snapshot directory not found: ${sDir}`);
  }

  // Idempotency check: scan index.jsonl for an existing reverted:true row.
  const existingRows = readIndexLines(indexPath);
  const alreadyReverted = existingRows.some(
    (r) => (r as unknown as { reverted: boolean; refersTo?: string }).reverted &&
      (r as unknown as { refersTo?: string }).refersTo === snapshotId,
  );
  if (alreadyReverted) {
    return { reverted: false };
  }

  // Read meta.json for pieceName
  const metaPath = join(sDir, 'meta.json');
  if (!existsSync(metaPath)) {
    throw new Error(`revertSnapshot: meta.json not found in ${sDir}`);
  }
  const snapshotMeta = JSON.parse(readFileSync(metaPath, 'utf-8')) as SnapshotMeta;
  const { pieceName } = snapshotMeta;

  const memDataDir = deps.memoryDataDir ?? deps.dataDir;
  const liveMemDir = join(memDataDir, userId, 'memory');
  const liveMemDirAlt = join(deps.dataDir, userId, 'memory');

  // Ensure live memory dir exists
  const resolvedLiveMemDir = existsSync(liveMemDir) ? liveMemDir : liveMemDirAlt;
  mkdirSync(resolvedLiveMemDir, { recursive: true });

  // Collect beforeFiles and afterFiles from the snapshot directories
  const beforeDir = join(sDir, 'memory.before');
  const afterDir = join(sDir, 'memory.after');

  const beforeFiles = new Set<string>(
    existsSync(beforeDir) ? readdirSync(beforeDir) : [],
  );
  const afterFiles = new Set<string>(
    existsSync(afterDir) ? readdirSync(afterDir) : [],
  );

  // Step 1: Copy memory.before/* → liveMemDir/
  for (const file of beforeFiles) {
    const src = join(beforeDir, file);
    const dst = join(resolvedLiveMemDir, file);
    copyFileSync(src, dst);
    logger.debug(`[reflection/snapshot] revert: restored ${file}`);
  }

  // Step 2: Delete files that were ADDED by the reflection
  //   (present in afterFiles but NOT in beforeFiles)
  for (const file of afterFiles) {
    if (!beforeFiles.has(file)) {
      const dst = join(resolvedLiveMemDir, file);
      if (existsSync(dst)) {
        unlinkSync(dst);
        logger.debug(`[reflection/snapshot] revert: deleted added file ${file}`);
      }
    }
  }

  // Step 3: Restore piece.before.yaml if it exists
  const pieceBeforePath = join(sDir, 'piece.before.yaml');
  if (existsSync(pieceBeforePath) && pieceName) {
    const livePiecesDir = join(deps.dataDir, userId, 'pieces');
    mkdirSync(livePiecesDir, { recursive: true });
    const dst = join(livePiecesDir, `${pieceName}.yaml`);
    copyFileSync(pieceBeforePath, dst);
    logger.debug(`[reflection/snapshot] revert: restored piece ${pieceName}.yaml`);
  }

  // Step 4: Append revert row to index.jsonl
  const revertRow = {
    ts: toIsoUtc(),
    reverted: true,
    refersTo: snapshotId,
  };
  appendFileSync(indexPath, JSON.stringify(revertRow) + '\n', 'utf-8');

  logger.info(
    `[reflection/snapshot] reverted snapshotId=${snapshotId} userId=${userId}`,
  );

  return { reverted: true };
}

/**
 * Locate the userId that owns a given snapshotId by scanning the history dirs.
 * In practice the caller should always know the userId; this is a fallback
 * for the revert endpoint which receives only the snapshotId.
 *
 * Returns null if not found.
 */
async function _extractUserIdFromSnapshot(
  deps: SnapshotDeps,
  snapshotId: string,
): Promise<string | null> {
  // Fast path: scan {dataDir}/*/.reflection-history/{snapshotId}
  const dataRoot = deps.dataDir;
  if (!existsSync(dataRoot)) return null;

  let userDirs: string[];
  try {
    userDirs = readdirSync(dataRoot);
  } catch {
    return null;
  }

  for (const uid of userDirs) {
    const sDir = snapshotDir(dataRoot, uid, snapshotId);
    if (existsSync(sDir)) return uid;
  }
  return null;
}

/**
 * List all snapshot index entries for a user (most recent first).
 * `opts.limit` defaults to 50; pass `opts.before` (ISO ts) to paginate.
 */
export function listSnapshots(
  deps: SnapshotDeps,
  userId: string,
  opts: { limit?: number; before?: string } = {},
): SnapshotIndexEntry[] {
  const indexPath = join(historyDir(deps.dataDir, userId), 'index.jsonl');
  const all = readIndexLines(indexPath);
  const limit = opts.limit ?? 50;

  // Sort descending by ts
  all.sort((a, b) => (b.ts > a.ts ? 1 : b.ts < a.ts ? -1 : 0));

  let result = all;
  if (opts.before) {
    result = result.filter((e) => e.ts < opts.before!);
  }

  return result.slice(0, limit);
}

/**
 * Read the full detail of a snapshot (for the Phase 7.2 history detail API).
 * Returns null if the snapshot directory does not exist.
 */
export function readSnapshot(
  deps: SnapshotDeps,
  userId: string,
  snapshotId: string,
): SnapshotDetail | null {
  const sDir = snapshotDir(deps.dataDir, userId, snapshotId);
  if (!existsSync(sDir)) return null;

  const metaPath = join(sDir, 'meta.json');
  if (!existsSync(metaPath)) return null;

  const meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as SnapshotMeta;

  const beforeDir = join(sDir, 'memory.before');
  const afterDir = join(sDir, 'memory.after');

  const readDir = (dir: string): FileSnapshot => {
    if (!existsSync(dir)) return {};
    const result: FileSnapshot = {};
    for (const file of readdirSync(dir)) {
      result[file] = readFileSync(join(dir, file), 'utf-8');
    }
    return result;
  };

  const pieceBeforePath = join(sDir, 'piece.before.yaml');
  const pieceAfterPath = join(sDir, 'piece.after.yaml');
  const diffPath = join(sDir, 'diff.txt');

  return {
    ...meta,
    beforeFiles: readDir(beforeDir),
    afterFiles: readDir(afterDir),
    pieceBeforeYaml: existsSync(pieceBeforePath)
      ? readFileSync(pieceBeforePath, 'utf-8')
      : undefined,
    pieceAfterYaml: existsSync(pieceAfterPath)
      ? readFileSync(pieceAfterPath, 'utf-8')
      : undefined,
    diff: existsSync(diffPath) ? readFileSync(diffPath, 'utf-8') : undefined,
  };
}

/**
 * Revert a snapshot by snapshotId when the userId is already known.
 * Avoids the filesystem scan in the generic `revertSnapshot`.
 */
export async function revertSnapshotForUser(
  deps: SnapshotDeps,
  userId: string,
  snapshotId: string,
): Promise<{ reverted: boolean }> {
  const TS_LEN = 16;
  return withUserLock(deps.dataDir, userId, async () => {
    return _doRevert(deps, userId, snapshotId, TS_LEN);
  });
}
