// restoreCore.ts
// Scope: restore/recovery execution, write snapshots, A/B backups, manual restore points,
// scrape restore, scrape capture, and tree/directory capture support.
// Deck does not import this directly long-term; use restore.ts/capture.ts facades.

import { promises as fs } from "node:fs";
import path from "node:path";
import { ROOT, DataPaths, SettingsPaths, isInsideRoot } from "./pathList";
import { controller, BACKSTAGE_SERIAL } from "./controller";
import { validateManifestShape, type RecorderManifest } from "./manifestContract";

export type RecoveryOperation = "undo" | "restore_from_scrape" | "restore_from_backup" | "input_manifest" | "timeline";
export type RestoreKind = "auto" | "manual" | "scrape" | "backup" | "unknown";
export type RecoveryResult = { ok: boolean; operation: RecoveryOperation; summary: string; warnings: string[]; errors: string[]; details?: Record<string, any> };
export type UndoInput = { count: number; serial?: string; dryRun?: boolean };
export type RestoreFromBackupInput = { name: string; serial?: string; dryRun?: boolean; exact?: boolean };
export type RestoreFromScrapeInput = { name: string; serial?: string; dryRun?: boolean };
export type InputManifestInput = { manifest: RecorderManifest | any; dryRun?: boolean };
export type WriteSnapshotInput = { dubID: string; serial: string; filePath: string; before: string | null; after: string; action: string };
export type AutoBackupSlot = "A" | "B";
export type CaptureTreeNode = { name: string; path: string; type: "file" | "dir"; size?: number; children?: CaptureTreeNode[] };
export type CaptureScrapeInput = { serial?: string; root?: string; name?: string; includeContent?: boolean; maxBytes?: number };

const RESTORABLE_EXTENSIONS = new Set([".ts", ".js", ".css", ".html", ".md", ".txt", ".json", ".bat"]);
const ASSET_REFERENCE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp", ".bmp", ".svg"]);
const SKIP_DIRS = new Set(["logs", "node_modules", ".git"]);
const SKIP_FILES = new Set(["package.json", "package-lock.json"]);
const SKIP_NAME_CONTAINS = ["scrape", "restore"];
const AUTO_BACKUP_MIN_DAYS_MS = 24 * 60 * 60 * 1000;
const AUTO_BACKUP_MIN_WRITES = 15;

function serialOf(serial?: string): string { return String(serial || controller.activeProjectInquiry().projectSerial || BACKSTAGE_SERIAL).trim() || BACKSTAGE_SERIAL; }
function recoveryDir(serial?: string): string { return path.join(DataPaths.logsDir, serialOf(serial), "restore"); }
function scrapesDir(serial?: string): string { return path.join(DataPaths.logsDir, serialOf(serial), "scrapes"); }
function writesDir(serial?: string): string { return path.join(DataPaths.logsDir, serialOf(serial), "writes"); }
function systemLog(serial?: string): string { return path.join(DataPaths.logsDir, serialOf(serial), "system.log"); }
function tapeFile(serial?: string): string { return path.join(DataPaths.logsDir, serialOf(serial), "tape.json"); }
function autoStatePath(serial: string): string { return path.join(recoveryDir(serial), "auto-backup-state.json"); }
function autoSlotName(slot: AutoBackupSlot): string { return `AUTO_${slot}`; }
function autoSlotPath(serial: string, slot: AutoBackupSlot): string { return path.join(recoveryDir(serial), autoSlotName(slot)); }
function nextAutoSlot(current: AutoBackupSlot): AutoBackupSlot { return current === "A" ? "B" : "A"; }
function now(): number { return Date.now(); }
function pad(n: number): string { return String(n).padStart(2, "0"); }
function ts(value = Date.now()): string { const d = new Date(value); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; }
function tsCompact(): string { return ts().replace(/:/g, "-").replace(" ", "_"); }
function normalizeSlashes(v: string): string { return String(v || "").replace(/\\/g, "/"); }
function activeRestoreProject() {
  const active = controller.activeProjectInquiry();
  if (!active.ok) throw new Error(active.error || "project_required_for_restore");
  return {
    serial: String(active.projectSerial || BACKSTAGE_SERIAL).trim() || BACKSTAGE_SERIAL,
    name: String(active.projectName || active.activeProject || "").trim(),
    root: path.resolve(active.projectRoot || ROOT),
    isBackstage: Boolean(active.isBackstage)
  };
}
function relFromProject(abs: string, root = activeRestoreProject().root): string { return normalizeSlashes(path.relative(root, abs)); }
function isObject(v: unknown): v is Record<string, any> { return Boolean(v) && typeof v === "object" && !Array.isArray(v); }
async function exists(fp: string): Promise<boolean> { try { await fs.access(fp); return true; } catch { return false; } }
async function readTextSafe(fp: string, fallback = ""): Promise<string> { try { return await fs.readFile(fp, "utf8"); } catch { return fallback; } }
async function readJsonSafe<T>(fp: string, fallback: T): Promise<T> { try { const raw = await fs.readFile(fp, "utf8"); return raw.trim() ? JSON.parse(raw) as T : fallback; } catch { return fallback; } }
async function writeJson(fp: string, value: unknown): Promise<void> { await fs.mkdir(path.dirname(fp), { recursive: true }); await fs.writeFile(fp, JSON.stringify(value, null, 2), "utf8"); }
async function appendSystemLog(serial: string, event: string, payload: Record<string, any>): Promise<void> {
  const line = `${ts()} [Restore] ${event} ${Object.entries(payload || {}).filter(([,v]) => v != null).map(([k,v]) => `${k}:${typeof v === "string" ? v : JSON.stringify(v)}`).join(" ")}`.trim();
  await fs.mkdir(path.dirname(systemLog(serial)), { recursive: true });
  await fs.appendFile(systemLog(serial), line + "\n", "utf8").catch(() => {});
}
function resolveInsideProject(relOrAbs: string): string {
  const project = activeRestoreProject();
  const clean = normalizeSlashes(String(relOrAbs || "").trim()).replace(/^\.\//, "");
  if (!clean) throw new Error("path_missing");
  const abs = path.isAbsolute(clean) || /^[A-Za-z]:[\\/]/.test(clean) ? path.resolve(clean) : path.resolve(project.root, clean);
  if (!isInsideRoot(abs, project.root)) throw new Error(project.isBackstage ? "path_outside_nv_root" : "outside_project_restore_root");
  return abs;
}
function restoreKindFromName(name: string): RestoreKind {
  const c = String(name || "").toLowerCase();
  if (!c) return "unknown";
  if (c === "auto_a" || c === "auto_b" || c.includes("auto")) return "auto";
  if (c.includes("scrape")) return "scrape";
  if (c.includes("backup") || c.includes("restore")) return "backup";
  return "manual";
}
function summarizeManifest(manifest: any): string {
  return String(manifest?.Task || "").trim() || [manifest?.Action, manifest?.FilePath].filter(Boolean).join(" ") || "manifest";
}
function safeLabelPart(name: string): string { return String(name || "Unknown").trim().replace(/[^A-Za-z0-9._ -]+/g, "").replace(/\s+/g, "_") || "Unknown"; }
function shouldSkipFile(name: string): boolean {
  const clean = String(name || "").toLowerCase();
  return !clean || SKIP_FILES.has(clean) || clean.endsWith(".log") || SKIP_NAME_CONTAINS.some(p => clean.includes(p));
}
function classifyCaptureFile(name: string): "code" | "asset" | "skip" {
  if (shouldSkipFile(name)) return "skip";
  const ext = path.extname(name).toLowerCase();
  if (RESTORABLE_EXTENSIONS.has(ext)) return "code";
  if (ASSET_REFERENCE_EXTENSIONS.has(ext)) return "asset";
  return "skip";
}

// ── Capture tree / scrape ─────────────────────────────────────────────────────

export async function captureFileTree(input: { root?: string; serial?: string; includeSizes?: boolean } = {}): Promise<{ ok: true; serial: string; root: string; tree: CaptureTreeNode }> {
  const activeProject = controller.activeProjectInquiry();
  if (!activeProject.ok) throw new Error(activeProject.error || "project_required_for_reads");

  const serial = serialOf(input.serial || activeProject.projectSerial);
  const projectRoot = path.resolve(activeProject.projectRoot || ROOT);
  const root = controller.resolveProjectPath(input.root || ".");

  const walk = async (abs: string): Promise<CaptureTreeNode> => {
    const stat = await fs.stat(abs);
    const rel = normalizeSlashes(path.relative(projectRoot, abs)) || ".";
    if (stat.isFile()) return { name: path.basename(abs), path: rel, type: "file", ...(input.includeSizes ? { size: stat.size } : {}) };
    const children: CaptureTreeNode[] = [];
    for (const entry of (await fs.readdir(abs, { withFileTypes: true })).sort((a,b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))) {
      if (entry.name.startsWith(".") && entry.name !== ".temp") continue;
      if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
      children.push(await walk(path.join(abs, entry.name)));
    }
    return { name: path.basename(abs) || "root", path: rel, type: "dir", children };
  };

  return { ok: true, serial, root, tree: await walk(root) };
}
export async function captureScrape(input: CaptureScrapeInput = {}): Promise<{ ok: true; serial: string; name: string; filePath: string; fileCount: number }> {
  const serial = serialOf(input.serial);
  const root = input.root ? resolveInsideProject(input.root) : activeRestoreProject().root;
  const name = String(input.name || `scrape_${tsCompact()}.txt`).replace(/[^A-Za-z0-9._-]+/g, "_");
  const outPath = path.join(scrapesDir(serial), name.endsWith(".txt") ? name : `${name}.txt`);
  const maxBytes = Math.max(1024, Number(input.maxBytes || 250000));
  const parts: string[] = [];
  let count = 0;
  const walk = async (abs: string) => {
    const stat = await fs.stat(abs).catch(() => null);
    if (!stat) return;
    if (stat.isDirectory()) {
      if (SKIP_DIRS.has(path.basename(abs))) return;
      for (const entry of await fs.readdir(abs).catch(() => [])) await walk(path.join(abs, entry));
      return;
    }
    if (!stat.isFile() || classifyCaptureFile(path.basename(abs)) !== "code") return;
    if (stat.size > maxBytes) return;
    const rel = relFromProject(abs);
    const text = input.includeContent === false ? "" : await readTextSafe(abs, "");
    parts.push(`/${rel}\n${text}\n`);
    count++;
  };
  await walk(root);
  await fs.mkdir(path.dirname(outPath), { recursive: true });
  await fs.writeFile(outPath, parts.join("\n"), "utf8");
  await appendSystemLog(serial, "scrape_captured", { name: path.basename(outPath), fileCount: count });
  return { ok: true, serial, name: path.basename(outPath), filePath: outPath, fileCount: count };
}

function isScrapePathHeader(line: string): boolean {
  const c = String(line || "").trim();
  return Boolean(c) && c.startsWith("/") && RESTORABLE_EXTENSIONS.has(path.extname(c).toLowerCase());
}
async function parseScrapeFile(filePath: string): Promise<Map<string, string>> {
  const lines = (await fs.readFile(filePath, "utf8")).split(/\r?\n/);
  const out = new Map<string, string>();
  let i = 0;
  while (i < lines.length) {
    if (!isScrapePathHeader(lines[i])) { i++; continue; }
    const relPath = lines[i].trim().replace(/^\//, "");
    const body: string[] = [];
    i++;
    while (i < lines.length && !isScrapePathHeader(lines[i])) { body.push(lines[i]); i++; }
    out.set(normalizeSlashes(relPath), body.join("\n").replace(/\n+$/g, ""));
  }
  return out;
}

// ── Write snapshots / A-B backup ──────────────────────────────────────────────

export async function saveWriteSnapshot(input: WriteSnapshotInput): Promise<void> {
  const serial = serialOf(input.serial);
  const dir = path.join(writesDir(serial), input.dubID);
  const rel = relFromProject(path.resolve(input.filePath));
  await writeJson(path.join(dir, "meta.json"), { dubID: input.dubID, serial, action: input.action, filePath: rel, createdAt: new Date().toISOString() }).catch(() => {});
  if (input.before !== null) {
    const beforePath = path.join(dir, "before", rel);
    await fs.mkdir(path.dirname(beforePath), { recursive: true });
    await fs.writeFile(beforePath, input.before, "utf8").catch(() => {});
  }
  const afterPath = path.join(dir, "after", rel);
  await fs.mkdir(path.dirname(afterPath), { recursive: true });
  await fs.writeFile(afterPath, input.after, "utf8").catch(() => {});
}

export async function saveRewriteOriginal(input: { dubID: string; serial: string; filePath: string; content: string }): Promise<void> {
  const serial = serialOf(input.serial);
  const rel = relFromProject(path.resolve(input.filePath));
  const beforePath = path.join(writesDir(serial), input.dubID, "before", rel);
  await fs.mkdir(path.dirname(beforePath), { recursive: true });
  await fs.writeFile(beforePath, input.content, "utf8").catch(() => {});
}

type AutoBackupState = { activeSlot: AutoBackupSlot; successfulRunsSinceBackup: number; lastBackupTimestamp: number; lastValidSlot: AutoBackupSlot };
async function loadAutoBackupState(serial: string): Promise<AutoBackupState> {
  return await readJsonSafe<AutoBackupState>(autoStatePath(serial), { activeSlot: "B", successfulRunsSinceBackup: 0, lastBackupTimestamp: 0, lastValidSlot: "B" });
}
async function saveAutoBackupState(serial: string, state: AutoBackupState): Promise<void> { await writeJson(autoStatePath(serial), state); }
function normalizeBackupFilePath(rawPath: string): string {
  if (!rawPath) return "";
  const abs = resolveInsideProject(rawPath);
  const rel = relFromProject(abs);
  return rel && !rel.startsWith("..") ? rel : "";
}
function isRestorableCodeFile(filePath: string): boolean { return RESTORABLE_EXTENSIONS.has(path.extname(String(filePath || "").toLowerCase())); }

export async function createFullProjectSnapshot(input: { serial: string; slot: AutoBackupSlot; filePaths: string[]; dubID?: string; source?: string }): Promise<{ ok: true; slot: AutoBackupSlot; name: string; fileCount: number; folderPath: string }> {
  const serial = serialOf(input.serial);
  const slot = input.slot;
  const name = autoSlotName(slot);
  const folderPath = autoSlotPath(serial, slot);
  const tmpPath = `${folderPath}.tmp`;
  await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {});
  await fs.mkdir(path.join(tmpPath, "files"), { recursive: true });
  const entries: any[] = [];
  const seen = new Set<string>();
  for (const rawPath of input.filePaths || []) {
    const rel = normalizeBackupFilePath(rawPath);
    if (!rel || seen.has(rel) || !isRestorableCodeFile(rel)) continue;
    seen.add(rel);
    const abs = resolveInsideProject(rel);
    if (!(await exists(abs))) { entries.push({ sourcePath: `./${rel}`, kind: "code", existedBefore: false, reason: "periodic" }); continue; }
    const text = await fs.readFile(abs, "utf8");
    const savedAbs = path.join(tmpPath, "files", rel);
    await fs.mkdir(path.dirname(savedAbs), { recursive: true });
    await fs.writeFile(savedAbs, text, "utf8");
    entries.push({ sourcePath: `./${rel}`, savedPath: `./files/${rel}`, kind: "code", existedBefore: true, reason: "periodic", size: Buffer.byteLength(text, "utf8"), lineCount: text.split(/\r?\n/).length });
  }
  await writeJson(path.join(tmpPath, "manifest.json"), { kind: "restore", restoreKind: "auto_project_backup", serial, name, slot, createdAt: new Date().toISOString(), dubID: input.dubID || "", source: input.source || "recorder", project: { serial, root: activeRestoreProject().root }, includedExtensions: [...RESTORABLE_EXTENSIONS], assetReferenceExtensions: [], excludedRules: { directories: [], filenames: [], nameContains: [], blockedExtensions: [] }, files: entries, assets: [] });
  await fs.rm(folderPath, { recursive: true, force: true }).catch(() => {});
  await fs.rename(tmpPath, folderPath);
  await appendSystemLog(serial, "auto_project_backup_created", { slot, fileCount: entries.length, dubID: input.dubID || "" });
  return { ok: true, slot, name, fileCount: entries.length, folderPath };
}

async function exportAutoSlotToScrape(serial: string, slot: AutoBackupSlot): Promise<void> {
  const name = autoSlotName(slot);
  const manifest = await readJsonSafe<any>(path.join(autoSlotPath(serial, slot), "manifest.json"), null);
  const files = Array.isArray(manifest?.files) ? manifest.files : [];
  if (!files.length) return;
  const parts: string[] = [];
  for (const entry of files) {
    if (!entry?.existedBefore || !entry?.savedPath) continue;
    const sourcePath = String(entry.sourcePath || "").replace(/^\.\//, "");
    const savedAbs = path.join(autoSlotPath(serial, slot), String(entry.savedPath || "").replace(/^\.\//, ""));
    parts.push(`/${sourcePath}\n${await readTextSafe(savedAbs, "")}\n`);
  }
  if (!parts.length) return;
  await fs.mkdir(scrapesDir(serial), { recursive: true });
  const scrapeName = `overflow_${tsCompact()}_${serial}_${name}.txt`;
  await fs.writeFile(path.join(scrapesDir(serial), scrapeName), parts.join("\n"), "utf8");
  await appendSystemLog(serial, "overflow_scrape_created", { slot, scrapeName });
}

export async function noteSuccessfulRecorderRun(input: { manifests?: any[]; dubID?: string; source?: string; serial?: string } = {}): Promise<any> {
  const serial = serialOf(input.serial);
  const touched = (Array.isArray(input.manifests) ? input.manifests : []).map(m => normalizeBackupFilePath(String(m?.FilePath || ""))).filter(Boolean).filter(isRestorableCodeFile);
  const state = await loadAutoBackupState(serial);
  const nextCount = Number(state.successfulRunsSinceBackup || 0) + 1;
  const timeThresholdMet = now() - Number(state.lastBackupTimestamp || 0) >= AUTO_BACKUP_MIN_DAYS_MS;
  const writeThresholdMet = nextCount >= AUTO_BACKUP_MIN_WRITES;
  if (!(timeThresholdMet && writeThresholdMet && touched.length)) {
    await saveAutoBackupState(serial, { ...state, successfulRunsSinceBackup: nextCount });
    return { ok: true, serial, checkpointCreated: false, successfulRunsSinceBackup: nextCount, timeThresholdMet, writeThresholdMet };
  }
  const slot = nextAutoSlot(state.activeSlot || "B");
  await exportAutoSlotToScrape(serial, slot);
  const result = await createFullProjectSnapshot({ serial, slot, filePaths: touched, dubID: input.dubID, source: input.source });
  await saveAutoBackupState(serial, { activeSlot: slot, successfulRunsSinceBackup: 0, lastBackupTimestamp: now(), lastValidSlot: slot });
  return { ...result, serial, checkpointCreated: true, successfulRunsSinceBackup: 0, timeThresholdMet, writeThresholdMet };
}

// ── Manual restore points ─────────────────────────────────────────────────────

export async function createRestorePoint(input: { system: boolean; settings: boolean; projectNames?: string[] }): Promise<any> {
  const activeProject = activeRestoreProject();
  const selection = {
    system: Boolean(input.system && activeProject.isBackstage),
    settings: Boolean(input.settings && activeProject.isBackstage),
    projectNames: [...new Set((input.projectNames || [activeProject.name]).map(String).map(s => s.trim()).filter(Boolean))]
  };

  if (!selection.system && !selection.settings && !selection.projectNames.length) throw new Error("restore_selection_empty");

  const parts = ["restore"];
  if (selection.system) parts.push("System");
  if (selection.settings) parts.push("Settings");
  if (selection.projectNames.length === 1) parts.push(safeLabelPart(selection.projectNames[0]));
  else if (selection.projectNames.length > 1) parts.push("Projects");
  parts.push(tsCompact());

  const name = parts.join("_");
  const folderPath = path.join(recoveryDir(), name);
  const filesDir = path.join(folderPath, "files");
  const files: any[] = [];
  const assets: any[] = [];
  const visited = new Set<string>();

  const copyRestoreSource = async (sourcePath: string): Promise<void> => {
    const abs = path.resolve(sourcePath);
    if (visited.has(abs) || !isInsideRoot(abs, activeProject.root)) return;
    visited.add(abs);

    const stat = await fs.stat(abs).catch(() => null);
    if (!stat) return;

    const name = path.basename(abs);
    if (stat.isDirectory()) {
      if (SKIP_DIRS.has(name)) return;
      for (const entry of await fs.readdir(abs).catch(() => [])) await copyRestoreSource(path.join(abs, entry));
      return;
    }

    if (!stat.isFile()) return;

    const cls = classifyCaptureFile(name);
    if (cls === "skip") return;

    const rel = relFromProject(abs);
    if (cls === "asset") {
      assets.push({ sourcePath: `./${rel}`, kind: "asset_reference" });
      return;
    }

    const savedAbs = path.join(filesDir, rel);
    await fs.mkdir(path.dirname(savedAbs), { recursive: true });
    await fs.copyFile(abs, savedAbs);
    files.push({ sourcePath: `./${rel}`, savedPath: `./files/${rel}`, kind: "code" });
  };

  await fs.mkdir(filesDir, { recursive: true });

  const roots: string[] = [];
  if (selection.system) roots.push(ROOT);
  if (selection.settings) roots.push(SettingsPaths.dir);
  if (activeProject.isBackstage) for (const project of selection.projectNames) roots.push(path.join(DataPaths.projectsDir, project));
  else roots.push(activeProject.root);

  for (const root of [...new Set(roots.map(p => path.resolve(p)))]) {
    if (await exists(root)) await copyRestoreSource(root);
  }

  const manifest = {
    kind: "restore",
    name,
    createdAt: new Date().toISOString(),
    selection,
    includedExtensions: [...RESTORABLE_EXTENSIONS],
    assetReferenceExtensions: [...ASSET_REFERENCE_EXTENSIONS],
    excludedRules: { directories: [...SKIP_DIRS], filenames: [...SKIP_FILES], nameContains: [...SKIP_NAME_CONTAINS], blockedExtensions: [".log"] },
    files: files.sort((a,b) => a.sourcePath.localeCompare(b.sourcePath)),
    assets: assets.sort((a,b) => a.sourcePath.localeCompare(b.sourcePath))
  };

  await writeJson(path.join(folderPath, "manifest.json"), manifest);
  await appendSystemLog(serialOf(), "manual_restore_point_created", { restoreName: name, fileCount: files.length, assetCount: assets.length });

  return { ok: true, name, folderPath, fileCount: files.length, assetCount: assets.length, manifest };
}

export async function listRestorePoints(): Promise<string[]> {
  try { return (await fs.readdir(recoveryDir(), { withFileTypes: true })).filter(e => e.isDirectory()).map(e => e.name).sort((a,b) => b.localeCompare(a)); }
  catch { return []; }
}

export async function validateRestoreManifest(manifestPath: string): Promise<{ ok: boolean; errors: string[] }> {
  const errors: string[] = [];
  const manifest = await readJsonSafe<any>(manifestPath, null);
  if (!manifest || manifest.kind !== "restore") return { ok: false, errors: ["manifest_invalid_or_missing"] };
  for (const entry of Array.isArray(manifest.files) ? manifest.files : []) {
    if (!entry?.savedPath || entry.existedBefore === false) continue;
    const savedAbs = path.join(path.dirname(manifestPath), String(entry.savedPath || "").replace(/^\.\//, ""));
    if (!(await exists(savedAbs))) errors.push(`missing_saved_file:${entry.sourcePath}`);
  }
  return { ok: errors.length === 0, errors };
}

async function selectiveWrite(targetRelPath: string, desired: string, restoreTs: number, dryRun: boolean): Promise<any> {
  const abs = resolveInsideProject(targetRelPath);
  if (!(await exists(abs))) {
    if (!dryRun) { await fs.mkdir(path.dirname(abs), { recursive: true }); await fs.writeFile(abs, desired, "utf8"); }
    return { action: "created", rewritten: true, filePath: normalizeSlashes(targetRelPath) };
  }
  const current = await fs.readFile(abs, "utf8");
  if (current === desired) return { action: "unchanged", rewritten: false, filePath: normalizeSlashes(targetRelPath) };
  let shouldWrite = true;
  try { shouldWrite = (await fs.stat(abs)).mtimeMs >= restoreTs; } catch {}
  if (!shouldWrite) return { action: "unchanged", rewritten: false, filePath: normalizeSlashes(targetRelPath) };
  if (!dryRun) { await fs.mkdir(path.dirname(abs), { recursive: true }); await fs.writeFile(abs, desired, "utf8"); }
  return { action: "restored", rewritten: true, filePath: normalizeSlashes(targetRelPath) };
}

export async function restoreFromBackup(input: RestoreFromBackupInput): Promise<RecoveryResult> {
  const name = String(input?.name || "").trim(), dryRun = Boolean(input?.dryRun), serial = serialOf(input?.serial);
  if (!name) return { ok: false, operation: "restore_from_backup", summary: "Missing backup name.", warnings: [], errors: ["restore_name_missing"] };
  const manifestPath = path.join(recoveryDir(serial), name, "manifest.json");
  const manifest = await readJsonSafe<any>(manifestPath, null);
  if (!manifest || manifest.kind !== "restore") return { ok: false, operation: "restore_from_backup", summary: "Restore manifest not found or invalid.", warnings: [], errors: ["restore_manifest_invalid"] };
  const validation = await validateRestoreManifest(manifestPath);
  if (!validation.ok) return { ok: false, operation: "restore_from_backup", summary: "Restore manifest failed validation.", warnings: [], errors: validation.errors };
  const restoreTs = Date.parse(String(manifest.createdAt || ""));
  const files: any[] = [];
  for (const entry of Array.isArray(manifest.files) ? manifest.files : []) {
    if (entry?.existedBefore === false || !entry?.savedPath) continue;
    const sourceAbs = path.join(recoveryDir(serial), name, String(entry.savedPath).replace(/^\.\//, ""));
    files.push(await selectiveWrite(String(entry.sourcePath || "").replace(/^\.\//, ""), await readTextSafe(sourceAbs, ""), Number.isFinite(restoreTs) ? restoreTs : 0, dryRun));
  }
  await appendSystemLog(serial, "recovery_restore_backup_completed", { name, dryRun, rewritten: files.filter(f => f.rewritten).length });
  return { ok: true, operation: "restore_from_backup", summary: dryRun ? `Planned restore from backup ${name}.` : `Restored from backup ${name}.`, warnings: files.length ? [] : ["restore_manifest_contains_no_files"], errors: [], details: { name, dryRun, rewrittenCount: files.filter(f => f.rewritten).length, unchangedCount: files.filter(f => !f.rewritten).length, files } };
}

export async function restoreFromPoint(name: string): Promise<any> {
  const result = await restoreFromBackup({ name, serial: serialOf(), dryRun: false });
  if (!result.ok) throw new Error(result.errors?.[0] || "restore_failed");
  const restoredFiles = Number(result.details?.rewrittenCount || 0);
  await appendSystemLog(serialOf(), "manual_restore_point_restored", { restoreName: name, restoredFiles });
  return { ok: true, name, restoredFiles };
}

async function resolveScrapePath(serial: string, name: string): Promise<string> {
  const scoped = path.join(scrapesDir(serial), name);
  if (await exists(scoped)) return scoped;

  const legacy = path.join(DataPaths.scrapesDir, name);
  if (await exists(legacy)) return legacy;

  return "";
}

export async function restoreFromScrape(input: RestoreFromScrapeInput): Promise<RecoveryResult> {
  const name = String(input?.name || "").trim(), dryRun = Boolean(input?.dryRun), serial = serialOf(input?.serial);
  if (!name) return { ok: false, operation: "restore_from_scrape", summary: "Missing scrape name.", warnings: [], errors: ["scrape_name_missing"] };
  const scrapePath = await resolveScrapePath(serial, name);
  if (!scrapePath) return { ok: false, operation: "restore_from_scrape", summary: "Scrape file not found.", warnings: [], errors: ["scrape_file_missing"] };
  const stat = await fs.stat(scrapePath);
  const parsed = await parseScrapeFile(scrapePath);
  const files: any[] = [];
  for (const [rel, content] of parsed.entries()) files.push(await selectiveWrite(rel, content, stat.mtimeMs || stat.birthtimeMs || 0, dryRun));
  await appendSystemLog(serial, "recovery_restore_scrape_completed", { name, dryRun, rewritten: files.filter(f => f.rewritten).length });
  return { ok: true, operation: "restore_from_scrape", summary: dryRun ? `Planned restore from scrape ${name}.` : `Restored from scrape ${name}.`, warnings: parsed.size ? [] : ["scrape_parsed_with_no_restorable_files"], errors: [], details: { name, dryRun, rewrittenCount: files.filter(f => f.rewritten).length, unchangedCount: files.filter(f => !f.rewritten).length, files } };
}

// ── Timeline / undo / input manifest ──────────────────────────────────────────

async function loadTape(serial?: string): Promise<any[]> { return await readJsonSafe<any[]>(tapeFile(serial), []); }
async function listRestorePointNames(serial: string): Promise<string[]> {
  try { return (await fs.readdir(recoveryDir(serial), { withFileTypes: true })).filter(e => e.isDirectory()).map(e => e.name).sort((a,b) => b.localeCompare(a)); }
  catch { return []; }
}
async function listScrapeNames(serial: string): Promise<string[]> {
  const names = new Set<string>();

  for (const dir of [scrapesDir(serial), DataPaths.scrapesDir]) {
    try {
      for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
        if (entry.isFile() && entry.name.toLowerCase().endsWith(".txt")) names.add(entry.name);
      }
    } catch {}
  }

  return [...names].sort((a, b) => b.localeCompare(a));
}
export async function getTimeline(limit = 25, serial?: string): Promise<RecoveryResult> {
  const s = serialOf(serial);
  const tape = await loadTape(s);
  const restoreNames = await listRestorePointNames(s);
  const tasks = tape.map(item => ({ kind: "task", ts: Number(item.timestamp || 0), label: summarizeManifest(item.manifest), status: item.status, action: String(item.manifest?.Action || ""), filePath: String(item.manifest?.FilePath || ""), dubID: item.dubID, meta: item.result?.error ? { error: String(item.result.error) } : {} }));
  const restores = await Promise.all(restoreNames.map(async name => {
    const manifest = await readJsonSafe<any>(path.join(recoveryDir(s), name, "manifest.json"), {});
    const createdAt = Date.parse(String(manifest?.createdAt || ""));
    return { kind: "restore_point", ts: Number.isFinite(createdAt) ? createdAt : 0, label: name, status: "created", restoreName: name, restoreKind: restoreKindFromName(name), meta: { fileCount: Array.isArray(manifest.files) ? manifest.files.length : 0 } };
  }));
  const entries = [...tasks, ...restores].sort((a,b) => Number(b.ts) - Number(a.ts)).slice(0, Math.max(1, Number(limit || 25)));
  return { ok: true, operation: "timeline", summary: `Loaded ${entries.length} timeline entries.`, warnings: [], errors: [], details: { entries } };
}

function buildUndoManifest(item: any): { ok: true; manifest: any } | { ok: false; reason: string } {
  const m = item.manifest || {}, p = isObject(m.Payload) ? m.Payload : {}, action = String(m.Action || "").toLowerCase(), filePath = String(m.FilePath || "");
  if (action === "replacestring") return { ok: true, manifest: { Task: `Undo: ${summarizeManifest(m)}`, Action: "replacestring", FilePath: filePath, Payload: { target: String(p.replacement || ""), replacement: String(p.target || "") }, Comment: "Generated by restore undo." } };
  if (action === "insertbefore") return { ok: true, manifest: { Task: `Undo: ${summarizeManifest(m)}`, Action: "replacestring", FilePath: filePath, Payload: { target: String(p.content || "") + String(p.find || ""), replacement: String(p.find || "") }, Comment: "Generated by restore undo." } };
  if (action === "insertafter") return { ok: true, manifest: { Task: `Undo: ${summarizeManifest(m)}`, Action: "replacestring", FilePath: filePath, Payload: { target: String(p.find || "") + String(p.content || ""), replacement: String(p.find || "") }, Comment: "Generated by restore undo." } };
  return { ok: false, reason: `undo_not_exactly_reversible:${action}` };
}

export async function undo(input: UndoInput): Promise<RecoveryResult> {
  const count = Math.max(1, Math.min(15, Number(input?.count || 1))), dryRun = Boolean(input?.dryRun), serial = serialOf(input?.serial);
  const items = (await loadTape(serial)).filter(i => i?.status === "done").sort((a,b) => Number(b.timestamp || 0) - Number(a.timestamp || 0)).slice(0, count);
  if (!items.length) return { ok: false, operation: "undo", summary: "No successful manifest history to undo.", warnings: [], errors: ["undo_history_empty"] };
  const planned = items.map(item => ({ item, inverse: buildUndoManifest(item) }));
  const blocked = planned.filter(p => !p.inverse.ok);
  if (blocked.length) return { ok: false, operation: "undo", summary: "Undo stopped — one or more tasks are not exactly reversible.", warnings: [], errors: blocked.map(b => (b.inverse as any).reason), details: { blocked } };
  const manifests = planned.map(p => (p.inverse as any).manifest);
  return {
    ok: true,
    operation: "undo",
    summary: dryRun ? `Prepared ${manifests.length} inverse manifests.` : "Undo manifests prepared for recorder execution.",
    warnings: [],
    errors: [],
    details: {
      dryRun,
      recorderReady: true,
      manifests,
      applied: manifests
    }
  };
}

export async function inputManifest(input: InputManifestInput): Promise<RecoveryResult> {
  const validation = validateManifestShape(input?.manifest, { requirePayload: true });
  if (!validation.ok) return { ok: false, operation: "input_manifest", summary: "Manifest validation failed.", warnings: [], errors: [validation.error || "invalid_manifest"], details: { manifest: input?.manifest } };
  return {
    ok: true,
    operation: "input_manifest",
    summary: input?.dryRun ? "Manifest validated successfully." : "Manifest accepted for recorder execution.",
    warnings: [],
    errors: [],
    details: {
      dryRun: Boolean(input?.dryRun),
      recorderReady: true,
      manifest: input.manifest,
      manifests: [input.manifest]
    }
  };
}

export async function listRecoverySources(): Promise<RecoveryResult> {
  const serial = serialOf();
  const [restorePoints, scrapes] = await Promise.all([listRestorePointNames(serial), listScrapeNames(serial)]);
  return { ok: true, operation: "timeline", summary: "Loaded recovery sources.", warnings: [], errors: [], details: { restorePoints, scrapes } };
}

export function recoveryShellHtml(reason?: string): string {
  const banner = reason ? `<div style="background:#c0392b;color:#fff;padding:10px 16px;margin-bottom:16px;border-radius:4px"><strong>Recovery mode:</strong> ${reason}</div>` : "";
  return `<!doctype html><html><head><meta charset="utf-8"/><title>NV Recovery</title><style>body{font-family:sans-serif;margin:20px;background:#1a1a2e;color:#e0e0e0}section{margin:0 0 18px;padding:12px;border:1px solid #444;background:#16213e;border-radius:4px}textarea,input,select{width:100%;box-sizing:border-box;margin:6px 0;background:#0f3460;color:#e0e0e0;border:1px solid #555;padding:6px}button{margin:6px 6px 0 0;padding:6px 14px;background:#e94560;color:#fff;border:0;border-radius:3px}pre{white-space:pre-wrap;background:#0f3460;padding:10px}</style></head><body><h1>NV Recovery</h1>${banner}<section><button onclick="loadTimeline()">Refresh Timeline</button><pre id="timeline">Loading...</pre></section><section><input id="undoCount" type="number" value="1"/><button onclick="undoRun(true)">Preview Undo</button><button onclick="undoRun(false)">Prepare Undo</button></section><section><textarea id="manifest"></textarea><button onclick="manifestRun(true)">Validate</button></section><section><pre id="out">Ready.</pre></section><script>async function api(p,m='GET',b){const o={method:m,headers:{}};if(b!==undefined){o.headers['Content-Type']='application/json';o.body=JSON.stringify(b)}const r=await fetch(p,o);return await r.json()}function show(v){out.textContent=JSON.stringify(v,null,2)}async function loadTimeline(){const d=await api('/recovery/timeline?limit=25');timeline.textContent=JSON.stringify(d,null,2)}async function undoRun(dryRun){show(await api('/recovery/undo','POST',{count:Number(undoCount.value||1),dryRun}));await loadTimeline()}async function manifestRun(dryRun){let m={};try{m=JSON.parse(manifest.value||'{}')}catch(e){show({ok:false,error:e.message});return}show(await api('/recovery/manifest','POST',{manifest:m,dryRun}));await loadTimeline()}loadTimeline().catch(e=>show({ok:false,error:e.message}))</script></body></html>`;
}

export function getScopeRecoveryDir(serial: string): string { return recoveryDir(serial); }
export function getScopeScrapesDir(serial: string): string { return scrapesDir(serial); }
export function getScopeWritesDir(serial: string): string { return writesDir(serial); }
export function getProjectRecoveryDir(serial: string): string { return recoveryDir(serial); }
export function getProjectScrapesDir(serial: string): string { return scrapesDir(serial); }
export function getProjectWritesDir(serial: string): string { return writesDir(serial); }

