import type { LatexLineEnding } from './line-endings';
import type { SourceTextEdit } from './text-edit';

/**
 * Shared Learnitive LaTeX math region model.
 *
 * `.tex` remains canonical. Both CodeMirror and Visual mode detect/open the
 * same regions and commit the exact `replaceFrom..replaceTo` edit generated by
 * this module. The shape is shared across Learnitive Source and Visual editing so the
 * two editor surfaces cannot drift into separate MathLive parsing rules.
 */
export type LatexMathKind = 'inline' | 'display';
export type LatexMathDelimiter = '$' | '$$' | '\\(' | '\\[' | 'environment';

export type LatexMathRegion = {
  kind: LatexMathKind;
  /** Compatibility alias used by shared MathLive adapters. */
  type: LatexMathKind;
  delimiter: LatexMathDelimiter;
  environment?: string;
  /** Whole matched expression/environment. */
  from: number;
  to: number;
  /** Trimmed editable content range inside the whole expression. */
  contentFrom: number;
  contentTo: number;
  /** Raw content boundary aliases (before whitespace trim). */
  contentStart: number;
  contentEnd: number;
  fileType: 'latex';
  open: string;
  close: string;
  /** Delimiter aliases. */
  delimiterStart: string;
  delimiterEnd: string;
  content: string;
  rawContent: string;
  leadingWhitespace: string;
  trailingWhitespace: string;
  /** Compatibility aliases. */
  leadingWS: string;
  trailingWS: string;
  /** Exact range replaced by MathLive Apply. For a multi-row environment this
   * can be one row while `from..to` continues to describe the whole env. */
  replaceFrom: number;
  replaceTo: number;
  /** Value rendered by MathLive preview. */
  previewLatex: string;
  rowInEnvironment?: boolean;
};

const DISPLAY_ENVIRONMENTS = new Set([
  'equation', 'equation*',
  'align', 'align*',
  'aligned', 'aligned*',
  'gather', 'gather*',
  'multline', 'multline*',
  'split',
  'cases',
  'array',
  'matrix', 'matrix*',
  'pmatrix', 'pmatrix*',
  'bmatrix', 'bmatrix*',
  'Bmatrix', 'Bmatrix*',
  'vmatrix', 'vmatrix*',
  'Vmatrix', 'Vmatrix*',
]);

const OPAQUE_ENVIRONMENTS = new Set([
  'verbatim', 'verbatim*', 'lstlisting', 'minted',
]);

// Learnitive can edit these through MathLive and keeps the safety filter for
// comments/labels/tags below so unsupported source never reaches MathLive.
const MATHLIVE_EDITABLE_ENVIRONMENTS = new Set(DISPLAY_ENVIRONMENTS);

const ROW_SPLIT_ENVIRONMENTS = new Set([
  'align', 'align*',
  'aligned', 'aligned*',
  'gather', 'gather*',
  'cases', 'array',
  'matrix', 'matrix*',
  'pmatrix', 'pmatrix*',
  'bmatrix', 'bmatrix*',
  'Bmatrix', 'Bmatrix*',
  'vmatrix', 'vmatrix*',
  'Vmatrix', 'Vmatrix*',
]);

const AMPERSAND_ENVIRONMENTS = new Set([
  'align', 'align*', 'aligned', 'aligned*', 'array', 'cases',
  'matrix', 'matrix*', 'pmatrix', 'pmatrix*', 'bmatrix', 'bmatrix*',
  'Bmatrix', 'Bmatrix*', 'vmatrix', 'vmatrix*', 'Vmatrix', 'Vmatrix*',
]);

function isEscaped(source: string, index: number): boolean {
  let slashes = 0;
  for (let cursor = index - 1; cursor >= 0 && source[cursor] === '\\'; cursor -= 1) {
    slashes += 1;
  }
  return slashes % 2 === 1;
}

function isCommentStart(source: string, index: number): boolean {
  return source[index] === '%' && !isEscaped(source, index);
}

function lineEnd(source: string, index: number): number {
  const next = source.indexOf('\n', index);
  return next === -1 ? source.length : next + 1;
}

function trimBounds(value: string): {
  leading: string;
  trailing: string;
  from: number;
  to: number;
} {
  const leading = value.match(/^\s*/)?.[0] ?? '';
  const trailing = value.match(/\s*$/)?.[0] ?? '';
  const from = leading.length;
  const to = Math.max(from, value.length - trailing.length);
  return { leading, trailing, from, to };
}

function makeRegion(options: {
  source: string;
  from: number;
  to: number;
  contentFrom: number;
  contentTo: number;
  kind: LatexMathKind;
  delimiter: LatexMathDelimiter;
  open: string;
  close: string;
  environment?: string;
}): LatexMathRegion {
  const raw = options.source.slice(options.contentFrom, options.contentTo);
  const trimmed = trimBounds(raw);
  const content = raw.slice(trimmed.from, trimmed.to);
  return {
    kind: options.kind,
    type: options.kind,
    delimiter: options.delimiter,
    ...(options.environment ? { environment: options.environment } : {}),
    from: options.from,
    to: options.to,
    contentFrom: options.contentFrom + trimmed.from,
    contentTo: options.contentFrom + trimmed.to,
    contentStart: options.contentFrom,
    contentEnd: options.contentTo,
    fileType: 'latex',
    open: options.open,
    close: options.close,
    delimiterStart: options.open,
    delimiterEnd: options.close,
    content,
    rawContent: raw,
    leadingWhitespace: trimmed.leading,
    trailingWhitespace: trimmed.trailing,
    leadingWS: trimmed.leading,
    trailingWS: trimmed.trailing,
    replaceFrom: options.from,
    replaceTo: options.to,
    previewLatex: content,
  };
}

function findUnescaped(source: string, needle: string, start: number): number {
  let cursor = start;
  while (cursor < source.length) {
    const found = source.indexOf(needle, cursor);
    if (found === -1) return -1;
    if (!isEscaped(source, found)) return found;
    cursor = found + needle.length;
  }
  return -1;
}

function findEnvironmentEnd(source: string, environment: string, start: number): number {
  return source.indexOf(`\\end{${environment}}`, start);
}

function scanLatexMathRegions(
  source: string,
  targetPosition?: number,
): LatexMathRegion[] {
  const regions: LatexMathRegion[] = [];
  const target = targetPosition === undefined
    ? undefined
    : Math.max(0, Math.min(targetPosition, source.length));
  let cursor = 0;
  let opaqueEnvironment: string | undefined;

  while (cursor < source.length) {
    // Position lookups never need to scan source after the caret. Full-project
    // region discovery leaves target undefined and retains the original scan.
    if (target !== undefined && cursor > target) break;

    if (opaqueEnvironment) {
      const close = `\\end{${opaqueEnvironment}}`;
      const end = source.indexOf(close, cursor);
      if (end === -1) break;
      if (target !== undefined && target <= end + close.length) return regions;
      cursor = end + close.length;
      opaqueEnvironment = undefined;
      continue;
    }

    if (isCommentStart(source, cursor)) {
      const end = lineEnd(source, cursor);
      if (target !== undefined && target < end) return regions;
      cursor = end;
      continue;
    }

    if (source.startsWith('\\begin{', cursor)) {
      const nameEnd = source.indexOf('}', cursor + 7);
      if (nameEnd !== -1) {
        const environment = source.slice(cursor + 7, nameEnd);
        if (OPAQUE_ENVIRONMENTS.has(environment)) {
          opaqueEnvironment = environment;
          cursor = nameEnd + 1;
          continue;
        }
        if (DISPLAY_ENVIRONMENTS.has(environment)) {
          const open = source.slice(cursor, nameEnd + 1);
          const close = `\\end{${environment}}`;
          const closeStart = findEnvironmentEnd(source, environment, nameEnd + 1);
          if (closeStart !== -1) {
            const region = makeRegion({
              source,
              from: cursor,
              to: closeStart + close.length,
              contentFrom: nameEnd + 1,
              contentTo: closeStart,
              kind: 'display',
              delimiter: 'environment',
              environment,
              open,
              close,
            });
            if (target !== undefined) {
              if (target <= region.to) return [region];
            } else {
              regions.push(region);
            }
            cursor = closeStart + close.length;
            continue;
          }
        }
      }
    }

    if (source.startsWith('\\[', cursor) && !isEscaped(source, cursor)) {
      const closeStart = findUnescaped(source, '\\]', cursor + 2);
      if (closeStart !== -1) {
        const region = makeRegion({
          source,
          from: cursor,
          to: closeStart + 2,
          contentFrom: cursor + 2,
          contentTo: closeStart,
          kind: 'display',
          delimiter: '\\[',
          open: '\\[',
          close: '\\]',
        });
        if (target !== undefined) {
          if (target <= region.to) return [region];
        } else {
          regions.push(region);
        }
        cursor = closeStart + 2;
        continue;
      }
    }

    if (source.startsWith('\\(', cursor) && !isEscaped(source, cursor)) {
      const closeStart = findUnescaped(source, '\\)', cursor + 2);
      if (closeStart !== -1) {
        const region = makeRegion({
          source,
          from: cursor,
          to: closeStart + 2,
          contentFrom: cursor + 2,
          contentTo: closeStart,
          kind: 'inline',
          delimiter: '\\(',
          open: '\\(',
          close: '\\)',
        });
        if (target !== undefined) {
          if (target <= region.to) return [region];
        } else {
          regions.push(region);
        }
        cursor = closeStart + 2;
        continue;
      }
    }

    if (source[cursor] === '$' && !isEscaped(source, cursor)) {
      const display = source[cursor + 1] === '$';
      const open = display ? '$$' : '$';
      const closeStart = findUnescaped(source, open, cursor + open.length);
      if (closeStart !== -1) {
        const body = source.slice(cursor + open.length, closeStart);
        if (display || !body.includes('\n')) {
          const region = makeRegion({
            source,
            from: cursor,
            to: closeStart + open.length,
            contentFrom: cursor + open.length,
            contentTo: closeStart,
            kind: display ? 'display' : 'inline',
            delimiter: display ? '$$' : '$',
            open,
            close: open,
          });
          if (target !== undefined) {
            if (target <= region.to) return [region];
          } else {
            regions.push(region);
          }
          cursor = closeStart + open.length;
          continue;
        }
      }
    }

    cursor += 1;
  }

  return regions;
}

export function findLatexMathRegions(source: string): LatexMathRegion[] {
  return scanLatexMathRegions(source);
}

function refineEnvironmentRowAtPosition(
  source: string,
  region: LatexMathRegion,
  position: number,
): LatexMathRegion {
  const environment = region.environment;
  if (!environment || !ROW_SPLIT_ENVIRONMENTS.has(environment)) return region;

  const rawStart = region.from + region.open.length;
  const rawEnd = region.to - region.close.length;
  const raw = source.slice(rawStart, rawEnd);
  const relativePosition = position - rawStart;
  if (relativePosition < 0 || relativePosition > raw.length) return region;

  const separators = [...raw.matchAll(/\\\\(?:\[[^\]]*\])?/g)].map((match) => ({
    from: match.index ?? 0,
    to: (match.index ?? 0) + match[0].length,
  }));
  if (separators.length === 0) return region;

  let rowStart = 0;
  for (let index = 0; index <= separators.length; index += 1) {
    const rowEnd = index < separators.length ? separators[index]!.from : raw.length;
    if (relativePosition >= rowStart && relativePosition <= rowEnd) {
      const rowRaw = raw.slice(rowStart, rowEnd);
      const trimmed = trimBounds(rowRaw);
      const rowContent = rowRaw.slice(trimmed.from, trimmed.to);
      return {
        ...region,
        contentFrom: rawStart + rowStart + trimmed.from,
        contentTo: rawStart + rowStart + trimmed.to,
        content: rowContent,
        rawContent: rowRaw,
        leadingWhitespace: trimmed.leading,
        trailingWhitespace: trimmed.trailing,
        leadingWS: trimmed.leading,
        trailingWS: trimmed.trailing,
        replaceFrom: rawStart + rowStart,
        replaceTo: rawStart + rowEnd,
        previewLatex: `\\begin{aligned}${trimmed.leading}${rowContent}${trimmed.trailing}\\end{aligned}`,
        rowInEnvironment: true,
      };
    }
    rowStart = index < separators.length ? separators[index]!.to : raw.length;
  }

  return region;
}

export function findLatexMathAtPosition(
  source: string,
  position: number,
): LatexMathRegion | undefined {
  const safePosition = Math.max(0, Math.min(position, source.length));
  const region = scanLatexMathRegions(source, safePosition)[0];
  return region ? refineEnvironmentRowAtPosition(source, region, safePosition) : undefined;
}

export function latexMathRegionToMathLiveValue(region: LatexMathRegion): string {
  return region.previewLatex || region.content;
}

function unwrapMathLiveRowValue(region: LatexMathRegion, value: string): string {
  if (!region.rowInEnvironment) return value;
  const match = value.match(/^\\begin\{aligned\}([\s\S]*)\\end\{aligned\}$/);
  return match?.[1] ?? value;
}

function normalizeMathLiveValue(region: LatexMathRegion, value: string): string {
  const unwrapped = unwrapMathLiveRowValue(region, value);
  return region.environment && AMPERSAND_ENVIRONMENTS.has(region.environment)
    ? unwrapped.replace(/\\&/g, '&')
    : unwrapped;
}

/** Backwards-compatible content conversion helper. */
export function mathLiveValueToLatexMathContent(
  region: LatexMathRegion,
  value: string,
): string {
  return normalizeMathLiveValue(region, value);
}

const MATHLIVE_UNSAFE_SOURCE =
  /(^|[^\\])%|\\(?:label|tag\*?|nonumber|notag|intertext|shortintertext|verb)\b/;

export function canEditLatexMathWithMathLive(region: LatexMathRegion): boolean {
  if (region.environment && !MATHLIVE_EDITABLE_ENVIRONMENTS.has(region.environment)) {
    return false;
  }
  return !MATHLIVE_UNSAFE_SOURCE.test(region.content);
}

/**
 * Exact source replacement. For row edits the delimiters and row
 * separator stay byte-for-byte untouched; for whole environments their
 * original delimiters/whitespace are preserved.
 */
export function createLatexMathReplacementEdit(
  region: LatexMathRegion,
  value: string,
  lineEnding: LatexLineEnding = '\n',
): SourceTextEdit {
  const normalizedContent = normalizeMathLiveValue(region, value);
  const isRowEdit = region.replaceFrom !== region.from || region.replaceTo !== region.to;
  if (isRowEdit) {
    return {
      from: region.replaceFrom,
      to: region.replaceTo,
      text: `${region.leadingWhitespace}${normalizedContent}${region.trailingWhitespace}`,
    };
  }

  if (region.delimiter === 'environment') {
    const leading = region.leadingWhitespace || lineEnding;
    const trailing = region.trailingWhitespace || lineEnding;
    return {
      from: region.from,
      to: region.to,
      text: `${region.open}${leading}${normalizedContent}${trailing}${region.close}`,
    };
  }

  return {
    from: region.from,
    to: region.to,
    text: `${region.open}${region.leadingWhitespace}${normalizedContent}${region.trailingWhitespace}${region.close}`,
  };
}

/** Legacy helper retained for callers that intentionally target trimmed body. */
export function createLatexMathContentEdit(
  region: LatexMathRegion,
  content: string,
): SourceTextEdit {
  return { from: region.contentFrom, to: region.contentTo, text: content };
}

export type LatexMathInsertionStyle = 'inline' | 'display' | 'equation';

export function createLatexMathInsertion(options: {
  cursor: number;
  sourceLength: number;
  content?: string;
  kind?: LatexMathKind;
  style?: LatexMathInsertionStyle;
  replaceTo?: number;
  lineEnding?: LatexLineEnding;
}): { edit: SourceTextEdit; contentFrom: number; contentTo: number } {
  const cursor = Math.max(0, Math.min(options.cursor, options.sourceLength));
  const replaceTo = Math.max(cursor, Math.min(options.replaceTo ?? cursor, options.sourceLength));
  const content = options.content ?? '';
  const style = options.style ?? (options.kind === 'inline' ? 'inline' : 'display');
  const lineEnding = options.lineEnding ?? '\n';
  if (style === 'inline') {
    const text = `\\(${content}\\)`;
    return {
      edit: { from: cursor, to: replaceTo, text },
      contentFrom: cursor + 2,
      contentTo: cursor + 2 + content.length,
    };
  }

  const prefix = cursor > 0 ? lineEnding + lineEnding : '';
  if (style === 'equation') {
    const open = `\\begin{equation}${lineEnding}`;
    const close = `${lineEnding}\\end{equation}${lineEnding}`;
    const text = `${prefix}${open}${content}${close}`;
    const contentFrom = cursor + prefix.length + open.length;
    return {
      edit: { from: cursor, to: replaceTo, text },
      contentFrom,
      contentTo: contentFrom + content.length,
    };
  }

  const open = `\\[${lineEnding}`;
  const close = `${lineEnding}\\]${lineEnding}`;
  const text = `${prefix}${open}${content}${close}`;
  const contentFrom = cursor + prefix.length + open.length;
  return {
    edit: { from: cursor, to: replaceTo, text },
    contentFrom,
    contentTo: contentFrom + content.length,
  };
}
