// @vitest-environment jsdom

import { afterEach, describe, expect, it, vi } from 'vitest';
import { TextSelection } from '@learnitive/prosemirror-editor';
import { projectFilePath } from '@learnitive/project-storage';
import { parseLatexFile } from '@learnitive/project-semantic-index';
import { createLatexVisualEditor, type LatexVisualAction } from '../index';

const editors: Array<ReturnType<typeof createLatexVisualEditor>> = [];

afterEach(() => {
  for (const editor of editors.splice(0)) editor.destroy();
  document.body.replaceChildren();
  vi.restoreAllMocks();
});

describe('R14.18 Visual editability and slash lifecycle', () => {
  it('turns native typed slash into the same suggestion mark used by the Learnitive left + path', () => {
    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
      callback(0);
      return 1;
    });
    const source = String.raw`\begin{document}
Editable paragraph
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
      getSlashOptionCount: () => 10,
    });
    editors.push(editor);

    const view = editor.view;
    const handled = view.someProp('handleTextInput', (handler) =>
      handler(
        view,
        view.state.selection.from,
        view.state.selection.to,
        '/',
        () => view.state.tr.insertText('/'),
      ),
    );
    expect(handled).toBe(true);

    let slashMarked = false;
    view.state.doc.descendants((node) => {
      if (node.isText && node.marks.some((mark) => mark.type.name === 'slash_command')) {
        slashMarked = true;
      }
      return true;
    });
    expect(slashMarked).toBe(true);
    expect(actions.some((action) => action.kind === 'slash-menu')).toBe(true);
  });

  it('keeps the ProseMirror surface explicitly editable', () => {
    const source = String.raw`\begin{document}
Editable paragraph
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const editor = createLatexVisualEditor({ host, blocks, onEdits() {} });
    editors.push(editor);
    expect(editor.view.editable).toBe(true);
    expect(editor.view.dom.contentEditable).toBe('true');
    expect(editor.view.dom.getAttribute('tabindex')).toBe('0');
  });

  it('opens the native slash engine inside formatted rich text, lists and simple table cells', () => {
    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
      callback(0);
      return 1;
    });
    const source = String.raw`\begin{document}
Before \textbf{formatted} after.
\begin{itemize}
\item List text
\end{itemize}
\begin{tabular}{cc}
Cell & Other \\
\end{tabular}
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
      getSlashOptionCount: () => 10,
    });
    editors.push(editor);

    const slashIn = (needle: string) => {
      let at: number | undefined;
      editor.view.state.doc.descendants((node, pos) => {
        if (at !== undefined) return false;
        if (node.type.name !== 'visual_source_text' || !node.textContent.includes(needle)) {
          return true;
        }
        at = pos + 1 + Math.min(1, node.content.size);
        return false;
      });
      if (at === undefined) throw new Error(`Missing editable inline ${needle}`);
      const position = at;
      editor.view.dispatch(
        editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, position)),
      );
      const handled = editor.view.someProp('handleTextInput', (handler) =>
        handler(editor.view, position, position, '/', () => editor.view.state.tr.insertText('/')),
      );
      expect(handled).toBe(true);
      expect(actions.at(-1)?.kind).toBe('slash-menu');
      editor.dismissSlashSuggestion();
    };

    slashIn('formatted');
    slashIn('List text');
    slashIn('Cell');
  });
});

describe('R14.22 source-map-safe Visual transactions', () => {
  function positionInside(
    editor: ReturnType<typeof createLatexVisualEditor>,
    nodeType: string,
    offset = 0,
  ): number {
    let position: number | undefined;
    editor.view.state.doc.descendants((node, pos) => {
      if (position !== undefined) return false;
      if (node.type.name !== nodeType) return true;
      position = pos + 1 + Math.min(offset, node.content.size);
      return false;
    });
    if (position === undefined) throw new Error(`Missing Visual node ${nodeType}`);
    return position;
  }

  it('accepts direct typing and deletion in a plain source-backed paragraph', () => {
    const source = String.raw`\begin{document}
Editable paragraph
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const changes: Array<readonly { from: number; to: number; text: string }[]> = [];
    const editor = createLatexVisualEditor({
      host,
      blocks,
      onEdits(edits) {
        changes.push(edits);
      },
    });
    editors.push(editor);

    const insertAt = positionInside(editor, 'visual_paragraph', 'Editable '.length);
    editor.view.dispatch(editor.view.state.tr.insertText('direct ', insertAt));
    expect(editor.view.state.doc.textContent).toContain('Editable direct paragraph');
    expect(changes.at(-1)).toEqual([
      {
        from: source.indexOf('Editable paragraph'),
        to: source.indexOf('Editable paragraph') + 'Editable paragraph'.length,
        text: 'Editable direct paragraph',
      },
    ]);

    editor.view.dispatch(editor.view.state.tr.delete(insertAt, insertAt + 'direct '.length));
    expect(editor.view.state.doc.textContent).toContain('Editable paragraph');
  });

  it('allows text edits in headings, list items, and table cells without changing mapped structure', () => {
    const source = String.raw`\begin{document}
\section{Heading}
\begin{itemize}
\item First item
\end{itemize}
\begin{table}
\begin{tabular}{cc}
A & B \\
\end{tabular}
\end{table}
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const editor = createLatexVisualEditor({ host, blocks, onEdits() {} });
    editors.push(editor);

    const headingAt = positionInside(editor, 'visual_heading', 'Head'.length);
    editor.view.dispatch(editor.view.state.tr.insertText('X', headingAt));
    expect(editor.view.state.doc.textContent).toContain('HeadXing');

    const listAt = positionInside(editor, 'visual_source_text', 'First'.length);
    editor.view.dispatch(editor.view.state.tr.insertText(' editable', listAt));
    expect(editor.view.state.doc.textContent).toContain('First editable item');

    let cellAt: number | undefined;
    editor.view.state.doc.descendants((node, pos) => {
      if (cellAt !== undefined) return false;
      if (node.type.name !== 'visual_table_cell') return true;
      const inline = node.child(0);
      cellAt = pos + 2 + Math.min(1, inline.content.size);
      return false;
    });
    if (cellAt === undefined) throw new Error('Editable table-cell text missing');
    editor.view.dispatch(editor.view.state.tr.insertText('lpha', cellAt));
    expect(editor.view.state.doc.textContent).toContain('Alpha');
  });

  it('keeps protected inline LaTeX immutable even when a transaction is dispatched programmatically', () => {
    const source = String.raw`\begin{document}
Before \cite{smith2026} after.
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const editor = createLatexVisualEditor({ host, blocks, onEdits() {} });
    editors.push(editor);

    let protectedPosition: number | undefined;
    editor.view.state.doc.descendants((node, pos) => {
      if (node.type.name === 'visual_source_text' && node.attrs.editable === false) {
        protectedPosition = pos + 1;
        return false;
      }
      return true;
    });
    if (protectedPosition === undefined) throw new Error('Protected citation node missing');
    const before = editor.view.state.doc;
    editor.view.dispatch(editor.view.state.tr.insertText('MUTATE', protectedPosition));
    expect(editor.view.state.doc.eq(before)).toBe(true);
  });
});

describe('R14.23.2 natural Visual structural keyboard semantics', () => {
  function positionInsideType(
    editor: ReturnType<typeof createLatexVisualEditor>,
    nodeType: string,
    offset = 0,
    occurrence = 0,
  ): number {
    let seen = 0;
    let position: number | undefined;
    editor.view.state.doc.descendants((node, pos) => {
      if (position !== undefined) return false;
      if (node.type.name !== nodeType) return true;
      if (seen++ !== occurrence) return true;
      position = pos + 1 + Math.min(offset, node.content.size);
      return false;
    });
    if (position === undefined) throw new Error(`Missing Visual node ${nodeType}`);
    return position;
  }

  function sendEnter(
    editor: ReturnType<typeof createLatexVisualEditor>,
    options: { shiftKey?: boolean } = {},
  ) {
    return editor.view.someProp('handleKeyDown', (handler) =>
      handler(
        editor.view,
        new KeyboardEvent('keydown', {
          key: 'Enter',
          bubbles: true,
          shiftKey: options.shiftKey ?? false,
        }),
      ),
    );
  }

  it('BF2 exits a section heading through a canonical heading-exit action', () => {
    const source = String.raw`\begin{document}
\section{Methods}
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);
    const at = positionInsideType(editor, 'visual_heading', 'Methods'.length);
    editor.view.dispatch(
      editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, at)),
    );
    expect(sendEnter(editor)).toBe(true);
    const action = actions.at(-1);
    expect(action?.kind).toBe('heading-exit');
    if (!action || action.kind !== 'heading-exit') throw new Error('heading exit action missing');
    expect(action.headingTo).toBe(source.indexOf('\\section{Methods}') + '\\section{Methods}'.length);
  });

  it('turns Enter in a paragraph into a canonical paragraph-break action', () => {
    const source = String.raw`\begin{document}
Alpha beta
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);
    const at = positionInsideType(editor, 'visual_paragraph', 'Alpha'.length);
    editor.view.dispatch(
      editor.view.state.tr.setSelection(
        TextSelection.create(editor.view.state.doc, at),
      ),
    );
    expect(sendEnter(editor)).toBe(true);
    const action = actions.at(-1);
    expect(action?.kind).toBe('paragraph-break');
    if (!action || action.kind !== 'paragraph-break') throw new Error('paragraph action missing');
    expect(action.sourceFrom).toBe(source.indexOf('Alpha') + 'Alpha'.length);
    expect(action.sourceTo).toBe(action.sourceFrom);
  });

  it('uses Enter for a new list item and Enter on an empty item to exit the list', () => {
    const source = String.raw`\begin{document}
\begin{itemize}
\item First
\item
\end{itemize}
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);

    const first = positionInsideType(editor, 'visual_source_text', 'First'.length, 0);
    editor.view.dispatch(
      editor.view.state.tr.setSelection(
        TextSelection.create(editor.view.state.doc, first),
      ),
    );
    expect(sendEnter(editor)).toBe(true);
    expect(actions.at(-1)?.kind).toBe('list-item-break');

    const empty = positionInsideType(editor, 'visual_list_item', 0, 1);
    editor.view.dispatch(
      editor.view.state.tr.setSelection(
        TextSelection.create(editor.view.state.doc, empty),
      ),
    );
    expect(sendEnter(editor)).toBe(true);
    expect(actions.at(-1)?.kind).toBe('list-exit');
  });

  it('focuses an empty list item through its editable inline child after canonical reparse', () => {
    const source = String.raw`\begin{document}
\begin{itemize}
\item One
\item 
\end{itemize}
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const list = blocks.find((block) => block.kind === 'list');
    if (!list || list.kind !== 'list') throw new Error('Expected list block');
    const emptyItem = list.items[1];
    if (!emptyItem) throw new Error('Expected empty second item');

    const host = document.createElement('div');
    document.body.appendChild(host);
    const editor = createLatexVisualEditor({ host, blocks, onEdits() {} });
    editors.push(editor);

    expect(editor.focusSourceOffset(emptyItem.bodyRange.from)).toBe(true);
    const $from = editor.view.state.selection.$from;
    const ancestors = Array.from({ length: $from.depth + 1 }, (_, depth) =>
      $from.node(depth).type.name,
    );
    expect(ancestors).toContain('visual_source_text');
  });

  it('turns Shift+Enter into a canonical hard-break action', () => {
    const source = String.raw`\begin{document}
Alpha beta
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);
    const at = positionInsideType(editor, 'visual_paragraph', 'Alpha'.length);
    editor.view.dispatch(
      editor.view.state.tr.setSelection(
        TextSelection.create(editor.view.state.doc, at),
      ),
    );
    expect(sendEnter(editor, { shiftKey: true })).toBe(true);
    expect(actions.at(-1)?.kind).toBe('hard-break');
  });

  it('treats rich paragraphs, formatted text, list items and table cells as hard-break hosts', () => {
    const source = String.raw`\begin{document}
Rich\newline{}tail and \textbf{bold text}.
\begin{itemize}
\item \emph{List text}
\end{itemize}
\begin{tabular}{p{2cm}c}
A\newline{}B & C \\
\end{tabular}
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);

    const positionInText = (needle: string, offset: number) => {
      let result: number | undefined;
      editor.view.state.doc.descendants((node, pos) => {
        if (result !== undefined) return false;
        if (node.type.name !== 'visual_source_text' || !node.textContent.includes(needle)) {
          return true;
        }
        result = pos + 1 + Math.min(offset, node.content.size);
        return false;
      });
      if (result === undefined) throw new Error(`Missing source text ${needle}`);
      return result;
    };

    for (const [needle, offset] of [
      ['tail', 2],
      ['bold text', 4],
      ['List text', 4],
      ['B', 1],
    ] as const) {
      const at = positionInText(needle, offset);
      editor.view.dispatch(
        editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, at)),
      );
      expect(sendEnter(editor, { shiftKey: true })).toBe(true);
      const action = actions.at(-1);
      expect(action?.kind).toBe('hard-break');
    }
  });

  it('intercepts Backspace/Delete next to a hard break without selecting adjacent text', () => {
    const source = String.raw`\begin{document}
Alpha\newline{}Beta
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);
    let beforeBreak: number | undefined;
    let afterBreak: number | undefined;
    editor.view.state.doc.descendants((node, pos) => {
      if (node.type.name !== 'visual_source_text') return true;
      if (node.textContent === 'Alpha') beforeBreak = pos + 1 + node.content.size;
      if (node.textContent === 'Beta') afterBreak = pos + 1;
      return true;
    });
    if (beforeBreak === undefined || afterBreak === undefined) {
      throw new Error('Hard-break neighboring text missing');
    }

    const sendDelete = (key: 'Backspace' | 'Delete', position: number) => {
      editor.view.dispatch(
        editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, position)),
      );
      return editor.view.someProp('handleKeyDown', (handler) =>
        handler(editor.view, new KeyboardEvent('keydown', { key, bubbles: true })),
      );
    };

    // These positions are inside the neighboring source-text wrappers rather
    // than directly between sibling PM nodes. The source-boundary fallback is
    // what prevents browser-default Backspace/Delete from consuming user text.
    expect(sendDelete('Backspace', afterBreak)).toBe(true);
    expect(actions.at(-1)?.kind).toBe('delete-hard-break');
    expect(sendDelete('Delete', beforeBreak)).toBe(true);
    expect(actions.at(-1)?.kind).toBe('delete-hard-break');
  });

  it('keeps an empty new paragraph editable until its first character materializes in source', () => {
    const source = String.raw`\begin{document}
Alpha
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);
    const anchor = source.indexOf('Alpha') + 'Alpha'.length + 2;
    editor.insertSyntheticParagraphAtSourceOffset(anchor);
    expect(host.querySelector('[data-visual-synthetic="true"]')).not.toBeNull();
    const handled = editor.view.someProp('handleTextInput', (handler) =>
      handler(
        editor.view,
        editor.view.state.selection.from,
        editor.view.state.selection.to,
        'N',
        () => editor.view.state.tr.insertText('N'),
      ),
    );
    expect(handled).toBe(true);
    const action = actions.at(-1);
    expect(action?.kind).toBe('materialize-text');
    if (!action || action.kind !== 'materialize-text') throw new Error('materialize action missing');
    expect(action.sourceAt).toBe(anchor);
    expect(action.text).toBe('N');
  });

  it('keeps repeated Shift+Enter and Enter on the source-backed terminal hard-break continuation', () => {
    const source = String.raw`\begin{document}
Alpha\newline{}
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);
    const anchor = source.indexOf('\\newline{}') + '\\newline{}'.length;

    expect(editor.focusSourceOffset(anchor)).toBe(true);
    expect(host.querySelector('[data-visual-synthetic-inline="true"]')).toBeNull();
    expect(sendEnter(editor, { shiftKey: true })).toBe(true);
    const hardBreak = actions.at(-1);
    expect(hardBreak?.kind).toBe('hard-break');
    if (!hardBreak || hardBreak.kind !== 'hard-break') throw new Error('hard break action missing');
    expect(hardBreak.sourceFrom).toBe(anchor);

    actions.length = 0;
    expect(sendEnter(editor)).toBe(true);
    const paragraphBreak = actions.at(-1);
    expect(paragraphBreak?.kind).toBe('paragraph-break');
    if (!paragraphBreak || paragraphBreak.kind !== 'paragraph-break') throw new Error('paragraph break action missing');
    expect(paragraphBreak.sourceFrom).toBe(anchor);
  });

  it('materializes Enter and repeated Shift+Enter from a synthetic continuation paragraph', () => {
    const source = String.raw`\begin{document}
Alpha
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);
    const anchor = source.indexOf('Alpha') + 'Alpha'.length;

    editor.insertSyntheticParagraphAtSourceOffset(anchor);
    expect(sendEnter(editor, { shiftKey: true })).toBe(true);
    const hardBreak = actions.at(-1);
    expect(hardBreak?.kind).toBe('hard-break');
    if (!hardBreak || hardBreak.kind !== 'hard-break') throw new Error('hard break action missing');
    expect(hardBreak.sourceFrom).toBe(anchor);
    expect(hardBreak.sourceTo).toBe(anchor);

    actions.length = 0;
    expect(sendEnter(editor)).toBe(true);
    const paragraphBreak = actions.at(-1);
    expect(paragraphBreak?.kind).toBe('paragraph-break');
    if (!paragraphBreak || paragraphBreak.kind !== 'paragraph-break') throw new Error('paragraph break action missing');
    expect(paragraphBreak.sourceFrom).toBe(anchor);
    expect(paragraphBreak.sourceTo).toBe(anchor);
  });
});


  it('BF3 atomically replaces a structural projection and installs an exact source selection', () => {
    const before = '\\begin{document}\nAlpha\n\\end{document}';
    const after = '\\begin{document}\nAlpha\n\n\\end{document}';
    const host = document.createElement('div');
    document.body.appendChild(host);
    const editor = createLatexVisualEditor({
      host,
      blocks: parseLatexFile(projectFilePath('main.tex'), before).visualBlocks,
      onEdits() {},
    });
    editors.push(editor);
    const anchor = after.indexOf('\\end{document}');
    const mapped = editor.replaceProjection(
      parseLatexFile(projectFilePath('main.tex'), after).visualBlocks,
      new Map(),
      { anchorSource: anchor, headSource: anchor, focus: true },
    );
    expect(mapped).toBe(true);
    expect(host.querySelector('[data-visual-synthetic="true"]')).toBeNull();
    expect(host.querySelector('[data-visual-synthetic-inline="true"]')).toBeNull();
  });

describe('Phase 1 stable Visual editor session', () => {
  function textPosition(
    editor: ReturnType<typeof createLatexVisualEditor>,
    nodeType: string,
    offset: number,
  ): number {
    let position: number | undefined;
    editor.view.state.doc.descendants((node, pos) => {
      if (position !== undefined) return false;
      if (node.type.name !== nodeType) return true;
      position = pos + 1 + Math.min(offset, node.content.size);
      return false;
    });
    if (position === undefined) throw new Error(`Missing Visual node ${nodeType}`);
    return position;
  }

  it('swaps a re-parsed projection onto the same EditorView and preserves the caret', () => {
    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
      callback(0);
      return 1;
    });
    const sourceA = String.raw`\begin{document}
Editable paragraph.
\section{Tail}
Tail body.
\end{document}`;
    const sourceB = String.raw`\begin{document}
Editable paragraph.
\section{Tail updated}
Tail body.
\end{document}`;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const editor = createLatexVisualEditor({
      host,
      blocks: parseLatexFile(projectFilePath('main.tex'), sourceA).visualBlocks,
      onEdits() {},
    });
    editors.push(editor);

    const sameView = editor.view;
    const sameDom = editor.view.dom;
    const caret = textPosition(editor, 'visual_paragraph', 'Editable'.length);
    editor.view.dispatch(
      editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, caret)),
    );

    editor.replaceProjection(
      parseLatexFile(projectFilePath('main.tex'), sourceB).visualBlocks,
    );

    expect(editor.view).toBe(sameView);
    expect(editor.view.dom).toBe(sameDom);
    expect(editor.view.dom.contentEditable).toBe('true');
    expect(editor.view.state.doc.textContent).toContain('Tail updated');
    expect(editor.view.state.selection.$from.parent.textContent).toBe('Editable paragraph.');
    expect(editor.view.state.selection.$from.parentOffset).toBe('Editable'.length);
  });

  it('publishes a source-mapped caret for bidirectional Source/Visual mode sync', () => {
    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
      callback(0);
      return 1;
    });
    const source = String.raw`\begin{document}
Editable paragraph.
\end{document}`;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const cursors: Array<{ offset: number; id: string }> = [];
    const editor = createLatexVisualEditor({
      host,
      blocks: parseLatexFile(projectFilePath('main.tex'), source).visualBlocks,
      onEdits() {},
      onCursorChange(cursor) {
        if (cursor) cursors.push({ offset: cursor.offset, id: cursor.id });
      },
    });
    editors.push(editor);

    const relative = 'Editable'.length;
    const caret = textPosition(editor, 'visual_paragraph', relative);
    editor.view.dispatch(
      editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, caret)),
    );

    expect(cursors.at(-1)?.offset).toBe(source.indexOf('Editable') + relative);
    expect(cursors.at(-1)?.id).toBeTruthy();
  });

  it('updates figure asset URLs without losing in-progress Visual text edits', () => {
    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
      callback(0);
      return 1;
    });
    const source = String.raw`\begin{document}
\begin{figure}
\includegraphics{figure.png}
\end{figure}
Editable paragraph.
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const figure = blocks.find((block) => block.kind === 'figure');
    if (!figure) throw new Error('Figure block missing');
    const host = document.createElement('div');
    document.body.appendChild(host);
    const edits: Array<readonly { from: number; to: number; text: string }[]> = [];
    const editor = createLatexVisualEditor({
      host,
      blocks,
      onEdits(next) {
        edits.push(next);
      },
    });
    editors.push(editor);

    const sameDom = editor.view.dom;
    const insertAt = textPosition(editor, 'visual_paragraph', 'Editable'.length);
    editor.view.dispatch(editor.view.state.tr.insertText(' live', insertAt));
    expect(editor.view.state.doc.textContent).toContain('Editable live paragraph.');
    const editsBeforeAssetRefresh = edits.length;

    editor.updateAssetUrls(new Map([[figure.id, 'blob:interactive-phase1-preview']]));

    expect(edits).toHaveLength(editsBeforeAssetRefresh);
    expect(editor.view.dom).toBe(sameDom);
    expect(editor.view.state.doc.textContent).toContain('Editable live paragraph.');
    expect(editor.view.dom.querySelector('img')?.getAttribute('src')).toBe(
      'blob:interactive-phase1-preview',
    );

    const nextAt = textPosition(editor, 'visual_paragraph', 'Editable live'.length);
    editor.view.dispatch(editor.view.state.tr.insertText(' stable', nextAt));
    expect(editor.view.state.doc.textContent).toContain('Editable live stable paragraph.');
    expect(edits.length).toBeGreaterThanOrEqual(2);
  });
});

describe('R2 Visual table keyboard navigation', () => {
  it('moves Tab/Shift+Tab between cells and requests a new row from the final cell', () => {
    const source = String.raw`\begin{document}
\begin{tabular}{cc}
A & B \\
\end{tabular}
\end{document}`;
    const blocks = parseLatexFile(projectFilePath('main.tex'), source).visualBlocks;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);

    const cellPositions: number[] = [];
    editor.view.state.doc.descendants((node, pos) => {
      if (node.type.name === 'visual_table_cell') cellPositions.push(pos + 1);
      return true;
    });
    expect(cellPositions).toHaveLength(2);
    editor.view.dispatch(
      editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, cellPositions[0]!)),
    );

    const pressTab = (shiftKey = false) => editor.view.someProp('handleKeyDown', (handler) =>
      handler(editor.view, new KeyboardEvent('keydown', { key: 'Tab', shiftKey })),
    );

    expect(pressTab()).toBe(true);
    expect(editor.view.state.selection.from).toBe(cellPositions[1]);
    expect(pressTab(true)).toBe(true);
    expect(editor.view.state.selection.from).toBe(cellPositions[0]);

    editor.view.dispatch(
      editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, cellPositions[1]!)),
    );
    expect(pressTab()).toBe(true);
    expect(actions.at(-1)).toMatchObject({
      kind: 'table-structure',
      operation: 'add-row-below',
      row: 0,
      column: 0,
    });
  });
});

describe('R31 BF3 Phase 4 structural list actions', () => {
  const positionInNode = (
    editor: ReturnType<typeof createLatexVisualEditor>,
    nodeType: string,
    occurrence = 0,
    atEnd = false,
  ): number => {
    let seen = 0;
    let result: number | undefined;
    editor.view.state.doc.descendants((node, pos) => {
      if (result !== undefined) return false;
      if (node.type.name !== nodeType) return true;
      if (seen++ !== occurrence) return true;
      result = pos + 1 + (atEnd ? node.content.size : 0);
      return false;
    });
    if (result === undefined) throw new Error(`Missing ${nodeType}`);
    return result;
  };

  it('exposes canonical Delete item/Delete list actions from the list menu', () => {
    const source = String.raw`\begin{document}
\begin{itemize}
\item Alpha
\item Beta
\end{itemize}
\end{document}`;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks: parseLatexFile(projectFilePath('main.tex'), source).visualBlocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);

    editor.view.dispatch(editor.view.state.tr.setSelection(
      TextSelection.create(editor.view.state.doc, positionInNode(editor, 'visual_source_text', 0)),
    ));
    host.querySelector<HTMLElement>('[data-visual-list-command="delete-item"]')?.click();
    expect(actions.at(-1)).toMatchObject({ kind: 'list-structure', operation: 'delete-item' });

    host.querySelector<HTMLElement>('[data-visual-list-command="delete-list"]')?.click();
    expect(actions.at(-1)).toMatchObject({ kind: 'list-structure', operation: 'delete-list' });
  });

  it('Delete across all list text requests canonical whole-list deletion', () => {
    const source = String.raw`\begin{document}
\begin{itemize}
\item Alpha
\item Beta
\end{itemize}
\end{document}`;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks: parseLatexFile(projectFilePath('main.tex'), source).visualBlocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);
    const from = positionInNode(editor, 'visual_source_text', 0);
    const to = positionInNode(editor, 'visual_source_text', 1, true);
    editor.view.dispatch(editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, from, to)));
    const handled = editor.view.someProp('handleKeyDown', (handler) =>
      handler(editor.view, new KeyboardEvent('keydown', { key: 'Delete' })),
    );
    expect(handled).toBe(true);
    expect(actions.at(-1)).toMatchObject({ kind: 'list-structure', operation: 'delete-list' });
  });

  it('Backspace on an empty sole item requests canonical whole-list deletion', () => {
    const source = String.raw`\begin{document}
\begin{enumerate}
\item 
\end{enumerate}
\end{document}`;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks: parseLatexFile(projectFilePath('main.tex'), source).visualBlocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);
    const at = positionInNode(editor, 'visual_list_item');
    editor.view.dispatch(editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, at)));
    const handled = editor.view.someProp('handleKeyDown', (handler) =>
      handler(editor.view, new KeyboardEvent('keydown', { key: 'Backspace' })),
    );
    expect(handled).toBe(true);
    expect(actions.at(-1)).toMatchObject({ kind: 'list-structure', operation: 'delete-list' });
  });
});

describe('R31 BF3 Phase 4 table boundary keyboard policy', () => {
  const tableCells = (editor: ReturnType<typeof createLatexVisualEditor>) => {
    const cells: Array<{ start: number; end: number; row: number; column: number }> = [];
    editor.view.state.doc.descendants((node, pos) => {
      if (node.type.name === 'visual_table_cell') {
        cells.push({
          start: pos + 1,
          end: pos + 1 + node.content.size,
          row: Number(node.attrs.row),
          column: Number(node.attrs.column),
        });
      }
      return true;
    });
    return cells;
  };

  const press = (editor: ReturnType<typeof createLatexVisualEditor>, key: string, shiftKey = false) =>
    editor.view.someProp('handleKeyDown', (handler) =>
      handler(editor.view, new KeyboardEvent('keydown', { key, shiftKey })),
    );

  it('uses Enter as navigation in simple cells and leaves the table from the final cell', () => {
    const source = String.raw`\begin{document}
\begin{tabular}{cc}
A & B \\
\end{tabular}
\end{document}`;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks: parseLatexFile(projectFilePath('main.tex'), source).visualBlocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);
    const cells = tableCells(editor);
    expect(cells).toHaveLength(2);

    editor.view.dispatch(editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, cells[0]!.start)));
    expect(press(editor, 'Enter')).toBe(true);
    expect(editor.view.state.selection.from).toBe(cells[1]!.start);

    editor.view.dispatch(editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, cells[0]!.start)));
    expect(press(editor, 'Enter', true)).toBe(true);
    expect(editor.view.state.selection.from).toBe(cells[1]!.start);

    editor.view.dispatch(editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, cells[1]!.end)));
    expect(press(editor, 'Enter')).toBe(true);
    expect(actions.at(-1)).toMatchObject({ kind: 'table-boundary', direction: 'after' });
  });

  it('uses Shift+Enter as a hard break only in paragraph-capable p/m/b columns', () => {
    const source = String.raw`\begin{document}
\begin{tabular}{p{0.4\linewidth}c}
A & B \\
\end{tabular}
\end{document}`;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks: parseLatexFile(projectFilePath('main.tex'), source).visualBlocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);
    const cells = tableCells(editor);
    editor.view.dispatch(editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, cells[0]!.end)));
    expect(press(editor, 'Enter', true)).toBe(true);
    expect(actions.at(-1)?.kind).toBe('hard-break');
  });

  it('ArrowDown/ArrowUp leave only at the final/first table row boundary', () => {
    const source = String.raw`\begin{document}
\begin{tabular}{cc}
A & B \\
C & D \\
\end{tabular}
\end{document}`;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks: parseLatexFile(projectFilePath('main.tex'), source).visualBlocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);
    const cells = tableCells(editor);

    editor.view.dispatch(editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, cells[3]!.end)));
    expect(press(editor, 'ArrowDown')).toBe(true);
    expect(actions.at(-1)).toMatchObject({ kind: 'table-boundary', direction: 'after' });

    editor.view.dispatch(editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, cells[0]!.start)));
    expect(press(editor, 'ArrowUp')).toBe(true);
    expect(actions.at(-1)).toMatchObject({ kind: 'table-boundary', direction: 'before' });
  });
});

describe('R31 BF3 Phase 4 protected block boundary navigation', () => {
  it('ArrowDown from top-level display math requests a source-backed paragraph after it', () => {
    const source = String.raw`\begin{document}
\[
a^2+b^2=c^2
\]
\end{document}`;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const actions: LatexVisualAction[] = [];
    const editor = createLatexVisualEditor({
      host,
      blocks: parseLatexFile(projectFilePath('main.tex'), source).visualBlocks,
      onEdits() {},
      onAction(action) { actions.push(action); },
    });
    editors.push(editor);
    const math = host.querySelector<HTMLElement>('.interactive-visual-math-display');
    expect(math).not.toBeNull();
    math!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
    expect(actions.at(-1)).toMatchObject({ kind: 'block-boundary', direction: 'after' });
  });
});
