import { describe, expect, it } from 'vitest';
import { parseBibTeX } from '@learnitive/bibliography';
import { projectFilePath } from '@learnitive/project-storage';
import {
  buildProjectSemanticIndex,
  createSemanticDoctorReport,
  parseLatexFile,
  resolveInputTarget,
  searchProjectSemanticIndex,
} from '../index';

describe('LaTeX semantic index', () => {
  it('indexes headings and preserves exact editable body ranges', () => {
    const source = '\\begin{document}\n\\section{Results}\nPlain paragraph.\n\\end{document}';
    const index = parseLatexFile(projectFilePath('main.tex'), source);
    const heading = index.nodes.find((node) => node.kind === 'section');
    expect(heading?.title).toBe('Results');
    expect(heading?.bodyRange && source.slice(heading.bodyRange.from, heading.bodyRange.to)).toBe('Results');
    expect(index.visualBlocks.map((block) => block.kind)).toEqual(['heading', 'paragraph']);
  });

  it('indexes project relationships and reports broken targets', () => {
    const main = projectFilePath('main.tex');
    const sources = new Map([
      [main, '\\input{chapters/results}\n\\label{dup}\\label{dup}\\ref{missing}\\includegraphics{figures/nope.png}'],
    ]);
    const snapshot = buildProjectSemanticIndex(sources, main, new Set([main]));
    expect(snapshot.inputs).toHaveLength(1);
    expect(snapshot.labels).toHaveLength(2);
    expect(snapshot.diagnostics.map((item) => item.kind).sort()).toEqual([
      'duplicate-label',
      'missing-asset',
      'missing-input',
      'missing-reference',
    ]);
  });

  it('does not index commands in comments or verbatim', () => {
    const source = '% \\section{Hidden}\n\\begin{verbatim}\n\\section{Also hidden}\n\\end{verbatim}\n\\section{Visible}';
    const index = parseLatexFile(projectFilePath('main.tex'), source);
    expect(index.nodes.filter((node) => node.kind === 'section').map((node) => node.title)).toEqual(['Visible']);
  });
});

it('projects R7 figures, citations, footnotes, tables and theorem blocks without flattening source', () => {
  const source = String.raw`\begin{document}
Text \cite{smith2026} and \ref{fig:one} with a \footnote{plain note}.

\begin{figure}
\centering
\includegraphics{figures/result.png}
\caption{Result image}
\label{fig:one}
\end{figure}

\begin{table}
\caption{Numbers}
\begin{tabular}{cc}
A & B \\
1 & 2 \\
\end{tabular}
\end{table}

\begin{theorem}[Useful]
Plain theorem body.
\end{theorem}
\end{document}`;
  const index = parseLatexFile(projectFilePath('main.tex'), source);
  expect(index.visualBlocks.map((block) => block.kind)).toEqual([
    'rich-paragraph',
    'figure',
    'table',
    'theorem',
  ]);
  const paragraph = index.visualBlocks[0];
  expect(paragraph?.kind === 'rich-paragraph' && paragraph.inlines.map((item) => item.kind)).toContain('citation');
  const figure = index.visualBlocks[1];
  expect(figure?.kind === 'figure' && figure.caption).toBe('Result image');
  const table = index.visualBlocks[2];
  expect(table?.kind === 'table' && table.editable).toBe(true);
  expect(table?.kind === 'table' && table.cells?.map((cell) => cell.text)).toEqual(['A', 'B', '1', '2']);
  const theorem = index.visualBlocks[3];
  expect(theorem?.kind === 'theorem' && theorem.editable).toBe(true);
});

it('reports missing and unused bibliography keys from the shared project index', async () => {
  const { parseBibTeX } = await import('@learnitive/bibliography');
  const main = projectFilePath('main.tex');
  const bib = projectFilePath('refs.bib');
  const sources = new Map([[main, '\\cite{used,missing}']]);
  const bibliography = new Map([[bib, parseBibTeX('@article{used,title={Used}}\n@book{unused,title={Unused}}')]]);
  const snapshot = buildProjectSemanticIndex(sources, main, new Set([main, bib]), bibliography);
  expect(snapshot.bibliographyEntries.map((entry) => entry.key).sort()).toEqual(['unused', 'used']);
  expect(snapshot.diagnostics.some((item) => item.kind === 'missing-citation' && item.target === 'missing')).toBe(true);
  expect(snapshot.diagnostics.some((item) => item.kind === 'unused-bibliography-entry' && item.target === 'unused')).toBe(true);
});

it('preserves includegraphics option and asset body ranges for targeted figure edits', () => {
  const source = String.raw`\begin{document}
\begin{figure}
\includegraphics[page=2,width=0.8\linewidth]{figures/result.png}
\caption{Result}
\end{figure}
\end{document}`;
  const index = parseLatexFile(projectFilePath('main.tex'), source);
  const figure = index.visualBlocks.find((block) => block.kind === 'figure');
  expect(figure?.kind).toBe('figure');
  if (!figure || figure.kind !== 'figure') throw new Error('figure missing');
  expect(figure.graphicsOptions).toBe('page=2,width=0.8\\linewidth');
  expect(
    figure.graphicsOptionsBodyRange &&
      source.slice(
        figure.graphicsOptionsBodyRange.from,
        figure.graphicsOptionsBodyRange.to,
      ),
  ).toBe('page=2,width=0.8\\linewidth');
  expect(
    figure.assetBodyRange &&
      source.slice(figure.assetBodyRange.from, figure.assetBodyRange.to),
  ).toBe('figures/result.png');
});


it('searches shared semantic meaning and supports safe parent-directory references', () => {
  const main = projectFilePath('chapters/results.tex');
  const root = projectFilePath('main.tex');
  const sources = new Map([
    [root, '\\input{chapters/results}'],
    [main, '\\section{Results}\\label{sec:results} Text \\cite{smith2026}.'],
  ]);
  const snapshot = buildProjectSemanticIndex(sources, root, new Set(sources.keys()));
  expect(resolveInputTarget(main, '../main')).toBe(root);
  expect(searchProjectSemanticIndex(snapshot, 'results').some((item) => item.kind === 'outline')).toBe(true);
  expect(searchProjectSemanticIndex(snapshot, 'smith2026').some((item) => item.kind === 'citation')).toBe(true);
});

it('derives a stable Doctor health report from semantic diagnostics', () => {
  const main = projectFilePath('main.tex');
  const snapshot = buildProjectSemanticIndex(
    new Map([[main, '\\label{dup}\\label{dup}\\ref{missing}']]),
    main,
    new Set([main]),
  );
  const report = createSemanticDoctorReport(snapshot);
  expect(report.errors).toBe(1);
  expect(report.warnings).toBe(1);
  expect(report.score).toBeLessThan(100);
  expect(report.issues.map((item) => item.category).sort()).toEqual([
    'references',
    'references',
  ]);
});

it('R14.4 projects inline and display math as source-backed visual MathLive ranges', () => {
  const source = String.raw`\begin{document}
Energy $E=mc^2$ and \(a^2+b^2=c^2\).

\[
\frac{a}{b}
\]
\end{document}`;
  const index = parseLatexFile(projectFilePath('main.tex'), source);
  const rich = index.visualBlocks.filter(
    (block) => block.kind === 'rich-paragraph',
  );
  expect(rich.length).toBe(2);
  const math = rich.flatMap((block) =>
    block.kind === 'rich-paragraph'
      ? block.inlines.filter((inline) => inline.kind === 'math')
      : [],
  );
  expect(math).toHaveLength(3);
  expect(math.map((inline) => inline.kind === 'math' && inline.mathKind)).toEqual([
    'inline',
    'inline',
    'display',
  ]);
  const first = math[0];
  expect(first?.kind).toBe('math');
  if (!first || first.kind !== 'math') throw new Error('math inline missing');
  expect(source.slice(first.bodyRange.from, first.bodyRange.to)).toBe('E=mc^2');
  expect(first.editable).toBe(true);
});

it('R14.4 keeps structural math visible but read-only in Visual mode', () => {
  const source = String.raw`\begin{document}
\begin{equation}
E=mc^2\label{eq:energy}
\end{equation}
\end{document}`;
  const index = parseLatexFile(projectFilePath('main.tex'), source);
  const rich = index.visualBlocks.find(
    (block) => block.kind === 'rich-paragraph',
  );
  expect(rich?.kind).toBe('rich-paragraph');
  if (!rich || rich.kind !== 'rich-paragraph') throw new Error('rich math missing');
  const math = rich.inlines.find((inline) => inline.kind === 'math');
  expect(math?.kind === 'math' && math.editable).toBe(false);
});

it('R14.9 keeps common formatting editable and isolates unsupported inline LaTeX', () => {
  const source = String.raw`\begin{document}
Before \custommacro{LOCKED} after \textbf{bold text}, \emph{italic text}, \underline{underlined}, \texttt{code}, \url{https://example.test} and \href{https://example.test}{a link}.
\end{document}`;
  const index = parseLatexFile(projectFilePath('main.tex'), source);
  expect(index.visualBlocks).toHaveLength(1);
  const paragraph = index.visualBlocks[0];
  expect(paragraph?.kind).toBe('rich-paragraph');
  if (!paragraph || paragraph.kind !== 'rich-paragraph') throw new Error('rich paragraph missing');
  const kinds = paragraph.inlines.map((inline) => inline.kind);
  expect(kinds).toContain('raw');
  expect(kinds.filter((kind) => kind === 'styled-text')).toHaveLength(6);
  const raw = paragraph.inlines.find((inline) => inline.kind === 'raw');
  expect(raw?.kind === 'raw' && raw.text).toBe('\\custommacro{LOCKED}');
  const styled = paragraph.inlines.filter((inline) => inline.kind === 'styled-text');
  expect(styled.map((inline) => inline.kind === 'styled-text' && inline.style)).toEqual([
    'bold',
    'italic',
    'underline',
    'monospace',
    'link',
    'link',
  ]);
  expect(styled.map((inline) => inline.kind === 'styled-text' && inline.text)).toEqual([
    'bold text',
    'italic text',
    'underlined',
    'code',
    'https://example.test',
    'a link',
  ]);
});

it('R14.9 projects itemize and enumerate as source-backed editable visual lists', () => {
  const source = String.raw`\begin{document}
\begin{itemize}
\item First item
\item Second \emph{item}
\end{itemize}
\begin{enumerate}
\item One
\item Two
\end{enumerate}
\end{document}`;
  const index = parseLatexFile(projectFilePath('main.tex'), source);
  expect(index.visualBlocks.map((block) => block.kind)).toEqual(['list', 'list']);
  const [bullets, numbers] = index.visualBlocks;
  expect(bullets?.kind === 'list' && bullets.ordered).toBe(false);
  expect(numbers?.kind === 'list' && numbers.ordered).toBe(true);
  expect(bullets?.kind === 'list' && bullets.items).toHaveLength(2);
  expect(
    bullets?.kind === 'list' &&
      bullets.items[1]?.inlines.some((inline) => inline.kind === 'styled-text' && inline.style === 'italic'),
  ).toBe(true);
});

it('R14.9 keeps simple unknown text environments editable without freezing adjacent prose', () => {
  const source = String.raw`\begin{document}
Editable before.

\begin{customenv}
KEEP-EDITABLE-CONTENT
\end{customenv}

Editable after.
\end{document}`;
  const index = parseLatexFile(projectFilePath('main.tex'), source);
  expect(index.visualBlocks.map((block) => block.kind)).toEqual(['paragraph', 'text-environment', 'paragraph']);
  const environment = index.visualBlocks[1];
  expect(environment?.kind).toBe('text-environment');
  if (!environment || environment.kind !== 'text-environment') throw new Error('Missing text environment');
  expect(environment.environment).toBe('customenv');
  expect(environment.text).toBe('KEEP-EDITABLE-CONTENT');
  expect(environment.prefix).toContain('\\begin{customenv}');
  expect(environment.suffix).toContain('\\end{customenv}');
});

it('R14.9 still protects structurally complex unknown environments as one source-only block', () => {
  const source = String.raw`\begin{document}
Before.
\begin{customenv}
\begin{minipage}{.5\linewidth}
Nested structural content.
\end{minipage}
\end{customenv}
After.
\end{document}`;
  const index = parseLatexFile(projectFilePath('main.tex'), source);
  const raw = index.visualBlocks.find((block) => block.kind === 'raw');
  expect(raw?.kind === 'raw' && raw.text).toContain('minipage');
});

it('R1 projects canonical \\newline{}, legacy \\newline and \\\\ as first-class visual hard breaks', () => {
  const source = String.raw`\begin{document}
First\newline{}second, legacy\newline third and fourth\\fifth.
\end{document}`;
  const index = parseLatexFile(projectFilePath('main.tex'), source);
  const paragraph = index.visualBlocks.find(
    (block) => block.kind === 'rich-paragraph',
  );
  expect(paragraph?.kind).toBe('rich-paragraph');
  if (!paragraph || paragraph.kind !== 'rich-paragraph') {
    throw new Error('hard-break paragraph missing');
  }
  const breaks = paragraph.inlines.filter(
    (inline) => inline.kind === 'hard-break',
  );
  expect(breaks).toHaveLength(3);
  expect(
    breaks.map((inline) =>
      inline.kind === 'hard-break' ? inline.sourceText : '',
    ),
  ).toEqual(['\\newline{}', '\\newline', '\\\\']);
  expect(paragraph.inlines.map((inline) => inline.kind === 'text' ? inline.text : '').join(''))
    .toContain('second, legacy');
});

it('R14.25.1 detects duplicate works, DOI conflicts and probable renamed citation keys without inventing metadata', () => {
  const main = projectFilePath('main.tex');
  const bib = projectFilePath('refs.bib');
  const sources = new Map([
    [main, String.raw`\begin{document}
A result \cite{smith2024oldd}.
\bibliography{refs}
\end{document}`],
  ]);
  const bibliography = parseBibTeX(String.raw`@article{smith2024old,
  title={Browser Local Evidence},
  author={Smith, Ada},
  year={2024},
  doi={10.1000/example}
}
@article{smith2024duplicate,
  title={Browser-Local Evidence Revised},
  author={Smith, Ada},
  year={2025},
  doi={10.1000/example}
}
@article{smith2024samework,
  title={Browser Local Evidence},
  author={Smith, Ada},
  year={2024}
}`);
  const snapshot = buildProjectSemanticIndex(
    sources,
    main,
    new Set([main, bib]),
    new Map([[bib, bibliography]]),
  );
  const kinds = snapshot.diagnostics.map((item) => item.kind);
  expect(kinds).toContain('duplicate-doi');
  expect(kinds).toContain('bibliography-title-mismatch');
  expect(kinds).toContain('bibliography-author-year-mismatch');
  expect(kinds).toContain('duplicate-bibliography-work');
  expect(kinds).toContain('missing-citation');
  expect(kinds).toContain('possible-renamed-citation-key');
});


it('R2 keeps booktabs/hline tables Visual-editable while preserving their rule commands outside cells', () => {
  const source = String.raw`\begin{document}
\begin{table}
\begin{tabular}{|l|c|}
\toprule
A & B \\
\midrule
C & D \\
\bottomrule
\end{tabular}
\caption{Results}
\end{table}
\end{document}`;
  const table = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks.find(
    (block) => block.kind === 'table',
  );
  expect(table?.kind).toBe('table');
  if (!table || table.kind !== 'table') throw new Error('Missing table');
  expect(table.editable).toBe(true);
  expect(table.rowCount).toBe(2);
  expect(table.columnCount).toBe(2);
  expect(table.cells?.map((cell) => cell.text)).toEqual(['A', 'B', 'C', 'D']);
});

it('BF2 treats canonical \\par commands as paragraph boundaries without exposing the delimiter', () => {
  const source = String.raw`\begin{document}
\section{Methods}
\par
First paragraph.\par
Second paragraph.
\end{document}`;
  const index = parseLatexFile(projectFilePath('main.tex'), source);
  const paragraphs = index.visualBlocks.filter((block) => block.kind === 'paragraph');
  expect(index.visualBlocks.map((block) => block.kind)).toEqual(['heading', 'paragraph', 'paragraph']);
  expect(paragraphs.map((block) => block.text)).toEqual(['First paragraph.', 'Second paragraph.']);
  expect(paragraphs.every((block) => !block.text.includes('\\par'))).toBe(true);
});

it('BF2 does not treat commented canonical paragraph commands as visual delimiters', () => {
  const source = String.raw`\begin{document}
Before % \\par is only a comment
After.
\end{document}`;
  const index = parseLatexFile(projectFilePath('main.tex'), source);
  expect(index.visualBlocks).toHaveLength(1);
  expect(index.visualBlocks[0]?.kind).toBe('paragraph');
});


it('BF2 preserves consecutive canonical paragraph breaks as durable empty Visual rows', () => {
  const file = projectFilePath('paper/main.tex');
  const source = String.raw`\documentclass{article}
\begin{document}
First.
\par
\par
\par
\end{document}`;
  const parsed = parseLatexFile(file, source);
  const paragraphs = parsed.visualBlocks.filter((block) => block.kind === 'paragraph');
  expect(paragraphs.map((block) => block.text)).toEqual(['First.', '', '', '']);
  for (const block of paragraphs.slice(1)) {
    expect(block.range.from).toBe(block.range.to);
    expect(block.bodyRange.from).toBe(block.bodyRange.to);
  }
});


it('Visual projection treats terminal blank-line source as spacing, not visible empty manuscript rows', () => {
  const file = projectFilePath('paper/main.tex');
  const source = '\\begin{document}\nAlpha\n\n\n\n\\end{document}';
  const parsed = parseLatexFile(file, source);
  const paragraphs = parsed.visualBlocks.filter((block) => block.kind === 'paragraph');
  expect(paragraphs.map((block) => block.text)).toEqual(['Alpha']);
  expect(source.slice(paragraphs[0]!.range.to, source.indexOf('\\end{document}'))).not.toContain('\\par');
});

it('BF3 keeps ordinary two-newline paragraph separation semantic without inventing an empty paragraph', () => {
  const source = '\\begin{document}\nAlpha\n\nBeta\n\\end{document}';
  const parsed = parseLatexFile(projectFilePath('main.tex'), source);
  expect(parsed.visualBlocks.filter((block) => block.kind === 'paragraph').map((block) => block.text)).toEqual([
    'Alpha',
    'Beta',
  ]);
});

it('labels/comments around headings never create visible empty Visual rows', () => {
  const source = String.raw`\begin{document}
\begin{keywords}
One, two
\end{keywords}
% source note
\section{Introduction}
\label{sec:intro}

Editable paragraph.
\end{document}`;
  const parsed = parseLatexFile(projectFilePath('main.tex'), source);
  const heading = parsed.visualBlocks.find((block) => block.kind === 'heading' && block.text === 'Introduction');
  expect(heading).toBeTruthy();
  if (!heading || heading.kind !== 'heading') throw new Error('Missing heading');
  expect(heading.suffix).toContain('\\label{sec:intro}');
  expect(parsed.visualBlocks.filter((block) => block.kind === 'paragraph' && block.text === '')).toHaveLength(0);
});

it('treats complete LaTeX comments as invisible trivia even when they contain commands', () => {
  const source = String.raw`\begin{document}
Before.

% Below is an example using \vspace and \centerline{\includegraphics{image1}}.
% \vfill\pagebreak
\begin{figure}
\includegraphics{image1}
\end{figure}

After.
\end{document}`;
  const parsed = parseLatexFile(projectFilePath('main.tex'), source);
  const commentText = parsed.visualBlocks.flatMap((block) => {
    if (block.kind === 'paragraph') return [block.text];
    if (block.kind === 'rich-paragraph') return block.inlines.map((inline) => inline.kind === 'text' ? inline.text : '');
    return [];
  }).join('\n');
  expect(commentText).not.toContain('Below is an example');
  expect(commentText).not.toContain('vfill');
  expect(commentText).not.toContain('pagebreak');
  const figure = parsed.visualBlocks.find((block) => block.kind === 'figure');
  expect(figure?.kind).toBe('figure');
});

it('figures retain every includegraphics target while marking nested layouts complex', () => {
  const source = String.raw`\begin{document}
\begin{figure}
\begin{minipage}{.48\linewidth}
\includegraphics{image-a}
\end{minipage}
\hfill
\begin{minipage}{.48\linewidth}
\includegraphics[width=4cm]{image-b}
\end{minipage}
\caption{Results}
\end{figure}
\end{document}`;
  const parsed = parseLatexFile(projectFilePath('main.tex'), source);
  const figure = parsed.visualBlocks.find((block) => block.kind === 'figure');
  if (!figure || figure.kind !== 'figure') throw new Error('Missing figure');
  expect(figure.assets?.map((asset) => asset.target)).toEqual(['image-a', 'image-b']);
  expect(figure.asset).toBe('image-a');
  expect(figure.complex).toBe(true);
});

it('BF3 anchors an empty list item at its canonical body start after horizontal padding', () => {
  const source = '\\begin{document}\n\\begin{itemize}\n\\item One\n\\item \n\\end{itemize}\n\\end{document}';
  const parsed = parseLatexFile(projectFilePath('main.tex'), source);
  const list = parsed.visualBlocks.find((block) => block.kind === 'list');
  if (!list || list.kind !== 'list') throw new Error('Missing list');
  const emptyItem = list.items[1];
  if (!emptyItem) throw new Error('Missing empty item');
  const expected = source.indexOf('\\item \n') + '\\item '.length;
  expect(emptyItem.bodyRange).toEqual({ from: expected, to: expected });
});

it('BF3 projects a terminal hard break with a source-backed zero-length continuation inline', () => {
  const source = '\\begin{document}\nAlpha\\newline{}\n\\end{document}';
  const parsed = parseLatexFile(projectFilePath('main.tex'), source);
  const paragraph = parsed.visualBlocks.find((block) => block.kind === 'rich-paragraph');
  if (!paragraph || paragraph.kind !== 'rich-paragraph') throw new Error('Missing rich paragraph');
  const anchor = source.indexOf('\\newline{}') + '\\newline{}'.length;
  const continuation = paragraph.inlines.at(-1);
  expect(continuation?.kind).toBe('text');
  if (!continuation || continuation.kind !== 'text') throw new Error('Missing continuation');
  expect(continuation.bodyRange).toEqual({ from: anchor, to: anchor });
});

it('keeps Learnitive canonical literal escapes as editable prose while unknown commands remain protected', () => {
  const source = String.raw`\begin{document}
100\% A\_B \#1 \& \$ \{x\} \textbackslash{} \textasciitilde{} \textasciicircum{}

\textbf{Bold 50\% A\_B \textasciitilde{}}

Before \unsupportedcommand{value} after.
\end{document}`;
  const parsed = parseLatexFile(projectFilePath('main.tex'), source);
  const first = parsed.visualBlocks[0];
  expect(first?.kind).toBe('paragraph');
  expect(first?.text).toContain('100\\% A\\_B');
  const styled = parsed.visualBlocks.find(
    (block) => block.kind === 'rich-paragraph' && block.text.includes('textbf'),
  );
  expect(styled?.kind).toBe('rich-paragraph');
  if (!styled || styled.kind !== 'rich-paragraph') throw new Error('Missing styled literal paragraph');
  expect(styled.inlines.some((inline) => inline.kind === 'styled-text')).toBe(true);
  expect(styled.inlines.some((inline) => inline.kind === 'raw')).toBe(false);
  const second = parsed.visualBlocks.find(
    (block) => block.kind === 'rich-paragraph' && block.text.includes('unsupportedcommand'),
  );
  expect(second?.kind).toBe('rich-paragraph');
  if (!second || second.kind !== 'rich-paragraph') throw new Error('Missing protected unknown command');
  expect(second.inlines.some((inline) => inline.kind === 'raw')).toBe(true);
});

it('R31 BF3 Phase 4 keeps a source-backed typing row after block math, tables and figures', () => {
  const cases = [
    {
      source: '\\begin{document}\n\\[\na^2+b^2=c^2\n\\]\n\n\\end{document}',
      owner: (block: ReturnType<typeof parseLatexFile>['visualBlocks'][number]) =>
        block.kind === 'rich-paragraph' && block.inlines.some((inline) => inline.kind === 'math' && inline.mathKind === 'display'),
    },
    {
      source: '\\begin{document}\n\\begin{table}\n\\begin{tabular}{|l|l|}\n\\hline\nA & B \\\\\n\\hline\n\\end{tabular}\n\\end{table}\n\n\\end{document}',
      owner: (block: ReturnType<typeof parseLatexFile>['visualBlocks'][number]) => block.kind === 'table',
    },
    {
      source: '\\begin{document}\n\\begin{figure}\n\\includegraphics{figure.png}\n\\end{figure}\n\n\\end{document}',
      owner: (block: ReturnType<typeof parseLatexFile>['visualBlocks'][number]) => block.kind === 'figure',
    },
  ] as const;

  for (const { source, owner } of cases) {
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const ownerIndex = blocks.findIndex(owner);
    expect(ownerIndex).toBeGreaterThanOrEqual(0);
    const next = blocks[ownerIndex + 1];
    expect(next?.kind).toBe('paragraph');
    if (!next || next.kind !== 'paragraph') throw new Error('Missing post-block paragraph');
    expect(next.text).toBe('');
    expect(next.range.from).toBe(next.range.to);
  }
});

it('BF4 reopens generated shortstack table-cell hard breaks as editable Visual inlines', () => {
  const source = String.raw`\begin{document}
\begin{tabular}{|l|l|}
\hline
\shortstack[l]{A\\Line two} & B \\
\hline
\end{tabular}
\end{document}`;
  const parsed = parseLatexFile(projectFilePath('main.tex'), source);
  const table = parsed.visualBlocks.find((block) => block.kind === 'table');
  expect(table?.kind).toBe('table');
  if (!table || table.kind !== 'table') throw new Error('Missing table');
  expect(table.editable).toBe(true);
  const first = table.cells?.[0];
  expect(first?.text).toBe('A\\\\Line two');
  expect(first?.inlines.map((inline) => inline.kind)).toEqual(['text', 'hard-break', 'text']);
  const hardBreak = first?.inlines.find((inline) => inline.kind === 'hard-break');
  expect(hardBreak?.kind).toBe('hard-break');
  if (!hardBreak || hardBreak.kind !== 'hard-break') throw new Error('Missing hard break');
  expect(hardBreak.sourceText).toBe('\\\\');
});

it('projects the full common LaTeX heading hierarchy exactly once without template-specific rules', () => {
  const source = String.raw`\begin{document}
\part{Part}
\chapter{Chapter}
\section{Section}
\subsection{Subsection}
\subsubsection{Subsubsection}
\paragraph{Paragraph}
\subparagraph{Subparagraph}
\end{document}`;
  const parsed = parseLatexFile(projectFilePath('main.tex'), source);
  const headings = parsed.visualBlocks.filter((block) => block.kind === 'heading');
  expect(headings.map((block) => block.text)).toEqual([
    'Part',
    'Chapter',
    'Section',
    'Subsection',
    'Subsubsection',
    'Paragraph',
    'Subparagraph',
  ]);
  expect(headings.map((block) => block.command)).toEqual([
    'part',
    'chapter',
    'section',
    'subsection',
    'subsubsection',
    'paragraph',
    'subparagraph',
  ]);
  expect(new Set(headings.map((block) => block.id)).size).toBe(7);
});

it('keeps starred headings semantic so Visual can fail closed into an immutable heading proxy', () => {
  const source = String.raw`\begin{document}
\section*{Unnumbered}
\end{document}`;
  const parsed = parseLatexFile(projectFilePath('main.tex'), source);
  const heading = parsed.visualBlocks.find((block) => block.kind === 'heading');
  expect(heading?.kind).toBe('heading');
  if (!heading || heading.kind !== 'heading') throw new Error('Missing heading');
  expect(heading.command).toBe('section');
  expect(heading.starred).toBe(true);
  expect(source.slice(heading.range.from, heading.range.to)).toContain('\\section*{Unnumbered}');
});

it('projects standalone includegraphics, including a simple TeX group, as one protected figure asset', () => {
  const source = String.raw`\begin{document}
{\includegraphics[width=4.0cm,page=2]{image3}}
\end{document}`;
  const parsed = parseLatexFile(projectFilePath('main.tex'), source);
  const figures = parsed.visualBlocks.filter((block) => block.kind === 'figure');
  expect(figures).toHaveLength(1);
  const figure = figures[0]!;
  if (figure.kind !== 'figure') throw new Error('Missing standalone figure');
  expect(figure.asset).toBe('image3');
  expect(figure.assets?.[0]?.options).toContain('page=2');
  expect(figure.complex).toBe(true);
  expect(source.slice(figure.range.from, figure.range.to)).toBe('{\\includegraphics[width=4.0cm,page=2]{image3}}');
});
