import { renderHook, act } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import {
  buildCodeMirrorRestoreState,
  captureRawEditorPositionSnapshot,
  captureRichEditorPositionSnapshot,
  type BlockNotePositionEditor,
  type CodeMirrorViewLike,
} from './editorModePosition'
import {
  createEditorModeRestoreTransition,
  useEditorModePositionSync,
} from './useEditorModePositionSync'

interface MockBlock {
  id: string
  markdown: string
  content?: unknown
}

const content = '---\ntitle: Demo\n---\n# Title\n\nParagraph one\n\n## Tail'
const blocks: MockBlock[] = [
  { id: 'title', markdown: '# Title', content: [] },
  { id: 'details', markdown: 'Paragraph one', content: [] },
  { id: 'tail', markdown: '## Tail', content: [] },
]

function makeEditor(): BlockNotePositionEditor {
  return {
    document: blocks,
    getSelection: () => undefined,
    getTextCursorPosition: () => ({ block: blocks[1] }),
    blocksToMarkdownLossy: (items: unknown[]) => (items as MockBlock[]).map(item => item.markdown).join('\n\n'),
    setSelection: vi.fn(),
    setTextCursorPosition: vi.fn(),
    focus: vi.fn(),
  }
}

function installBlockNoteScrollHost() {
  const host = document.createElement('div')
  host.className = 'editor-scroll-area'
  host.scrollTop = 72
  document.body.appendChild(host)
  const detailBlock = document.createElement('div')
  detailBlock.setAttribute('data-id', 'details')
  detailBlock.scrollIntoView = vi.fn()
  document.body.appendChild(detailBlock)
}

function installRawView(view: CodeMirrorViewLike) {
  const host = document.createElement('div')
  host.setAttribute('data-testid', 'raw-editor-codemirror')
  Object.assign(host, { __cmView: view })
  document.body.appendChild(host)
}

function createRestoreTransitionRef() {
  return { current: createEditorModeRestoreTransition() }
}

describe('useEditorModePositionSync', () => {
  beforeEach(() => {
    installBlockNoteScrollHost()
    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
      callback(0)
      return 1
    })
    vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
  })

  afterEach(() => {
    vi.restoreAllMocks()
    document.body.innerHTML = ''
  })

  it('restores the raw editor view after toggling from rich mode', () => {
    const editor = makeEditor()
    const dispatch = vi.fn()
    const focus = vi.fn()
    const restoreTransitionRef = createRestoreTransitionRef()
    installRawView({
      state: {
        doc: { toString: () => content },
        selection: { main: { anchor: 0, head: 0 } },
      },
      scrollDOM: { scrollTop: 0 },
      dispatch,
      focus,
    })

    const { result, rerender } = renderHook(
      ({ rawMode }) => {
        useEditorModePositionSync({
          activeTabPath: 'note.md',
          editor: editor as never,
          restoreTransitionRef,
          rawMode,
        })
        return { restoreTransitionRef }
      },
      { initialProps: { rawMode: false } },
    )

    act(() => {
      const snapshot = captureRichEditorPositionSnapshot(editor, document)
      result.current.restoreTransitionRef.current.rawRestore = snapshot
        ? buildCodeMirrorRestoreState(editor, content, snapshot)
        : null
    })
    rerender({ rawMode: true })

    const selectionCall = dispatch.mock.calls[0]?.[0]
    expect(selectionCall?.selection?.anchor).toBe(content.indexOf('Paragraph one'))
    expect(content.slice(selectionCall.selection.anchor, selectionCall.selection.head).trim()).toBe('Paragraph one')
    expect(focus).toHaveBeenCalled()
  })

  it('restores the BlockNote cursor after toggling back from raw mode', async () => {
    const editor = makeEditor()
    const paragraphOffset = content.indexOf('Paragraph one') + 5
    const restoreTransitionRef = createRestoreTransitionRef()
    installRawView({
      state: {
        doc: { toString: () => content },
        selection: { main: { anchor: paragraphOffset, head: paragraphOffset } },
      },
      scrollDOM: { scrollTop: 24 },
      dispatch: vi.fn(),
      focus: vi.fn(),
    })

    const { result, rerender } = renderHook(
      ({ rawMode }) => {
        useEditorModePositionSync({
          activeTabPath: 'note.md',
          editor: editor as never,
          restoreTransitionRef,
          rawMode,
        })
        return { restoreTransitionRef }
      },
      { initialProps: { rawMode: true } },
    )

    act(() => {
      result.current.restoreTransitionRef.current.richRestore = captureRawEditorPositionSnapshot(document)
    })
    rerender({ rawMode: false })
    await act(async () => {
      await Promise.resolve()
    })
    act(() => {
      window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', {
        detail: { path: 'note.md' },
      }))
    })

    expect(editor.setTextCursorPosition).toHaveBeenCalledWith('details', 'end')
    expect(editor.focus).toHaveBeenCalled()
  })

  it('keeps a raw-to-rich cursor restore pending until the active tab swap completes', () => {
    const editor = makeEditor()
    const paragraphOffset = content.indexOf('Paragraph one') + 5
    const restoreTransitionRef = createRestoreTransitionRef()
    installRawView({
      state: {
        doc: { toString: () => content },
        selection: { main: { anchor: paragraphOffset, head: paragraphOffset } },
      },
      scrollDOM: { scrollTop: 24 },
      dispatch: vi.fn(),
      focus: vi.fn(),
    })

    const { result, rerender } = renderHook(
      ({ activeTabPath, rawMode }) => {
        useEditorModePositionSync({
          activeTabPath,
          editor: editor as never,
          restoreTransitionRef,
          rawMode,
        })
        return { restoreTransitionRef }
      },
      { initialProps: { activeTabPath: 'note.md', rawMode: true } },
    )

    act(() => {
      result.current.restoreTransitionRef.current.richRestore = captureRawEditorPositionSnapshot(document)
    })
    rerender({ activeTabPath: 'note.md', rawMode: false })

    act(() => {
      window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', {
        detail: { path: 'other-note.md' },
      }))
    })

    expect(editor.setTextCursorPosition).not.toHaveBeenCalled()
    expect(result.current.restoreTransitionRef.current.richRestore).not.toBeNull()

    act(() => {
      window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', {
        detail: { path: 'note.md' },
      }))
    })

    expect(editor.setTextCursorPosition).toHaveBeenCalledWith('details', 'end')
    expect(result.current.restoreTransitionRef.current.richRestore).toBeNull()
  })

  it('cancels a pending BlockNote restore when raw mode starts again before the frame runs', () => {
    const editor = makeEditor()
    const paragraphOffset = content.indexOf('Paragraph one') + 5
    const restoreTransitionRef = createRestoreTransitionRef()
    const frames = new Map<number, FrameRequestCallback>()
    let nextFrame = 1
    vi.mocked(window.requestAnimationFrame).mockImplementation((callback: FrameRequestCallback) => {
      const id = nextFrame
      nextFrame += 1
      frames.set(id, callback)
      return id
    })
    installRawView({
      state: {
        doc: { toString: () => content },
        selection: { main: { anchor: paragraphOffset, head: paragraphOffset } },
      },
      scrollDOM: { scrollTop: 24 },
      dispatch: vi.fn(),
      focus: vi.fn(),
    })

    const { result, rerender } = renderHook(
      ({ rawMode }) => {
        useEditorModePositionSync({
          activeTabPath: 'note.md',
          editor: editor as never,
          restoreTransitionRef,
          rawMode,
        })
        return { restoreTransitionRef }
      },
      { initialProps: { rawMode: true } },
    )

    act(() => {
      result.current.restoreTransitionRef.current.richRestore = captureRawEditorPositionSnapshot(document)
    })
    rerender({ rawMode: false })
    act(() => {
      window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', {
        detail: { path: 'note.md' },
      }))
    })
    const restoreFrame = frames.get(1)
    expect(restoreFrame).toBeDefined()

    rerender({ rawMode: true })
    expect(window.cancelAnimationFrame).toHaveBeenCalledWith(1)

    act(() => {
      restoreFrame?.(0)
    })

    expect(editor.setTextCursorPosition).not.toHaveBeenCalled()
    expect(editor.focus).not.toHaveBeenCalled()
  })
})
