'use strict';

const path = require('node:path');
const { SnapshotManager } = require('../tools/snapshot');
const { newId } = require('./model-message');
const { SideGitSnapshotStore } = require('./side-git-snapshot');

const MUTATING_TOOLS = new Set(['write_file', 'edit_file', 'multi_edit', 'apply_patch']);
const MAX_PERSISTED_BOUNDARIES = 50;
const BOUNDARY_KV_PREFIX = 'coding:boundaries:';

/**
 * Persists snapshot boundary records to the brain KV store so rewind
 * survives process restarts. Falls back to module memory when the brain
 * is unavailable (tests, embedded contexts).
 */
const _memoryBoundaries = new Map();

class BoundaryStore {
  constructor({ brain = null } = {}) {
    this.brain = brain;
  }

  save(record) {
    if (!record?.sessionId || !record?.boundaryId) return;
    const key = BOUNDARY_KV_PREFIX + record.sessionId;
    const list = this.list(record.sessionId).filter((entry) => entry.boundaryId !== record.boundaryId);
    list.push(record);
    while (list.length > MAX_PERSISTED_BOUNDARIES) list.shift();
    this._setKv(key, JSON.stringify(list));
  }

  list(sessionId) {
    if (!sessionId) return [];
    const raw = this._getKv(BOUNDARY_KV_PREFIX + sessionId);
    if (!raw) return [];
    try {
      const parsed = JSON.parse(raw);
      return Array.isArray(parsed) ? parsed : [];
    } catch {
      return [];
    }
  }

  get(sessionId, boundaryId) {
    return this.list(sessionId).find((entry) => entry.boundaryId === boundaryId) || null;
  }

  _getKv(key) {
    if (this.brain?.getKv) return this.brain.getKv(key);
    try {
      const brain = require('../brain');
      if (brain?.getKv) return brain.getKv(key);
    } catch {}
    return _memoryBoundaries.get(key) || '';
  }

  _setKv(key, value) {
    if (this.brain?.setKv) return this.brain.setKv(key, value);
    try {
      const brain = require('../brain');
      if (brain?.setKv) return brain.setKv(key, value);
    } catch {}
    _memoryBoundaries.set(key, value);
    return null;
  }
}

class SnapshotService {
  // worktreeSnapshots and boundaryStore are deliberately opt-in: callers
  // that want whole-worktree snapshots + persisted boundaries (the agent
  // loop) pass them explicitly; embedded/test constructions stay cheap.
  constructor({ cwd = process.cwd(), snapshot = null, sideGitSnapshot = null, boundaryStore = null, worktreeSnapshots = false, now } = {}) {
    this.cwd = path.resolve(cwd);
    this.snapshot = snapshot || new SnapshotManager(this.cwd);
    this.now = now || (() => new Date().toISOString());
    this.sideGitSnapshot = sideGitSnapshot === false ? null : (sideGitSnapshot || new SideGitSnapshotStore({ now: this.now }));
    this.boundaryStore = boundaryStore || null;
    this.worktreeSnapshots = Boolean(worktreeSnapshots);
    this.boundaries = new Map();
    this.activeBoundaryId = null;
  }

  async captureStepStart({ sessionId = '', cwd = this.cwd, messageId = '' } = {}) {
    this.cwd = path.resolve(cwd || this.cwd);
    const boundaryId = newId('boundary');
    const record = {
      snapshotId: newId('snapshot'),
      boundaryId,
      kind: 'step_start',
      sessionId,
      cwd: this.cwd,
      messageId,
      timestamp: this.now(),
      stackDepths: this.snapshot.getStackDepths ? this.snapshot.getStackDepths() : {},
      capturedFiles: [],
    };
    // Whole-worktree snapshot per step: catches mutations the per-file path
    // can't see (run_shell sed/codegen/formatters). Reuses the previous
    // commit when nothing changed, so quiet turns cost one `git status`.
    if (this.worktreeSnapshots && this.sideGitSnapshot?.captureWorktree) {
      try {
        const worktree = this.sideGitSnapshot.captureWorktree({
          cwd: this.cwd,
          sessionId,
          boundaryId,
          label: 'step_start',
        });
        if (worktree?.commit) {
          record.worktree = { commit: worktree.commit, repoDir: worktree.repoDir, reused: Boolean(worktree.reused) };
        }
      } catch (err) {
        record.worktree = { error: err.message };
      }
    }
    this.boundaries.set(boundaryId, record);
    this.activeBoundaryId = boundaryId;
    this._persistBoundary(record);
    return publicBoundary(record);
  }

  async captureToolInput({ toolName, input = {}, sessionId = '', cwd = this.cwd, messageId = '' } = {}) {
    if (!MUTATING_TOOLS.has(toolName)) return null;
    this.cwd = path.resolve(cwd || this.cwd);
    const files = inferMutatingPaths(toolName, input, this.cwd);
    if (files.length === 0) return null;

    const captured = [];
    const capturedPaths = [];
    for (const filePath of files) {
      try {
        if (this.snapshot.capture(filePath)) {
          captured.push(filePath);
          capturedPaths.push(filePath);
        }
      } catch (err) {
        captured.push({ filePath, error: err.message });
      }
    }
    const sideGit = this._captureSideGit({
      sessionId,
      cwd: this.cwd,
      messageId,
      toolName,
      files: capturedPaths,
    });
    const boundary = this.boundaries.get(this.activeBoundaryId);
    if (boundary) {
      for (const file of captured) {
        const filePath = typeof file === 'string' ? file : file.filePath;
        if (filePath && !boundary.capturedFiles.includes(filePath)) boundary.capturedFiles.push(filePath);
      }
      if (sideGit) boundary.sideGit = sideGit;
    }

    return {
      snapshotId: newId('snapshot'),
      boundaryId: this.activeBoundaryId,
      kind: 'tool_pre_mutation',
      sessionId,
      cwd: this.cwd,
      messageId,
      toolName,
      files: captured,
      sideGit,
      timestamp: this.now(),
    };
  }

  async captureStepFinish({ sessionId = '', cwd = this.cwd, messageId = '' } = {}) {
    this.cwd = path.resolve(cwd || this.cwd);
    const summary = this.snapshot.summary();
    return {
      snapshotId: newId('snapshot'),
      boundaryId: this.activeBoundaryId,
      kind: 'step_finish',
      sessionId,
      cwd: this.cwd,
      messageId,
      timestamp: this.now(),
      files: summary.files,
      totalAdditions: summary.totalAdditions,
      totalDeletions: summary.totalDeletions,
      diffs: summary.diffs,
    };
  }

  revertToBoundary(boundaryId = this.activeBoundaryId) {
    const boundary = this.boundaries.get(boundaryId);
    if (!boundary) throw new Error(`No snapshot boundary: ${boundaryId}`);
    // Worktree snapshots restore everything (including shell mutations);
    // the per-file stack only knows files Wall-E's own edit tools touched.
    if (boundary.worktree?.commit && this.sideGitSnapshot?.restoreWorktree) {
      const worktree = this.sideGitSnapshot.restoreWorktree({
        cwd: boundary.cwd,
        commit: boundary.worktree.commit,
      });
      return {
        boundaryId,
        restoredFiles: [],
        worktree,
        sideGit: null,
        timestamp: this.now(),
      };
    }
    const restoredFiles = this.snapshot.restoreToStackDepths
      ? this.snapshot.restoreToStackDepths(boundary.stackDepths)
      : fallbackRestoreAll(this.snapshot);
    const sideGit = this._restoreSideGit(boundary);
    return {
      boundaryId,
      restoredFiles,
      sideGit,
      timestamp: this.now(),
    };
  }

  getBoundary(boundaryId = this.activeBoundaryId) {
    const boundary = this.boundaries.get(boundaryId);
    return boundary ? publicBoundary(boundary) : null;
  }

  _persistBoundary(record) {
    if (!this.boundaryStore?.save || !record.sessionId) return;
    try {
      this.boundaryStore.save({
        boundaryId: record.boundaryId,
        snapshotId: record.snapshotId,
        kind: record.kind,
        sessionId: record.sessionId,
        cwd: record.cwd,
        messageId: record.messageId,
        timestamp: record.timestamp,
        worktree: record.worktree || null,
      });
    } catch (err) {
      console.warn(`[snapshot] Failed to persist boundary ${record.boundaryId}: ${err.message}`);
    }
  }

  _captureSideGit({ sessionId, cwd, messageId, toolName, files }) {
    if (!this.sideGitSnapshot?.capture || !files.length) return null;
    try {
      return this.sideGitSnapshot.capture({
        sessionId,
        cwd,
        boundaryId: this.activeBoundaryId || messageId,
        toolName,
        files,
      });
    } catch (err) {
      return { kind: 'side_git', error: err.message };
    }
  }

  _restoreSideGit(boundary) {
    if (!this.sideGitSnapshot?.restore || !boundary?.sideGit?.commit) return null;
    try {
      return this.sideGitSnapshot.restore({
        cwd: boundary.cwd,
        commit: boundary.sideGit.commit,
        files: boundary.sideGit.files || boundary.capturedFiles || [],
      });
    } catch (err) {
      return { kind: 'side_git', error: err.message };
    }
  }
}

function inferMutatingPaths(toolName, input = {}, cwd = process.cwd()) {
  if (!MUTATING_TOOLS.has(toolName)) return [];
  if (toolName === 'apply_patch') return inferPatchPaths(input.patch_text || input.patch || '', cwd);
  const raw = input.file_path || input.path;
  if (!raw) return [];
  return [resolveToolPath(raw, input.projectRoot || cwd)];
}

function inferPatchPaths(patchText, cwd = process.cwd()) {
  const files = [];
  for (const line of String(patchText || '').split('\n')) {
    const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/);
    if (!match) continue;
    files.push(resolveToolPath(match[1].trim(), cwd));
  }
  return [...new Set(files)];
}

function resolveToolPath(filePath, root) {
  if (path.isAbsolute(filePath)) return path.resolve(filePath);
  return path.resolve(root || process.cwd(), filePath);
}

function publicBoundary(record) {
  return {
    snapshotId: record.snapshotId,
    boundaryId: record.boundaryId,
    kind: record.kind,
    sessionId: record.sessionId,
    cwd: record.cwd,
    messageId: record.messageId,
    timestamp: record.timestamp,
    capturedFiles: [...record.capturedFiles],
    sideGit: record.sideGit || null,
    worktree: record.worktree || null,
  };
}

function fallbackRestoreAll(snapshot) {
  snapshot.revertAll();
  return snapshot.files ? snapshot.files() : [];
}

/**
 * Standalone rewind surface for API/UI callers (works after process
 * restarts — boundaries come from the persisted store, restores go through
 * the side-git repo keyed by cwd).
 */
function listSessionBoundaries(sessionId, { brain = null } = {}) {
  return new BoundaryStore({ brain }).list(sessionId)
    .map((record) => ({ ...record }))
    .reverse(); // newest first
}

function revertSessionBoundary({ sessionId, boundaryId, brain = null, sideGitSnapshot = null } = {}) {
  if (!sessionId || !boundaryId) throw new Error('sessionId and boundaryId are required');
  const record = new BoundaryStore({ brain }).get(sessionId, boundaryId);
  if (!record) throw new Error(`No persisted boundary ${boundaryId} for session ${sessionId}`);
  if (!record.worktree?.commit) throw new Error(`Boundary ${boundaryId} has no worktree snapshot to restore`);
  const store = sideGitSnapshot || new SideGitSnapshotStore();
  const worktree = store.restoreWorktree({ cwd: record.cwd, commit: record.worktree.commit });
  return { boundaryId, sessionId, cwd: record.cwd, worktree };
}

module.exports = {
  SnapshotService,
  BoundaryStore,
  listSessionBoundaries,
  revertSessionBoundary,
  inferMutatingPaths,
  inferPatchPaths,
  MUTATING_TOOLS,
  MAX_PERSISTED_BOUNDARIES,
};
