// wall-e/tools/edit-replacers.js
// Ported from OpenCode (https://github.com/anomalyco/opencode)
// Source: packages/opencode/src/tool/edit.ts (689 lines)
// License: MIT
//
// 9-strategy replacer chain for fuzzy string replacement in files.
// Each replacer is a generator that yields candidate matches from
// most strict to most permissive. The replace() function tries them
// in order and uses the first unambiguous match.
'use strict';

// ── Similarity thresholds for BlockAnchorReplacer ──

const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0;
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3;

// ── Levenshtein distance ──
// Ported from OpenCode edit.ts line 199

function levenshtein(a, b) {
  if (a === '' || b === '') return Math.max(a.length, b.length);
  const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
    Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
  );
  for (let i = 1; i <= a.length; i++) {
    for (let j = 1; j <= b.length; j++) {
      const cost = a[i - 1] === b[j - 1] ? 0 : 1;
      matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
    }
  }
  return matrix[a.length][b.length];
}

// ── Line ending utilities ──

function normalizeLineEndings(text) {
  return text.replaceAll('\r\n', '\n');
}

function detectLineEnding(text) {
  return text.includes('\r\n') ? '\r\n' : '\n';
}

function convertToLineEnding(text, ending) {
  if (ending === '\n') return text;
  return text.replaceAll('\n', '\r\n');
}

// ── Strategy 1: SimpleReplacer ──
// Exact match — current behavior. Ported from OpenCode edit.ts line 217.

function* SimpleReplacer(_content, find) {
  yield find;
}

// ── Strategy 2: LineTrimmedReplacer ──
// Trim each line before comparing. Ported from OpenCode edit.ts line 221.

function* LineTrimmedReplacer(content, find) {
  const originalLines = content.split('\n');
  const searchLines = find.split('\n');

  if (searchLines[searchLines.length - 1] === '') searchLines.pop();

  for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
    let matches = true;
    for (let j = 0; j < searchLines.length; j++) {
      if (originalLines[i + j].trim() !== searchLines[j].trim()) {
        matches = false;
        break;
      }
    }
    if (matches) {
      let matchStartIndex = 0;
      for (let k = 0; k < i; k++) matchStartIndex += originalLines[k].length + 1;
      let matchEndIndex = matchStartIndex;
      for (let k = 0; k < searchLines.length; k++) {
        matchEndIndex += originalLines[i + k].length;
        if (k < searchLines.length - 1) matchEndIndex += 1;
      }
      yield content.substring(matchStartIndex, matchEndIndex);
    }
  }
}

// ── Strategy 3: BlockAnchorReplacer ──
// Match first/last lines, Levenshtein on middle. Ported from OpenCode edit.ts line 261.

function* BlockAnchorReplacer(content, find) {
  const originalLines = content.split('\n');
  const searchLines = find.split('\n');

  if (searchLines.length < 3) return;
  if (searchLines[searchLines.length - 1] === '') searchLines.pop();

  const firstLineSearch = searchLines[0].trim();
  const lastLineSearch = searchLines[searchLines.length - 1].trim();
  const searchBlockSize = searchLines.length;

  // Collect all candidate positions where both anchors match
  const candidates = [];
  for (let i = 0; i < originalLines.length; i++) {
    if (originalLines[i].trim() !== firstLineSearch) continue;
    for (let j = i + 2; j < originalLines.length; j++) {
      if (originalLines[j].trim() === lastLineSearch) {
        candidates.push({ startLine: i, endLine: j });
        break;
      }
    }
  }

  if (candidates.length === 0) return;

  // Helper: extract substring from line range
  function extractBlock(startLine, endLine) {
    let matchStartIndex = 0;
    for (let k = 0; k < startLine; k++) matchStartIndex += originalLines[k].length + 1;
    let matchEndIndex = matchStartIndex;
    for (let k = startLine; k <= endLine; k++) {
      matchEndIndex += originalLines[k].length;
      if (k < endLine) matchEndIndex += 1;
    }
    return content.substring(matchStartIndex, matchEndIndex);
  }

  if (candidates.length === 1) {
    const { startLine, endLine } = candidates[0];
    const actualBlockSize = endLine - startLine + 1;
    let similarity = 0;
    const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);

    if (linesToCheck > 0) {
      for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
        const originalLine = originalLines[startLine + j].trim();
        const searchLine = searchLines[j].trim();
        const maxLen = Math.max(originalLine.length, searchLine.length);
        if (maxLen === 0) continue;
        const distance = levenshtein(originalLine, searchLine);
        similarity += (1 - distance / maxLen) / linesToCheck;
        if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) break;
      }
    } else {
      similarity = 1.0;
    }

    if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
      yield extractBlock(startLine, endLine);
    }
    return;
  }

  // Multiple candidates — pick best by similarity
  let bestMatch = null;
  let maxSimilarity = -1;

  for (const candidate of candidates) {
    const { startLine, endLine } = candidate;
    const actualBlockSize = endLine - startLine + 1;
    let similarity = 0;
    const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);

    if (linesToCheck > 0) {
      for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
        const originalLine = originalLines[startLine + j].trim();
        const searchLine = searchLines[j].trim();
        const maxLen = Math.max(originalLine.length, searchLine.length);
        if (maxLen === 0) continue;
        const distance = levenshtein(originalLine, searchLine);
        similarity += 1 - distance / maxLen;
      }
      similarity /= linesToCheck;
    } else {
      similarity = 1.0;
    }

    if (similarity > maxSimilarity) {
      maxSimilarity = similarity;
      bestMatch = candidate;
    }
  }

  if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
    yield extractBlock(bestMatch.startLine, bestMatch.endLine);
  }
}

// ── Strategy 4: WhitespaceNormalizedReplacer ──
// Collapse all whitespace to single space. Ported from OpenCode edit.ts line 396.

function* WhitespaceNormalizedReplacer(content, find) {
  const normalizeWhitespace = (text) => text.replace(/\s+/g, ' ').trim();
  const normalizedFind = normalizeWhitespace(find);

  const lines = content.split('\n');
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (normalizeWhitespace(line) === normalizedFind) {
      yield line;
    } else {
      const normalizedLine = normalizeWhitespace(line);
      if (normalizedLine.includes(normalizedFind)) {
        const words = find.trim().split(/\s+/);
        if (words.length > 0) {
          const pattern = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s+');
          try {
            const regex = new RegExp(pattern);
            const match = line.match(regex);
            if (match) yield match[0];
          } catch (_e) { /* invalid regex, skip */ }
        }
      }
    }
  }

  // Multi-line matches
  const findLines = find.split('\n');
  if (findLines.length > 1) {
    for (let i = 0; i <= lines.length - findLines.length; i++) {
      const block = lines.slice(i, i + findLines.length);
      if (normalizeWhitespace(block.join('\n')) === normalizedFind) {
        yield block.join('\n');
      }
    }
  }
}

// ── Strategy 5: IndentationFlexibleReplacer ──
// Strip common indentation prefix. Ported from OpenCode edit.ts line 440.

function* IndentationFlexibleReplacer(content, find) {
  const removeIndentation = (text) => {
    const lines = text.split('\n');
    const nonEmpty = lines.filter((l) => l.trim().length > 0);
    if (nonEmpty.length === 0) return text;
    const minIndent = Math.min(
      ...nonEmpty.map((l) => { const m = l.match(/^(\s*)/); return m ? m[1].length : 0; }),
    );
    return lines.map((l) => (l.trim().length === 0 ? l : l.slice(minIndent))).join('\n');
  };

  const normalizedFind = removeIndentation(find);
  const contentLines = content.split('\n');
  const findLines = find.split('\n');

  for (let i = 0; i <= contentLines.length - findLines.length; i++) {
    const block = contentLines.slice(i, i + findLines.length).join('\n');
    if (removeIndentation(block) === normalizedFind) {
      yield block;
    }
  }
}

// ── Strategy 6: EscapeNormalizedReplacer ──
// Unescape \n, \t, \', etc. Ported from OpenCode edit.ts line 468.

function* EscapeNormalizedReplacer(content, find) {
  const unescapeString = (str) => {
    return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, ch) => {
      switch (ch) {
        case 'n': return '\n';
        case 't': return '\t';
        case 'r': return '\r';
        case "'": return "'";
        case '"': return '"';
        case '`': return '`';
        case '\\': return '\\';
        case '\n': return '\n';
        case '$': return '$';
        default: return match;
      }
    });
  };

  const unescapedFind = unescapeString(find);

  if (content.includes(unescapedFind)) {
    yield unescapedFind;
  }

  const lines = content.split('\n');
  const findLines = unescapedFind.split('\n');

  for (let i = 0; i <= lines.length - findLines.length; i++) {
    const block = lines.slice(i, i + findLines.length).join('\n');
    if (unescapeString(block) === unescapedFind) {
      yield block;
    }
  }
}

// ── Strategy 7: TrimmedBoundaryReplacer ──
// Trim entire old_string boundaries. Ported from OpenCode edit.ts line 531.

function* TrimmedBoundaryReplacer(content, find) {
  const trimmedFind = find.trim();
  if (trimmedFind === find) return; // Already trimmed, no point

  if (content.includes(trimmedFind)) {
    yield trimmedFind;
  }

  const lines = content.split('\n');
  const findLines = find.split('\n');

  for (let i = 0; i <= lines.length - findLines.length; i++) {
    const block = lines.slice(i, i + findLines.length).join('\n');
    if (block.trim() === trimmedFind) {
      yield block;
    }
  }
}

// ── Strategy 8: ContextAwareReplacer ──
// Anchor lines + 50% middle match heuristic. Ported from OpenCode edit.ts line 557.

function* ContextAwareReplacer(content, find) {
  const findLines = find.split('\n');
  if (findLines.length < 3) return;
  if (findLines[findLines.length - 1] === '') findLines.pop();

  const contentLines = content.split('\n');
  const firstLine = findLines[0].trim();
  const lastLine = findLines[findLines.length - 1].trim();

  for (let i = 0; i < contentLines.length; i++) {
    if (contentLines[i].trim() !== firstLine) continue;

    for (let j = i + 2; j < contentLines.length; j++) {
      if (contentLines[j].trim() === lastLine) {
        const blockLines = contentLines.slice(i, j + 1);

        if (blockLines.length === findLines.length) {
          let matchingLines = 0;
          let totalNonEmptyLines = 0;

          for (let k = 1; k < blockLines.length - 1; k++) {
            const blockLine = blockLines[k].trim();
            const findLine = findLines[k].trim();
            if (blockLine.length > 0 || findLine.length > 0) {
              totalNonEmptyLines++;
              if (blockLine === findLine) matchingLines++;
            }
          }

          if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
            yield blockLines.join('\n');
            break;
          }
        }
        break;
      }
    }
  }
}

// ── Strategy 9: MultiOccurrenceReplacer ──
// All exact matches (for replaceAll mode). Ported from OpenCode edit.ts line 517.

function* MultiOccurrenceReplacer(content, find) {
  let startIndex = 0;
  while (true) {
    const index = content.indexOf(find, startIndex);
    if (index === -1) break;
    yield find;
    startIndex = index + find.length;
  }
}

// ── Main replace() function ──
// Tries all 9 strategies in order. Ported from OpenCode edit.ts line 651.

const REPLACERS = [
  { name: 'SimpleReplacer', fn: SimpleReplacer },
  { name: 'LineTrimmedReplacer', fn: LineTrimmedReplacer },
  { name: 'BlockAnchorReplacer', fn: BlockAnchorReplacer },
  { name: 'WhitespaceNormalizedReplacer', fn: WhitespaceNormalizedReplacer },
  { name: 'IndentationFlexibleReplacer', fn: IndentationFlexibleReplacer },
  { name: 'EscapeNormalizedReplacer', fn: EscapeNormalizedReplacer },
  { name: 'TrimmedBoundaryReplacer', fn: TrimmedBoundaryReplacer },
  { name: 'ContextAwareReplacer', fn: ContextAwareReplacer },
  { name: 'MultiOccurrenceReplacer', fn: MultiOccurrenceReplacer },
];

/**
 * Replace oldString with newString in content using a 9-strategy chain.
 * @param {string} content - File content
 * @param {string} oldString - Text to find (may be slightly wrong)
 * @param {string} newString - Replacement text
 * @param {boolean} [replaceAll=false] - Replace all occurrences
 * @returns {string} - Updated content
 * @throws {Error} If not found or ambiguous
 */
function replace(content, oldString, newString, replaceAll = false) {
  if (oldString === newString) {
    throw new Error('No changes to apply: oldString and newString are identical.');
  }

  // Binary file guard
  if (content.includes('\x00')) {
    throw new Error('Cannot edit binary file (contains null bytes).');
  }

  // Normalize line endings for matching
  const ending = detectLineEnding(content);
  const old = convertToLineEnding(normalizeLineEndings(oldString), ending);
  const next = convertToLineEnding(normalizeLineEndings(newString), ending);

  let notFound = true;

  for (const { name, fn } of REPLACERS) {
    for (const search of fn(content, old)) {
      const index = content.indexOf(search);
      if (index === -1) continue;
      notFound = false;

      if (replaceAll) {
        return content.replaceAll(search, next);
      }

      const lastIndex = content.lastIndexOf(search);
      if (index !== lastIndex) continue; // ambiguous — try next strategy
      return content.substring(0, index) + next + content.substring(index + search.length);
    }
  }

  if (notFound) {
    throw new Error(
      'Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.',
    );
  }
  throw new Error('Found multiple matches for oldString. Provide more surrounding context to make the match unique.');
}

/**
 * Apply multiple edits to a single file sequentially.
 * Each edit sees the result of the previous one. Stops on first failure.
 *
 * @param {string} filePath - Absolute path to file
 * @param {Array<{ old_string: string, new_string: string }>} edits - Edit pairs
 * @returns {{ edits_applied: number, edits_failed: number, first_failure_index?: number, error?: string, path: string }}
 */
function multiEdit(filePath, edits) {
  const fs = require('node:fs');
  let content = fs.readFileSync(filePath, 'utf8');
  let applied = 0;

  for (let i = 0; i < edits.length; i++) {
    try {
      content = replace(content, edits[i].old_string, edits[i].new_string);
      applied++;
    } catch (err) {
      // Write what we have so far
      if (applied > 0) fs.writeFileSync(filePath, content, 'utf8');
      return {
        edits_applied: applied,
        edits_failed: edits.length - applied,
        first_failure_index: i,
        error: err.message,
        path: filePath,
      };
    }
  }

  fs.writeFileSync(filePath, content, 'utf8');
  return { edits_applied: applied, edits_failed: 0, path: filePath };
}

module.exports = {
  replace,
  multiEdit,
  levenshtein,
  normalizeLineEndings,
  detectLineEnding,
  convertToLineEnding,
  SimpleReplacer,
  LineTrimmedReplacer,
  BlockAnchorReplacer,
  WhitespaceNormalizedReplacer,
  IndentationFlexibleReplacer,
  EscapeNormalizedReplacer,
  TrimmedBoundaryReplacer,
  ContextAwareReplacer,
  MultiOccurrenceReplacer,
  REPLACERS,
};
