import { detectLatexLineEnding, type LatexLineEnding } from './line-endings';
import { canonicalEmptySourceAnchor, createSourceBackedBlockDeletion } from './structural-break';
import type { SourceTextEdit } from './text-edit';

export type LatexTableSize = {
  rows: number;
  columns: number;
};

export type LatexTableTemplate = {
  text: string;
  firstCellOffset: number;
};

export type LatexTableTemplateOptions = {
  /** Optional initial cell contents. Missing cells remain empty. */
  cells?: readonly (readonly string[])[];
  caption?: string;
  label?: string;
  includeLabel?: boolean;
  lineEnding?: LatexLineEnding;
};

export type LatexColumnToken = {
  index: number;
  from: number;
  to: number;
  text: string;
};

export type LatexTableCellRange = {
  column: number;
  from: number;
  to: number;
  rawFrom: number;
  rawTo: number;
};

export type LatexTableRowRange = {
  row: number;
  from: number;
  to: number;
  lineFrom: number;
  lineTo: number;
  terminatorFrom?: number;
  terminatorTo?: number;
  cells: readonly LatexTableCellRange[];
};

export type LatexTabularModel = {
  from: number;
  to: number;
  bodyFrom: number;
  bodyTo: number;
  spec: string;
  specFrom: number;
  specTo: number;
  columns: readonly LatexColumnToken[];
  rows: readonly LatexTableRowRange[];
  complex: boolean;
};

export type LatexTableStructureOperation =
  | 'add-row-above'
  | 'add-row-below'
  | 'add-column-left'
  | 'add-column-right'
  | 'delete-row'
  | 'delete-column'
  | 'delete-table';

export type LatexTableStructureMutation = {
  edits: readonly SourceTextEdit[];
  focusOffset: number;
};

function clampInteger(value: number, min: number, max: number): number {
  if (!Number.isFinite(value)) return min;
  return Math.max(min, Math.min(max, Math.trunc(value)));
}

/**
 * Canonical table insertion shared by Source and Visual mode. Empty cells are
 * intentional: TeX source remains canonical and Visual focuses the first
 * source-backed cell immediately after insertion.
 */
export function createLatexTableTemplate(
  rows: number,
  columns: number,
  options: LatexTableTemplateOptions = {},
): LatexTableTemplate {
  const rowCount = clampInteger(rows, 1, 64);
  const lineEnding = options.lineEnding ?? '\n';
  const columnCount = clampInteger(columns, 1, 64);
  // Learnitive-created tables use a visible full grid by default. Existing user
  // tables are never rewritten into this style; only new shared templates use it.
  const spec = `|${Array.from({ length: columnCount }, () => 'l').join('|')}|`;
  const rowValues = Array.from({ length: rowCount }, (_, rowIndex) =>
    Array.from({ length: columnCount }, (_, columnIndex) =>
      options.cells?.[rowIndex]?.[columnIndex] ?? '',
    ),
  );
  const bodyLines = ['\\hline'];
  for (const values of rowValues) {
    bodyLines.push(`  ${values.join(' & ')} \\\\`, '\\hline');
  }
  const caption = options.caption ?? '';
  const includeLabel = options.includeLabel ?? true;
  const label = options.label ?? 'tab:';
  const lines = [
    '\\begin{table}[htbp]',
    '\\begin{center}',
    `\\begin{tabular}{${spec}}`,
    ...bodyLines,
    '\\end{tabular}',
    '\\end{center}',
    `\\caption{${caption}}`,
    ...(includeLabel ? [`\\label{${label}}`] : []),
    '\\end{table}',
  ];
  const text = lines.join(lineEnding);
  // Derive the insertion focus from the same parser used by Visual mode.
  // Empty cells include source-formatting whitespace before `&`/`\\`, so a
  // hand-counted template offset can disagree with the canonical empty-cell
  // anchor after reprojection.
  const parsed = parseLatexTabular(text);
  const firstCellOffset = parsed?.rows[0]?.cells[0]?.from
    ?? text.indexOf('\n', text.indexOf(`\\begin{tabular}{${spec}}`)) + 1;
  return { text, firstCellOffset };
}

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

function readBalancedEnd(
  source: string,
  openAt: number,
  open = '{',
  close = '}',
): number | undefined {
  if (source[openAt] !== open) return undefined;
  let depth = 0;
  for (let index = openAt; index < source.length; index += 1) {
    const char = source[index];
    if (char === open && !isEscaped(source, index)) depth += 1;
    else if (char === close && !isEscaped(source, index)) {
      depth -= 1;
      if (depth === 0) return index + 1;
    }
  }
  return undefined;
}

function skipWhitespace(source: string, from: number, to: number): number {
  let at = from;
  while (at < to && /\s/.test(source[at] ?? '')) at += 1;
  return at;
}

function trimRange(source: string, from: number, to: number): { from: number; to: number } {
  let start = from;
  let end = to;
  while (start < end && /\s/.test(source[start] ?? '')) start += 1;
  while (end > start && /\s/.test(source[end - 1] ?? '')) end -= 1;
  return { from: start, to: end };
}

function decorationEnd(source: string, from: number, to: number): number | undefined {
  const rest = source.slice(from, to);
  const simple = /^\\(?:hline|toprule|midrule|bottomrule)\b/.exec(rest);
  if (simple) return from + simple[0].length;

  const ranged = /^\\(?:cline|cmidrule)(?:\([^)]*\))?\s*\{/.exec(rest);
  if (ranged) {
    const openAt = from + ranged[0].lastIndexOf('{');
    return readBalancedEnd(source, openAt);
  }
  return undefined;
}

function contentStartAfterDecorations(source: string, from: number, to: number): number {
  let at = skipWhitespace(source, from, to);
  while (at < to) {
    const end = decorationEnd(source, at, to);
    if (end === undefined) break;
    at = skipWhitespace(source, end, to);
  }
  return at;
}

function parseColumnTokens(
  source: string,
  specFrom: number,
  specTo: number,
): readonly LatexColumnToken[] | undefined {
  const columns: LatexColumnToken[] = [];
  let at = specFrom;
  while (at < specTo) {
    const char = source[at] ?? '';
    if (/\s/.test(char) || char === '|') {
      at += 1;
      continue;
    }
    if (char === 'l' || char === 'c' || char === 'r' || char === 'X') {
      columns.push({ index: columns.length, from: at, to: at + 1, text: char });
      at += 1;
      continue;
    }
    if (char === 'p' || char === 'm' || char === 'b') {
      const openAt = skipWhitespace(source, at + 1, specTo);
      const end = readBalancedEnd(source, openAt);
      if (end === undefined || end > specTo) return undefined;
      columns.push({
        index: columns.length,
        from: at,
        to: end,
        text: source.slice(at, end),
      });
      at = end;
      continue;
    }
    if (char === '@' || char === '>' || char === '<' || char === '!') {
      const openAt = skipWhitespace(source, at + 1, specTo);
      const end = readBalancedEnd(source, openAt);
      if (end === undefined || end > specTo) return undefined;
      at = end;
      continue;
    }
    // Repetition (`*{n}{...}`), custom column types and other constructs are
    // retained losslessly but remain Source-only for structural mutations.
    return undefined;
  }
  return columns.length > 0 ? columns : undefined;
}

function findLineStart(source: string, at: number): number {
  return source.lastIndexOf('\n', Math.max(0, at - 1)) + 1;
}

function findLineEndIncludingNewline(source: string, at: number, limit: number): number {
  const newline = source.indexOf('\n', at);
  if (newline < 0 || newline >= limit) return at;
  return newline + 1;
}

function parseRowCells(
  source: string,
  contentFrom: number,
  contentTo: number,
): readonly LatexTableCellRange[] {
  const separators: number[] = [];
  let depth = 0;
  for (let index = contentFrom; index < contentTo; index += 1) {
    const char = source[index];
    if (char === '{' && !isEscaped(source, index)) depth += 1;
    else if (char === '}' && !isEscaped(source, index)) depth = Math.max(0, depth - 1);
    else if (char === '&' && !isEscaped(source, index) && depth === 0) separators.push(index);
  }

  const cells: LatexTableCellRange[] = [];
  for (let column = 0; column <= separators.length; column += 1) {
    const rawFrom = column === 0 ? contentFrom : separators[column - 1]! + 1;
    const rawTo = column === separators.length ? contentTo : separators[column]!;
    const trimmed = trimRange(source, rawFrom, rawTo);
    const emptyAnchor = trimmed.from === trimmed.to
      ? canonicalEmptySourceAnchor(source, rawFrom, rawTo)
      : undefined;
    cells.push({
      column,
      from: emptyAnchor ?? trimmed.from,
      to: emptyAnchor ?? trimmed.to,
      rawFrom,
      rawTo,
    });
  }
  return cells;
}

/**
 * Parse just enough tabular structure for safe source-range mutation. It does
 * not interpret cell LaTeX; multirow/multicolumn are explicitly marked complex
 * so callers can fail closed instead of rewriting them.
 */
export function parseLatexTabular(
  source: string,
  searchRange: { from: number; to: number } = { from: 0, to: source.length },
): LatexTabularModel | undefined {
  const from = Math.max(0, Math.min(source.length, searchRange.from));
  const to = Math.max(from, Math.min(source.length, searchRange.to));
  const slice = source.slice(from, to);
  const begin = /\\begin\s*\{tabular\}/.exec(slice);
  if (!begin || begin.index === undefined) return undefined;
  const beginFrom = from + begin.index;
  const afterBegin = beginFrom + begin[0].length;
  const specOpen = skipWhitespace(source, afterBegin, to);
  const specEnd = readBalancedEnd(source, specOpen);
  if (specEnd === undefined || specEnd > to) return undefined;
  const specFrom = specOpen + 1;
  const specTo = specEnd - 1;
  const columns = parseColumnTokens(source, specFrom, specTo);
  if (!columns) return undefined;

  const endToken = '\\end{tabular}';
  const bodyFrom = specEnd;
  const bodyTo = source.indexOf(endToken, bodyFrom);
  if (bodyTo < 0 || bodyTo > to) return undefined;
  const tabularTo = bodyTo + endToken.length;
  const body = source.slice(bodyFrom, bodyTo);
  const complex = /\\(?:multirow|multicolumn)\b/.test(body);

  const segments: Array<{ from: number; to: number; terminatorFrom?: number; terminatorTo?: number }> = [];
  let segmentFrom = bodyFrom;
  let depth = 0;
  for (let index = bodyFrom; index < bodyTo; index += 1) {
    const char = source[index];
    if (char === '{' && !isEscaped(source, index)) depth += 1;
    else if (char === '}' && !isEscaped(source, index)) depth = Math.max(0, depth - 1);
    else if (
      char === '\\' &&
      source[index + 1] === '\\' &&
      depth === 0 &&
      !isEscaped(source, index)
    ) {
      segments.push({ from: segmentFrom, to: index, terminatorFrom: index, terminatorTo: index + 2 });
      segmentFrom = index + 2;
      index += 1;
    }
  }
  if (segmentFrom < bodyTo) segments.push({ from: segmentFrom, to: bodyTo });

  const rows: LatexTableRowRange[] = [];
  for (const segment of segments) {
    const contentFromRaw = contentStartAfterDecorations(source, segment.from, segment.to);
    const content = trimRange(source, contentFromRaw, segment.to);
    const hasTerminator = segment.terminatorFrom !== undefined;
    const rawContent = source.slice(content.from, content.to);
    const hasMeaningfulRow = hasTerminator
      ? content.to > content.from || rawContent.includes('&') || columns.length === 1
      : content.to > content.from;
    if (!hasMeaningfulRow) continue;

    const cells = parseRowCells(source, contentFromRaw, segment.to);
    const lineFrom = findLineStart(source, contentFromRaw);
    const terminatorTo = segment.terminatorTo;
    const afterTerminator = terminatorTo ?? segment.to;
    const lineTo = terminatorTo
      ? findLineEndIncludingNewline(source, terminatorTo, bodyTo)
      : findLineEndIncludingNewline(source, segment.to, bodyTo) || segment.to;
    rows.push({
      row: rows.length,
      from: contentFromRaw,
      to: segment.to,
      lineFrom,
      lineTo: Math.max(afterTerminator, lineTo),
      ...(segment.terminatorFrom === undefined ? {} : { terminatorFrom: segment.terminatorFrom }),
      ...(segment.terminatorTo === undefined ? {} : { terminatorTo: segment.terminatorTo }),
      cells,
    });
  }

  return {
    from: beginFrom,
    to: tabularTo,
    bodyFrom,
    bodyTo,
    spec: source.slice(specFrom, specTo),
    specFrom,
    specTo,
    columns,
    rows,
    complex,
  };
}

function sourceOffsetAfterEdits(offset: number, edits: readonly SourceTextEdit[]): number {
  let mapped = offset;
  for (const edit of [...edits].sort((a, b) => a.from - b.from || a.to - b.to)) {
    if (edit.to <= offset) mapped += edit.text.length - (edit.to - edit.from);
    else if (edit.from < offset) mapped = edit.from + edit.text.length;
  }
  return mapped;
}

function rowIndent(source: string, row: LatexTableRowRange): string {
  const prefix = source.slice(row.lineFrom, row.from);
  return /^[\t ]*$/.test(prefix) ? prefix : '  ';
}

function emptyRowText(
  columns: number,
  indent = '  ',
  lineEnding: LatexLineEnding = '\n',
): { text: string; firstCellOffset: number } {
  const text = `${indent}${Array.from({ length: columns }, () => '').join(' & ')} \\\\${lineEnding}`;
  return { text, firstCellOffset: indent.length };
}

function columnBoundarySegment(
  source: string,
  model: LatexTabularModel,
  index: number,
  direction: 'left' | 'right',
): string {
  const token = model.columns[index]!;
  if (direction === 'left') {
    const previous = model.columns[index - 1];
    return source.slice(previous?.to ?? model.specFrom, token.from);
  }
  const next = model.columns[index + 1];
  return source.slice(token.to, next?.from ?? model.specTo);
}

function isSafeBoundaryDecoration(value: string): boolean {
  let rest = value.replace(/\s+/g, '');
  while (rest.length > 0) {
    if (rest.startsWith('|')) {
      rest = rest.slice(1);
      continue;
    }
    const modifier = /^[@!><]\{/.exec(rest);
    if (modifier) {
      const end = readBalancedEnd(rest, 1);
      if (end === undefined) return false;
      rest = rest.slice(end);
      continue;
    }
    return false;
  }
  return true;
}

function isFullGridHlineTable(source: string, model: LatexTabularModel): boolean {
  if (model.rows.length === 0) return false;
  // Only opt into Learnitive-style grid maintenance when each boundary contains
  // exactly one hline plus whitespace. Custom rule stacks/comments/booktabs
  // stay on the source-preserving path instead of being normalized.
  const isSingleHline = (from: number, to: number) =>
    source.slice(from, to).replace(/\s+/gu, '') === '\\hline';
  if (!isSingleHline(model.bodyFrom, model.rows[0]!.lineFrom)) return false;
  for (let index = 0; index < model.rows.length - 1; index += 1) {
    if (!isSingleHline(model.rows[index]!.lineTo, model.rows[index + 1]!.lineFrom)) return false;
  }
  return isSingleHline(model.rows.at(-1)!.lineTo, model.bodyTo);
}

/**
 * Build minimal source edits for table structure. Existing booktabs/custom
 * rules remain source-preserved. Learnitive-created full-grid `\hline` tables are
 * detected explicitly so row mutations preserve one rule at every boundary.
 * Complex multirow/multicolumn tables fail closed.
 */
export function createLatexTableStructureMutation(
  source: string,
  tableRange: { from: number; to: number },
  operation: LatexTableStructureOperation,
  selectedRow = 0,
  selectedColumn = 0,
): LatexTableStructureMutation | undefined {
  if (operation === 'delete-table') {
    const deletion = createSourceBackedBlockDeletion(source, tableRange);
    if (!deletion) return undefined;
    return { edits: [deletion.edit], focusOffset: deletion.focusOffset };
  }

  const model = parseLatexTabular(source, tableRange);
  if (!model || model.complex || model.rows.length === 0) return undefined;
  const rowIndex = clampInteger(selectedRow, 0, model.rows.length - 1);
  const columnIndex = clampInteger(selectedColumn, 0, model.columns.length - 1);
  const row = model.rows[rowIndex]!;
  if (model.rows.some((candidate) => candidate.cells.length !== model.columns.length)) return undefined;
  const fullGrid = isFullGridHlineTable(source, model);
  const lineEnding = detectLatexLineEnding(source);

  if (operation === 'add-row-above' || operation === 'add-row-below') {
    const insertAt = operation === 'add-row-above' ? row.lineFrom : row.lineTo;
    const template = emptyRowText(model.columns.length, rowIndent(source, row), lineEnding);
    const text = !fullGrid
      ? template.text
      : operation === 'add-row-above'
        ? `${template.text}\\hline${lineEnding}`
        : `\\hline${lineEnding}${template.text}`;
    const firstCellOffset = !fullGrid || operation === 'add-row-above'
      ? template.firstCellOffset
      : `\\hline${lineEnding}`.length + template.firstCellOffset;
    return {
      edits: [{ from: insertAt, to: insertAt, text }],
      focusOffset: insertAt + firstCellOffset,
    };
  }

  if (operation === 'delete-row') {
    if (model.rows.length <= 1) return undefined;
    let edit: SourceTextEdit;
    if (!fullGrid) {
      edit = { from: row.lineFrom, to: row.lineTo, text: '' };
    } else if (rowIndex < model.rows.length - 1) {
      // Remove this row plus the following grid rule. The rule immediately
      // before the row remains as the boundary for the next row.
      edit = { from: row.lineFrom, to: model.rows[rowIndex + 1]!.lineFrom, text: '' };
    } else {
      // Last row: remove the preceding grid rule plus the row, leaving the
      // existing trailing \\hline as the new bottom border.
      edit = { from: model.rows[rowIndex - 1]!.lineTo, to: row.lineTo, text: '' };
    }
    const target = model.rows[rowIndex + 1] ?? model.rows[rowIndex - 1];
    const targetColumn = Math.min(columnIndex, (target?.cells.length ?? 1) - 1);
    const targetOffset = target?.cells[targetColumn]?.from ?? row.lineFrom;
    return {
      edits: [edit],
      focusOffset: sourceOffsetAfterEdits(targetOffset, [edit]),
    };
  }

  if (model.columns.length < 1) return undefined;
  const token = model.columns[columnIndex]!;
  if (operation === 'add-column-left' || operation === 'add-column-right') {
    const boundary = columnBoundarySegment(
      source,
      model,
      columnIndex,
      operation === 'add-column-left' ? 'left' : 'right',
    );
    if (!isSafeBoundaryDecoration(boundary)) return undefined;
    const specInsertAt = operation === 'add-column-left' ? token.from : token.to;
    const specInsertText = operation === 'add-column-left'
      ? `${token.text}${boundary}`
      : `${boundary}${token.text}`;
    const edits: SourceTextEdit[] = [{ from: specInsertAt, to: specInsertAt, text: specInsertText }];
    let selectedCellEdit: SourceTextEdit | undefined;
    for (let candidateIndex = 0; candidateIndex < model.rows.length; candidateIndex += 1) {
      const candidate = model.rows[candidateIndex]!;
      const cell = candidate.cells[columnIndex];
      if (!cell) return undefined;
      const at = operation === 'add-column-left' ? cell.rawFrom : cell.rawTo;
      const cellEdit: SourceTextEdit = { from: at, to: at, text: ' & ' };
      edits.push(cellEdit);
      if (candidateIndex === rowIndex) selectedCellEdit = cellEdit;
    }
    if (!selectedCellEdit) return undefined;
    const selected = row.cells[columnIndex]!;
    const focusBase = operation === 'add-column-left' ? selected.rawFrom : selected.rawTo;
    // For a left insert, the new blank cell is before the selected row edit;
    // for a right insert, it is after that edit. Excluding the selected edit
    // lets us map to the left edge without same-position insertion bias.
    const otherEdits = edits.filter((edit) => edit !== selectedCellEdit);
    const mappedBase = sourceOffsetAfterEdits(focusBase, otherEdits);
    const focusOffset = mappedBase + (operation === 'add-column-right' ? selectedCellEdit.text.length : 0);
    return { edits, focusOffset };
  }

  if (operation === 'delete-column') {
    if (model.columns.length <= 1) return undefined;
    const edits: SourceTextEdit[] = [];
    if (columnIndex < model.columns.length - 1) {
      const next = model.columns[columnIndex + 1]!;
      edits.push({ from: token.from, to: next.from, text: '' });
    } else {
      const previous = model.columns[columnIndex - 1]!;
      edits.push({ from: previous.to, to: token.to, text: '' });
    }

    for (const candidate of model.rows) {
      const cells = candidate.cells;
      const cell = cells[columnIndex];
      if (!cell) return undefined;
      if (columnIndex < cells.length - 1) {
        edits.push({ from: cell.rawFrom, to: cells[columnIndex + 1]!.rawFrom, text: '' });
      } else {
        const previous = cells[columnIndex - 1];
        if (!previous) return undefined;
        edits.push({ from: previous.rawTo, to: cell.rawTo, text: '' });
      }
    }
    const nextColumn = Math.min(columnIndex, model.columns.length - 2);
    const target = row.cells[nextColumn]?.from ?? row.from;
    return { edits, focusOffset: sourceOffsetAfterEdits(target, edits) };
  }

  return undefined;
}
