'use strict';

const crypto = require('node:crypto');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');

// Side-repo exclude defaults: applied even when the target worktree has no
// .gitignore, so a stray `node_modules` never lands in a snapshot commit.
const DEFAULT_EXCLUDES = [
  'node_modules/',
  '.git/',
  'dist/',
  'build/',
  'coverage/',
  '.next/',
  '.cache/',
  '.venv/',
  '__pycache__/',
  '*.log',
  '.DS_Store',
];

const MAX_SNAPSHOT_FILE_BYTES = 2 * 1024 * 1024;

class SideGitSnapshotStore {
  constructor({
    snapshotsDir = defaultSnapshotsDir(),
    now = () => new Date().toISOString(),
  } = {}) {
    this.snapshotsDir = snapshotsDir;
    this.now = now;
  }

  capture({ cwd, files = [], sessionId = '', boundaryId = '', toolName = '' } = {}) {
    const root = path.resolve(cwd || process.cwd());
    const rels = relativePaths(root, files);
    if (rels.length === 0) return null;
    const repo = this._repo(root);
    this._ensureRepo(repo);
    this._git(repo, ['add', '-A', '-f', '--', ...rels]);
    this._git(repo, [
      'commit',
      '--allow-empty',
      '-m',
      `Wall-E snapshot ${sessionId || 'session'} ${boundaryId || ''} ${toolName || ''}`.trim(),
    ]);
    const commit = this._git(repo, ['rev-parse', 'HEAD']).trim();
    return {
      kind: 'side_git',
      repoDir: repo.repoDir,
      gitDir: repo.gitDir,
      cwd: root,
      commit,
      files: rels.map((rel) => path.join(root, rel)),
      timestamp: this.now(),
    };
  }

  /**
   * Snapshot the ENTIRE worktree into the side repo (one commit per step
   * boundary). Respects the worktree's .gitignore plus DEFAULT_EXCLUDES,
   * skips files over MAX_SNAPSHOT_FILE_BYTES, and reuses the previous
   * commit when nothing changed (per-turn debounce).
   */
  captureWorktree({ cwd, sessionId = '', boundaryId = '', label = '' } = {}) {
    const root = path.resolve(cwd || process.cwd());
    const repo = this._repo(root);
    this._ensureRepo(repo);

    this._git(repo, ['add', '-A', '.']);
    this._unstageLargeFiles(repo);

    const head = this._headCommit(repo);
    if (head && !this._hasStagedChanges(repo, head)) {
      return {
        kind: 'side_git_worktree',
        repoDir: repo.repoDir,
        gitDir: repo.gitDir,
        cwd: root,
        commit: head,
        reused: true,
        timestamp: this.now(),
      };
    }

    this._git(repo, [
      'commit',
      '--allow-empty',
      '-m',
      `Wall-E worktree ${sessionId || 'session'} ${boundaryId || ''} ${label || ''}`.trim(),
    ]);
    return {
      kind: 'side_git_worktree',
      repoDir: repo.repoDir,
      gitDir: repo.gitDir,
      cwd: root,
      commit: this._git(repo, ['rev-parse', 'HEAD']).trim(),
      reused: false,
      timestamp: this.now(),
    };
  }

  /**
   * Restore the worktree to a snapshot commit. Resets tracked files to the
   * snapshot state; files created AFTER the snapshot (untracked in it) are
   * left in place — deliberately, so a rewind never deletes user files the
   * snapshot system has never seen.
   */
  restoreWorktree({ cwd, commit } = {}) {
    if (!commit) throw new Error('commit is required');
    const root = path.resolve(cwd || process.cwd());
    const repo = this._repo(root);
    if (!fs.existsSync(repo.gitDir)) throw new Error(`No side-git repo for ${root}`);
    this._git(repo, ['reset', '--hard', commit]);
    return {
      kind: 'side_git_worktree',
      commit,
      cwd: root,
      timestamp: this.now(),
    };
  }

  /** Diff between two worktree snapshots (or a snapshot and HEAD). */
  diffWorktree({ cwd, fromCommit, toCommit = 'HEAD' } = {}) {
    if (!fromCommit) throw new Error('fromCommit is required');
    const root = path.resolve(cwd || process.cwd());
    const repo = this._repo(root);
    if (!fs.existsSync(repo.gitDir)) throw new Error(`No side-git repo for ${root}`);
    return this._git(repo, ['diff', '--stat', fromCommit, toCommit]);
  }

  /** Housekeeping: drop unreferenced snapshot objects older than `days`. */
  prune({ cwd, days = 7 } = {}) {
    const root = path.resolve(cwd || process.cwd());
    const repo = this._repo(root);
    if (!fs.existsSync(repo.gitDir)) return null;
    this._git(repo, ['gc', `--prune=${days}.days`, '--quiet']);
    return { pruned: true, repoDir: repo.repoDir };
  }

  _headCommit(repo) {
    try {
      return this._git(repo, ['rev-parse', 'HEAD']).trim();
    } catch {
      return null;
    }
  }

  _hasStagedChanges(repo, headCommit) {
    try {
      this._git(repo, ['diff', '--cached', '--quiet', headCommit]);
      return false;
    } catch {
      return true;
    }
  }

  _unstageLargeFiles(repo) {
    let staged;
    try {
      staged = this._git(repo, ['diff', '--cached', '--name-only', '-z']);
    } catch {
      return;
    }
    for (const rel of staged.split('\0')) {
      if (!rel) continue;
      try {
        const abs = path.join(repo.cwd, rel);
        if (fs.statSync(abs).size > MAX_SNAPSHOT_FILE_BYTES) {
          this._git(repo, ['reset', '-q', '--', rel]);
        }
      } catch {
        // deleted/unreadable — leave staged (deletions must be recorded)
      }
    }
  }

  restore({ cwd, commit, files = [] } = {}) {
    if (!commit) throw new Error('commit is required');
    const root = path.resolve(cwd || process.cwd());
    const rels = relativePaths(root, files);
    const repo = this._repo(root);
    if (!fs.existsSync(repo.gitDir)) throw new Error(`No side-git repo for ${root}`);
    const restoredFiles = [];
    for (const rel of rels) {
      const abs = path.join(root, rel);
      if (this._existsInCommit(repo, commit, rel)) {
        this._git(repo, ['checkout', commit, '--', rel]);
      } else {
        fs.rmSync(abs, { recursive: true, force: true });
      }
      restoredFiles.push(abs);
    }
    return {
      kind: 'side_git',
      commit,
      cwd: root,
      restoredFiles,
      timestamp: this.now(),
    };
  }

  _repo(cwd) {
    const hash = crypto.createHash('sha256').update(path.resolve(cwd)).digest('hex').slice(0, 16);
    const repoDir = path.join(this.snapshotsDir, hash);
    return {
      repoDir,
      gitDir: path.join(repoDir, '.git'),
      cwd: path.resolve(cwd),
    };
  }

  _ensureRepo(repo) {
    if (!fs.existsSync(repo.gitDir)) {
      fs.mkdirSync(repo.repoDir, { recursive: true });
      execFileSync('git', ['init', repo.repoDir], { stdio: 'ignore' });
    }
    const excludeFile = path.join(repo.gitDir, 'info', 'exclude');
    try {
      const current = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, 'utf8') : '';
      if (!current.includes('node_modules/')) {
        fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
        fs.writeFileSync(excludeFile, `${current.trimEnd()}\n${DEFAULT_EXCLUDES.join('\n')}\n`);
      }
    } catch {
      // exclude file is an optimization, not a requirement
    }
  }

  _existsInCommit(repo, commit, relPath) {
    try {
      this._git(repo, ['cat-file', '-e', `${commit}:${toGitPath(relPath)}`]);
      return true;
    } catch {
      return false;
    }
  }

  _git(repo, args) {
    return execFileSync('git', [
      '--git-dir', repo.gitDir,
      '--work-tree', repo.cwd,
      '-c', 'user.name=Wall-E',
      '-c', 'user.email=walle@example.invalid',
      ...args,
    ], {
      encoding: 'utf8',
      stdio: ['ignore', 'pipe', 'pipe'],
      // Relative pathspecs ('.', 'src/a.js') resolve against the git
      // process cwd, not --work-tree — run from the worktree root.
      cwd: fs.existsSync(repo.cwd) ? repo.cwd : undefined,
    });
  }
}

function defaultSnapshotsDir(env = process.env) {
  const dataDir = env.WALL_E_DATA_DIR || env.WALLE_DATA_DIR || path.join(os.homedir(), '.walle', 'data');
  return path.join(dataDir, 'snapshots');
}

function relativePaths(root, files) {
  const out = [];
  for (const file of files || []) {
    if (!file) continue;
    const abs = path.resolve(file);
    const rel = path.relative(root, abs);
    if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
    out.push(toGitPath(rel));
  }
  return [...new Set(out)];
}

function toGitPath(filePath) {
  return String(filePath || '').split(path.sep).join('/');
}

module.exports = {
  SideGitSnapshotStore,
  defaultSnapshotsDir,
  relativePaths,
  DEFAULT_EXCLUDES,
  MAX_SNAPSHOT_FILE_BYTES,
};
