export interface EditReplacement {
  oldText: string;
  newText: string;
}

export interface EditApplyResult {
  baseContent: string;
  newContent: string;
  firstChangedLine?: number;
  diff: string;
}

interface FuzzyMatchResult {
  found: boolean;
  index: number;
  matchLength: number;
  usedFuzzyMatch: boolean;
  contentForReplacement: string;
}

interface MatchedEdit {
  editIndex: number;
  matchIndex: number;
  matchLength: number;
  newText: string;
}

export function stripBom(content: string): { bom: string; text: string } {
  return content.startsWith('\uFEFF')
    ? { bom: '\uFEFF', text: content.slice(1) }
    : { bom: '', text: content };
}

export function detectLineEnding(content: string): '\r\n' | '\n' {
  const crlf = content.indexOf('\r\n');
  const lf = content.indexOf('\n');
  if (lf === -1) return '\n';
  if (crlf === -1) return '\n';
  return crlf < lf ? '\r\n' : '\n';
}

export function normalizeToLf(text: string): string {
  return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
}

export function restoreLineEndings(text: string, ending: '\r\n' | '\n'): string {
  return ending === '\r\n' ? text.replace(/\n/g, '\r\n') : text;
}

export function normalizeForFuzzyMatch(text: string): string {
  return text
    .normalize('NFKC')
    .split('\n')
    .map(line => line.trimEnd())
    .join('\n')
    .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
    .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
    .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, '-')
    .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, ' ');
}

export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult {
  const exactIndex = content.indexOf(oldText);
  if (exactIndex !== -1) {
    return {
      found: true,
      index: exactIndex,
      matchLength: oldText.length,
      usedFuzzyMatch: false,
      contentForReplacement: content,
    };
  }

  const fuzzyContent = normalizeForFuzzyMatch(content);
  const fuzzyOldText = normalizeForFuzzyMatch(oldText);
  const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);

  if (fuzzyIndex === -1) {
    return {
      found: false,
      index: -1,
      matchLength: 0,
      usedFuzzyMatch: false,
      contentForReplacement: content,
    };
  }

  return {
    found: true,
    index: fuzzyIndex,
    matchLength: fuzzyOldText.length,
    usedFuzzyMatch: true,
    contentForReplacement: fuzzyContent,
  };
}

export function applyEditsToContent(
  normalizedContent: string,
  edits: EditReplacement[],
  filePath: string
): EditApplyResult {
  if (!Array.isArray(edits) || edits.length === 0) {
    throw new Error('edit requires at least one replacement in edits[]');
  }

  const normalizedEdits = edits.map(edit => ({
    oldText: normalizeToLf(edit.oldText),
    newText: normalizeToLf(edit.newText),
  }));

  for (let index = 0; index < normalizedEdits.length; index++) {
    if (!normalizedEdits[index].oldText) {
      throw getEmptyOldTextError(filePath, index, normalizedEdits.length);
    }
  }

  const initialMatches = normalizedEdits.map(edit => fuzzyFindText(normalizedContent, edit.oldText));
  const baseContent = initialMatches.some(match => match.usedFuzzyMatch)
    ? normalizeForFuzzyMatch(normalizedContent)
    : normalizedContent;

  const matched: MatchedEdit[] = [];
  for (let index = 0; index < normalizedEdits.length; index++) {
    const edit = normalizedEdits[index];
    const matchResult = fuzzyFindText(baseContent, edit.oldText);
    if (!matchResult.found) {
      throw getNotFoundError(filePath, index, normalizedEdits.length);
    }

    const occurrences = countOccurrences(baseContent, edit.oldText);
    if (occurrences > 1) {
      throw getDuplicateError(filePath, index, normalizedEdits.length, occurrences);
    }

    matched.push({
      editIndex: index,
      matchIndex: matchResult.index,
      matchLength: matchResult.matchLength,
      newText: edit.newText,
    });
  }

  matched.sort((a, b) => a.matchIndex - b.matchIndex);
  for (let index = 1; index < matched.length; index++) {
    const previous = matched[index - 1];
    const current = matched[index];
    if (previous.matchIndex + previous.matchLength > current.matchIndex) {
      throw new Error(
        `edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${filePath}. Merge them into one edit or target disjoint regions.`
      );
    }
  }

  let next = baseContent;
  for (let index = matched.length - 1; index >= 0; index--) {
    const edit = matched[index];
    next =
      next.slice(0, edit.matchIndex) +
      edit.newText +
      next.slice(edit.matchIndex + edit.matchLength);
  }

  if (next === baseContent) {
    throw getNoChangeError(filePath, normalizedEdits.length);
  }

  const firstChangedLine = computeFirstChangedLine(baseContent, next);
  return {
    baseContent,
    newContent: next,
    firstChangedLine,
    diff: createLineNumberedDiff(baseContent, next),
  };
}

function countOccurrences(content: string, oldText: string): number {
  const fuzzyContent = normalizeForFuzzyMatch(content);
  const fuzzyOldText = normalizeForFuzzyMatch(oldText);
  return fuzzyContent.split(fuzzyOldText).length - 1;
}

function getNotFoundError(filePath: string, editIndex: number, totalEdits: number): Error {
  if (totalEdits === 1) {
    return new Error(
      `Could not find the exact text in ${filePath}. The old text must match exactly including all whitespace and newlines.`
    );
  }
  return new Error(
    `Could not find edits[${editIndex}] in ${filePath}. The oldText must match exactly including all whitespace and newlines.`
  );
}

function getDuplicateError(filePath: string, editIndex: number, totalEdits: number, occurrences: number): Error {
  if (totalEdits === 1) {
    return new Error(
      `Found ${occurrences} occurrences of the text in ${filePath}. The text must be unique. Please provide more context to make it unique.`
    );
  }
  return new Error(
    `Found ${occurrences} occurrences of edits[${editIndex}] in ${filePath}. Each oldText must be unique. Please provide more context to make it unique.`
  );
}

function getEmptyOldTextError(filePath: string, editIndex: number, totalEdits: number): Error {
  if (totalEdits === 1) {
    return new Error(`oldText must not be empty in ${filePath}.`);
  }
  return new Error(`edits[${editIndex}].oldText must not be empty in ${filePath}.`);
}

function getNoChangeError(filePath: string, totalEdits: number): Error {
  if (totalEdits === 1) {
    return new Error(
      `No changes made to ${filePath}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`
    );
  }
  return new Error(`No changes made to ${filePath}. The replacements produced identical content.`);
}

export function computeFirstChangedLine(before: string, after: string): number | undefined {
  const beforeLines = before.split('\n');
  const afterLines = after.split('\n');
  const max = Math.max(beforeLines.length, afterLines.length);
  for (let index = 0; index < max; index++) {
    if (beforeLines[index] !== afterLines[index]) {
      return index + 1;
    }
  }
  return undefined;
}

export function createLineNumberedDiff(before: string, after: string, contextLines = 4): string {
  const beforeLines = before.split('\n');
  const afterLines = after.split('\n');
  const first = computeFirstChangedLine(before, after);
  if (!first) return '';

  const start = Math.max(0, first - contextLines - 1);
  const end = Math.min(Math.max(beforeLines.length, afterLines.length), first + contextLines);
  const width = String(Math.max(beforeLines.length, afterLines.length)).length;
  const lines: string[] = [];

  if (start > 0) {
    lines.push(` ${''.padStart(width, ' ')} ...`);
  }

  for (let index = start; index < end; index++) {
    const beforeLine = beforeLines[index];
    const afterLine = afterLines[index];
    const lineNumber = String(index + 1).padStart(width, ' ');
    if (beforeLine === afterLine) {
      lines.push(` ${lineNumber} ${beforeLine ?? ''}`);
    } else {
      if (beforeLine !== undefined) lines.push(`-${lineNumber} ${beforeLine}`);
      if (afterLine !== undefined) lines.push(`+${lineNumber} ${afterLine}`);
    }
  }

  if (end < Math.max(beforeLines.length, afterLines.length)) {
    lines.push(` ${''.padStart(width, ' ')} ...`);
  }

  return lines.join('\n');
}
