// wall-e/tools/patch.js
// Ported from OpenCode (https://github.com/anomalyco/opencode)
// Source: packages/opencode/src/patch/index.ts (681 lines)
// License: MIT
//
// Structured patch parser and applier for multi-file diffs.
// Supports Add, Delete, Update (with chunked diffs), and Move operations.
// Uses 4-pass fuzzy matching (seekSequence) for resilient chunk location.
'use strict';

const fs = require('node:fs');
const fsPromises = require('node:fs/promises');
const path = require('node:path');

// ── Unicode normalization (matches OpenCode's Rust normalize_unicode) ──

function normalizeUnicode(str) {
  return str
    .replace(/[\u2018\u2019\u201A\u201B]/g, "'")   // single quotes
    .replace(/[\u201C\u201D\u201E\u201F]/g, '"')    // double quotes
    .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, '-') // dashes
    .replace(/\u2026/g, '...')                        // ellipsis
    .replace(/\u00A0/g, ' ');                         // non-breaking space
}

// ── 4-pass fuzzy line matcher ──
// Ported from OpenCode patch/index.ts line 464

function tryMatch(lines, pattern, startIndex, compare, eof) {
  // If EOF anchor, try matching from end of file first
  if (eof) {
    const fromEnd = lines.length - pattern.length;
    if (fromEnd >= startIndex) {
      let matches = true;
      for (let j = 0; j < pattern.length; j++) {
        if (!compare(lines[fromEnd + j], pattern[j])) { matches = false; break; }
      }
      if (matches) return fromEnd;
    }
  }

  // Forward search from startIndex
  for (let i = startIndex; i <= lines.length - pattern.length; i++) {
    let matches = true;
    for (let j = 0; j < pattern.length; j++) {
      if (!compare(lines[i + j], pattern[j])) { matches = false; break; }
    }
    if (matches) return i;
  }

  return -1;
}

/**
 * 4-pass fuzzy sequence finder.
 * Pass 1: exact match
 * Pass 2: rstrip (trim trailing whitespace)
 * Pass 3: trim (both ends)
 * Pass 4: Unicode normalized + trim
 *
 * @param {string[]} lines - File lines
 * @param {string[]} pattern - Lines to find
 * @param {number} startIndex - Start searching from this line
 * @param {boolean} [eof=false] - If true, prefer match near end of file
 * @returns {number} Line index or -1
 */
function seekSequence(lines, pattern, startIndex, eof = false) {
  if (pattern.length === 0) return -1;

  // Pass 1: exact match
  const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof);
  if (exact !== -1) return exact;

  // Pass 2: rstrip (trim trailing whitespace)
  const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof);
  if (rstrip !== -1) return rstrip;

  // Pass 3: trim (both ends)
  const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof);
  if (trim !== -1) return trim;

  // Pass 4: normalized (Unicode punctuation to ASCII)
  const normalized = tryMatch(
    lines, pattern, startIndex,
    (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()),
    eof,
  );
  return normalized;
}

// ── Patch parser ──
// Ported from OpenCode patch/index.ts line 75-246

function parsePatchHeader(lines, startIdx) {
  const line = lines[startIdx];

  if (line.startsWith('*** Add File:')) {
    const filePath = line.slice('*** Add File:'.length).trim();
    return filePath ? { filePath, nextIdx: startIdx + 1 } : null;
  }

  if (line.startsWith('*** Delete File:')) {
    const filePath = line.slice('*** Delete File:'.length).trim();
    return filePath ? { filePath, nextIdx: startIdx + 1 } : null;
  }

  if (line.startsWith('*** Update File:')) {
    const filePath = line.slice('*** Update File:'.length).trim();
    let movePath;
    let nextIdx = startIdx + 1;

    if (nextIdx < lines.length && lines[nextIdx].startsWith('*** Move to:')) {
      movePath = lines[nextIdx].slice('*** Move to:'.length).trim();
      nextIdx++;
    }

    return filePath ? { filePath, movePath, nextIdx } : null;
  }

  return null;
}

function parseUpdateFileChunks(lines, startIdx) {
  const chunks = [];
  let i = startIdx;

  // Outer loop: find @@ chunk headers. Stop at *** lines (except *** End of File).
  while (i < lines.length) {
    // Stop at file-level markers (Add/Delete/Update/End Patch), but not End of File
    if (lines[i].startsWith('***') && lines[i] !== '*** End of File') break;

    if (lines[i].startsWith('@@')) {
      const contextLine = lines[i].substring(2).trim();
      i++;

      const oldLines = [];
      const newLines = [];
      let isEndOfFile = false;

      while (i < lines.length && !lines[i].startsWith('@@')) {
        const changeLine = lines[i];

        // *** End of File is a chunk-level marker
        if (changeLine === '*** End of File') {
          isEndOfFile = true;
          i++;
          break;
        }

        // Any other *** line is a file-level marker — stop
        if (changeLine.startsWith('***')) break;

        if (changeLine.startsWith(' ')) {
          const content = changeLine.substring(1);
          oldLines.push(content);
          newLines.push(content);
        } else if (changeLine.startsWith('-')) {
          oldLines.push(changeLine.substring(1));
        } else if (changeLine.startsWith('+')) {
          newLines.push(changeLine.substring(1));
        }

        i++;
      }

      chunks.push({
        old_lines: oldLines,
        new_lines: newLines,
        change_context: contextLine || undefined,
        is_end_of_file: isEndOfFile || undefined,
      });
    } else {
      i++;
    }
  }

  return { chunks, nextIdx: i };
}

function parseAddFileContent(lines, startIdx) {
  let content = '';
  let i = startIdx;

  while (i < lines.length && !lines[i].startsWith('***')) {
    if (lines[i].startsWith('+')) {
      content += lines[i].substring(1) + '\n';
    }
    i++;
  }

  // Remove trailing newline
  if (content.endsWith('\n')) content = content.slice(0, -1);

  return { content, nextIdx: i };
}

function stripHeredoc(input) {
  const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/);
  if (heredocMatch) return heredocMatch[2];
  return input;
}

/**
 * Parse a structured patch into hunks.
 * @param {string} patchText - The patch text
 * @returns {{ hunks: Array }} Parsed hunks
 */
function parsePatch(patchText) {
  const cleaned = stripHeredoc(patchText.trim());
  const lines = cleaned.split('\n');
  const hunks = [];

  const beginIdx = lines.findIndex((line) => line.trim() === '*** Begin Patch');
  const endIdx = lines.findIndex((line) => line.trim() === '*** End Patch');

  if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) {
    throw new Error('Invalid patch format: missing Begin/End markers');
  }

  let i = beginIdx + 1;

  while (i < endIdx) {
    const header = parsePatchHeader(lines, i);
    if (!header) { i++; continue; }

    if (lines[i].startsWith('*** Add File:')) {
      const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx);
      hunks.push({ type: 'add', path: header.filePath, contents: content });
      i = nextIdx;
    } else if (lines[i].startsWith('*** Delete File:')) {
      hunks.push({ type: 'delete', path: header.filePath });
      i = header.nextIdx;
    } else if (lines[i].startsWith('*** Update File:')) {
      const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx);
      hunks.push({
        type: 'update',
        path: header.filePath,
        move_path: header.movePath,
        chunks,
      });
      i = nextIdx;
    } else {
      i++;
    }
  }

  return { hunks };
}

// ── Chunk application ──
// Ported from OpenCode patch/index.ts line 311-419

function computeReplacements(originalLines, filePath, chunks) {
  const replacements = [];
  let lineIndex = 0;

  for (const chunk of chunks) {
    // Handle context-based seeking
    if (chunk.change_context) {
      // First try exact line match via seekSequence
      let contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex);
      // Fall back to substring/contains match (context is often a partial line like "function foo()")
      if (contextIdx === -1) {
        const ctx = chunk.change_context.trim();
        for (let ci = lineIndex; ci < originalLines.length; ci++) {
          if (originalLines[ci].trim().startsWith(ctx) || originalLines[ci].includes(ctx)) {
            contextIdx = ci;
            break;
          }
        }
      }
      if (contextIdx === -1) {
        throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`);
      }
      lineIndex = contextIdx + 1;
    }

    // Handle pure addition (no old lines)
    if (chunk.old_lines.length === 0) {
      const insertionIdx =
        originalLines.length > 0 && originalLines[originalLines.length - 1] === ''
          ? originalLines.length - 1
          : originalLines.length;
      replacements.push([insertionIdx, 0, chunk.new_lines]);
      continue;
    }

    // Try to match old lines in the file
    let pattern = chunk.old_lines;
    let newSlice = chunk.new_lines;
    let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file);

    // Retry without trailing empty line if not found
    if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === '') {
      pattern = pattern.slice(0, -1);
      if (newSlice.length > 0 && newSlice[newSlice.length - 1] === '') {
        newSlice = newSlice.slice(0, -1);
      }
      found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file);
    }

    if (found !== -1) {
      replacements.push([found, pattern.length, newSlice]);
      lineIndex = found + pattern.length;
    } else {
      // Inspired by OpenCode's verified application error reporting
      // Include full chunk context so callers can diagnose which chunk failed
      const chunkIdx = chunks.indexOf(chunk);
      const ctx = chunk.change_context ? ` (context: '${chunk.change_context}')` : '';
      const eof = chunk.is_end_of_file ? ' [EOF anchor]' : '';
      throw new Error(
        `Failed to find expected lines in ${filePath}, chunk #${chunkIdx}${ctx}${eof}:\n` +
        `  old_lines: ${JSON.stringify(chunk.old_lines)}\n` +
        `  new_lines: ${JSON.stringify(chunk.new_lines)}\n` +
        `  searched from line ${lineIndex}`
      );
    }
  }

  // Sort replacements by index
  replacements.sort((a, b) => a[0] - b[0]);

  return replacements;
}

function applyReplacements(lines, replacements) {
  const result = [...lines];

  // Apply in reverse order to avoid index shifting
  for (let i = replacements.length - 1; i >= 0; i--) {
    const [startIdx, oldLen, newSegment] = replacements[i];
    result.splice(startIdx, oldLen, ...newSegment);
  }

  return result;
}

/**
 * Derive new file contents from update chunks.
 * Reads the file, applies all chunks via seekSequence, returns new content + diff.
 *
 * @param {string} filePath - Absolute path to file
 * @param {Array} chunks - UpdateFileChunk array
 * @returns {{ content: string, unified_diff: string }}
 */
function deriveNewContentsFromChunks(filePath, chunks) {
  let originalContent;
  try {
    originalContent = fs.readFileSync(filePath, 'utf-8');
  } catch (err) {
    throw new Error(`Failed to read file ${filePath}: ${err.message}`);
  }

  let originalLines = originalContent.split('\n');

  // Drop trailing empty element for consistent line counting
  if (originalLines.length > 0 && originalLines[originalLines.length - 1] === '') {
    originalLines.pop();
  }

  const replacements = computeReplacements(originalLines, filePath, chunks);
  let newLines = applyReplacements(originalLines, replacements);

  // Ensure trailing newline
  if (newLines.length === 0 || newLines[newLines.length - 1] !== '') {
    newLines.push('');
  }

  const newContent = newLines.join('\n');

  // Simple unified diff (for metadata, not for applying)
  const diffLines = [];
  const maxLen = Math.max(originalLines.length, newLines.length);
  let hasChanges = false;
  for (let i = 0; i < maxLen; i++) {
    const oldLine = originalLines[i] || '';
    const newLine = newLines[i] || '';
    if (oldLine !== newLine) {
      if (oldLine) diffLines.push(`-${oldLine}`);
      if (newLine) diffLines.push(`+${newLine}`);
      hasChanges = true;
    } else if (oldLine) {
      diffLines.push(` ${oldLine}`);
    }
  }

  return {
    content: newContent,
    unified_diff: hasChanges ? diffLines.join('\n') : '',
  };
}

// ── Apply hunks to filesystem ──
// Ported from OpenCode patch/index.ts line 518-572

/**
 * Apply parsed hunks to the filesystem.
 * @param {Array} hunks - Parsed hunk array
 * @returns {Promise<{ added: string[], modified: string[], deleted: string[] }>}
 */
function isPathWithin(base, target) {
  return target === base || target.startsWith(base + path.sep);
}

function realpathBestEffort(filePath) {
  const resolved = path.resolve(filePath);
  let current = resolved;
  const suffix = [];
  while (!fs.existsSync(current)) {
    const parent = path.dirname(current);
    if (parent === current) return resolved;
    suffix.unshift(path.basename(current));
    current = parent;
  }
  try {
    return path.join(fs.realpathSync(current), ...suffix);
  } catch {
    return resolved;
  }
}

function resolvePatchPath(filePath, baseDir) {
  if (!baseDir) return filePath;
  const resolvedBase = realpathBestEffort(baseDir);
  const resolved = path.isAbsolute(filePath)
    ? path.resolve(filePath)
    : path.resolve(path.resolve(baseDir), filePath);
  const realResolved = realpathBestEffort(resolved);
  if (!isPathWithin(resolvedBase, realResolved)) {
    throw new Error(`Patch path ${filePath} is outside project directory ${resolvedBase}`);
  }
  return realResolved;
}

async function applyHunksToFiles(hunks, { baseDir } = {}) {
  if (hunks.length === 0) throw new Error('No files were modified.');

  const added = [];
  const modified = [];
  const deleted = [];

  for (const hunk of hunks) {
    const hunkPath = resolvePatchPath(hunk.path, baseDir);
    const movePath = hunk.move_path ? resolvePatchPath(hunk.move_path, baseDir) : null;
    switch (hunk.type) {
      case 'add': {
        const dir = path.dirname(hunkPath);
        if (dir !== '.' && dir !== '/') {
          await fsPromises.mkdir(dir, { recursive: true });
        }
        await fsPromises.writeFile(hunkPath, hunk.contents, 'utf-8');
        added.push(hunkPath);
        break;
      }

      case 'delete': {
        await fsPromises.unlink(hunkPath);
        deleted.push(hunkPath);
        break;
      }

      case 'update': {
        const fileUpdate = deriveNewContentsFromChunks(hunkPath, hunk.chunks);

        if (movePath) {
          const moveDir = path.dirname(movePath);
          if (moveDir !== '.' && moveDir !== '/') {
            await fsPromises.mkdir(moveDir, { recursive: true });
          }
          await fsPromises.writeFile(movePath, fileUpdate.content, 'utf-8');
          await fsPromises.unlink(hunkPath);
          modified.push(movePath);
        } else {
          await fsPromises.writeFile(hunkPath, fileUpdate.content, 'utf-8');
          modified.push(hunkPath);
        }
        break;
      }
    }
  }

  return { added, modified, deleted };
}

/**
 * Parse and apply a structured patch.
 * @param {string} patchText - The full patch text
 * @returns {Promise<{ added: string[], modified: string[], deleted: string[] }>}
 */
async function applyPatch(patchText, options = {}) {
  const { hunks } = parsePatch(patchText);
  return applyHunksToFiles(hunks, options);
}

module.exports = {
  parsePatch,
  seekSequence,
  normalizeUnicode,
  resolvePatchPath,
  deriveNewContentsFromChunks,
  applyHunksToFiles,
  applyPatch,
};
