/**
 * Tests for `mosaic restore` (#791 PR2 Task 12).
 *
 * The durable pre-update snapshot (install.sh: make_durable_snapshot) writes the
 * operator-owned surface to $XDG_STATE_HOME/mosaic/backups/pre-update-<ts>/ with
 * 0700 dirs / 0600 files. `mosaic restore` is the recovery counterpart:
 *   • --list (default)   enumerate snapshots by timestamp — dry-run, never mutates.
 *   • --from <ts>        restore that snapshot over MOSAIC_HOME, confirmation-gated.
 * It reports counts and relative paths ONLY — a snapshot may contain secrets
 * (credentials.json), so no file content is ever printed (secrev invariant).
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
  mkdtempSync,
  rmSync,
  mkdirSync,
  writeFileSync,
  readFileSync,
  statSync,
  lstatSync,
  symlinkSync,
} from 'node:fs';
import { join } from 'node:path';
import { tmpdir, homedir } from 'node:os';
import { Command } from 'commander';
import {
  resolveBackupRoot,
  listSnapshots,
  resolveSnapshotDir,
  planRestore,
  applyRestore,
  runRestore,
  registerRestoreCommand,
} from './restore.js';

const SECRET = 'SUPER-SECRET-TOKEN-do-not-log-restore';

function seedSnapshot(root: string, ts: string, files: Record<string, string>): string {
  const dir = join(root, `pre-update-${ts}`);
  for (const [rel, content] of Object.entries(files)) {
    const abs = join(dir, rel);
    mkdirSync(join(abs, '..'), { recursive: true });
    writeFileSync(abs, content);
  }
  return dir;
}

describe('mosaic restore (#791 PR2)', () => {
  let tmp: string;
  let backups: string;

  beforeEach(() => {
    tmp = mkdtempSync(join(tmpdir(), 'mosaic-restore-'));
    backups = join(tmp, 'state', 'mosaic', 'backups');
    mkdirSync(backups, { recursive: true });
    vi.clearAllMocks();
  });

  afterEach(() => {
    rmSync(tmp, { recursive: true, force: true });
  });

  describe('resolveBackupRoot', () => {
    it('honors XDG_STATE_HOME', () => {
      expect(resolveBackupRoot({ XDG_STATE_HOME: '/x/state' })).toBe('/x/state/mosaic/backups');
    });
    it('falls back to ~/.local/state', () => {
      expect(resolveBackupRoot({})).toBe(join(homedir(), '.local', 'state', 'mosaic', 'backups'));
    });
  });

  describe('listSnapshots', () => {
    it('returns [] when the backup root does not exist', () => {
      expect(listSnapshots(join(tmp, 'nope'))).toEqual([]);
    });

    it('lists pre-update snapshots newest-first with file counts, ignoring other dirs', () => {
      seedSnapshot(backups, '20240101T000000Z', { 'SOUL.md': 'a' });
      seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'b', 'agents/x.conf': 'c' });
      mkdirSync(join(backups, 'unrelated-dir'), { recursive: true });

      const snaps = listSnapshots(backups);
      expect(snaps.map((s) => s.timestamp)).toEqual(['20260101T000000Z', '20240101T000000Z']);
      expect(snaps[0]!.fileCount).toBe(2);
      expect(snaps[1]!.fileCount).toBe(1);
    });
  });

  describe('resolveSnapshotDir', () => {
    it('resolves by bare timestamp and by full pre-update-<ts> name', () => {
      const dir = seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a' });
      expect(resolveSnapshotDir(backups, '20260101T000000Z')).toBe(dir);
      expect(resolveSnapshotDir(backups, 'pre-update-20260101T000000Z')).toBe(dir);
    });
    it('returns undefined for an unknown timestamp', () => {
      expect(resolveSnapshotDir(backups, '19990101T000000Z')).toBeUndefined();
    });
  });

  describe('planRestore', () => {
    it('walks nested dirs and returns every relative file path', () => {
      const dir = seedSnapshot(backups, '20260101T000000Z', {
        'SOUL.md': 'a',
        'agents/x.conf': 'b',
        'tools/_lib/credentials.json': 'c',
      });
      expect(planRestore(dir).sort()).toEqual(
        ['SOUL.md', 'agents/x.conf', 'tools/_lib/credentials.json'].sort(),
      );
    });
  });

  describe('applyRestore', () => {
    it('restores byte-exact content, creates parent dirs, and sets 0600', () => {
      const dir = seedSnapshot(backups, '20260101T000000Z', {
        'SOUL.md': 'original-soul',
        'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
      });
      const home = join(tmp, 'home');
      mkdirSync(home, { recursive: true });
      // A diverged operator file that restore must overwrite.
      writeFileSync(join(home, 'SOUL.md'), 'CORRUPTED');

      const n = applyRestore(dir, home, planRestore(dir));
      expect(n).toBe(2);
      expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('original-soul');
      expect(readFileSync(join(home, 'tools/_lib/credentials.json'), 'utf8')).toBe(
        `TOKEN=${SECRET}\n`,
      );
      expect(statSync(join(home, 'SOUL.md')).mode & 0o777).toBe(0o600);
      expect(statSync(join(home, 'tools/_lib/credentials.json')).mode & 0o777).toBe(0o600);
    });
  });

  describe('runRestore', () => {
    it('--list prints timestamps and counts, mutating nothing', async () => {
      seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a', 'agents/x.conf': 'b' });
      const home = join(tmp, 'home');
      mkdirSync(home, { recursive: true });
      const log = vi.spyOn(console, 'log').mockImplementation(() => {});

      const code = await runRestore({
        list: true,
        mosaicHome: home,
        env: { XDG_STATE_HOME: join(tmp, 'state') },
      });

      expect(code).toBe(0);
      const out = log.mock.calls.flat().join('\n');
      expect(out).toContain('20260101T000000Z');
      expect(out).toMatch(/2\b/); // the file count is surfaced
      log.mockRestore();
    });

    it('--from restores the snapshot over MOSAIC_HOME byte-exact (yes bypasses prompt)', async () => {
      seedSnapshot(backups, '20260101T000000Z', {
        'SOUL.md': 'restored-soul',
        'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
      });
      const home = join(tmp, 'home');
      mkdirSync(home, { recursive: true });
      writeFileSync(join(home, 'SOUL.md'), 'STALE');

      const code = await runRestore({
        from: '20260101T000000Z',
        yes: true,
        mosaicHome: home,
        env: { XDG_STATE_HOME: join(tmp, 'state') },
      });

      expect(code).toBe(0);
      expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('restored-soul');
      expect(readFileSync(join(home, 'tools/_lib/credentials.json'), 'utf8')).toBe(
        `TOKEN=${SECRET}\n`,
      );
    });

    it('--from with an unknown timestamp fails without mutating', async () => {
      const home = join(tmp, 'home');
      mkdirSync(home, { recursive: true });
      writeFileSync(join(home, 'SOUL.md'), 'KEEP');
      const err = vi.spyOn(console, 'error').mockImplementation(() => {});

      const code = await runRestore({
        from: '19990101T000000Z',
        yes: true,
        mosaicHome: home,
        env: { XDG_STATE_HOME: join(tmp, 'state') },
      });

      expect(code).toBe(1);
      expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('KEEP');
      err.mockRestore();
    });

    it('--dry-run with --from reports the plan but mutates nothing', async () => {
      seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'snap' });
      const home = join(tmp, 'home');
      mkdirSync(home, { recursive: true });
      writeFileSync(join(home, 'SOUL.md'), 'UNCHANGED');
      const log = vi.spyOn(console, 'log').mockImplementation(() => {});

      const code = await runRestore({
        from: '20260101T000000Z',
        dryRun: true,
        mosaicHome: home,
        env: { XDG_STATE_HOME: join(tmp, 'state') },
      });

      expect(code).toBe(0);
      expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('UNCHANGED');
      log.mockRestore();
    });

    it('--from prompts and applies the restore when the operator confirms', async () => {
      seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'confirmed-soul' });
      const home = join(tmp, 'home');
      mkdirSync(home, { recursive: true });
      writeFileSync(join(home, 'SOUL.md'), 'STALE');
      vi.spyOn(console, 'log').mockImplementation(() => {});
      const confirm = vi.fn().mockResolvedValue(true);

      const code = await runRestore({
        from: '20260101T000000Z',
        mosaicHome: home,
        env: { XDG_STATE_HOME: join(tmp, 'state') },
        confirm,
      });

      expect(code).toBe(0);
      expect(confirm).toHaveBeenCalledOnce();
      expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('confirmed-soul');
    });

    it('--from aborts without mutating when the operator declines', async () => {
      seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'snap' });
      const home = join(tmp, 'home');
      mkdirSync(home, { recursive: true });
      writeFileSync(join(home, 'SOUL.md'), 'KEEP');
      vi.spyOn(console, 'log').mockImplementation(() => {});
      const confirm = vi.fn().mockResolvedValue(false);

      const code = await runRestore({
        from: '20260101T000000Z',
        mosaicHome: home,
        env: { XDG_STATE_HOME: join(tmp, 'state') },
        confirm,
      });

      expect(code).toBe(0);
      expect(confirm).toHaveBeenCalledOnce();
      expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('KEEP');
    });

    it('MOSAIC_ASSUME_YES=1 bypasses the confirmation prompt', async () => {
      seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'env-yes' });
      const home = join(tmp, 'home');
      mkdirSync(home, { recursive: true });
      writeFileSync(join(home, 'SOUL.md'), 'STALE');
      vi.spyOn(console, 'log').mockImplementation(() => {});
      const confirm = vi.fn().mockResolvedValue(false);

      const code = await runRestore({
        from: '20260101T000000Z',
        mosaicHome: home,
        env: { XDG_STATE_HOME: join(tmp, 'state'), MOSAIC_ASSUME_YES: '1' },
        confirm,
      });

      expect(code).toBe(0);
      expect(confirm).not.toHaveBeenCalled();
      expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('env-yes');
    });

    it('--list reports gracefully when no snapshots exist', async () => {
      const home = join(tmp, 'home');
      mkdirSync(home, { recursive: true });
      const log = vi.spyOn(console, 'log').mockImplementation(() => {});

      const code = await runRestore({
        list: true,
        mosaicHome: home,
        env: { XDG_STATE_HOME: join(tmp, 'empty-state') },
      });

      expect(code).toBe(0);
      expect(log.mock.calls.flat().join('\n')).toMatch(/No pre-update snapshots/);
    });

    it('never prints a secret value found inside a backed-up file', async () => {
      seedSnapshot(backups, '20260101T000000Z', {
        'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
      });
      const home = join(tmp, 'home');
      mkdirSync(home, { recursive: true });
      const log = vi.spyOn(console, 'log').mockImplementation(() => {});
      const err = vi.spyOn(console, 'error').mockImplementation(() => {});

      await runRestore({
        list: true,
        mosaicHome: home,
        env: { XDG_STATE_HOME: join(tmp, 'state') },
      });
      await runRestore({
        from: '20260101T000000Z',
        yes: true,
        mosaicHome: home,
        env: { XDG_STATE_HOME: join(tmp, 'state') },
      });

      const all = [...log.mock.calls, ...err.mock.calls].flat().join('\n');
      expect(all).not.toContain(SECRET);
      log.mockRestore();
      err.mockRestore();
    });
  });

  // Regression coverage for the codex code+security review of PR2 (#791):
  // CWE-22 traversal via --from, and CWE-59 symlink write-through in applyRestore.
  describe('security hardening', () => {
    it.each([
      '../../etc',
      'pre-update-/../../tmp/poison',
      'pre-update-../evil',
      '20260101T000000Z/../../../tmp',
      'not-a-timestamp',
      '2026-01-01',
    ])('resolveSnapshotDir rejects traversal / malformed selector %j', (bad) => {
      // Even if a matching directory exists on disk, a non-timestamp selector
      // must not resolve — the only accepted shape is <8>T<6>Z[-n].
      expect(resolveSnapshotDir(backups, bad)).toBeUndefined();
    });

    it('runRestore --from a traversal selector fails closed without copying', async () => {
      // Plant a real dir one level ABOVE the backup root. The naive resolver
      // `join(root, from)` with `from='../poison'` would reach it (backups is
      // .../mosaic/backups, so `../poison` == .../mosaic/poison) and import it.
      const outside = join(tmp, 'state', 'mosaic', 'poison');
      mkdirSync(outside, { recursive: true });
      writeFileSync(join(outside, 'x'), 'attacker');
      const home = join(tmp, 'home');
      mkdirSync(home, { recursive: true });
      const err = vi.spyOn(console, 'error').mockImplementation(() => {});

      const code = await runRestore({
        from: '../poison',
        yes: true,
        mosaicHome: home,
        env: { XDG_STATE_HOME: join(tmp, 'state') },
      });

      expect(code).toBe(1);
      expect(statSync(home).isDirectory()).toBe(true);
      // Nothing from `outside` was imported.
      expect(() => statSync(join(home, 'x'))).toThrow();
      err.mockRestore();
    });

    it('applyRestore refuses to write a secret through a symlinked leaf (CWE-59)', () => {
      const dir = seedSnapshot(backups, '20260101T000000Z', {
        'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
      });
      const home = join(tmp, 'home');
      mkdirSync(join(home, 'tools', '_lib'), { recursive: true });
      // Attacker points the operator credentials file at a file they can read.
      const exfil = join(tmp, 'exfil-target');
      writeFileSync(exfil, 'original-attacker-content');
      symlinkSync(exfil, join(home, 'tools', '_lib', 'credentials.json'));

      expect(() => applyRestore(dir, home, planRestore(dir))).toThrow();
      // The secret was NOT written through the link into the attacker's file.
      expect(readFileSync(exfil, 'utf8')).toBe('original-attacker-content');
    });

    it('applyRestore refuses to write through a symlinked ancestor (CWE-59)', () => {
      const dir = seedSnapshot(backups, '20260101T000000Z', {
        'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
      });
      const home = join(tmp, 'home');
      mkdirSync(join(home, 'tools'), { recursive: true });
      // Attacker replaces the `tools/_lib` ancestor with a symlink out of the root.
      const exfilDir = join(tmp, 'exfil-dir');
      mkdirSync(exfilDir, { recursive: true });
      symlinkSync(exfilDir, join(home, 'tools', '_lib'));

      expect(() => applyRestore(dir, home, planRestore(dir))).toThrow();
      // Nothing was written into the attacker-controlled directory.
      expect(() => statSync(join(exfilDir, 'credentials.json'))).toThrow();
    });

    it('runRestore surfaces a symlink violation as exit 1 without leaking the secret', async () => {
      seedSnapshot(backups, '20260101T000000Z', {
        'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
      });
      const home = join(tmp, 'home');
      mkdirSync(join(home, 'tools', '_lib'), { recursive: true });
      const exfil = join(tmp, 'exfil-target');
      writeFileSync(exfil, 'attacker');
      symlinkSync(exfil, join(home, 'tools', '_lib', 'credentials.json'));
      const log = vi.spyOn(console, 'log').mockImplementation(() => {});
      const err = vi.spyOn(console, 'error').mockImplementation(() => {});

      const code = await runRestore({
        from: '20260101T000000Z',
        yes: true,
        mosaicHome: home,
        env: { XDG_STATE_HOME: join(tmp, 'state') },
      });

      expect(code).toBe(1);
      expect(readFileSync(exfil, 'utf8')).toBe('attacker');
      const all = [...log.mock.calls, ...err.mock.calls].flat().join('\n');
      expect(all).not.toContain(SECRET);
      log.mockRestore();
      err.mockRestore();
    });

    it('applyRestore replaces a diverged regular file in place with 0600 (not a symlink)', () => {
      const dir = seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'restored' });
      const home = join(tmp, 'home');
      mkdirSync(home, { recursive: true });
      writeFileSync(join(home, 'SOUL.md'), 'stale');

      const n = applyRestore(dir, home, planRestore(dir));
      expect(n).toBe(1);
      expect(lstatSync(join(home, 'SOUL.md')).isSymbolicLink()).toBe(false);
      expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('restored');
      expect(statSync(join(home, 'SOUL.md')).mode & 0o777).toBe(0o600);
    });
  });

  describe('registerRestoreCommand', () => {
    it('registers `restore` with the expected flags', () => {
      const program = new Command();
      program.exitOverride();
      registerRestoreCommand(program);
      const cmd = program.commands.find((c) => c.name() === 'restore');
      expect(cmd).toBeDefined();
      const longs = cmd!.options.map((o) => o.long);
      expect(longs).toEqual(
        expect.arrayContaining(['--list', '--from', '--dry-run', '--yes', '--mosaic-home']),
      );
    });

    it('runs the list action end-to-end via the parsed command', async () => {
      seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a' });
      const prevXdg = process.env['XDG_STATE_HOME'];
      process.env['XDG_STATE_HOME'] = join(tmp, 'state');
      const log = vi.spyOn(console, 'log').mockImplementation(() => {});
      try {
        const program = new Command();
        program.exitOverride();
        registerRestoreCommand(program);
        await program.parseAsync(['restore', '--list', '--mosaic-home', join(tmp, 'home')], {
          from: 'user',
        });
        expect(log.mock.calls.flat().join('\n')).toContain('20260101T000000Z');
      } finally {
        log.mockRestore();
        if (prevXdg === undefined) delete process.env['XDG_STATE_HOME'];
        else process.env['XDG_STATE_HOME'] = prevXdg;
      }
    });
  });
});
