// wall-e/tools/snapshot.js
// Lightweight file snapshot system for coding agent rollback.
// Inspired by OpenCode's git-based snapshot, but uses file copies
// (simpler for Wall-E's shorter-lived coding tasks).
'use strict';

const fs = require('node:fs');
const path = require('node:path');

const MAX_SNAPSHOT_SIZE = 10 * 1024 * 1024; // 10MB

// Directories and patterns to always skip during bulk operations.
// Matches common .gitignore patterns without requiring a full parser.
const SKIP_DIRS = new Set([
  'node_modules', '.git', '.hg', '.svn', '__pycache__', '.tox',
  '.mypy_cache', '.pytest_cache', '.next', '.nuxt', 'dist', 'build',
  '.cache', 'coverage', '.venv', 'venv', '.env', 'vendor',
  '.terraform', '.serverless',
]);

const SKIP_EXTENSIONS = new Set([
  '.exe', '.dll', '.so', '.dylib', '.o', '.obj', '.a', '.lib',
  '.pyc', '.pyo', '.class', '.jar', '.war',
  '.png', '.jpg', '.jpeg', '.gif', '.ico', '.bmp', '.tiff', '.webp',
  '.mp3', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv',
  '.zip', '.tar', '.gz', '.bz2', '.7z', '.rar', '.xz',
  '.woff', '.woff2', '.ttf', '.eot', '.otf',
  '.pdf', '.doc', '.docx', '.xls', '.xlsx',
  '.sqlite', '.db', '.sqlite3',
  '.wasm',
]);

/**
 * Check if a file is likely binary by reading the first 8KB and looking for null bytes.
 * @param {string} filePath
 * @returns {boolean}
 */
function isBinaryFile(filePath) {
  try {
    const fd = fs.openSync(filePath, 'r');
    const buf = Buffer.alloc(8192);
    const bytesRead = fs.readSync(fd, buf, 0, 8192, 0);
    fs.closeSync(fd);
    for (let i = 0; i < bytesRead; i++) {
      if (buf[i] === 0) return true;
    }
    return false;
  } catch {
    return false;
  }
}

/**
 * Check if a path should be skipped in bulk operations (honors .gitignore patterns).
 * @param {string} filePath - Absolute path
 * @returns {boolean}
 */
function shouldSkip(filePath) {
  const parts = filePath.split(path.sep);
  for (const part of parts) {
    if (SKIP_DIRS.has(part)) return true;
  }
  const ext = path.extname(filePath).toLowerCase();
  if (SKIP_EXTENSIONS.has(ext)) return true;
  return false;
}

class SnapshotManager {
  /**
   * @param {string} cwd - Working directory for this session
   */
  constructor(cwd) {
    this.cwd = cwd;
    // filePath -> [{ content: string|null, timestamp: number }]
    // null content means the file did not exist (new file case)
    this.snapshots = new Map();
  }

  /**
   * Capture current file content before modification.
   * Skips binary files (won't crash on them) and files exceeding size limit.
   * @param {string} filePath - Absolute path
   * @returns {boolean} true if captured, false if skipped (too large or binary)
   */
  capture(filePath) {
    const resolved = path.resolve(filePath);
    try {
      const stat = fs.statSync(resolved);
      if (stat.size > MAX_SNAPSHOT_SIZE) {
        console.warn(`[snapshot] Skipping ${filePath}: ${(stat.size / 1024 / 1024).toFixed(1)}MB exceeds 10MB limit`);
        return false;
      }
      // Binary files are stored as Buffer (not utf8 string) to preserve data integrity
      const binary = isBinaryFile(resolved);
      const content = binary ? fs.readFileSync(resolved) : fs.readFileSync(resolved, 'utf8');
      const stack = this.snapshots.get(resolved) || [];
      stack.push({ content, timestamp: Date.now() });
      this.snapshots.set(resolved, stack);
      return true;
    } catch (err) {
      if (err.code === 'ENOENT') {
        // File doesn't exist -- record as new file
        this.captureNew(resolved);
        return true;
      }
      throw err;
    }
  }

  /**
   * Record that a file was newly created (no prior content).
   * Revert will delete the file.
   * @param {string} filePath - Absolute path
   */
  captureNew(filePath) {
    const resolved = path.resolve(filePath);
    const stack = this.snapshots.get(resolved) || [];
    stack.push({ content: null, timestamp: Date.now() });
    this.snapshots.set(resolved, stack);
  }

  /**
   * Revert a file to its most recent snapshot.
   * @param {string} filePath - Absolute path
   */
  revert(filePath) {
    const resolved = path.resolve(filePath);
    const stack = this.snapshots.get(resolved);
    if (!stack || stack.length === 0) {
      throw new Error(`No snapshot for ${filePath}`);
    }
    const { content } = stack.pop();
    if (content === null) {
      // File was new -- delete it
      try { fs.unlinkSync(resolved); } catch { /* already gone */ }
    } else if (Buffer.isBuffer(content)) {
      fs.writeFileSync(resolved, content);
    } else {
      fs.writeFileSync(resolved, content, 'utf8');
    }
    if (stack.length === 0) this.snapshots.delete(resolved);
  }

  /**
   * Revert ALL files to their initial (first) snapshot.
   * Used on subtask failure to restore clean state.
   */
  revertAll() {
    for (const [filePath, stack] of this.snapshots) {
      if (stack.length > 0) {
        const { content } = stack[0]; // first = initial state
        if (content === null) {
          try { fs.unlinkSync(filePath); } catch { /* already gone */ }
        } else if (Buffer.isBuffer(content)) {
          fs.writeFileSync(filePath, content);
        } else {
          fs.writeFileSync(filePath, content, 'utf8');
        }
        // Keep only the initial snapshot so subsequent retries can revert again
        stack.length = 1;
      }
    }
    // Don't clear — keep initial snapshots for next retry cycle
  }

  /**
   * List all files that have snapshots.
   * @returns {string[]}
   */
  files() {
    return [...this.snapshots.keys()];
  }

  /**
   * Return current snapshot stack depths by file. SnapshotService uses this as
   * a cheap boundary marker before a coding step starts.
   *
   * @returns {Record<string, number>}
   */
  getStackDepths() {
    const depths = {};
    for (const [filePath, stack] of this.snapshots) {
      depths[filePath] = stack.length;
    }
    return depths;
  }

  /**
   * Restore files to the state captured immediately after the provided stack
   * depths. If a file was first captured after the boundary, depth 0 restores
   * the pre-boundary file content (or deletes it when it was new).
   *
   * @param {Record<string, number>} depths
   * @returns {string[]} restored file paths
   */
  restoreToStackDepths(depths = {}) {
    const restored = [];
    for (const [filePath, stack] of this.snapshots) {
      const depth = Number.isFinite(depths[filePath]) ? depths[filePath] : 0;
      if (stack.length <= depth) continue;
      const targetIndex = Math.min(depth, stack.length - 1);
      const { content } = stack[targetIndex];
      if (content === null) {
        try { fs.unlinkSync(filePath); } catch { /* already gone */ }
      } else if (Buffer.isBuffer(content)) {
        fs.writeFileSync(filePath, content);
      } else {
        fs.writeFileSync(filePath, content, 'utf8');
      }
      stack.length = targetIndex + 1;
      restored.push(filePath);
    }
    return restored;
  }

  /**
   * Return a unified diff string between the last snapshot and the current file content.
   * Uses a simple line-by-line diff algorithm (no external dependencies).
   *
   * Inspired by OpenCode's patch diff generation.
   *
   * @param {string} filePath - Absolute path
   * @returns {string} Unified diff string, or empty string if unchanged/no snapshot
   */
  diff(filePath) {
    const resolved = path.resolve(filePath);
    const stack = this.snapshots.get(resolved);
    if (!stack || stack.length === 0) return '';

    const lastSnapshot = stack[stack.length - 1];
    const oldContent = lastSnapshot.content;

    // If file was new (null content), the old side is empty
    const oldLines = oldContent === null ? [] : oldContent.split('\n');

    let currentContent;
    try {
      currentContent = fs.readFileSync(resolved, 'utf8');
    } catch (err) {
      if (err.code === 'ENOENT') {
        // File was deleted — diff is all removals
        if (oldLines.length === 0) return '';
        return `--- ${resolved}\n+++ /dev/null\n` +
          oldLines.map(l => `-${l}`).join('\n') + '\n';
      }
      throw err;
    }

    const newLines = currentContent.split('\n');
    return _computeUnifiedDiff(resolved, oldLines, newLines);
  }

  /**
   * Return diffs for ALL snapshotted files.
   *
   * @returns {Array<{ filePath: string, diff: string, additions: number, deletions: number }>}
   */
  diffAll() {
    const results = [];
    for (const filePath of this.snapshots.keys()) {
      const diffStr = this.diff(filePath);
      if (!diffStr) continue;

      let additions = 0;
      let deletions = 0;
      for (const line of diffStr.split('\n')) {
        if (line.startsWith('+') && !line.startsWith('+++')) additions++;
        if (line.startsWith('-') && !line.startsWith('---')) deletions++;
      }

      results.push({ filePath, diff: diffStr, additions, deletions });
    }
    return results;
  }

  /**
   * Generate a summary of all changes across snapshotted files.
   * Returns file paths, total additions/deletions, and individual diffs.
   *
   * @returns {{ files: string[], totalAdditions: number, totalDeletions: number, diffs: Array<{ filePath: string, diff: string, additions: number, deletions: number }> }}
   */
  summary() {
    const diffs = this.diffAll();
    let totalAdditions = 0;
    let totalDeletions = 0;
    const files = [];

    for (const d of diffs) {
      files.push(d.filePath);
      totalAdditions += d.additions;
      totalDeletions += d.deletions;
    }

    return { files, totalAdditions, totalDeletions, diffs };
  }

  /**
   * Discard all snapshots. Called on task completion.
   */
  cleanup() {
    this.snapshots.clear();
  }
}

/**
 * Compute a unified diff between two arrays of lines.
 * Simple LCS-based implementation — no external deps required.
 *
 * @param {string} filePath - For header display
 * @param {string[]} oldLines
 * @param {string[]} newLines
 * @returns {string} Unified diff string, empty if identical
 */
function _computeUnifiedDiff(filePath, oldLines, newLines) {
  // Quick equality check
  if (oldLines.length === newLines.length && oldLines.every((l, i) => l === newLines[i])) {
    return '';
  }

  // Myers-like edit script using simple O(NM) LCS for correctness
  const edits = _diffLines(oldLines, newLines);
  if (edits.length === 0) return '';

  // Group edits into hunks (with 3-line context)
  const CONTEXT = 3;
  const hunks = [];
  let hunkStart = 0;

  while (hunkStart < edits.length) {
    // Find contiguous changed region with context
    let hunkEnd = hunkStart;
    while (hunkEnd < edits.length) {
      if (hunkEnd + 1 < edits.length) {
        // Merge hunks that are close together
        const gap = edits[hunkEnd + 1].oldIdx - edits[hunkEnd].oldIdx;
        if (gap <= CONTEXT * 2 + 1) {
          hunkEnd++;
          continue;
        }
      }
      break;
    }
    hunks.push(edits.slice(hunkStart, hunkEnd + 1));
    hunkStart = hunkEnd + 1;
  }

  // Format as unified diff
  let result = `--- ${filePath}\n+++ ${filePath}\n`;

  for (const hunk of hunks) {
    const firstEdit = hunk[0];
    const lastEdit = hunk[hunk.length - 1];
    const startOld = Math.max(0, firstEdit.oldIdx - CONTEXT);
    const endOld = Math.min(oldLines.length, lastEdit.oldIdx + lastEdit.oldCount + CONTEXT);
    const startNew = Math.max(0, firstEdit.newIdx - CONTEXT);
    const endNew = Math.min(newLines.length, lastEdit.newIdx + lastEdit.newCount + CONTEXT);

    result += `@@ -${startOld + 1},${endOld - startOld} +${startNew + 1},${endNew - startNew} @@\n`;

    // Walk through old and new lines in this range
    let oi = startOld;
    let ni = startNew;

    for (const edit of hunk) {
      // Context lines before this edit
      while (oi < edit.oldIdx) {
        result += ` ${oldLines[oi]}\n`;
        oi++;
        ni++;
      }
      // Removed lines
      for (let j = 0; j < edit.oldCount; j++) {
        result += `-${oldLines[oi + j]}\n`;
      }
      // Added lines
      for (let j = 0; j < edit.newCount; j++) {
        result += `+${newLines[ni + j]}\n`;
      }
      oi += edit.oldCount;
      ni += edit.newCount;
    }
    // Trailing context
    while (oi < endOld) {
      result += ` ${oldLines[oi]}\n`;
      oi++;
      ni++;
    }
  }

  return result;
}

/**
 * Simple line diff: returns an array of edit operations.
 * Each edit: { oldIdx, oldCount, newIdx, newCount }
 *
 * Uses a patience-style approach: find matching lines, then
 * group non-matching regions as edits.
 */
function _diffLines(oldLines, newLines) {
  // Build LCS via simple DP (acceptable for file sizes under ~10k lines)
  const m = oldLines.length;
  const n = newLines.length;

  // For very large files, fall back to simple line-by-line comparison
  if (m * n > 10_000_000) {
    return _diffLinesSimple(oldLines, newLines);
  }

  // LCS length table (space-optimized: two rows)
  const prev = new Uint16Array(n + 1);
  const curr = new Uint16Array(n + 1);
  const directions = new Uint8Array(m * n); // 0=diag, 1=up, 2=left

  // Build full direction matrix for backtracking
  const dirMatrix = [];
  for (let i = 0; i <= m; i++) dirMatrix.push(new Uint8Array(n + 1));

  const lcs = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1));
  for (let i = m - 1; i >= 0; i--) {
    for (let j = n - 1; j >= 0; j--) {
      if (oldLines[i] === newLines[j]) {
        lcs[i][j] = lcs[i + 1][j + 1] + 1;
      } else {
        lcs[i][j] = Math.max(lcs[i + 1][j], lcs[i][j + 1]);
      }
    }
  }

  // Walk the LCS to find matching pairs
  const edits = [];
  let oi = 0, ni = 0;
  let editOldStart = -1, editNewStart = -1;

  function flushEdit() {
    if (editOldStart !== -1) {
      edits.push({
        oldIdx: editOldStart,
        oldCount: oi - editOldStart,
        newIdx: editNewStart,
        newCount: ni - editNewStart,
      });
      editOldStart = -1;
      editNewStart = -1;
    }
  }

  while (oi < m && ni < n) {
    if (oldLines[oi] === newLines[ni]) {
      flushEdit();
      oi++;
      ni++;
    } else if (lcs[oi + 1] !== undefined && lcs[oi][ni + 1] !== undefined &&
               (lcs[oi + 1][ni] >= lcs[oi][ni + 1])) {
      if (editOldStart === -1) { editOldStart = oi; editNewStart = ni; }
      oi++;
    } else {
      if (editOldStart === -1) { editOldStart = oi; editNewStart = ni; }
      ni++;
    }
  }

  // Remaining lines
  if (oi < m || ni < n) {
    if (editOldStart === -1) { editOldStart = oi; editNewStart = ni; }
    oi = m;
    ni = n;
  }
  flushEdit();

  return edits;
}

/**
 * Fallback simple diff for large files: just compare line by line.
 */
function _diffLinesSimple(oldLines, newLines) {
  const edits = [];
  const maxLen = Math.max(oldLines.length, newLines.length);
  let editStart = -1;
  let editOldStart = -1, editNewStart = -1;

  for (let i = 0; i < maxLen; i++) {
    const oldLine = i < oldLines.length ? oldLines[i] : undefined;
    const newLine = i < newLines.length ? newLines[i] : undefined;

    if (oldLine === newLine) {
      if (editOldStart !== -1) {
        edits.push({
          oldIdx: editOldStart,
          oldCount: Math.min(i, oldLines.length) - editOldStart,
          newIdx: editNewStart,
          newCount: Math.min(i, newLines.length) - editNewStart,
        });
        editOldStart = -1;
        editNewStart = -1;
      }
    } else {
      if (editOldStart === -1) {
        editOldStart = Math.min(i, oldLines.length);
        editNewStart = Math.min(i, newLines.length);
      }
    }
  }

  if (editOldStart !== -1) {
    edits.push({
      oldIdx: editOldStart,
      oldCount: oldLines.length - editOldStart,
      newIdx: editNewStart,
      newCount: newLines.length - editNewStart,
    });
  }

  return edits;
}

module.exports = {
  SnapshotManager, MAX_SNAPSHOT_SIZE, _computeUnifiedDiff, _diffLines,
  isBinaryFile, shouldSkip, SKIP_DIRS, SKIP_EXTENSIONS,
};
