import type { SourceMutationOrigin } from '@learnitive/latex-source';
import type { SyncTexFileMapping, SyncTexRect } from '@learnitive/latex-synctex';

const BEGIN_DOCUMENT = /\\begin\s*\{document\}/u;
const END_DOCUMENT = /\\end\s*\{document\}/u;
const UNSAFE_PLAIN_PROSE = /[\\{}$%&#_^~]/u;
const MAX_INSTANT_BLOCK_CHARS = 12_000;
const MIN_INSTANT_WIDTH_PT = 72;
const MAX_INSTANT_WIDTH_PT = 520;

export type LiveInstantParagraph = {
  startOffset: number;
  endOffset: number;
  startLine: number;
  endLine: number;
  text: string;
};

export type LiveInstantDecision =
  | { kind: 'skip'; reason: string }
  | {
      kind: 'patch';
      paragraph: LiveInstantParagraph;
      previousText: string;
    };

export type LiveInstantTarget = {
  page: number;
  x: number;
  y: number;
  width: number;
  height: number;
  hsize: number;
  /** Number of rendered baseline line bands. R6 must not patch when reflow changes it. */
  lineCount: number;
};

export function liveInstantOriginEligible(origin: SourceMutationOrigin): boolean {
  // R6 starts deliberately narrow. Visual prose ultimately mutates the same
  // canonical LaTeX source, so it can use the same path; structural Visual,
  // MathLive, collaboration, AI/review and programmatic edits reconcile via R5.
  return origin === 'source' || origin === 'visual';
}

function lineAt(source: string, offset: number): number {
  let line = 1;
  for (let i = 0; i < offset; i += 1) if (source.charCodeAt(i) === 10) line += 1;
  return line;
}

function documentBody(source: string): { start: number; end: number } | undefined {
  const begin = BEGIN_DOCUMENT.exec(source);
  if (!begin || begin.index === undefined) return undefined;
  const start = begin.index + begin[0].length;
  END_DOCUMENT.lastIndex = 0;
  const tail = source.slice(start);
  const endMatch = END_DOCUMENT.exec(tail);
  if (!endMatch || endMatch.index === undefined) return undefined;
  return { start, end: start + endMatch.index };
}

function paragraphRanges(source: string): LiveInstantParagraph[] {
  const body = documentBody(source);
  if (!body) return [];
  const text = source.slice(body.start, body.end);
  const ranges: LiveInstantParagraph[] = [];
  const separator = /\n[\t ]*\n+/gu;
  let cursor = 0;
  const push = (localStart: number, localEnd: number) => {
    let start = localStart;
    let end = localEnd;
    while (start < end && /[\t\r\n ]/u.test(text[start] ?? '')) start += 1;
    while (end > start && /[\t\r\n ]/u.test(text[end - 1] ?? '')) end -= 1;
    if (end <= start) return;
    const absoluteStart = body.start + start;
    const absoluteEnd = body.start + end;
    ranges.push({
      startOffset: absoluteStart,
      endOffset: absoluteEnd,
      startLine: lineAt(source, absoluteStart),
      endLine: lineAt(source, Math.max(absoluteStart, absoluteEnd - 1)),
      text: source.slice(absoluteStart, absoluteEnd),
    });
  };
  for (const match of text.matchAll(separator)) {
    if (match.index === undefined) continue;
    push(cursor, match.index);
    cursor = match.index + match[0].length;
  }
  push(cursor, text.length);
  return ranges;
}


function openEnvironmentsAt(source: string, offset: number): string[] | undefined {
  const body = documentBody(source);
  if (!body || offset < body.start) return undefined;
  const prefix = source.slice(body.start, Math.min(offset, body.end));
  const stack: string[] = [];
  const token = /\\(begin|end)\s*\{([^}\r\n]+)\}/gu;
  for (const match of prefix.matchAll(token)) {
    const kind = match[1];
    const name = match[2]?.trim();
    if (!name) continue;
    if (kind === 'begin') {
      stack.push(name);
      continue;
    }
    if (stack.at(-1) !== name) return undefined;
    stack.pop();
  }
  return stack;
}

function plainProseEligible(text: string): boolean {
  if (!text.trim() || text.length > MAX_INSTANT_BLOCK_CHARS) return false;
  if (UNSAFE_PLAIN_PROSE.test(text)) return false;
  // R6 phase 1 is prose-only. TeX command arguments, environments, comments,
  // inline/display math and special alignment characters stay on exact R5.
  if (/^\s*\[/u.test(text)) return false;
  return true;
}

/**
 * Conservative R6 edit classifier. Exactly one body paragraph may differ from
 * the last reconciled R5 source, and every byte outside that paragraph must be
 * identical. This makes R6 an accelerator, never a source-of-truth parser.
 */
export function decideLiveInstantParagraph(
  baseline: string,
  current: string,
): LiveInstantDecision {
  if (baseline === current) return { kind: 'skip', reason: 'no source change' };
  const oldBody = documentBody(baseline);
  const newBody = documentBody(current);
  if (!oldBody || !newBody) return { kind: 'skip', reason: 'document body unavailable' };
  // Any preamble change invalidates the warm tiny-document context.
  if (baseline.slice(0, oldBody.start) !== current.slice(0, newBody.start)) {
    return { kind: 'skip', reason: 'preamble changed' };
  }
  if (baseline.slice(oldBody.end) !== current.slice(newBody.end)) {
    return { kind: 'skip', reason: 'document tail changed' };
  }

  const before = paragraphRanges(baseline);
  const after = paragraphRanges(current);
  if (before.length !== after.length) {
    return { kind: 'skip', reason: 'paragraph structure changed' };
  }

  let changed = -1;
  for (let i = 0; i < after.length; i += 1) {
    if (before[i]?.text === after[i]?.text) continue;
    if (changed >= 0) return { kind: 'skip', reason: 'multiple paragraphs changed' };
    changed = i;
  }
  if (changed < 0) return { kind: 'skip', reason: 'non-paragraph bytes changed' };

  const oldParagraph = before[changed];
  const newParagraph = after[changed];
  if (!oldParagraph || !newParagraph) return { kind: 'skip', reason: 'paragraph unavailable' };

  // Prove the splice is isolated even when the edit changes character count.
  if (
    baseline.slice(0, oldParagraph.startOffset) !== current.slice(0, newParagraph.startOffset) ||
    baseline.slice(oldParagraph.endOffset) !== current.slice(newParagraph.endOffset)
  ) {
    return { kind: 'skip', reason: 'change escaped paragraph boundary' };
  }
  if (!plainProseEligible(newParagraph.text) || !plainProseEligible(oldParagraph.text)) {
    return { kind: 'skip', reason: 'paragraph contains structural LaTeX' };
  }
  const oldEnvironment = openEnvironmentsAt(baseline, oldParagraph.startOffset);
  const newEnvironment = openEnvironmentsAt(current, newParagraph.startOffset);
  if (!oldEnvironment || !newEnvironment || oldEnvironment.length || newEnvironment.length) {
    return { kind: 'skip', reason: 'paragraph is inside a layout-sensitive environment' };
  }
  return {
    kind: 'patch',
    paragraph: newParagraph,
    previousText: oldParagraph.text,
  };
}

function rectsForMapping(mapping: SyncTexFileMapping): SyncTexRect[] {
  return mapping.rects.filter(
    (rect) =>
      Number.isFinite(rect.x) &&
      Number.isFinite(rect.y) &&
      Number.isFinite(rect.width) &&
      Number.isFinite(rect.height) &&
      rect.width > 0 &&
      rect.height > 0,
  );
}

function countVerticalBands(rects: readonly SyncTexRect[]): number {
  const ys = rects.map((rect) => rect.y).sort((a, b) => a - b);
  let bands = 0;
  let last = Number.NEGATIVE_INFINITY;
  for (const y of ys) {
    if (bands === 0 || Math.abs(y - last) > 1.5) {
      bands += 1;
      last = y;
    }
  }
  return bands;
}

/**
 * Anchors an instant paragraph to the last exact R5 SyncTeX generation. All
 * selected mappings must agree on one page; ambiguous cross-page paragraphs
 * reconcile normally rather than risking a misplaced overlay.
 */
export function selectLiveInstantTarget(
  mappings: readonly SyncTexFileMapping[],
  startLine: number,
  endLine: number,
): LiveInstantTarget | undefined {
  const inRange = mappings.filter(
    (mapping) => mapping.line >= startLine && mapping.line <= endLine && rectsForMapping(mapping).length > 0,
  );
  if (!inRange.length) return undefined;

  const pageWeights = new Map<number, number>();
  for (const mapping of inRange) {
    pageWeights.set(mapping.page, (pageWeights.get(mapping.page) ?? 0) + rectsForMapping(mapping).length);
  }
  const rankedPages = [...pageWeights.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]);
  const page = rankedPages[0]?.[0];
  if (!page) return undefined;
  // A paragraph already spanning pages is too layout-sensitive for R6 phase 1.
  if (rankedPages.length > 1) return undefined;

  const rects = inRange.flatMap((mapping) => rectsForMapping(mapping));
  if (!rects.length) return undefined;
  const minX = Math.min(...rects.map((rect) => rect.x));
  const minY = Math.min(...rects.map((rect) => rect.y));
  const maxX = Math.max(...rects.map((rect) => rect.x + rect.width));
  const maxY = Math.max(...rects.map((rect) => rect.y + rect.height));
  const widest = Math.max(...rects.map((rect) => rect.width));
  const width = Math.max(1, maxX - minX);
  const height = Math.max(1, maxY - minY);
  // A short baseline paragraph does not reveal its full line width. Borrow the
  // widest exact SyncTeX box on the same page whose left edge belongs to the
  // same column. This keeps one-column and two-column wrapping distinct.
  const sameColumnWidths = mappings
    .filter((mapping) => mapping.page === page)
    .flatMap((mapping) => rectsForMapping(mapping))
    .filter((rect) => Math.abs(rect.x - minX) <= 28 && rect.width <= MAX_INSTANT_WIDTH_PT)
    .map((rect) => rect.width);
  const inferredColumnWidth = sameColumnWidths.length ? Math.max(...sameColumnWidths) : 0;
  const hsize = Math.min(
    MAX_INSTANT_WIDTH_PT,
    Math.max(MIN_INSTANT_WIDTH_PT, widest, width, inferredColumnWidth),
  );
  const lineCount = countVerticalBands(rects);
  if (!lineCount) return undefined;
  return { page, x: minX, y: minY, width, height, hsize, lineCount };
}
