#!/usr/bin/env node
/**
 * CLI helper: apply pending live copy edits as one AI-owned batch.
 *
 * The browser Save path stages copy edits in .impeccable/live. This script is
 * called by /manual-edit-commit when the user clicks Apply copy edits. It gives
 * the local AI runner the full staged batch plus evidence, validates the files
 * the runner reports touching, and clears only entries reported as applied.
 *
 * Usage:
 *   node live-commit-manual-edits.mjs
 *   node live-commit-manual-edits.mjs --page-url=/
 *
 * Output JSON:
 *   { applied, failed, files, cleared, count, pageUrl }
 */

import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
import { isGeneratedFile } from './lib/is-generated.mjs';
import {
  runCopyEditBatchAgent,
  runCopyEditPostApplyChecks,
} from './live-copy-edit-agent.mjs';
import fs from 'node:fs';
import path from 'node:path';

const ROLLBACK_EXTENSIONS = new Set([
  '.astro',
  '.cjs',
  '.css',
  '.eex',
  '.ex',
  '.heex',
  '.htm',
  '.html',
  '.js',
  '.json',
  '.jsx',
  '.md',
  '.mdx',
  '.mjs',
  '.scss',
  '.svelte',
  '.svg',
  '.ts',
  '.tsx',
  '.txt',
  '.vue',
  '.yaml',
  '.yml',
]);
const ROLLBACK_SKIP_DIRS = new Set([
  '.astro',
  '.git',
  '.impeccable',
  '.next',
  '.nuxt',
  '.svelte-kit',
  'build',
  'coverage',
  'dist',
  'node_modules',
  'out',
]);
const DEFAULT_REPAIR_ATTEMPTS = 3;

function argVal(args, name) {
  const prefix = name + '=';
  for (const arg of args) {
    if (arg === name) return true;
    if (arg.startsWith(prefix)) return arg.slice(prefix.length);
  }
  return null;
}

function countOps(entries) {
  let count = 0;
  for (const entry of entries || []) count += Array.isArray(entry.ops) ? entry.ops.length : 0;
  return count;
}

function summarizeAppliedEntries(entries, appliedEntryIds) {
  const ids = new Set(appliedEntryIds);
  const out = [];
  for (const entry of entries || []) {
    if (!ids.has(entry.id)) continue;
    for (const op of entry.ops || []) {
      out.push({
        id: entry.id,
        ref: op.ref,
        originalText: op.originalText,
        newText: op.newText,
      });
    }
  }
  return out;
}

function normalizeFailedEntries(batch, result, fallbackReason) {
  const failed = [];
  const failedByEntryId = new Map();
  for (const item of result?.failed || []) {
    const entryId = item.entryId || item.id || null;
    if (!entryId) continue;
    failedByEntryId.set(entryId, item);
  }

  for (const entry of batch.entries || []) {
    const item = failedByEntryId.get(entry.id);
    if (!item) continue;
    failed.push({
      id: entry.id,
      reason: item.reason || item.message || fallbackReason || 'failed',
      candidates: Array.isArray(item.candidates) && item.candidates.length > 0
        ? item.candidates
        : candidatesForEntry(batch, entry.id),
    });
  }
  return failed;
}

function mergeFailedEntries(...groups) {
  const out = [];
  const indexById = new Map();
  for (const item of groups.flatMap((group) => Array.isArray(group) ? group : [])) {
    if (!item || typeof item !== 'object') continue;
    const id = typeof item.id === 'string' && item.id ? item.id : null;
    if (!id) {
      out.push(item);
      continue;
    }
    const existingIndex = indexById.get(id);
    if (existingIndex === undefined) {
      indexById.set(id, out.length);
      out.push(item);
      continue;
    }
    out[existingIndex] = {
      ...out[existingIndex],
      ...item,
      candidates: item.candidates || out[existingIndex].candidates,
      checks: item.checks || out[existingIndex].checks,
    };
  }
  return out;
}

function candidatesForEntry(batch, entryId) {
  return (batch.candidates || [])
    .filter((candidate) => candidate.entryId === entryId)
    .flatMap((candidate) => [
      ...(candidate.sourceHint ? [candidate.sourceHint] : []),
      ...(candidate.textMatches || []),
      ...(candidate.objectKeyMatches || []),
      ...(candidate.locatorMatches || []),
      ...(candidate.contextTextMatches || []),
    ])
    .slice(0, 12);
}

function uniqueStrings(values) {
  return [...new Set(values.filter((value) => typeof value === 'string' && value.trim()))];
}

function allEntryIds(batch) {
  return (batch?.entries || []).map((entry) => entry.id).filter(Boolean);
}

function mergeUniqueStrings(...groups) {
  return uniqueStrings(groups.flatMap((group) => Array.isArray(group) ? group : []));
}

function repairAttemptLimit(env = process.env) {
  const value = Number(env.IMPECCABLE_LIVE_MANUAL_EDIT_REPAIR_ATTEMPTS || DEFAULT_REPAIR_ATTEMPTS);
  if (!Number.isFinite(value)) return DEFAULT_REPAIR_ATTEMPTS;
  return Math.max(1, Math.min(10, Math.trunc(value)));
}

function summarizeRepairFailures(failures = []) {
  return failures.map((failure) => {
    const out = {
      reason: failure.reason || failure.detail || 'validation_failed',
    };
    if (failure.id || failure.entryId) out.entryId = failure.id || failure.entryId;
    if (failure.ref) out.ref = failure.ref;
    if (failure.detail) out.detail = failure.detail;
    if (failure.file) out.file = failure.file;
    if (failure.message) out.message = failure.message;
    if (failure.marker) out.marker = failure.marker;
    if (Array.isArray(failure.files)) out.files = failure.files.slice(0, 8);
    if (Array.isArray(failure.candidates)) {
      out.candidates = failure.candidates.slice(0, 8).map((candidate) => ({
        file: candidate.file,
        line: candidate.line,
        kind: candidate.kind,
        reason: candidate.reason,
      }));
    }
    if (Array.isArray(failure.failures)) {
      out.failures = failure.failures.slice(0, 8).map((item) => ({
        ref: item.ref,
        reason: item.reason || item.detail,
        detail: item.detail,
        candidates: Array.isArray(item.candidates)
          ? item.candidates.slice(0, 6).map((candidate) => ({
              file: candidate.file,
              line: candidate.line,
              kind: candidate.kind,
              reason: candidate.reason,
            }))
          : undefined,
      }));
    }
    if (failure.checks) out.checks = failure.checks;
    return out;
  }).slice(0, 20);
}

function buildRepairBatch(batch, repair) {
  return {
    ...batch,
    repair,
  };
}

function normalizeProjectSourcePath(cwd, file, opts = {}) {
  if (!file || typeof file !== 'string') return null;
  const absolute = path.isAbsolute(file) ? file : path.resolve(cwd, file);
  const relative = path.relative(cwd, absolute);
  if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
  if (opts.requireExists && !fs.existsSync(absolute)) return null;
  if (isGeneratedFile(absolute, { cwd })) return null;
  return relative;
}

function normalizeRelativeFile(cwd, file) {
  return normalizeProjectSourcePath(cwd, file, { requireExists: true });
}

function sourceHintWindowFailure(cwd, op) {
  const hint = op?.sourceHint;
  if (!hint?.file || !hint.line) return null;
  const relative = normalizeRelativeFile(cwd, hint.file);
  if (!relative) return null;
  const absolute = path.resolve(cwd, relative);
  let content;
  try { content = fs.readFileSync(absolute, 'utf-8'); } catch { return null; }
  const lines = content.split('\n');
  const line = Math.max(1, Number(hint.line) || 1);
  const lineText = lines[line - 1] || '';
  const start = Math.max(0, line - 5);
  const end = Math.min(lines.length, line + 4);
  if (
    typeof op.originalText === 'string'
    && op.originalText
    && lineText.includes(op.originalText)
    && !lineShowsAppliedOp(lineText, op)
  ) {
    return {
      file: relative,
      line,
      reason: 'source_hint_still_contains_original_text',
    };
  }
  if (lines.slice(start, end).some((candidateLine) => lineShowsAppliedOp(candidateLine, op))) return null;
  return null;
}

function verificationTargetsForOp(batch, op, reportedFiles, cwd) {
  const candidate = (batch.candidates || []).find((item) => item.entryId === op.entryId && item.ref === op.ref);
  const out = [];
  const reportedFileSet = new Set(reportedFiles || []);
  const add = (file, line, kind) => {
    const relativeFile = normalizeRelativeFile(cwd, file);
    const lineNumber = Number(line);
    if (!relativeFile || !Number.isFinite(lineNumber) || lineNumber < 1) return;
    out.push({ file: relativeFile, line: lineNumber, kind, reported: reportedFileSet.has(relativeFile) });
  };

  add(op.sourceHint?.file, op.sourceHint?.line, 'source_hint');
  add(candidate?.sourceHint?.relativeFile || candidate?.sourceHint?.file, candidate?.sourceHint?.line, 'candidate_source_hint');
  for (const item of candidate?.textMatches || []) add(item.file, item.line, 'text_match');
  for (const item of candidate?.objectKeyMatches || []) add(item.file, item.line, 'object_key_match');
  for (const item of candidate?.locatorMatches || []) add(item.file, item.line, 'locator_match');
  for (const item of candidate?.contextTextMatches || []) add(item.file, item.line, 'context_text_match');

  // Manual copy edits often stage coupled leaves from the same UI object, e.g.
  // a card label plus its count. Dynamic source stores both on the label/key
  // line, so the count op may need the sibling label's data candidates.
  for (const siblingCandidate of siblingCandidatesForEntry(batch, op)) {
    add(siblingCandidate.sourceHint?.relativeFile || siblingCandidate.sourceHint?.file, siblingCandidate.sourceHint?.line, 'entry_source_hint');
    for (const item of siblingCandidate.textMatches || []) add(item.file, item.line, 'entry_text_match');
    for (const item of siblingCandidate.objectKeyMatches || []) add(item.file, item.line, 'entry_object_key_match');
    for (const item of siblingCandidate.contextTextMatches || []) add(item.file, item.line, 'entry_context_text_match');
  }

  for (const relativeFile of reportedFiles || []) {
    for (const target of locatorTargetsInFile(cwd, relativeFile, op)) {
      out.push(target);
    }
  }

  const seen = new Set();
  return out.filter((target) => {
    const key = target.file + ':' + target.line + ':' + target.kind;
    if (seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}

function objectKeyCandidatesForOp(batch, op) {
  const candidates = (batch.candidates || [])
    .filter((item) => item.entryId === op.entryId && item.ref === op.ref);
  return candidates.flatMap((candidate) => candidate.objectKeyMatches || []);
}

function lineHasObjectKey(line, text) {
  if (typeof text !== 'string' || text.length === 0) return false;
  const quotedKey = new RegExp('(^|[\\s,{])([\'"`])' + escapeRegExp(text) + '\\2\\s*:');
  if (quotedKey.test(line)) return true;
  const identifierSafe = /^[A-Za-z_$][\w$]*$/.test(text);
  if (!identifierSafe) return false;
  const bareKey = new RegExp('(^|[\\s,{])' + escapeRegExp(text) + '\\s*:');
  return bareKey.test(line);
}

function objectKeyMatchStillUsesOriginal(cwd, match, op) {
  const relative = normalizeRelativeFile(cwd, match?.file);
  const lineNumber = Number(match?.line);
  if (!relative || !Number.isFinite(lineNumber) || lineNumber < 1) return false;
  let lines;
  try { lines = fs.readFileSync(path.resolve(cwd, relative), 'utf-8').split('\n'); } catch { return false; }
  const start = Math.max(0, lineNumber - 4);
  const end = Math.min(lines.length, lineNumber + 3);
  const windowLines = lines.slice(start, end);
  if (windowLines.some((line) => lineHasObjectKey(line, op.newText))) return false;
  return windowLines.some((line) => lineHasObjectKey(line, op.originalText));
}

function coupledObjectKeyFailuresForOp(batch, op, cwd) {
  if (
    typeof op?.originalText !== 'string'
    || typeof op?.newText !== 'string'
    || op.originalText === op.newText
  ) return [];
  return objectKeyCandidatesForOp(batch, op)
    .filter((match) => objectKeyMatchStillUsesOriginal(cwd, match, op))
    .map((match) => ({
      ref: op.ref,
      reason: 'source_verification_failed',
      detail: 'edited_text_source_key_dependency_not_updated',
      candidates: [{
        file: normalizeRelativeFile(cwd, match.file) || match.file,
        line: match.line,
        kind: 'object_key_match',
        reason: 'edited text is also a source key; update the coupled key to newText or fail the entry',
      }],
    }));
}

function siblingCandidatesForEntry(batch, op) {
  if (!op?.entryId) return [];
  return (batch.candidates || []).filter((item) => item.entryId === op.entryId && item.ref !== op.ref);
}

function locatorTargetsInFile(cwd, relativeFile, op) {
  if (!opHasLocator(op)) return [];
  const absolute = path.resolve(cwd, relativeFile);
  let lines;
  try { lines = fs.readFileSync(absolute, 'utf-8').split('\n'); } catch { return []; }
  const out = [];
  for (let index = 0; index < lines.length; index += 1) {
    if (!lineMatchesManualEditLocator(lines[index], op)) continue;
    out.push({ file: relativeFile, line: index + 1, kind: 'reported_locator_match' });
    if (out.length >= 20) break;
  }
  return out;
}

function verificationTargetPasses(cwd, target, op) {
  let lines;
  try { lines = fs.readFileSync(path.resolve(cwd, target.file), 'utf-8').split('\n'); } catch { return false; }
  return verificationTargetPassesLines(lines, target, op);
}

function verificationTargetPassesLines(lines, target, op) {
  const line = lines[target.line - 1] || '';
  if (lineShowsAppliedOp(line, op)) return true;
  const originalText = typeof op?.originalText === 'string' ? op.originalText : '';
  if (originalText && line.includes(originalText)) return false;
  const kind = String(target.kind || '');
  const canSearchWindow = target.reported
    || kind.includes('context_text_match')
    || kind.includes('object_key_match')
    || kind.includes('text_match');
  if (!canSearchWindow) return false;
  const radius = kind.includes('context_text_match') ? 20 : 4;
  const start = Math.max(0, target.line - radius - 1);
  const end = Math.min(lines.length, target.line + radius);
  const windowLines = lines.slice(start, end);
  if (windowLines.some((candidateLine) => lineShowsAppliedOp(candidateLine, op))) return true;
  if (windowShowsAppliedOp(windowLines, op)) return true;
  return false;
}

function windowShowsAppliedOp(lines, op) {
  const newText = typeof op?.newText === 'string' ? op.newText : '';
  if (!newText) return false;
  const originalText = typeof op?.originalText === 'string' ? op.originalText : '';
  const normalizedNew = normalizeVerificationText(newText);
  const normalizedOriginal = normalizeVerificationText(originalText);
  const normalizedWindow = normalizeVerificationText(lines.join('\n'));
  if (!normalizedNew || !normalizedWindow.includes(normalizedNew)) return false;
  if (normalizedOriginal && !normalizedNew.includes(normalizedOriginal) && normalizedWindow.includes(normalizedOriginal)) return false;
  return true;
}

function normalizeVerificationText(text) {
  return String(text || '').replace(/\s+/g, ' ').trim();
}

function lineShowsAppliedOp(line, op) {
  const originalText = typeof op?.originalText === 'string' ? op.originalText : '';
  const newText = typeof op?.newText === 'string' ? op.newText : '';
  const deletion = op?.deleted === true || newText.length === 0;
  if (deletion) return !!originalText && !line.includes(originalText);
  if (!line.includes(newText)) return false;
  if (originalText && !newText.includes(originalText) && line.includes(originalText)) return false;
  return true;
}

function opHasLocator(op) {
  return !!(
    op?.tag
    || op?.elementId
    || (Array.isArray(op?.classes) && op.classes.filter(Boolean).length > 0)
  );
}

function lineMatchesManualEditLocator(line, op) {
  if (op.tag) {
    const tagRe = new RegExp('<\\s*' + escapeRegExp(op.tag) + '(?=[\\s>/]|$)', 'i');
    if (!tagRe.test(line)) return false;
  }

  if (op.elementId) {
    const idRe = new RegExp('\\bid\\s*=\\s*["\']' + escapeRegExp(op.elementId) + '["\']');
    if (!idRe.test(line)) return false;
  }

  const classes = Array.isArray(op.classes) ? op.classes.filter(Boolean) : [];
  for (const className of classes) {
    if (!line.includes(className)) return false;
  }

  return true;
}

function verifyAppliedEntry({ batch, entry, reportedFiles, cwd }) {
  const failures = [];
  for (const rawOp of entry.ops || []) {
    const op = { ...rawOp, entryId: entry.id };
    if (op.deleted === true && typeof op.newText !== 'string') op.newText = '';
    if (typeof op.newText !== 'string') {
      failures.push({
        ref: op.ref,
        reason: 'source_verification_failed',
        detail: 'missing_newText',
        candidates: candidatesForEntry(batch, entry.id).slice(0, 12),
      });
      continue;
    }
    const targets = verificationTargetsForOp(batch, op, reportedFiles, cwd);
    const coupledObjectKeyFailures = coupledObjectKeyFailuresForOp(batch, op, cwd);
    if (
      coupledObjectKeyFailures.length === 0
      && targets.some((target) => verificationTargetPasses(cwd, target, op))
    ) continue;

    if (coupledObjectKeyFailures.length > 0) {
      failures.push(...coupledObjectKeyFailures.map((failure) => ({
        ...failure,
        candidates: [
          ...(failure.candidates || []),
          ...targets.map((target) => ({ file: target.file, line: target.line, kind: target.kind })),
          ...candidatesForEntry(batch, entry.id),
        ].slice(0, 12),
      })));
      continue;
    }

    const hintedOldText = sourceHintWindowFailure(cwd, op);
    if (hintedOldText) {
      failures.push({
        ref: op.ref,
        reason: 'source_verification_failed',
        detail: hintedOldText.reason,
        candidates: [hintedOldText, ...targets.map((target) => ({ file: target.file, line: target.line, kind: target.kind })), ...candidatesForEntry(batch, entry.id)].slice(0, 12),
      });
      continue;
    }

    failures.push({
      ref: op.ref,
      reason: 'source_verification_failed',
      detail: op.newText.length === 0 ? 'originalText_still_present_in_plausible_source_location' : 'newText_not_found_in_plausible_source_location',
      candidates: targets.map((target) => ({ file: target.file, line: target.line, kind: target.kind })).concat(candidatesForEntry(batch, entry.id)).slice(0, 12),
    });
  }
  return failures;
}

function snapshotTargetPasses(snapshot, target, op) {
  const before = snapshot.get(target.file)?.content;
  if (typeof before !== 'string') return false;
  return verificationTargetPassesLines(before.split('\n'), target, op);
}

function findUnappliedEntrySourceChanges({ batch, entries, reportedFiles, cwd, rollbackSnapshot }) {
  const failures = [];
  for (const entry of entries || []) {
    for (const rawOp of entry.ops || []) {
      const op = { ...rawOp, entryId: entry.id };
      if (typeof op.newText !== 'string' || op.newText.length === 0) continue;
      const targets = verificationTargetsForOp(batch, op, reportedFiles, cwd);
      const leakedTargets = targets.filter((target) =>
        verificationTargetPasses(cwd, target, op)
        && !snapshotTargetPasses(rollbackSnapshot, target, op)
      );
      if (leakedTargets.length === 0) continue;
      failures.push({
        id: entry.id,
        reason: 'failed_entry_source_changed',
        ref: op.ref,
        newText: op.newText,
        candidates: leakedTargets
          .map((target) => ({ file: target.file, line: target.line, kind: target.kind }))
          .concat(candidatesForEntry(batch, entry.id))
          .slice(0, 12),
      });
      break;
    }
  }
  return failures;
}

function verificationFailuresForEntries(batch, entries, reason, extra = {}) {
  return entries.map((entry) => ({
    id: entry.id,
    reason,
    candidates: candidatesForEntry(batch, entry.id),
    ...extra,
  }));
}

function clearAppliedEntries(cwd, appliedEntryIds) {
  const ids = new Set(appliedEntryIds);
  if (ids.size === 0) return 0;
  const buffer = readBuffer(cwd);
  let cleared = 0;
  const kept = [];
  for (const entry of buffer.entries || []) {
    if (ids.has(entry.id)) {
      cleared += Array.isArray(entry.ops) ? entry.ops.length : 0;
    } else {
      kept.push(entry);
    }
  }
  writeBuffer(cwd, { version: buffer.version || 1, entries: kept });
  return cleared;
}

function snapshotRollbackFiles(cwd, files = null) {
  const snapshot = new Map();
  const rollbackFiles = Array.isArray(files) && files.length > 0
    ? uniqueStrings(files).map((file) => normalizeRollbackPath(cwd, file)).filter(Boolean)
    : collectRollbackFiles(cwd);
  for (const relativeFile of rollbackFiles) {
    const absolute = path.resolve(cwd, relativeFile);
    try {
      snapshot.set(relativeFile, {
        existed: true,
        content: fs.readFileSync(absolute, 'utf-8'),
      });
    } catch (err) {
      if (err?.code === 'ENOENT') {
        snapshot.set(relativeFile, { existed: false });
      }
      // Other read failures are not safe to roll back.
    }
  }
  return snapshot;
}

function collectRollbackFiles(cwd) {
  const out = [];
  const seenDirs = new Set();
  const seenFiles = new Set();
  scanRollbackDir(cwd, cwd, out, seenDirs, seenFiles, 0);
  return out;
}

function scanRollbackDir(dir, cwd, out, seenDirs, seenFiles, depth) {
  if (depth > 10) return;
  let realDir;
  try { realDir = fs.realpathSync(dir); } catch { return; }
  if (seenDirs.has(realDir)) return;
  seenDirs.add(realDir);

  let entries;
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
  for (const entry of entries) {
    if (entry.isDirectory()) {
      if (ROLLBACK_SKIP_DIRS.has(entry.name)) continue;
      scanRollbackDir(path.join(dir, entry.name), cwd, out, seenDirs, seenFiles, depth + 1);
      continue;
    }
    if (!entry.isFile()) continue;
    if (!ROLLBACK_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) continue;
    const absolute = path.join(dir, entry.name);
    if (isGeneratedFile(absolute, { cwd })) continue;
    let realFile;
    try { realFile = fs.realpathSync(absolute); } catch { continue; }
    if (seenFiles.has(realFile)) continue;
    seenFiles.add(realFile);
    const relative = path.relative(cwd, absolute);
    if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) continue;
    out.push(relative);
  }
}

function changedFilesSinceSnapshot(cwd, snapshot, scopeFiles = null) {
  const changed = new Map();
  const scopedFiles = Array.isArray(scopeFiles) && scopeFiles.length > 0
    ? scopeFiles.map((file) => normalizeRollbackPath(cwd, file)).filter(Boolean)
    : null;
  const currentFiles = new Set(scopedFiles || collectRollbackFiles(cwd));
  for (const [relativeFile, before] of snapshot.entries()) {
    if (scopedFiles && !currentFiles.has(relativeFile)) continue;
    const absolute = path.resolve(cwd, relativeFile);
    if (before?.existed === false) {
      if (fs.existsSync(absolute)) changed.set(relativeFile, { file: relativeFile, kind: 'added' });
      continue;
    }
    if (!fs.existsSync(absolute)) {
      changed.set(relativeFile, { file: relativeFile, kind: 'deleted' });
      continue;
    }
    let content;
    try { content = fs.readFileSync(absolute, 'utf-8'); } catch { continue; }
    if (content !== before.content) {
      changed.set(relativeFile, { file: relativeFile, kind: 'modified' });
    }
  }
  for (const relativeFile of currentFiles) {
    if (!snapshot.has(relativeFile)) {
      changed.set(relativeFile, { file: relativeFile, kind: 'unknown' });
    }
  }
  return [...changed.values()];
}

function rollbackChangedFiles(cwd, snapshot, extraFiles = [], scopeFiles = []) {
  const scope = new Set(
    [...(scopeFiles || []), ...(extraFiles || [])]
      .map((file) => normalizeRollbackPath(cwd, file))
      .filter(Boolean),
  );
  const changed = changedFilesSinceSnapshot(cwd, snapshot, [...scope]);
  const byFile = new Map(changed.map((item) => [item.file, item]));
  for (const file of extraFiles || []) {
    const relative = normalizeRollbackPath(cwd, file);
    if (relative && !byFile.has(relative)) {
      byFile.set(relative, { file: relative, kind: snapshot.has(relative) ? 'reported' : 'unknown' });
    }
  }

  const rolledBackFiles = [];
  const rollbackFailures = [];
  for (const item of byFile.values()) {
    if (!scope.has(item.file)) continue;
    const absolute = path.resolve(cwd, item.file);
    const before = snapshot.get(item.file);
    try {
      if (before?.existed !== false && typeof before?.content === 'string') {
        fs.mkdirSync(path.dirname(absolute), { recursive: true });
        fs.writeFileSync(absolute, before.content, 'utf-8');
      } else if (before?.existed === false && item.kind === 'added' && fs.existsSync(absolute)) {
        fs.rmSync(absolute);
      } else {
        rollbackFailures.push({ file: item.file, reason: 'no_snapshot' });
        continue;
      }
      rolledBackFiles.push(item.file);
    } catch (err) {
      rollbackFailures.push({ file: item.file, reason: 'restore_failed', message: err.message || String(err) });
    }
  }
  return { rolledBackFiles, rollbackFailures };
}

function collectApplyOwnedFiles(batch, cwd, extraFiles = []) {
  const files = [];
  for (const entry of batch?.entries || []) {
    for (const op of entry.ops || []) files.push(op.sourceHint?.file);
  }
  for (const candidate of batch?.candidates || []) {
    files.push(candidate.sourceHint?.relativeFile, candidate.sourceHint?.file);
    for (const item of candidate.textMatches || []) files.push(item.file);
    for (const item of candidate.objectKeyMatches || []) files.push(item.file);
    for (const item of candidate.locatorMatches || []) files.push(item.file);
    for (const item of candidate.contextTextMatches || []) files.push(item.file);
  }
  files.push(...(extraFiles || []));
  return uniqueStrings(files)
    .map((file) => normalizeRollbackPath(cwd, file))
    .filter(Boolean);
}

function unreportedChangedFiles(cwd, snapshot, reportedFiles, scopeFiles = []) {
  const reported = new Set(
    (reportedFiles || [])
      .map((file) => normalizeRollbackPath(cwd, file))
      .filter(Boolean),
  );
  const scope = new Set(
    (scopeFiles || [])
      .map((file) => normalizeRollbackPath(cwd, file))
      .filter(Boolean),
  );
  return changedFilesSinceSnapshot(cwd, snapshot, [...scope])
    .map((item) => item.file)
    .filter((file) => scope.has(file))
    .filter((file) => !reported.has(file));
}

function normalizeRollbackPath(cwd, file) {
  return normalizeProjectSourcePath(cwd, file);
}

function verifyEntriesAfterRepair({ batch, appliedEntryIds, files, cwd }) {
  const reportedFiles = uniqueStrings(files || [])
    .map((file) => normalizeRelativeFile(cwd, file))
    .filter(Boolean);
  const entries = (batch.entries || []).filter((entry) => appliedEntryIds.includes(entry.id));
  const verifiedIds = [];
  const failed = [];
  for (const entry of entries) {
    const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
    if (failures.length === 0) {
      verifiedIds.push(entry.id);
    } else {
      failed.push({
        id: entry.id,
        reason: 'source_verification_failed',
        failures,
        candidates: candidatesForEntry(batch, entry.id),
      });
    }
  }
  return { verifiedIds, failed, reportedFiles };
}

async function repairPostApplyValidation({
  batch,
  cwd,
  pageUrl,
  count,
  provider,
  env,
  timeoutMs,
  applyBatchToSource,
  chatAvailable,
  transactionId,
  appliedEntryIds,
  files,
  failed,
  notes,
  warnings,
  postChecks,
  repairReason = 'post_apply_validation_failed',
  repairFailures = null,
}) {
  const maxAttempts = repairAttemptLimit(env);
  let currentFiles = mergeUniqueStrings(files || []);
  let currentAppliedIds = mergeUniqueStrings(appliedEntryIds || []);
  let currentFailed = Array.isArray(failed) ? failed : [];
  let currentNotes = Array.isArray(notes) ? notes : [];
  let currentWarnings = Array.isArray(warnings) ? warnings : [];
  let currentFailures = Array.isArray(repairFailures) ? repairFailures : (postChecks?.failures || []);

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    const repair = {
      attempt,
      maxAttempts,
      transactionId: transactionId || null,
      reason: repairReason,
      failures: summarizeRepairFailures(currentFailures),
      files: currentFiles,
      pageUrl,
    };
    let repairResult;
    try {
      repairResult = await runCopyEditBatchAgent(buildRepairBatch(batch, repair), {
        cwd,
        provider,
        env,
        timeoutMs,
        applyBatchToSource,
        chatAvailable,
      });
    } catch (err) {
      currentFailures = [{
        reason: 'repair_agent_failed',
        message: err.message || String(err),
      }];
      continue;
    }

    currentFiles = mergeUniqueStrings(currentFiles, repairResult.files || []);
    currentNotes = [...currentNotes, ...(repairResult.notes || [])];
    currentWarnings = [...currentWarnings, ...(repairResult.warnings || [])];
    currentAppliedIds = mergeUniqueStrings(currentAppliedIds, repairResult.appliedEntryIds || []);
    currentFailed = mergeFailedEntries(
      currentFailed,
      normalizeFailedEntries(batch, repairResult, 'repair_failed'),
    );

    const verified = verifyEntriesAfterRepair({
      batch,
      appliedEntryIds: currentAppliedIds,
      files: currentFiles,
      cwd,
    });
    if (verified.failed.length > 0) {
      currentFailures = verified.failed;
      continue;
    }

    const repairedChecks = runCopyEditPostApplyChecks({ cwd, files: currentFiles });
    currentWarnings = [...currentWarnings, ...(repairedChecks.warnings || [])];
    if (!repairedChecks.ok) {
      currentFailures = repairedChecks.failures || [];
      continue;
    }

    const cleared = clearAppliedEntries(cwd, verified.verifiedIds);
    const counts = countByPage(cwd);
    const verifiedIdSet = new Set(verified.verifiedIds);
    return {
      applied: summarizeAppliedEntries(batch.entries, verified.verifiedIds),
      failed: mergeFailedEntries(currentFailed).filter((item) => !verifiedIdSet.has(item.id)),
      files: currentFiles,
      cleared,
      count,
      pageUrl,
      warnings: currentWarnings,
      notes: currentNotes,
      repair: {
        status: 'repaired',
        attempts: attempt,
        maxAttempts,
        transactionId: transactionId || null,
      },
      ...counts,
    };
  }

  const decisionFailedEntries = currentAppliedIds.length > 0
    ? (batch.entries || [])
        .filter((entry) => currentAppliedIds.includes(entry.id))
        .map((entry) => ({
          id: entry.id,
          reason: repairReason,
          checks: currentFailures,
          candidates: candidatesForEntry(batch, entry.id),
        }))
    : verificationFailuresForEntries(batch, batch.entries || [], repairReason, { checks: currentFailures });
  return {
    applied: [],
    failed: mergeFailedEntries(decisionFailedEntries, currentFailed),
    files: currentFiles,
    cleared: 0,
    count,
    pageUrl,
    warnings: currentWarnings,
    notes: currentNotes,
    reason: 'manual_edit_repair_needs_decision',
    needsManualDecision: true,
    repair: {
      status: 'needs_decision',
      attempts: maxAttempts,
      maxAttempts,
      transactionId: transactionId || null,
      failures: summarizeRepairFailures(currentFailures),
      files: currentFiles,
    },
    ...countByPage(cwd),
  };
}

export async function commitManualEdits({
  cwd = process.cwd(),
  pageUrl = null,
  provider = undefined,
  env = process.env,
  timeoutMs = undefined,
  applyBatchToSource = undefined,
  chatAvailable = undefined,
  repairOnly = false,
  transactionId = null,
  batch: providedBatch = null,
} = {}) {
  try {
    readBufferStrict(cwd);
  } catch (err) {
    return {
      applied: [],
      failed: [],
      files: [],
      cleared: 0,
      count: 0,
      pageUrl,
      reason: 'manual_edit_buffer_invalid',
      message: err.message || String(err),
      ...countByPage(cwd),
    };
  }

  const batch = providedBatch || buildManualEditEvidence({ cwd, pageUrl });
  const count = countOps(batch.entries);
  if (count === 0) {
    return {
      applied: [],
      failed: [],
      files: [],
      cleared: 0,
      count: 0,
      pageUrl,
      reason: 'no_pending_edits',
      ...countByPage(cwd),
    };
  }

  const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
  const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
  let result;
  try {
    result = repairOnly
      ? {
          status: 'done',
          appliedEntryIds: allEntryIds(batch),
          failed: [],
          files: collectApplyOwnedFiles(batch, cwd),
          notes: ['repair-only validation pass'],
        }
      : await runCopyEditBatchAgent(batch, {
          cwd,
          provider,
          env,
          timeoutMs,
          applyBatchToSource,
          chatAvailable,
        });
  } catch (err) {
    const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
    return {
      applied: [],
      failed: batch.entries.map((entry) => ({
        id: entry.id,
        reason: err.message || String(err),
        candidates: candidatesForEntry(batch, entry.id),
      })),
      files: [],
      cleared: 0,
      count,
      pageUrl,
      rolledBackFiles: rollback.rolledBackFiles,
      rollbackFailures: rollback.rollbackFailures,
      ...countByPage(cwd),
    };
  }

  if (result.status === 'error') {
    const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
    const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
    const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
    return {
      applied: [],
      failed: failed.length > 0
        ? failed
        : verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
      files: result.files || [],
      cleared: 0,
      count,
      pageUrl,
      notes: result.notes || [],
      rolledBackFiles: rollback.rolledBackFiles,
      rollbackFailures: rollback.rollbackFailures,
      ...countByPage(cwd),
    };
  }

  const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
  const reportedFiles = uniqueStrings(result.files || [])
    .map((file) => normalizeRelativeFile(cwd, file))
    .filter(Boolean);
  const aiFailed = normalizeFailedEntries(batch, result, 'AI copy edit failed');
  const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
  const failedIds = new Set(aiFailed.map((item) => item.id).filter(Boolean));
  const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));

  if (conflictingAppliedIds.length > 0) {
    const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
    const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
    return {
      applied: [],
      failed: [
        ...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
        ...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
      ],
      files: result.files || [],
      cleared: 0,
      count,
      pageUrl,
      notes: result.notes || [],
      rolledBackFiles: rollback.rolledBackFiles,
      rollbackFailures: rollback.rollbackFailures,
      ...countByPage(cwd),
    };
  }

  const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
  if (unreportedFiles.length > 0) {
    const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
    return {
      applied: [],
      failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
      files: result.files || [],
      unreportedFiles,
      cleared: 0,
      count,
      pageUrl,
      notes: result.notes || [],
      rolledBackFiles: rollback.rolledBackFiles,
      rollbackFailures: rollback.rollbackFailures,
      ...countByPage(cwd),
    };
  }

  if (result.status === 'done' && reportedAppliedIds.length === 0) {
    const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
    return {
      applied: [],
      failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
      files: result.files || [],
      cleared: 0,
      count,
      pageUrl,
      notes: result.notes || [],
      rolledBackFiles: rollback.rolledBackFiles,
      rollbackFailures: rollback.rollbackFailures,
      ...countByPage(cwd),
    };
  }

  const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
  if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
    return repairPostApplyValidation({
      batch,
      cwd,
      pageUrl,
      count,
      provider,
      env,
      timeoutMs,
      applyBatchToSource,
      chatAvailable,
      transactionId,
      appliedEntryIds: reportedAppliedIds,
      files: result.files || [],
      failed: aiFailed,
      notes: result.notes || [],
      warnings: result.warnings || [],
      repairReason: 'missing_touched_files',
      repairFailures: verificationFailuresForEntries(batch, reportedAppliedEntries, 'missing_touched_files'),
    });
  }

  const verifiedAppliedIds = [];
  const verificationFailed = [];
  for (const entry of reportedAppliedEntries) {
    const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
    if (failures.length === 0) {
      verifiedAppliedIds.push(entry.id);
    } else {
      verificationFailed.push({
        id: entry.id,
        reason: 'source_verification_failed',
        failures,
        candidates: candidatesForEntry(batch, entry.id),
      });
    }
  }
  const unreportedEntries = result.status === 'done' || result.status === 'partial'
    ? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
    : [];
  const nonRepairFailed = [
    ...verificationFailuresForEntries(batch, unreportedEntries, 'not_reported_applied'),
    ...aiFailed,
  ];
  const failed = [
    ...verificationFailed,
    ...nonRepairFailed,
  ];

  const unappliedEntries = batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id));
  const leakedUnapplied = findUnappliedEntrySourceChanges({
    batch,
    entries: unappliedEntries,
    reportedFiles,
    cwd,
    rollbackSnapshot,
  });
  if (leakedUnapplied.length > 0) {
    const leakedIds = new Set(leakedUnapplied.map((item) => item.id).filter(Boolean));
    const rolledBackVerified = reportedAppliedEntries
      .filter((entry) => verifiedAppliedIds.includes(entry.id))
      .map((entry) => ({
        id: entry.id,
        reason: 'rolled_back_due_to_failed_entry_source_changed',
        candidates: candidatesForEntry(batch, entry.id),
      }));
    const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
    return {
      applied: [],
      failed: [
        ...leakedUnapplied,
        ...failed.filter((item) => !leakedIds.has(item.id)),
        ...rolledBackVerified,
      ],
      files: result.files || [],
      cleared: 0,
      count,
      pageUrl,
      rolledBackFiles: rollback.rolledBackFiles,
      rollbackFailures: rollback.rollbackFailures,
      notes: result.notes || [],
      ...countByPage(cwd),
    };
  }

  if (verificationFailed.length > 0) {
    return repairPostApplyValidation({
      batch,
      cwd,
      pageUrl,
      count,
      provider,
      env,
      timeoutMs,
      applyBatchToSource,
      chatAvailable,
      transactionId,
      appliedEntryIds: reportedAppliedIds,
      files: result.files || [],
      failed: nonRepairFailed,
      notes: result.notes || [],
      warnings: result.warnings || [],
      repairReason: 'source_verification_failed',
      repairFailures: verificationFailed,
    });
  }

  const postChecks = runCopyEditPostApplyChecks({ cwd, files: result.files || [] });
  if (!postChecks.ok) {
    const postCheckEntries = verifiedAppliedIds.length > 0
      ? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
      : batch.entries;
    return repairPostApplyValidation({
      batch,
      cwd,
      pageUrl,
      count,
      provider,
      env,
      timeoutMs,
      applyBatchToSource,
      chatAvailable,
      transactionId,
      appliedEntryIds: verifiedAppliedIds.length > 0
        ? verifiedAppliedIds
        : postCheckEntries.map((entry) => entry.id).filter(Boolean),
      files: result.files || [],
      failed,
      notes: result.notes || [],
      warnings: [...(result.warnings || []), ...(postChecks.warnings || [])],
      postChecks,
    });
  }

  const cleared = clearAppliedEntries(cwd, verifiedAppliedIds);
  const counts = countByPage(cwd);
  return {
    applied: summarizeAppliedEntries(batch.entries, verifiedAppliedIds),
    failed,
    files: result.files || [],
    cleared,
    count,
    pageUrl,
    warnings: [...(result.warnings || []), ...(postChecks.warnings || [])],
    notes: result.notes || [],
    ...counts,
  };
}

async function main() {
  const args = process.argv.slice(2);
  if (args.includes('--help') || args.includes('-h')) {
    console.log('Usage: node live-commit-manual-edits.mjs [--page-url=<url>] [--provider=auto|codex|claude|mock]');
    process.exit(0);
  }

  const result = await commitManualEdits({
    cwd: process.cwd(),
    pageUrl: argVal(args, '--page-url'),
    provider: argVal(args, '--provider') || undefined,
    timeoutMs: Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000),
  });
  console.log(JSON.stringify(result));
}

if (process.argv[1]?.endsWith('live-commit-manual-edits.mjs')) {
  main().catch((err) => {
    console.error(JSON.stringify({ error: 'commit_failed', message: err.message || String(err) }));
    process.exit(1);
  });
}

function escapeRegExp(value) {
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
