/**
 * restore.ts — top-level `mosaic restore` command (#791 PR2 Task 12)
 *
 * Recovery counterpart to the durable pre-update snapshot taken by install.sh
 * (make_durable_snapshot). Before a keep-mode upgrade mutates anything, the
 * installer copies the operator-owned surface to
 *   $XDG_STATE_HOME/mosaic/backups/pre-update-<UTC-ts>/   (0700 dirs / 0600 files)
 * This command lets the operator inspect and roll back to those snapshots:
 *
 *   mosaic restore                 # == --list: enumerate snapshots (dry-run)
 *   mosaic restore --list
 *   mosaic restore --from <ts>     # restore that snapshot over MOSAIC_HOME
 *   mosaic restore --from <ts> --dry-run
 *
 * SECREV INVARIANT: a snapshot may contain secrets (e.g. tools/_lib/credentials.json).
 * This command reports counts and RELATIVE PATHS only — it never reads a backed-up
 * file into any logged string. Restored files are written back 0600 (owner-only),
 * matching the snapshot's own private posture. The path convention here mirrors
 * install.sh `backup_root()`; keep the two in sync (no shared code across the boundary).
 */

import {
  existsSync,
  readdirSync,
  statSync,
  lstatSync,
  readFileSync,
  openSync,
  writeSync,
  fchmodSync,
  closeSync,
  constants,
} from 'node:fs';
import { createInterface } from 'node:readline';
import { homedir } from 'node:os';
import { join, dirname, relative } from 'node:path';
import type { Command } from 'commander';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import { assertCanonicalContainment, ensureManagedDirectory } from '../fleet/secure-file.js';

// ─── types ───────────────────────────────────────────────────────────────────

export interface SnapshotInfo {
  /** The UTC stamp after the `pre-update-` prefix, e.g. "20260716T232225Z". */
  readonly timestamp: string;
  /** Absolute path to the snapshot directory. */
  readonly dir: string;
  /** Number of files captured in the snapshot. */
  readonly fileCount: number;
}

export interface RestoreOptions {
  list?: boolean;
  from?: string;
  dryRun?: boolean;
  yes?: boolean;
  mosaicHome: string;
  /** Environment source (injectable for tests); defaults to process.env. */
  env?: NodeJS.ProcessEnv;
  /**
   * Confirmation gate (injectable for tests); defaults to an interactive
   * readline prompt. Returns true to proceed with the overwrite.
   */
  confirm?: (question: string) => Promise<boolean>;
}

const SNAPSHOT_PREFIX = 'pre-update-';

/**
 * The exact shape install.sh `make_durable_snapshot()` stamps: `<8>T<6>Z` UTC,
 * with an optional `-<n>` same-second collision suffix. `--from` is matched
 * against this — nothing containing a path separator or `..` can pass, so a
 * selector can never escape the backup root (CWE-22).
 */
const SNAPSHOT_TS_RE = /^\d{8}T\d{6}Z(?:-\d+)?$/;

// ─── pure helpers ─────────────────────────────────────────────────────────────

/** Resolve the durable-snapshot root, mirroring install.sh `backup_root()`. */
export function resolveBackupRoot(env: NodeJS.ProcessEnv = process.env): string {
  const stateHome = env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state');
  return join(stateHome, 'mosaic', 'backups');
}

/** Recursively collect every file under `dir` as a path relative to `dir`. */
export function planRestore(dir: string): string[] {
  const out: string[] = [];
  const walk = (cur: string): void => {
    for (const entry of readdirSync(cur, { withFileTypes: true })) {
      const abs = join(cur, entry.name);
      if (entry.isDirectory()) {
        walk(abs);
      } else if (entry.isFile()) {
        out.push(relative(dir, abs));
      }
    }
  };
  if (existsSync(dir)) walk(dir);
  return out;
}

/** Enumerate snapshots newest-first (the `pre-update-<ts>` names sort chronologically). */
export function listSnapshots(root: string): SnapshotInfo[] {
  if (!existsSync(root)) return [];
  let entries: string[];
  try {
    entries = readdirSync(root);
  } catch {
    return [];
  }
  return entries
    .filter((name) => name.startsWith(SNAPSHOT_PREFIX))
    .map((name) => join(root, name))
    .filter((dir) => {
      try {
        return statSync(dir).isDirectory();
      } catch {
        return false;
      }
    })
    .sort()
    .reverse()
    .map((dir) => ({
      timestamp: dir.split('/').at(-1)!.slice(SNAPSHOT_PREFIX.length),
      dir,
      fileCount: planRestore(dir).length,
    }));
}

/**
 * Resolve a snapshot dir from a `--from` selector. Accepts ONLY a strict
 * generated identifier — a bare `<ts>` or the full `pre-update-<ts>` name — and
 * builds exactly `join(root, 'pre-update-' + ts)`. A selector containing `/`,
 * `..`, or anything but the timestamp shape is rejected (returns undefined), so
 * `--from` can never traverse outside the backup root (CWE-22). The resolved dir
 * must be a real, non-symlink directory (lstat, not stat), so a symlinked
 * snapshot entry can't redirect the restore either.
 */
export function resolveSnapshotDir(root: string, from: string): string | undefined {
  const ts = from.startsWith(SNAPSHOT_PREFIX) ? from.slice(SNAPSHOT_PREFIX.length) : from;
  if (!SNAPSHOT_TS_RE.test(ts)) return undefined;
  const dir = join(root, `${SNAPSHOT_PREFIX}${ts}`);
  try {
    if (lstatSync(dir).isDirectory()) return dir;
  } catch {
    /* absent or inaccessible */
  }
  return undefined;
}

/**
 * Copy each `relPaths` entry from the snapshot back into `mosaicHome`, forcing
 * 0600 on the restored file (owner-only — the operator surface may hold secrets).
 * Returns the number of files restored. Never reads a file's content into a
 * logged string.
 *
 * SYMLINK-SAFE (CWE-59): a snapshot may hold secrets, so we must never let a
 * tampered destination redirect the write. Every destination path is contained
 * within `mosaicHome` (assertCanonicalContainment) and every ancestor is proven
 * to be a real, non-symlink directory (ensureManagedDirectory) before we write.
 * The leaf itself is opened O_NOFOLLOW, so if it was swapped for a symlink the
 * open fails closed (ELOOP) rather than writing the secret through the link.
 */
export function applyRestore(
  snapDir: string,
  mosaicHome: string,
  relPaths: readonly string[],
): number {
  let restored = 0;
  for (const rel of relPaths) {
    const src = join(snapDir, rel);
    const dst = join(mosaicHome, rel);
    // Fail closed if the target path escapes the managed root or any ancestor is
    // a symlink; create missing ancestors as private (0700) real directories.
    assertCanonicalContainment(mosaicHome, dst);
    ensureManagedDirectory(mosaicHome, dirname(dst));
    // O_NOFOLLOW: refuse to follow a symlink at the leaf (secret exfil guard).
    // O_CREAT|O_TRUNC: create a fresh 0600 file, or overwrite a diverged real one.
    const fd = openSync(
      dst,
      constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW,
      0o600,
    );
    try {
      fchmodSync(fd, 0o600); // enforce 0600 even when the file pre-existed
      writeSync(fd, readFileSync(src));
    } finally {
      closeSync(fd);
    }
    restored += 1;
  }
  return restored;
}

// ─── orchestration ────────────────────────────────────────────────────────────

async function promptConfirm(question: string): Promise<boolean> {
  const rl = createInterface({ input: process.stdin, output: process.stdout });
  try {
    return await new Promise<boolean>((resolve) => {
      rl.question(`${question} [y/N] `, (ans) => resolve(ans.trim().toLowerCase() === 'y'));
    });
  } finally {
    rl.close();
  }
}

/**
 * Run `mosaic restore`. Returns a process exit code (0 ok, 1 error) rather than
 * calling process.exit, so it stays unit-testable.
 */
export async function runRestore(opts: RestoreOptions): Promise<number> {
  const env = opts.env ?? process.env;
  const root = resolveBackupRoot(env);

  // Default action (and explicit --list): enumerate, never mutate.
  if (opts.list || !opts.from) {
    const snaps = listSnapshots(root);
    if (snaps.length === 0) {
      console.log(`No pre-update snapshots found under ${root}.`);
      return 0;
    }
    console.log(`Pre-update snapshots under ${root} (newest first):\n`);
    for (const s of snaps) {
      console.log(`  ${s.timestamp}  —  ${s.fileCount} file(s)`);
    }
    console.log(`\nRestore one with:  mosaic restore --from <timestamp>`);
    return 0;
  }

  // --from <ts>: restore over the operator surface.
  const snapDir = resolveSnapshotDir(root, opts.from);
  if (!snapDir) {
    console.error(`No snapshot matching '${opts.from}' under ${root}.`);
    console.error(`Run 'mosaic restore --list' to see available timestamps.`);
    return 1;
  }

  const relPaths = planRestore(snapDir);
  const ts = snapDir.split('/').at(-1)!.slice(SNAPSHOT_PREFIX.length);

  if (opts.dryRun) {
    console.log(
      `[dry-run] Would restore ${relPaths.length} file(s) from snapshot ${ts} into ${opts.mosaicHome}:`,
    );
    for (const rel of relPaths) console.log(`  ${rel}`);
    console.log('[dry-run] No changes made.');
    return 0;
  }

  const assumeYes = opts.yes || env['MOSAIC_ASSUME_YES'] === '1';
  if (!assumeYes) {
    console.log(
      `About to restore ${relPaths.length} operator file(s) from snapshot ${ts} into ${opts.mosaicHome}.`,
    );
    console.log('This OVERWRITES those files with their pre-update contents.');
    const ok = await (opts.confirm ?? promptConfirm)('Proceed?');
    if (!ok) {
      console.log('Restore cancelled. No changes made.');
      return 0;
    }
  }

  let n: number;
  try {
    n = applyRestore(snapDir, opts.mosaicHome, relPaths);
  } catch (err) {
    // A containment/symlink violation is a fail-closed security stop, not a
    // routine error — surface it without leaking file contents and abort.
    console.error(
      `Restore aborted: a destination path under ${opts.mosaicHome} is unsafe to write ` +
        `(symlink or escapes the managed root). No files were restored. (${(err as Error).message})`,
    );
    return 1;
  }
  console.log(`Restored ${n} operator file(s) from snapshot ${ts} into ${opts.mosaicHome}.`);
  return 0;
}

// ─── commander registration ───────────────────────────────────────────────────

export function registerRestoreCommand(program: Command): void {
  program
    .command('restore')
    .description('List or restore durable pre-update snapshots of your operator config (#791)')
    .option('--list', 'List available snapshots by timestamp (default action)')
    .option('--from <timestamp>', 'Restore the snapshot with this timestamp over MOSAIC_HOME')
    .option('--dry-run', 'With --from: show what would be restored without changing anything')
    .option('--yes, -y', 'Skip the confirmation prompt (also: MOSAIC_ASSUME_YES=1)')
    .option(
      '--mosaic-home <path>',
      'Override MOSAIC_HOME directory',
      process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME,
    )
    .action(
      async (opts: {
        list?: boolean;
        from?: string;
        dryRun?: boolean;
        yes?: boolean;
        mosaicHome: string;
      }) => {
        const code = await runRestore({
          list: opts.list,
          from: opts.from,
          dryRun: opts.dryRun,
          yes: opts.yes,
          mosaicHome: opts.mosaicHome,
        });
        if (code !== 0) process.exit(code);
      },
    );
}
