import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { RICH_EDITOR_CHANGE_DEBOUNCE_MS, useEditorTabSwap } from './useEditorTabSwap'
import { cacheParsedNoteBlocks, clearParsedNoteBlockCache } from './editorParsedBlockCache'

const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }]

function makeTextParagraphBlock(text: string) {
  return { type: 'paragraph', content: [{ type: 'text', text, styles: {} }], children: [] }
}

function makeTab(path: string, title: string) {
  return {
    entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
    content: `---\ntitle: ${title}\n---\n\n# ${title}\n\nBody of ${title}.`,
  }
}

function makeUntitledTab(path: string, title = 'Untitled Note 1', remainder = '') {
  return {
    entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
    content: `---\ntype: Note\nstatus: Active\n---\n\n# \n\n${remainder}`,
  }
}

function makeBlankBodyTab(path: string, title = 'Untitled Note 1') {
  return {
    entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
    content: '---\ntype: Note\nstatus: Active\n---\n',
  }
}

function makeMockEditor(docRef: { current: unknown[] }) {
  const editor = {
    document: docRef.current,
    get prosemirrorView() { return {} },
    onMount: (cb: () => void) => { cb(); return () => {} },
    replaceBlocks: vi.fn((_old, newBlocks) => { docRef.current = newBlocks }),
    insertBlocks: vi.fn(),
    blocksToMarkdownLossy: vi.fn(() => ''),
    blocksToHTMLLossy: vi.fn(() => ''),
    tryParseMarkdownToBlocks: vi.fn(() => blocksA),
    _tiptapEditor: {
      state: { doc: { content: { size: 8 } } },
      commands: {
        setContent: vi.fn(),
        setTextSelection: vi.fn(),
      },
    },
    _docRef: docRef,
  }
  Object.defineProperty(editor, 'document', { get: () => docRef.current })
  return editor
}

function makeLongNoteBlocks(wordCount: number) {
  const words = Array.from({ length: wordCount }, (_, index) => `word${index}`)
  const paragraphs: unknown[] = []
  for (let index = 0; index < words.length; index += 20) {
    paragraphs.push({
      type: 'paragraph',
      content: [{ type: 'text', text: words.slice(index, index + 20).join(' '), styles: {} }],
      children: [],
    })
  }
  return paragraphs
}

async function flushEditorTick() {
  await act(() => new Promise<void>((resolve) => setTimeout(resolve, 0)))
}

function createDeferred<T>() {
  let resolve!: (value: T) => void
  const promise = new Promise<T>((res) => {
    resolve = res
  })
  return { promise, resolve }
}

function installEditorDomSpies(scrollTop = 0) {
  const scrollEl = { scrollTop }
  const frameSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
    cb(0)
    return 0
  })
  vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element)
  return { scrollEl, frameSpy }
}

function flushQueuedFrames(frameCallbacks: FrameRequestCallback[]) {
  act(() => {
    for (const callback of frameCallbacks.splice(0)) {
      callback(0)
    }
  })
}

type SwapHarnessProps = {
  tabs: ReturnType<typeof makeTab>[]
  activeTabPath: string | null
  rawMode?: boolean
  vaultPath?: string
}

async function createSwapHarness(options: {
  initialProps: SwapHarnessProps
  onContentChange?: (path: string, content: string) => void
  setupEditor?: (editor: ReturnType<typeof makeMockEditor>) => void
}) {
  installEditorDomSpies()

  const docRef = { current: blocksA as unknown[] }
  const mockEditor = makeMockEditor(docRef)
  options.setupEditor?.(mockEditor)

  let currentProps = options.initialProps
  const rendered = renderHook(
    (props: SwapHarnessProps) => useEditorTabSwap({
      ...props,
      editor: mockEditor as never,
      onContentChange: options.onContentChange,
    }),
    { initialProps: currentProps },
  )

  await flushEditorTick()

  return {
    ...rendered,
    docRef,
    mockEditor,
    async rerenderWith(nextProps: Partial<SwapHarnessProps>) {
      currentProps = { ...currentProps, ...nextProps }
      rendered.rerender(currentProps)
      await flushEditorTick()
    },
  }
}

describe('useEditorTabSwap raw mode sync', () => {
  afterEach(() => {
    clearParsedNoteBlockCache()
    vi.restoreAllMocks()
  })

  it('swaps in the new note when the path updates before tabs catch up', async () => {
    const tabA = makeTab('a.md', 'Note A')
    const tabB = makeTab('b.md', 'March 2024')

    const { mockEditor, rerenderWith } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
    })
    mockEditor.tryParseMarkdownToBlocks.mockClear()
    mockEditor.replaceBlocks.mockClear()

    await rerenderWith({ tabs: [tabA], activeTabPath: 'b.md' })
    expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()

    await rerenderWith({ tabs: [tabB] })

    expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
      expect.stringContaining('March 2024'),
    )
    expect(mockEditor.replaceBlocks).toHaveBeenCalled()
  })

  it('signals when the target tab content has been applied', async () => {
    const swapListener = vi.fn()
    window.addEventListener('laputa:editor-tab-swapped', swapListener)

    const tabA = makeTab('a.md', 'Note A')
    const tabB = makeTab('b.md', 'March 2024')

    const { rerenderWith } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
    })
    swapListener.mockClear()

    await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })

    expect(swapListener).toHaveBeenCalledTimes(1)
    const event = swapListener.mock.calls[0][0] as CustomEvent
    expect(event.detail.path).toBe('b.md')

    window.removeEventListener('laputa:editor-tab-swapped', swapListener)
  })

  it('hard-resets the editor when the target note body is blank', async () => {
    const populatedTab = makeTab('a.md', 'Note A')
    const untitledTab = makeBlankBodyTab('untitled.md')

    const { mockEditor, rerenderWith } = await createSwapHarness({
      initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
    })
    mockEditor._tiptapEditor.commands.setContent.mockClear()
    mockEditor.replaceBlocks.mockClear()

    await rerenderWith({ tabs: [untitledTab], activeTabPath: 'untitled.md' })

    expect(mockEditor._tiptapEditor.commands.setContent).not.toHaveBeenCalled()
    expect(mockEditor.replaceBlocks).toHaveBeenCalledWith(
      expect.any(Array),
      [expect.objectContaining({ id: expect.any(String), type: 'paragraph' })],
    )
  })

  it('renders empty H1 untitled notes via repaired BlockNote blocks', async () => {
    const populatedTab = makeTab('a.md', 'Note A')
    const untitledTab = makeUntitledTab('untitled.md')

    const { mockEditor, rerenderWith } = await createSwapHarness({
      initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
    })
    mockEditor.tryParseMarkdownToBlocks.mockClear()
    mockEditor.replaceBlocks.mockClear()
    mockEditor._tiptapEditor.commands.setContent.mockClear()

    await rerenderWith({ tabs: [untitledTab], activeTabPath: 'untitled.md' })

    expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
    expect(mockEditor._tiptapEditor.commands.setContent).not.toHaveBeenCalled()
    expect(mockEditor.replaceBlocks).toHaveBeenCalledWith(
      expect.any(Array),
      [
        expect.objectContaining({ id: expect.any(String), type: 'heading' }),
        expect.objectContaining({ id: expect.any(String), type: 'paragraph' }),
      ],
    )
  })

  it('renders empty H1 typed notes with template content under the title as repaired blocks', async () => {
    const populatedTab = makeTab('a.md', 'Note A')
    const typedUntitledTab = makeUntitledTab('untitled-note-123.md', 'Untitled Project 1', '## Objective\n\n')

    const { mockEditor, rerenderWith } = await createSwapHarness({
      initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
      setupEditor: (editor) => {
        editor.tryParseMarkdownToBlocks.mockReturnValue([
          {
            type: 'heading',
            props: { level: 2 },
            content: [{ type: 'text', text: 'Objective', styles: {} }],
            children: [],
          },
        ])
      },
    })
    mockEditor.tryParseMarkdownToBlocks.mockClear()
    mockEditor._tiptapEditor.commands.setContent.mockClear()

    await rerenderWith({ tabs: [typedUntitledTab], activeTabPath: typedUntitledTab.entry.path })

    expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith('## Objective\n\n')
    expect(mockEditor._tiptapEditor.commands.setContent).not.toHaveBeenCalled()
    expect(mockEditor.replaceBlocks).toHaveBeenCalledWith(
      expect.any(Array),
      [
        expect.objectContaining({ id: expect.any(String), type: 'heading' }),
        expect.objectContaining({ id: expect.any(String), type: 'heading' }),
      ],
    )
  })

  it('keeps immediate typing when an untitled note template parse resolves late', async () => {
    const templateParse = createDeferred<unknown[]>()
    const populatedTab = makeTab('a.md', 'Note A')
    const typedUntitledTab = makeUntitledTab('untitled-note-123.md', 'Untitled Project 1', '## Objective\n\n')
    const editorContainer = document.createElement('div')
    const editable = document.createElement('div')
    const userTypedBlocks = [
      {
        type: 'heading',
        props: { level: 1 },
        content: [{ type: 'text', text: 'Fast Title', styles: {} }],
        children: [],
      },
      makeTextParagraphBlock('Typed before the template finished rendering'),
    ]
    editorContainer.className = 'editor__blocknote-container'
    editable.contentEditable = 'true'
    editorContainer.appendChild(editable)
    document.body.appendChild(editorContainer)

    try {
      const { docRef, mockEditor, result, rerenderWith } = await createSwapHarness({
        initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
        setupEditor: (editor) => {
          editor.tryParseMarkdownToBlocks.mockReturnValue(templateParse.promise)
        },
      })
      mockEditor.replaceBlocks.mockClear()
      mockEditor.tryParseMarkdownToBlocks.mockClear()

      await rerenderWith({ tabs: [typedUntitledTab], activeTabPath: typedUntitledTab.entry.path })
      await flushEditorTick()
      expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith('## Objective\n\n')
      docRef.current = userTypedBlocks
      act(() => {
        editable.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }))
        result.current.handleEditorChange()
      })

      await act(async () => {
        templateParse.resolve([makeTextParagraphBlock('Late template')])
        await Promise.resolve()
      })

      expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
      expect(docRef.current).toBe(userTypedBlocks)
    } finally {
      editorContainer.remove()
    }
  })

  it('reuses cached editor blocks when reopening a recently visited note', async () => {
    const tabA = makeTab('a.md', 'Note A')
    const tabB = makeTab('b.md', 'Note B')

    const { mockEditor, rerenderWith } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
    })

    await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })

    mockEditor.tryParseMarkdownToBlocks.mockClear()
    mockEditor.replaceBlocks.mockClear()

    await rerenderWith({ tabs: [tabA], activeTabPath: 'a.md' })

    expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
    expect(mockEditor.replaceBlocks).toHaveBeenCalled()
  })

  it('uses parsed block cache for a note that was warmed before opening', async () => {
    const tabA = makeTab('a.md', 'Note A')
    const tabB = makeTab('b.md', 'Note B')
    const warmedBlocks = [makeTextParagraphBlock('Warmed body')]
    cacheParsedNoteBlocks({
      path: tabB.entry.path,
      sourceContent: tabB.content,
      blocks: warmedBlocks,
      scrollTop: 0,
    })

    const { mockEditor, rerenderWith } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
    })
    mockEditor.tryParseMarkdownToBlocks.mockClear()
    mockEditor.replaceBlocks.mockClear()

    await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })

    expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
    expect(mockEditor.replaceBlocks.mock.calls[0][1]).toEqual([
      expect.objectContaining({
        content: [{ type: 'text', text: 'Warmed body', styles: {} }],
      }),
    ])
  })

  it('clears stale editor DOM selection before switching notes', async () => {
    const tabA = makeTab('a.md', 'Note A')
    const tabB = makeTab('b.md', 'Note B')

    const { rerenderWith } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
    })

    const container = document.createElement('div')
    container.className = 'editor__blocknote-container'
    const editable = document.createElement('div')
    editable.contentEditable = 'true'
    editable.textContent = 'Old note caret'
    container.appendChild(editable)
    document.body.appendChild(container)

    try {
      const range = document.createRange()
      range.setStart(editable.firstChild!, 4)
      range.collapse(true)
      const selection = window.getSelection()!
      selection.removeAllRanges()
      selection.addRange(range)
      editable.focus()

      expect(selection.rangeCount).toBe(1)

      await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })

      expect(window.getSelection()?.rangeCount).toBe(0)
      expect(document.activeElement).not.toBe(editable)
    } finally {
      container.remove()
    }
  })

  it('resets stale TipTap selection before applying a switched note', async () => {
    const tabA = makeTab('a.md', 'Note A')
    const tabB = makeTab('b.md', 'Note B')

    const { mockEditor, rerenderWith } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
      setupEditor: (editor) => {
        editor._tiptapEditor.state.doc.content.size = 3
      },
    })
    mockEditor._tiptapEditor.commands.setTextSelection.mockClear()

    await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })

    expect(mockEditor._tiptapEditor.commands.setTextSelection).toHaveBeenCalledWith(1)
    expect(mockEditor.replaceBlocks).toHaveBeenCalled()
  })

  it('ignores stale same-path parse results when tab content refreshes', async () => {
    const staleParse = createDeferred<unknown[]>()
    const freshParse = createDeferred<unknown[]>()
    const tabA = makeTab('a.md', 'Note A')
    const refreshedTabA = {
      ...tabA,
      content: '---\ntitle: Note A\n---\n\n# Note A\n\nFresh filesystem content.',
    }
    const staleBlocks = [makeTextParagraphBlock('Stale content')]
    const freshBlocks = [makeTextParagraphBlock('Fresh filesystem content')]

    const { mockEditor, rerenderWith } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
      setupEditor: (editor) => {
        editor.tryParseMarkdownToBlocks
          .mockReturnValueOnce(staleParse.promise)
          .mockReturnValueOnce(freshParse.promise)
      },
    })

    await rerenderWith({ tabs: [refreshedTabA], activeTabPath: 'a.md' })
    mockEditor.replaceBlocks.mockClear()

    await act(async () => {
      staleParse.resolve(staleBlocks)
      await Promise.resolve()
    })

    expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()

    await act(async () => {
      freshParse.resolve(freshBlocks)
      await Promise.resolve()
    })

    expect(mockEditor.replaceBlocks).toHaveBeenCalledTimes(1)
    expect(mockEditor.replaceBlocks.mock.calls[0][1]).toEqual([
      expect.objectContaining({
        content: [{ type: 'text', text: 'Fresh filesystem content', styles: {} }],
      }),
    ])
  })

  it('re-parses when the active tab content changes without a path change', async () => {
    const tabA = makeTab('a.md', 'Note A')
    const refreshedTabA = {
      ...tabA,
      content: '---\ntitle: Note A\n---\n\n# Note A\n\nFresh after pull.',
    }

    const { mockEditor, rerenderWith } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
    })

    mockEditor.tryParseMarkdownToBlocks.mockClear()
    mockEditor.replaceBlocks.mockClear()

    await rerenderWith({ tabs: [refreshedTabA], activeTabPath: 'a.md' })

    expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
      expect.stringContaining('Fresh after pull.'),
    )
    expect(mockEditor.replaceBlocks).toHaveBeenCalled()
  })

  it('repairs parsed blocks with missing ids before applying them to the editor', async () => {
    const tabA = makeTab('a.md', 'Note A')
    const malformedTab = {
      ...makeTab('malformed.md', 'Malformed'),
      content: '---\ntitle: Malformed\n---\n\n# Malformed\n\n- Parent\n  - Child',
    }

    const { mockEditor, rerenderWith } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
      setupEditor: (editor) => {
        editor.tryParseMarkdownToBlocks.mockReturnValue([
          {
            type: 'bulletListItem',
            content: [{ type: 'text', text: 'Parent', styles: {} }],
            children: [
              {
                type: 'bulletListItem',
                content: [{ type: 'text', text: 'Child', styles: {} }],
                children: [],
              },
            ],
          },
        ])
      },
    })
    mockEditor.replaceBlocks.mockClear()

    await rerenderWith({ tabs: [malformedTab], activeTabPath: 'malformed.md' })

    const appliedBlocks = mockEditor.replaceBlocks.mock.calls[0][1]
    expect(appliedBlocks).toEqual([
      expect.objectContaining({
        id: expect.any(String),
        children: [
          expect.objectContaining({
            id: expect.any(String),
          }),
        ],
      }),
    ])
  })

  it('replaces non-object malformed parsed blocks before applying them to the editor', async () => {
    const tabA = makeTab('a.md', 'Note A')
    const malformedTab = {
      ...makeTab('malformed.md', 'Malformed'),
      content: '---\ntitle: Malformed\n---\n\n# Malformed\n\nRecovered body.',
    }

    const { mockEditor, rerenderWith } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
      setupEditor: (editor) => {
        editor.tryParseMarkdownToBlocks.mockReturnValue([
          null,
          'dangling content',
          {
            type: 'paragraph',
            content: [{ type: 'text', text: 'Parent', styles: {} }],
            children: [
              undefined,
              {
                type: 'paragraph',
                content: [{ type: 'text', text: 'Child', styles: {} }],
                children: [],
              },
            ],
          },
        ])
      },
    })
    mockEditor.replaceBlocks.mockClear()

    await rerenderWith({ tabs: [malformedTab], activeTabPath: 'malformed.md' })

    const appliedBlocks = mockEditor.replaceBlocks.mock.calls[0][1]
    expect(appliedBlocks).toEqual([
      expect.objectContaining({ id: expect.any(String), type: 'paragraph', content: [], children: [] }),
      expect.objectContaining({ id: expect.any(String), type: 'paragraph', content: [], children: [] }),
      expect.objectContaining({
        id: expect.any(String),
        content: [{ type: 'text', text: 'Parent', styles: {} }],
        children: [],
      }),
      expect.objectContaining({ id: expect.any(String), type: 'paragraph', content: [], children: [] }),
      expect.objectContaining({
        id: expect.any(String),
        content: [{ type: 'text', text: 'Child', styles: {} }],
        children: [],
      }),
    ])
  })

  it('ignores editor change events before the pending tab swap applies a new untitled note', async () => {
    vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

    const onContentChange = vi.fn()
    const docRef = { current: blocksA as unknown[] }
    const mockEditor = makeMockEditor(docRef)
    Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })

    const populatedTab = makeTab('a.md', 'Note A')
    const untitledTab = makeUntitledTab('untitled.md')

    const { result, rerender } = renderHook(
      ({ tabs, activeTabPath }) => useEditorTabSwap({
        tabs, activeTabPath, editor: mockEditor as never, onContentChange,
      }),
      { initialProps: { tabs: [populatedTab], activeTabPath: 'a.md' } },
    )

    await act(() => new Promise(r => setTimeout(r, 0)))

    rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md' })

    act(() => {
      result.current.handleEditorChange()
    })

    expect(onContentChange).not.toHaveBeenCalled()

    await flushEditorTick()
  })

  it('ignores delayed programmatic change events until a swapped note frame commits', async () => {
    vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
    const frameCallbacks: FrameRequestCallback[] = []
    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
      frameCallbacks.push(cb)
      return frameCallbacks.length
    })

    const onContentChange = vi.fn()
    const staleAlphaBlocks = [{
      type: 'paragraph',
      content: [{ type: 'text', text: 'Stale Alpha body', styles: {} }],
      children: [],
    }]
    const docRef = { current: staleAlphaBlocks as unknown[] }
    const mockEditor = makeMockEditor(docRef)
    Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
    mockEditor.blocksToMarkdownLossy.mockReturnValue('Stale Alpha body')

    const tabA = makeTab('a.md', 'Note A')
    const tabB = makeTab('b.md', 'Note B')

    const { result, rerender } = renderHook(
      ({ tabs, activeTabPath }) => useEditorTabSwap({
        tabs,
        activeTabPath,
        editor: mockEditor as never,
        onContentChange,
      }),
      { initialProps: { tabs: [tabA], activeTabPath: 'a.md' } },
    )

    await act(() => new Promise(r => setTimeout(r, 0)))
    flushQueuedFrames(frameCallbacks)
    onContentChange.mockClear()

    mockEditor.replaceBlocks.mockImplementation(() => {
      // Simulate BlockNote reporting a programmatic change before its document
      // getter reflects the new note content.
    })
    docRef.current = staleAlphaBlocks

    rerender({ tabs: [tabB], activeTabPath: 'b.md' })
    await flushEditorTick()

    act(() => {
      result.current.handleEditorChange()
      result.current.flushPendingEditorChange()
    })

    expect(onContentChange).not.toHaveBeenCalled()

    flushQueuedFrames(frameCallbacks)
  })

  it('saves BlockNote image asset URLs as vault-relative attachment links', async () => {
    const tabA = makeTab('a.md', 'Note A')
    const onContentChange = vi.fn()
    const { docRef, mockEditor, result } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false, vaultPath: '/vault' },
      onContentChange,
    })

    docRef.current = [{
      type: 'image',
      props: { url: 'asset://localhost/%2Fvault%2Fattachments%2Fshot.png' },
      children: [],
    }]
    mockEditor.blocksToMarkdownLossy.mockReturnValue(
      '![shot](asset://localhost/%2Fvault%2Fattachments%2Fshot.png)\n',
    )

    act(() => {
      result.current.handleEditorChange()
      result.current.flushPendingEditorChange()
    })

    expect(onContentChange).toHaveBeenCalledWith(
      'a.md',
      '---\ntitle: Note A\n---\n![shot](attachments/shot.png)\n',
    )
  })

  it('flushes unsaved file attachment blocks as portable links before switching notes', async () => {
    const tabA = makeTab('a.md', 'Note A')
    const tabB = makeTab('b.md', 'Note B')
    const onContentChange = vi.fn()
    const { docRef, mockEditor, rerender, result } = await createSwapHarness({
      initialProps: { tabs: [tabA, tabB], activeTabPath: 'a.md', rawMode: false, vaultPath: '/vault' },
      onContentChange,
    })

    docRef.current = [{
      type: 'file',
      props: {
        name: 'project brief.pdf',
        url: '/vault/attachments/project brief.pdf',
      },
      children: [],
    }]
    mockEditor.blocksToMarkdownLossy.mockClear()
    mockEditor.blocksToMarkdownLossy.mockReturnValue('unreachable lossy markdown')

    act(() => {
      result.current.handleEditorChange()
    })
    expect(onContentChange).not.toHaveBeenCalled()

    act(() => {
      rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md', rawMode: false, vaultPath: '/vault' })
    })
    await act(async () => { await Promise.resolve() })

    expect(onContentChange).toHaveBeenCalledWith(
      'a.md',
      '---\ntitle: Note A\n---\n[project brief.pdf](<attachments/project brief.pdf>)\n',
    )
    expect(mockEditor.blocksToMarkdownLossy).not.toHaveBeenCalled()
  })

  it('serializes rich inline math nodes back to Markdown on editor changes', async () => {
    vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

    const onContentChange = vi.fn()
    const docRef = { current: blocksA as unknown[] }
    const mockEditor = makeMockEditor(docRef)
    Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
    mockEditor.blocksToMarkdownLossy.mockImplementation((blocks: unknown[]) => (
      (blocks as Array<{ content?: Array<{ text?: string }> }>)
        .map((block) => block.content?.map((item) => item.text ?? '').join('') ?? '')
        .join('\n\n')
    ))

    const tabA = makeTab('a.md', 'Note A')

    const { result } = renderHook(
      () => useEditorTabSwap({
        tabs: [tabA],
        activeTabPath: 'a.md',
        editor: mockEditor as never,
        onContentChange,
      }),
    )

    await act(() => new Promise(r => setTimeout(r, 0)))

    docRef.current = [{
      type: 'paragraph',
      content: [
        { type: 'text', text: 'Inline ', styles: {} },
        { type: 'mathInline', props: { latex: 'x^2' } },
      ],
      children: [],
    }]

    act(() => {
      result.current.handleEditorChange()
    })

    expect(onContentChange).not.toHaveBeenCalled()

    act(() => {
      result.current.flushPendingEditorChange()
    })

    expect(onContentChange).toHaveBeenCalledWith(
      'a.md',
      '---\ntitle: Note A\n---\nInline $x^2$\n',
    )
  })

  it('coalesces rich-editor serialization for a 4000-word note', async () => {
    const tabA = makeTab('long.md', 'Long Note')
    const onContentChange = vi.fn()
    const { docRef, mockEditor, result } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'long.md', rawMode: false },
      onContentChange,
    })
    const longBlocks = makeLongNoteBlocks(4200)
    docRef.current = longBlocks
    mockEditor.blocksToMarkdownLossy.mockImplementation((blocks: unknown[]) => (
      (blocks as Array<{ content?: Array<{ text?: string }> }>)
        .map((block) => block.content?.map((item) => item.text ?? '').join('') ?? '')
        .join('\n\n')
    ))
    mockEditor.blocksToMarkdownLossy.mockClear()

    vi.useFakeTimers()
    try {
      act(() => {
        result.current.handleEditorChange()
        result.current.handleEditorChange()
        result.current.handleEditorChange()
      })
      expect(mockEditor.blocksToMarkdownLossy).not.toHaveBeenCalled()
      expect(onContentChange).not.toHaveBeenCalled()

      act(() => {
        vi.advanceTimersByTime(RICH_EDITOR_CHANGE_DEBOUNCE_MS - 1)
      })
      expect(mockEditor.blocksToMarkdownLossy).not.toHaveBeenCalled()

      act(() => {
        vi.advanceTimersByTime(1)
      })

      expect(mockEditor.blocksToMarkdownLossy).toHaveBeenCalledTimes(1)
      expect(onContentChange).toHaveBeenCalledTimes(1)
      expect(onContentChange.mock.calls[0][1].split(/\s+/).length).toBeGreaterThan(4000)
    } finally {
      vi.useRealTimers()
    }
  })

  it('flushes pending rich-editor content before switching notes', async () => {
    const tabA = makeTab('a.md', 'Note A')
    const tabB = makeTab('b.md', 'Note B')
    const onContentChange = vi.fn()
    const { docRef, mockEditor, rerender, result } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
      onContentChange,
    })
    docRef.current = [{
      type: 'paragraph',
      content: [{ type: 'text', text: 'Changed before switch', styles: {} }],
      children: [],
    }]
    mockEditor.blocksToMarkdownLossy.mockReturnValue('Changed before switch\n')

    vi.useFakeTimers()
    try {
      act(() => {
        result.current.handleEditorChange()
      })

      expect(onContentChange).not.toHaveBeenCalled()

      act(() => {
        rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md', rawMode: false })
      })
      await act(async () => { await Promise.resolve() })

      expect(onContentChange).toHaveBeenCalledWith(
        'a.md',
        '---\ntitle: Note A\n---\nChanged before switch\n',
      )
    } finally {
      vi.useRealTimers()
    }
  })

  it('flushes pending rich-editor content before a switched-away tab is removed', async () => {
    const tabA = makeTab('a.md', 'Note A')
    const tabB = makeTab('b.md', 'Note B')
    const onContentChange = vi.fn()
    const { docRef, mockEditor, rerender, result } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
      onContentChange,
    })
    docRef.current = [{
      type: 'paragraph',
      content: [{ type: 'text', text: 'Changed before close', styles: {} }],
      children: [],
    }]
    mockEditor.blocksToMarkdownLossy.mockReturnValue('Changed before close\n')

    vi.useFakeTimers()
    try {
      act(() => {
        result.current.handleEditorChange()
      })

      expect(onContentChange).not.toHaveBeenCalled()

      act(() => {
        rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false })
      })
      await act(async () => { await Promise.resolve() })

      expect(onContentChange).toHaveBeenCalledWith(
        'a.md',
        '---\ntitle: Note A\n---\nChanged before close\n',
      )
    } finally {
      vi.useRealTimers()
    }
  })

  it('reopens a switched-away note with the rich-editor content that was flushed during the switch', async () => {
    const tabA = makeTab('a.md', 'Note A')
    const tabB = makeTab('b.md', 'Note B')
    const savedContentByPath = new Map<string, string>()
    const onContentChange = vi.fn((path: string, content: string) => {
      savedContentByPath.set(path, content)
    })
    const { docRef, mockEditor, rerenderWith, result } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
      onContentChange,
      setupEditor: (editor) => {
        editor.tryParseMarkdownToBlocks.mockImplementation((markdown: string) => [
          makeTextParagraphBlock(markdown.includes('Changed before switch')
            ? 'Changed before switch'
            : 'Parsed tab body'),
        ])
      },
    })

    docRef.current = [makeTextParagraphBlock('Changed before switch')]
    mockEditor.blocksToMarkdownLossy.mockReturnValue('Changed before switch\n')

    act(() => {
      result.current.handleEditorChange()
    })

    await rerenderWith({ tabs: [tabA, tabB], activeTabPath: 'b.md' })

    const flushedTabAContent = savedContentByPath.get('a.md')
    expect(flushedTabAContent).toBe('---\ntitle: Note A\n---\nChanged before switch\n')

    mockEditor.replaceBlocks.mockClear()
    mockEditor.tryParseMarkdownToBlocks.mockClear()

    await rerenderWith({
      tabs: [{ ...tabA, content: flushedTabAContent! }, tabB],
      activeTabPath: 'a.md',
    })

    const appliedBlocks = mockEditor.replaceBlocks.mock.calls.at(-1)?.[1] as Array<{
      content?: Array<{ text?: string }>
    }>
    const appliedText = appliedBlocks
      .flatMap(block => block.content?.map(part => part.text ?? '') ?? [])
      .join('\n')

    expect(appliedText).toContain('Changed before switch')
  })

  it('rejects unserializable mixed paragraph and list content without crashing', async () => {
    const tabA = makeTab('mixed.md', 'Mixed Note')
    const onContentChange = vi.fn()
    const serializationError = new RangeError(
      'Invalid content for node blockContainer: <paragraph("User"), blockGroup(blockContainer(bulletListItem("Task")))>',
    )
    const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
    const { docRef, mockEditor, result } = await createSwapHarness({
      initialProps: { tabs: [tabA], activeTabPath: 'mixed.md', rawMode: false },
      onContentChange,
    })

    docRef.current = [{
      type: 'paragraph',
      content: [{ type: 'text', text: 'User', styles: {} }],
      children: [{
        type: 'bulletListItem',
        content: [{ type: 'text', text: 'Task', styles: {} }],
        children: [],
      }],
    }]
    mockEditor.blocksToMarkdownLossy.mockImplementation(() => {
      throw serializationError
    })

    act(() => {
      result.current.handleEditorChange()
    })

    expect(() => {
      act(() => {
        result.current.flushPendingEditorChange()
      })
    }).not.toThrow()
    expect(onContentChange).not.toHaveBeenCalled()
    expect(consoleSpy).toHaveBeenCalledWith(
      '[editor] Skipped editor change because BlockNote document could not be serialized:',
      serializationError,
    )
  })

  it('re-parses from tab.content when rawMode transitions from true to false', async () => {
    vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

    const docRef = { current: blocksA as unknown[] }
    const mockEditor = makeMockEditor(docRef)
    Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })

    const tabA = makeTab('a.md', 'Note A')

    const { rerender } = renderHook(
      ({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
        tabs, activeTabPath, editor: mockEditor as never, rawMode,
      }),
      { initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
    )

    // Initial load — parses and caches blocks
    await act(() => new Promise(r => setTimeout(r, 0)))

    // Enter raw mode
    rerender({ tabs: [tabA], activeTabPath: 'a.md', rawMode: true })
    await act(() => new Promise(r => setTimeout(r, 0)))

    // Simulate raw editing: tab content was updated externally
    const updatedTab = {
      ...tabA,
      content: '---\ntitle: Updated Title\n---\n\n# Updated Title\n\nNew body content.',
    }
    mockEditor.tryParseMarkdownToBlocks.mockClear()
    mockEditor.replaceBlocks.mockClear()

    // Exit raw mode with updated content
    rerender({ tabs: [updatedTab], activeTabPath: 'a.md', rawMode: false })
    await act(() => new Promise(r => setTimeout(r, 0)))

    // Verify re-parse happened with updated body content
    expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
      expect.stringContaining('Updated Title'),
    )
    expect(mockEditor.replaceBlocks).toHaveBeenCalled()
  })

  it('keeps formula Markdown visible when raw mode exits into a parser failure', async () => {
    vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
    const parseError = new Error('BlockNote parser failed on math')
    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

    const formulaTab = {
      ...makeTab('a.md', 'Formula'),
      content: [
        '---',
        'title: Formula',
        '---',
        '',
        '# Formula',
        '',
        'Pasted math:',
        '',
        '$$',
        '\\begin{aligned}',
        'x &= y + 1',
        '\\end{aligned}',
        '$$',
      ].join('\n'),
    }

    const { mockEditor, rerenderWith } = await createSwapHarness({
      initialProps: { tabs: [makeTab('a.md', 'Formula')], activeTabPath: 'a.md', rawMode: false },
      setupEditor: (editor) => {
        editor.tryParseMarkdownToBlocks.mockImplementation((markdown: string) => {
          if (markdown.includes('@@TOLARIA_MATH_BLOCK:')) throw parseError
          return blocksA
        })
      },
    })

    await rerenderWith({ rawMode: true })
    mockEditor.replaceBlocks.mockClear()

    await rerenderWith({ tabs: [formulaTab], rawMode: false })

    const appliedBlocks = mockEditor.replaceBlocks.mock.calls[0]?.[1] as Array<{ content?: Array<{ text?: string }> }>
    const renderedText = appliedBlocks
      .flatMap(block => block.content?.map(part => part.text ?? '') ?? [])
      .join('\n')

    expect(renderedText).toContain('# Formula')
    expect(renderedText).toContain('Pasted math:')
    expect(renderedText).toContain('\\begin{aligned}')
    expect(warnSpy).toHaveBeenCalledWith(
      '[editor] Rendering a.md as plain Markdown because BlockNote could not parse it:',
      parseError,
    )
  })

  it('does not skip swap when rawMode is on (editor hidden)', async () => {
    vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

    const docRef = { current: blocksA as unknown[] }
    const mockEditor = makeMockEditor(docRef)
    Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })

    const tabA = makeTab('a.md', 'Note A')

    const { rerender } = renderHook(
      ({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
        tabs, activeTabPath, editor: mockEditor as never, rawMode,
      }),
      { initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
    )

    await act(() => new Promise(r => setTimeout(r, 0)))
    mockEditor.replaceBlocks.mockClear()

    // Enter raw mode and update content
    const updatedTab = { ...tabA, content: '---\ntitle: Changed\n---\n\n# Changed\n\nEdited.' }
    rerender({ tabs: [updatedTab], activeTabPath: 'a.md', rawMode: true })
    await act(() => new Promise(r => setTimeout(r, 0)))

    // While in raw mode, the editor should NOT be updated
    expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
  })

  it('preserves content through multiple BlockNote→raw→BlockNote cycles', async () => {
    vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

    const docRef = { current: blocksA as unknown[] }
    const mockEditor = makeMockEditor(docRef)
    Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })

    const tabA = makeTab('a.md', 'Note A')

    const { rerender } = renderHook(
      ({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
        tabs, activeTabPath, editor: mockEditor as never, rawMode,
      }),
      { initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
    )
    await act(() => new Promise(r => setTimeout(r, 0)))

    // Cycle 1: raw mode on → edit → raw mode off
    rerender({ tabs: [tabA], activeTabPath: 'a.md', rawMode: true })
    await act(() => new Promise(r => setTimeout(r, 0)))

    const edit1 = { ...tabA, content: '---\ntitle: Edit 1\n---\n\n# Edit 1\n\nFirst edit.' }
    mockEditor.tryParseMarkdownToBlocks.mockClear()
    rerender({ tabs: [edit1], activeTabPath: 'a.md', rawMode: false })
    await act(() => new Promise(r => setTimeout(r, 0)))
    expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
      expect.stringContaining('Edit 1'),
    )

    // Cycle 2: raw mode on → edit → raw mode off
    rerender({ tabs: [edit1], activeTabPath: 'a.md', rawMode: true })
    await act(() => new Promise(r => setTimeout(r, 0)))

    const edit2 = { ...tabA, content: '---\ntitle: Edit 2\n---\n\n# Edit 2\n\nSecond edit.' }
    mockEditor.tryParseMarkdownToBlocks.mockClear()
    rerender({ tabs: [edit2], activeTabPath: 'a.md', rawMode: false })
    await act(() => new Promise(r => setTimeout(r, 0)))
    expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
      expect.stringContaining('Edit 2'),
    )
  })
})

describe('useEditorTabSwap scroll position', () => {

  afterEach(() => { vi.restoreAllMocks() })

  it('restores each note scroll position when switching tabs', async () => {
    const { scrollEl } = installEditorDomSpies()
    const docRef = { current: blocksA as unknown[] }
    const mockEditor = makeMockEditor(docRef)

    const tabA = makeTab('a.md', 'Note A')
    const tabB = makeTab('b.md', 'Note B')

    const rendered = renderHook(
      ({ tabs, activeTabPath }) => useEditorTabSwap({
        tabs,
        activeTabPath,
        editor: mockEditor as never,
      }),
      { initialProps: { tabs: [tabA, tabB], activeTabPath: 'a.md' } },
    )
    await flushEditorTick()

    scrollEl.scrollTop = 240
    rendered.rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md' })
    await flushEditorTick()
    expect(scrollEl.scrollTop).toBe(0)

    scrollEl.scrollTop = 75
    rendered.rerender({ tabs: [tabA, tabB], activeTabPath: 'a.md' })
    await flushEditorTick()
    expect(scrollEl.scrollTop).toBe(240)

    rendered.rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md' })
    await flushEditorTick()
    expect(scrollEl.scrollTop).toBe(75)
  })

  it('defaults to scroll top 0 for newly opened note', async () => {
    const scrollEl = { scrollTop: 0 }
    vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element)
    const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

    const docRef = { current: blocksA as unknown[] }
    const mockEditor = makeMockEditor(docRef)
    Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })

    const tabA = makeTab('a.md', 'Note A')

    renderHook(
      ({ tabs, activeTabPath }) => useEditorTabSwap({
        tabs,
        activeTabPath,
        editor: mockEditor as never,
      }),
      { initialProps: { tabs: [tabA], activeTabPath: 'a.md' } },
    )

    await act(() => new Promise(r => setTimeout(r, 0)))

    // For a fresh note, scroll should go to 0
    expect(rAF).toHaveBeenCalled()
    expect(scrollEl.scrollTop).toBe(0)
  })
})
