import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import {
  useTabManagement,
  prefetchNoteContent,
  cacheNoteContent,
  clearPrefetchCache,
  NOTE_CONTENT_CACHE_MAX_BYTES,
  NOTE_CONTENT_ENTRY_MAX_BYTES,
} from './useTabManagement'

vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
  isTauri: vi.fn(() => false),
  mockInvoke: vi.fn().mockResolvedValue('# Mock content'),
}))

const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
  path: '/vault/note/test.md',
  filename: 'test.md',
  title: 'Test Note',
  isA: 'Note',
  aliases: [],
  belongsTo: [],
  relatedTo: [],
  status: 'Active',
  archived: false,
  modifiedAt: 1700000000,
  createdAt: 1700000000,
  fileSize: 100,
  snippet: '',
  wordCount: 0,
  relationships: {},
  icon: null,
  color: null,
  order: null,
  template: null, sort: null,
  outgoingLinks: [],
  ...overrides,
})

type HookState = { current: ReturnType<typeof useTabManagement> }

async function selectNote(result: HookState, overrides: Partial<VaultEntry>) {
  await act(async () => {
    await result.current.handleSelectNote(makeEntry(overrides))
  })
}

async function replaceActiveNote(result: HookState, overrides: Partial<VaultEntry>) {
  await act(async () => {
    await result.current.handleReplaceActiveTab(makeEntry(overrides))
  })
}

async function prefetchResolvedContent(path: string, content: string) {
  vi.mocked(mockInvoke).mockResolvedValue(content)
  prefetchNoteContent(path)
  await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
  return mockInvoke
}

function expectSingleActiveTab(result: HookState, path: string) {
  expect(result.current.tabs).toHaveLength(1)
  expect(result.current.tabs[0].entry.path).toBe(path)
  expect(result.current.activeTabPath).toBe(path)
}

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

function makeAsciiContent(byteCount: number): string {
  return 'x'.repeat(byteCount)
}

function seedCacheBeyondByteLimit() {
  const cachedContent = makeAsciiContent(Math.floor(NOTE_CONTENT_ENTRY_MAX_BYTES * 0.9))
  const cachedPaths = Array.from(
    { length: Math.floor(NOTE_CONTENT_CACHE_MAX_BYTES / cachedContent.length) + 2 },
    (_, index) => `/vault/note/cached-${index + 1}.md`,
  )

  for (const path of cachedPaths) {
    cacheNoteContent(path, cachedContent)
  }

  return {
    cachedContent,
    oldestPath: cachedPaths[0],
    newestPath: cachedPaths[cachedPaths.length - 1],
  }
}

describe('useTabManagement (single-note model)', () => {
  beforeEach(() => {
    vi.resetAllMocks()
    clearPrefetchCache()
    vi.mocked(isTauri).mockReturnValue(false)
    vi.mocked(mockInvoke).mockResolvedValue('# Mock content')
    window.history.replaceState({}, '', '/')
  })

  it('starts with no note and null active path', () => {
    const { result } = renderHook(() => useTabManagement())
    expect(result.current.tabs).toEqual([])
    expect(result.current.activeTabPath).toBeNull()
  })

  describe('handleSelectNote', () => {
    it('opens a note and sets it active', async () => {
      const { result } = renderHook(() => useTabManagement())
      await selectNote(result, { path: '/vault/note/a.md' })
      expectSingleActiveTab(result, '/vault/note/a.md')
    })

    it('switches the active path immediately while the next note is still loading', async () => {
      let resolveContent: (value: string) => void
      vi.mocked(mockInvoke).mockImplementationOnce(
        () => new Promise<string>((resolve) => { resolveContent = resolve }),
      )

      const { result } = renderHook(() => useTabManagement())
      void act(() => {
        void result.current.handleSelectNote(makeEntry({ path: '/vault/note/pending.md', title: 'Pending' }))
      })

      expect(result.current.activeTabPath).toBe('/vault/note/pending.md')
      expect(result.current.tabs).toEqual([])

      await act(async () => {
        resolveContent!('# Pending content')
      })

      expect(result.current.tabs[0].entry.path).toBe('/vault/note/pending.md')
      expect(result.current.tabs[0].content).toBe('# Pending content')
    })

    it('replaces the current note when selecting a different one', async () => {
      const { result } = renderHook(() => useTabManagement())
      await selectNote(result, { path: '/vault/a.md', title: 'A' })
      await selectNote(result, { path: '/vault/b.md', title: 'B' })
      expectSingleActiveTab(result, '/vault/b.md')
    })

    it('is a no-op when selecting the already-open note', async () => {
      const { result } = renderHook(() => useTabManagement())
      const entry = { path: '/vault/a.md' }
      await selectNote(result, entry)
      await act(async () => {
        await result.current.handleSelectNote(makeEntry(entry))
      })

      expect(result.current.tabs).toHaveLength(1)
    })

    it('handles load content failure gracefully', async () => {
      vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('fail'))
      const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

      const { result } = renderHook(() => useTabManagement())
      await selectNote(result, {})

      expect(result.current.tabs).toHaveLength(1)
      expect(result.current.tabs[0].content).toBe('')
      warnSpy.mockRestore()
    })

    it('clears the active note when the file is missing on disk', async () => {
      vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File does not exist: /vault/note/missing.md'))
      const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
      const onMissingNotePath = vi.fn()

      const { result } = renderHook(() => useTabManagement({ onMissingNotePath }))
      await selectNote(result, { path: '/vault/note/missing.md', title: 'Missing Note' })

      expect(result.current.tabs).toEqual([])
      expect(result.current.activeTabPath).toBeNull()
      expect(onMissingNotePath).toHaveBeenCalledWith(
        expect.objectContaining({ path: '/vault/note/missing.md', title: 'Missing Note' }),
        expect.any(Error),
      )
      warnSpy.mockRestore()
    })

    it('returns to the empty state when note content is not valid UTF-8 text', async () => {
      vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File is not valid UTF-8 text: /vault/note/bad.csv'))
      const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
      const onUnreadableNoteContent = vi.fn()

      const { result } = renderHook(() => useTabManagement({ onUnreadableNoteContent }))
      await selectNote(result, {
        path: '/vault/note/bad.csv',
        filename: 'bad.csv',
        title: 'bad.csv',
        fileKind: 'text',
      })

      expect(result.current.tabs).toEqual([])
      expect(result.current.activeTabPath).toBeNull()
      expect(onUnreadableNoteContent).toHaveBeenCalledWith(
        expect.objectContaining({ path: '/vault/note/bad.csv', title: 'bad.csv' }),
        expect.any(Error),
      )
      warnSpy.mockRestore()
    })

    it('opens binary image files without trying to load them as text notes', async () => {
      const { result } = renderHook(() => useTabManagement())
      await selectNote(result, {
        path: '/vault/assets/photo.png',
        filename: 'photo.png',
        title: 'photo.png',
        fileKind: 'binary',
      })

      expectSingleActiveTab(result, '/vault/assets/photo.png')
      expect(result.current.tabs[0].content).toBe('')
      expect(vi.mocked(mockInvoke)).not.toHaveBeenCalled()
    })

    it('returns to the empty state when no active vault is selected', async () => {
      vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('No active vault selected'))
      const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

      const { result } = renderHook(() => useTabManagement())
      await selectNote(result, { path: '/vault/note/orphaned.md', title: 'Orphaned Note' })

      expect(result.current.tabs).toEqual([])
      expect(result.current.activeTabPath).toBeNull()
      warnSpy.mockRestore()
    })

    it('uses the note-window vault path when Tauri reloads the selected note', async () => {
      vi.mocked(isTauri).mockReturnValue(true)
      vi.mocked(invoke).mockResolvedValue('# Window content')
      window.history.replaceState(
        {},
        '',
        '/?window=note&path=%2Fvault%2Fnote%2Ftest.md&vault=%2Fvault&title=Test+Note',
      )

      const { result } = renderHook(() => useTabManagement())
      await selectNote(result, { path: '/vault/note/test.md', title: 'Test Note' })

      expect(vi.mocked(invoke)).toHaveBeenCalledWith('get_note_content', {
        path: '/vault/note/test.md',
        vaultPath: '/vault',
      })
      expect(result.current.tabs[0].content).toBe('# Window content')
    })
  })

  describe('handleReplaceActiveTab', () => {
    it('replaces the current note with a new entry', async () => {
      const { result } = renderHook(() => useTabManagement())
      await selectNote(result, { path: '/vault/a.md', title: 'A' })
      await replaceActiveNote(result, { path: '/vault/b.md', title: 'B' })
      expectSingleActiveTab(result, '/vault/b.md')
    })

    it('treats /tmp and /private/tmp aliases as the same active note', async () => {
      vi.mocked(mockInvoke)
        .mockResolvedValueOnce('# Stale before pull')
        .mockResolvedValueOnce('# Fresh after pull')
      const beforeNavigate = vi.fn().mockResolvedValue(undefined)

      const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
      await selectNote(result, { path: '/private/tmp/vault/active.md', title: 'Active' })

      await act(async () => {
        await result.current.handleReplaceActiveTab(
          makeEntry({ path: '/tmp/vault/active.md', title: 'Active' }),
        )
      })

      expect(beforeNavigate).not.toHaveBeenCalled()
      expect(result.current.activeTabPath).toBe('/tmp/vault/active.md')
      expect(result.current.tabs).toHaveLength(1)
      expect(result.current.tabs[0].content).toBe('# Fresh after pull')
    })

    it('reloads content when replacing with the same entry', async () => {
      vi.mocked(mockInvoke)
        .mockResolvedValueOnce('# Stale before pull')
        .mockResolvedValueOnce('# Fresh after pull')

      const { result } = renderHook(() => useTabManagement())
      const entry = { path: '/vault/a.md', title: 'A' }
      await selectNote(result, entry)

      await act(async () => {
        await result.current.handleReplaceActiveTab(makeEntry(entry))
      })

      expect(result.current.tabs).toHaveLength(1)
      expect(result.current.tabs[0].content).toBe('# Fresh after pull')
      expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
    })

    it('clears the active note when a forced reload hits a missing file path', async () => {
      vi.mocked(mockInvoke)
        .mockResolvedValueOnce('# Existing content')
        .mockRejectedValueOnce(new Error('File does not exist: /vault/a.md'))
      const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
      const onMissingNotePath = vi.fn()

      const { result } = renderHook(() => useTabManagement({ onMissingNotePath }))
      const entry = makeEntry({ path: '/vault/a.md', title: 'A' })
      await selectNote(result, entry)

      await act(async () => {
        await result.current.handleReplaceActiveTab(entry)
      })

      expect(result.current.tabs).toEqual([])
      expect(result.current.activeTabPath).toBeNull()
      expect(onMissingNotePath).toHaveBeenCalledWith(
        expect.objectContaining({ path: '/vault/a.md', title: 'A' }),
        expect.any(Error),
      )
      warnSpy.mockRestore()
    })

    it('opens a note when no note is active', async () => {
      const { result } = renderHook(() => useTabManagement())
      await replaceActiveNote(result, { path: '/vault/a.md' })
      expectSingleActiveTab(result, '/vault/a.md')
    })
  })

  describe('openTabWithContent', () => {
    it('opens a note with pre-loaded content', () => {
      const { result } = renderHook(() => useTabManagement())
      const entry = makeEntry({ path: '/vault/new.md' })

      act(() => {
        result.current.openTabWithContent(entry, '# New note')
      })

      expect(result.current.tabs).toHaveLength(1)
      expect(result.current.tabs[0].content).toBe('# New note')
      expect(result.current.activeTabPath).toBe('/vault/new.md')
    })
  })

  describe('setTabs entry sync', () => {
    it('updates note entry via setTabs mapper (vault entry sync pattern)', async () => {
      const { result } = renderHook(() => useTabManagement())
      const entry = makeEntry({ path: '/vault/a.md', archived: false })

      await act(async () => {
        await result.current.handleSelectNote(entry)
      })

      const freshEntry = { ...entry, archived: true }
      act(() => {
        result.current.setTabs(prev => prev.map(tab =>
          tab.entry.path === freshEntry.path ? { ...tab, entry: freshEntry } : tab
        ))
      })

      expect(result.current.tabs[0].entry.archived).toBe(true)
    })
  })

  describe('closeAllTabs', () => {
    it('clears the note and active path', async () => {
      const { result } = renderHook(() => useTabManagement())
      await selectNote(result, { path: '/vault/a.md' })

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

      expect(result.current.tabs).toHaveLength(0)
      expect(result.current.activeTabPath).toBeNull()
    })
  })

  describe('content prefetch cache', () => {
    it('prefetch paints cached content immediately and still validates it from disk', async () => {
      const mockInvoke = await prefetchResolvedContent('/vault/note/pre.md', '# Prefetched content')
      vi.mocked(mockInvoke).mockResolvedValue('# Prefetched content')

      const { result } = renderHook(() => useTabManagement())
      await selectNote(result, { path: '/vault/note/pre.md', title: 'Pre' })

      expect(result.current.tabs[0].content).toBe('# Prefetched content')
      expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
    })

    it('clearPrefetchCache prevents stale content from being served', async () => {
      const mockInvoke = await prefetchResolvedContent('/vault/note/stale.md', '# Stale')

      clearPrefetchCache()
      vi.mocked(mockInvoke).mockResolvedValue('# Fresh')

      const { result } = renderHook(() => useTabManagement())
      await selectNote(result, { path: '/vault/note/stale.md', title: 'Stale' })

      expect(result.current.tabs[0].content).toBe('# Fresh')
      expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
    })

    it('deduplicates concurrent prefetch requests for same path', async () => {
      vi.mocked(mockInvoke).mockResolvedValue('# Content')

      prefetchNoteContent('/vault/note/dup.md')
      prefetchNoteContent('/vault/note/dup.md')
      prefetchNoteContent('/vault/note/dup.md')

      await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
    })

    it('swallows no-active-vault prefetch failures and lets a later open recover', async () => {
      vi.mocked(mockInvoke)
        .mockRejectedValueOnce(new Error('No active vault selected'))
        .mockResolvedValueOnce('# Recovered content')

      prefetchNoteContent('/vault/note/recovered.md')
      await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
      await Promise.resolve()
      await Promise.resolve()

      const { result } = renderHook(() => useTabManagement())
      await selectNote(result, { path: '/vault/note/recovered.md', title: 'Recovered' })

      expect(result.current.tabs[0].content).toBe('# Recovered content')
      expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
    })

    it('serves refreshed cached content after a save replaces stale prefetched data', async () => {
      const mockInvoke = await prefetchResolvedContent('/vault/note/saved.md', '# Stale prefetched content')
      vi.mocked(mockInvoke).mockResolvedValue('# Persisted content')

      cacheNoteContent('/vault/note/saved.md', '# Persisted content')

      const { result } = renderHook(() => useTabManagement())
      await selectNote(result, { path: '/vault/note/saved.md', title: 'Saved' })

      expect(result.current.tabs[0].content).toBe('# Persisted content')
      expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
    })

    it('activates a warmed note immediately while reusing the cached content', async () => {
      const deferred = createDeferred<string>()
      vi.mocked(mockInvoke).mockImplementationOnce(() => deferred.promise)
      cacheNoteContent('/vault/note/warm.md', '# Warm content')

      const { result } = renderHook(() => useTabManagement())

      act(() => {
        void result.current.handleSelectNote(makeEntry({ path: '/vault/note/warm.md', title: 'Warm' }))
      })

      expect(result.current.activeTabPath).toBe('/vault/note/warm.md')
      expect(result.current.tabs).toHaveLength(1)
      expect(result.current.tabs[0].content).toBe('# Warm content')
      expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)

      await act(async () => {
        deferred.resolve('# Warm content')
        await Promise.resolve()
      })
    })

    it('does not retain oversized notes in the prefetch cache', async () => {
      const largeContent = makeAsciiContent(NOTE_CONTENT_ENTRY_MAX_BYTES + 1)
      const mockInvoke = await prefetchResolvedContent('/vault/note/oversized.md', largeContent)
      const deferred = createDeferred<string>()
      vi.mocked(mockInvoke).mockImplementationOnce(() => deferred.promise)

      const { result } = renderHook(() => useTabManagement())

      act(() => {
        void result.current.handleSelectNote(makeEntry({ path: '/vault/note/oversized.md', title: 'Oversized' }))
      })

      expect(result.current.activeTabPath).toBe('/vault/note/oversized.md')
      expect(result.current.tabs).toEqual([])
      expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)

      await act(async () => {
        deferred.resolve(largeContent)
        await Promise.resolve()
      })

      expect(result.current.tabs[0].content).toBe(largeContent)
    })

    it('evicts the oldest cached notes when retained bytes exceed the cache budget', async () => {
      const { cachedContent, oldestPath } = seedCacheBeyondByteLimit()
      const deferred = createDeferred<string>()
      vi.mocked(mockInvoke).mockImplementationOnce(() => deferred.promise)

      const { result } = renderHook(() => useTabManagement())

      act(() => {
        void result.current.handleSelectNote(makeEntry({ path: oldestPath, title: 'Oldest cached note' }))
      })

      expect(result.current.activeTabPath).toBe(oldestPath)
      expect(result.current.tabs).toEqual([])

      await act(async () => {
        deferred.resolve(cachedContent)
        await Promise.resolve()
      })

      expect(result.current.tabs[0].content).toBe(cachedContent)
    })

    it('keeps the newest cached notes warm when trimming to the byte budget', async () => {
      const { cachedContent, newestPath } = seedCacheBeyondByteLimit()
      const deferred = createDeferred<string>()
      vi.mocked(mockInvoke).mockImplementationOnce(() => deferred.promise)

      const { result } = renderHook(() => useTabManagement())

      act(() => {
        void result.current.handleSelectNote(makeEntry({ path: newestPath, title: 'Newest cached note' }))
      })

      expect(result.current.activeTabPath).toBe(newestPath)
      expect(result.current.tabs).toHaveLength(1)
      expect(result.current.tabs[0].content).toBe(cachedContent)

      await act(async () => {
        deferred.resolve(cachedContent)
        await Promise.resolve()
      })
    })

    it('reuses cached content when reopening a recently loaded note', async () => {
      vi.mocked(mockInvoke)
        .mockResolvedValueOnce('# A content')
        .mockResolvedValueOnce('# B content')
        .mockResolvedValueOnce('# A content')

      const { result } = renderHook(() => useTabManagement())
      await selectNote(result, { path: '/vault/a.md', title: 'A' })
      await selectNote(result, { path: '/vault/b.md', title: 'B' })
      await selectNote(result, { path: '/vault/a.md', title: 'A again' })

      expect(result.current.tabs[0].entry.path).toBe('/vault/a.md')
      expect(result.current.tabs[0].content).toBe('# A content')
      expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(3)
    })

    it('falls back instead of reopening cached content when the note file disappeared', async () => {
      vi.mocked(mockInvoke)
        .mockResolvedValueOnce('# Other note')
        .mockRejectedValueOnce(new Error('File does not exist: /vault/note/missing-cached.md'))
      const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
      cacheNoteContent('/vault/note/missing-cached.md', '# Cached but stale')
      const onMissingNotePath = vi.fn()

      const { result } = renderHook(() => useTabManagement({ onMissingNotePath }))
      await selectNote(result, { path: '/vault/other.md', title: 'Other' })
      await selectNote(result, { path: '/vault/note/missing-cached.md', title: 'Missing cached' })

      expect(result.current.tabs).toEqual([])
      expect(result.current.activeTabPath).toBeNull()
      expect(onMissingNotePath).toHaveBeenCalledWith(
        expect.objectContaining({ path: '/vault/note/missing-cached.md', title: 'Missing cached' }),
        expect.any(Error),
      )
      warnSpy.mockRestore()
    })

    it('deduplicates a late prefetch after note opening already started', async () => {
      let resolveContent!: (value: string) => void
      vi.mocked(mockInvoke).mockImplementationOnce(
        () => new Promise<string>((resolve) => { resolveContent = resolve }),
      )

      const { result } = renderHook(() => useTabManagement())

      await act(async () => {
        void result.current.handleSelectNote(makeEntry({ path: '/vault/note/rapid.md', title: 'Rapid' }))
        prefetchNoteContent('/vault/note/rapid.md')
        await Promise.resolve()
      })

      expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)

      await act(async () => {
        resolveContent('# Rapid content')
        await Promise.resolve()
      })

      expect(result.current.tabs[0].content).toBe('# Rapid content')
    })
  })

  describe('rapid switching safety', () => {
    it('only activates the last note when switching rapidly', async () => {
      let resolveA: (v: string) => void
      let resolveB: (v: string) => void
      vi.mocked(mockInvoke)
        .mockImplementationOnce(() => new Promise<string>((r) => { resolveA = r as (v: string) => void }))
        .mockImplementationOnce(() => new Promise<string>((r) => { resolveB = r as (v: string) => void }))

      const { result } = renderHook(() => useTabManagement())

      let selectADone = false
      await act(async () => {
        result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' })).then(() => { selectADone = true })
        await Promise.resolve()
      })

      let selectBDone = false
      await act(async () => {
        result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' })).then(() => { selectBDone = true })
        await Promise.resolve()
      })

      await act(async () => { resolveB!('# B content') })
      await act(async () => { resolveA!('# A content') })

      await vi.waitFor(() => expect(selectADone && selectBDone).toBe(true))

      expect(result.current.activeTabPath).toBe('/vault/b.md')
    })

    it('waits for beforeNavigate before switching away from the current note', async () => {
      const beforeNavigate = vi.fn(() => createDeferred<void>().promise)
      const deferred = createDeferred<void>()
      beforeNavigate.mockReturnValueOnce(deferred.promise)

      const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
      await selectNote(result, { path: '/vault/a.md', title: 'A' })

      let replaceDone = false
      await act(async () => {
        result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
          .then(() => { replaceDone = true })
        await Promise.resolve()
      })

      expect(beforeNavigate).toHaveBeenCalledWith('/vault/a.md', '/vault/b.md')
      expect(result.current.activeTabPath).toBe('/vault/a.md')
      expect(replaceDone).toBe(false)

      await act(async () => {
        deferred.resolve(undefined)
        await Promise.resolve()
      })

      await vi.waitFor(() => expect(replaceDone).toBe(true))
      expectSingleActiveTab(result, '/vault/b.md')
    })

    it('keeps only the latest target when note switches overlap during beforeNavigate', async () => {
      const first = createDeferred<void>()
      const second = createDeferred<void>()
      const beforeNavigate = vi.fn()
        .mockReturnValueOnce(first.promise)
        .mockReturnValueOnce(second.promise)

      const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
      await selectNote(result, { path: '/vault/a.md', title: 'A' })

      let switchToBDone = false
      await act(async () => {
        result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
          .then(() => { switchToBDone = true })
        await Promise.resolve()
      })

      let switchToCDone = false
      await act(async () => {
        result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/c.md', title: 'C' }))
          .then(() => { switchToCDone = true })
        await Promise.resolve()
      })

      await act(async () => {
        first.resolve(undefined)
        await Promise.resolve()
      })
      expect(result.current.activeTabPath).toBe('/vault/a.md')

      await act(async () => {
        second.resolve(undefined)
        await Promise.resolve()
      })

      await vi.waitFor(() => expect(switchToBDone && switchToCDone).toBe(true))
      expect(result.current.activeTabPath).toBe('/vault/c.md')
    })

    it('keeps the current note active when beforeNavigate fails', async () => {
      const beforeNavigate = vi.fn().mockRejectedValueOnce(new Error('save failed'))
      const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

      const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
      await selectNote(result, { path: '/vault/a.md', title: 'A' })

      await act(async () => {
        await result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
      })

      expect(result.current.activeTabPath).toBe('/vault/a.md')
      expect(warnSpy).toHaveBeenCalledWith(
        'Failed to persist note before navigation:',
        expect.any(Error),
      )
      warnSpy.mockRestore()
    })
  })
})
