import { describe, expect, it, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { MenuProvider } from './provider.js'
import { useMenus, useMenuTree } from './hooks.js'
import type { MenuClient, MenuItemNode } from './client.js'
import type { MenuRow, MenuTree } from './client.js'

const menuRow = (id: string, key: string): MenuRow => ({
  id,
  key,
  label: key,
  createdAtMs: 0,
  updatedAtMs: 0,
})

const itemRow = (
  id: string,
  label: string,
  overrides: Partial<MenuItemNode> = {},
): MenuItemNode => ({
  id,
  menuId: 'm1',
  parentId: null,
  position: 0,
  depth: 0,
  label,
  targetKind: 'url',
  url: '/',
  targetEntityType: null,
  targetEntityId: null,
  openInNew: false,
  createdAtMs: 0,
  updatedAtMs: 0,
  children: [],
  ...overrides,
})

const treeFixture = (key: string): MenuTree => ({
  menu: menuRow('m1', key),
  items: [
    {
      id: 'i1',
      menuId: 'm1',
      parentId: null,
      position: 0,
      depth: 0,
      label: 'Home',
      targetKind: 'url',
      url: '/',
      targetEntityType: null,
      targetEntityId: null,
      openInNew: false,
      createdAtMs: 0,
      updatedAtMs: 0,
      children: [],
    },
    {
      id: 'i2',
      menuId: 'm1',
      parentId: null,
      position: 1,
      depth: 0,
      label: 'About',
      targetKind: 'url',
      url: '/about',
      targetEntityType: null,
      targetEntityId: null,
      openInNew: false,
      createdAtMs: 0,
      updatedAtMs: 0,
      children: [],
    },
  ],
})

const wrap = (client: MenuClient) =>
  function Wrapper({ children }: { children: React.ReactNode }) {
    return <MenuProvider client={client}>{children}</MenuProvider>
  }

describe('useMenus', () => {
  it('captures list on mount with loading/error/data states', async () => {
    const listMenus = vi.fn(async () => [menuRow('m1', 'primary')])
    const client = { listMenus } as unknown as MenuClient
    const { result } = renderHook(() => useMenus(), { wrapper: wrap(client) })
    expect(result.current.loading).toBe(true)
    expect(result.current.menus).toEqual([])
    await waitFor(() => expect(result.current.loading).toBe(false))
    expect(listMenus).toHaveBeenCalledTimes(1)
    expect(result.current.menus).toEqual([menuRow('m1', 'primary')])
    expect(result.current.error).toBeNull()
  })

  it('captures list error without rejecting; reload retries', async () => {
    const listMenus = vi
      .fn()
      .mockRejectedValueOnce(new Error('boom'))
      .mockResolvedValue([menuRow('m1', 'primary')])
    const client = { listMenus } as unknown as MenuClient
    const { result } = renderHook(() => useMenus(), { wrapper: wrap(client) })
    await waitFor(() => expect(result.current.error).not.toBeNull())
    expect(result.current.loading).toBe(false)
    act(() => result.current.reload())
    await waitFor(() => expect(result.current.error).toBeNull())
    expect(result.current.menus).toEqual([menuRow('m1', 'primary')])
  })

  it('deleteMenu is optimistic and rolls back on rejection', async () => {
    const listMenus = vi.fn(async () => [menuRow('m1', 'primary'), menuRow('m2', 'footer')])
    const deleteMenu = vi.fn().mockRejectedValue(new Error('net'))
    const client = { listMenus, deleteMenu } as unknown as MenuClient
    const { result } = renderHook(() => useMenus(), { wrapper: wrap(client) })
    await waitFor(() => expect(result.current.menus.length).toBe(2))
    const before = result.current.menus
    let rejected = false
    await act(async () => {
      await result.current.deleteMenu('m1').catch(() => {
        rejected = true
      })
    })
    expect(rejected).toBe(true)
    expect(result.current.error).not.toBeNull()
    expect(result.current.menus).toEqual(before)
  })

  it('createMenu appends the created row', async () => {
    const created = menuRow('m3', 'sidebar')
    const listMenus = vi.fn(async () => [menuRow('m1', 'primary')])
    const createMenu = vi.fn(async () => created)
    const client = { listMenus, createMenu } as unknown as MenuClient
    const { result } = renderHook(() => useMenus(), { wrapper: wrap(client) })
    await waitFor(() => expect(result.current.menus.length).toBe(1))
    await act(async () => {
      await result.current.createMenu({ key: 'sidebar', label: 'Sidebar' })
    })
    expect(createMenu).toHaveBeenCalledWith({ key: 'sidebar', label: 'Sidebar' })
    expect(result.current.menus).toEqual([menuRow('m1', 'primary'), created])
  })

  it('updateMenu replaces the matching row', async () => {
    const updated = { ...menuRow('m1', 'primary'), label: 'Main' }
    const listMenus = vi.fn(async () => [menuRow('m1', 'primary')])
    const updateMenu = vi.fn(async () => updated)
    const client = { listMenus, updateMenu } as unknown as MenuClient
    const { result } = renderHook(() => useMenus(), { wrapper: wrap(client) })
    await waitFor(() => expect(result.current.menus.length).toBe(1))
    await act(async () => {
      await result.current.updateMenu('m1', { label: 'Main' })
    })
    expect(updateMenu).toHaveBeenCalledWith('m1', { label: 'Main' })
    expect(result.current.menus).toEqual([updated])
  })
})

describe('useMenuTree', () => {
  it('captures tree on mount with loading/error/data states', async () => {
    const getMenuTree = vi.fn(async () => treeFixture('primary'))
    const client = { getMenuTree } as unknown as MenuClient
    const { result } = renderHook(() => useMenuTree('primary'), { wrapper: wrap(client) })
    expect(result.current.loading).toBe(true)
    expect(result.current.tree).toBeNull()
    await waitFor(() => expect(result.current.loading).toBe(false))
    expect(getMenuTree).toHaveBeenCalledWith('primary')
    expect(result.current.tree?.items.map((i) => i.id)).toEqual(['i1', 'i2'])
    expect(result.current.error).toBeNull()
  })

  it('does NOT fetch on mount when initialData present', () => {
    const getMenuTree = vi.fn()
    const client = { getMenuTree } as unknown as MenuClient
    const seed = treeFixture('primary')
    const { result } = renderHook(() => useMenuTree('primary', { initialData: seed }), {
      wrapper: wrap(client),
    })
    expect(getMenuTree).not.toHaveBeenCalled()
    expect(result.current.tree).toEqual(seed)
    expect(result.current.loading).toBe(false)
  })

  it('removeItem is optimistic and rolls back on rejection', async () => {
    const getMenuTree = vi.fn(async () => treeFixture('primary'))
    const removeItem = vi.fn().mockRejectedValue(new Error('net'))
    const client = { getMenuTree, removeItem } as unknown as MenuClient
    const { result } = renderHook(() => useMenuTree('primary'), { wrapper: wrap(client) })
    await waitFor(() => expect(result.current.tree?.items.length).toBe(2))
    const beforeIds = result.current.tree!.items.map((i) => i.id)
    let rejected = false
    await act(async () => {
      await result.current.removeItem('i1').catch(() => {
        rejected = true
      })
    })
    expect(rejected).toBe(true)
    expect(result.current.error).not.toBeNull()
    expect(result.current.tree!.items.map((i) => i.id)).toEqual(beforeIds)
  })

  it('addItem reloads the tree after success', async () => {
    const seed = treeFixture('primary')
    const getMenuTree = vi
      .fn()
      .mockResolvedValueOnce(seed)
      .mockResolvedValueOnce({
        ...seed,
        items: [
          ...seed.items,
          itemRow('i3', 'Contact', { position: 2, url: '/contact' }),
        ],
      })
    const addItem = vi.fn(async () => itemRow('i3', 'Contact', { position: 2, url: '/contact' }))
    const client = { getMenuTree, addItem } as unknown as MenuClient
    const { result } = renderHook(() => useMenuTree('primary'), { wrapper: wrap(client) })
    await waitFor(() => expect(result.current.tree?.items.length).toBe(2))
    await act(async () => {
      await result.current.addItem('m1', { label: 'Contact', target: { kind: 'url', url: '/contact' } })
    })
    expect(addItem).toHaveBeenCalled()
    await waitFor(() => expect(result.current.tree?.items.map((i) => i.id)).toEqual(['i1', 'i2', 'i3']))
  })

  it('updateItem reloads the tree after success', async () => {
    const seed = treeFixture('primary')
    const updatedTree = {
      ...seed,
      items: seed.items.map((item) =>
        item.id === 'i1' ? { ...item, label: 'Start' } : item,
      ),
    }
    const getMenuTree = vi
      .fn()
      .mockResolvedValueOnce(seed)
      .mockResolvedValueOnce(updatedTree)
    const updateItem = vi.fn(async () => ({ ...seed.items[0]!, label: 'Start' }))
    const client = { getMenuTree, updateItem } as unknown as MenuClient
    const { result } = renderHook(() => useMenuTree('primary'), { wrapper: wrap(client) })
    await waitFor(() => expect(result.current.tree?.items[0]?.label).toBe('Home'))
    await act(async () => {
      await result.current.updateItem('i1', { label: 'Start' })
    })
    expect(updateItem).toHaveBeenCalledWith('i1', { label: 'Start' })
    await waitFor(() => expect(result.current.tree?.items[0]?.label).toBe('Start'))
  })

  it('moveItem is optimistic on success', async () => {
    const seed = treeFixture('primary')
    const getMenuTree = vi.fn(async () => seed)
    const moveItem = vi.fn(async () => seed.items[1]!)
    const client = { getMenuTree, moveItem } as unknown as MenuClient
    const { result } = renderHook(() => useMenuTree('primary'), { wrapper: wrap(client) })
    await waitFor(() => expect(result.current.tree?.items.length).toBe(2))
    await act(async () => {
      await result.current.moveItem('i2', { newParentId: null, newPosition: 0 })
    })
    expect(moveItem).toHaveBeenCalledWith('i2', { newParentId: null, newPosition: 0 })
    expect(result.current.tree?.items.map((i) => i.id)).toEqual(['i2', 'i1'])
    expect(getMenuTree).toHaveBeenCalledTimes(1)
  })

  it('moveItem rolls back on rejection', async () => {
    const seed = treeFixture('primary')
    const getMenuTree = vi.fn(async () => seed)
    const moveItem = vi.fn().mockRejectedValue(new Error('net'))
    const client = { getMenuTree, moveItem } as unknown as MenuClient
    const { result } = renderHook(() => useMenuTree('primary'), { wrapper: wrap(client) })
    await waitFor(() => expect(result.current.tree?.items.length).toBe(2))
    const beforeIds = result.current.tree!.items.map((i) => i.id)
    let rejected = false
    await act(async () => {
      await result.current.moveItem('i2', { newParentId: null, newPosition: 0 }).catch(() => {
        rejected = true
      })
    })
    expect(rejected).toBe(true)
    expect(result.current.error).not.toBeNull()
    expect(result.current.tree!.items.map((i) => i.id)).toEqual(beforeIds)
  })

  it('reorder is optimistic on success', async () => {
    const seed = treeFixture('primary')
    const getMenuTree = vi.fn(async () => seed)
    const reorder = vi.fn(async () => {})
    const client = { getMenuTree, reorder } as unknown as MenuClient
    const { result } = renderHook(() => useMenuTree('primary'), { wrapper: wrap(client) })
    await waitFor(() => expect(result.current.tree?.items.length).toBe(2))
    await act(async () => {
      await result.current.reorder('m1', null, ['i2', 'i1'])
    })
    expect(reorder).toHaveBeenCalledWith('m1', null, ['i2', 'i1'])
    expect(result.current.tree?.items.map((i) => i.id)).toEqual(['i2', 'i1'])
    expect(getMenuTree).toHaveBeenCalledTimes(1)
  })

  it('reorder rolls back on rejection', async () => {
    const seed = treeFixture('primary')
    const getMenuTree = vi.fn(async () => seed)
    const reorder = vi.fn().mockRejectedValue(new Error('net'))
    const client = { getMenuTree, reorder } as unknown as MenuClient
    const { result } = renderHook(() => useMenuTree('primary'), { wrapper: wrap(client) })
    await waitFor(() => expect(result.current.tree?.items.length).toBe(2))
    const beforeIds = result.current.tree!.items.map((i) => i.id)
    let rejected = false
    await act(async () => {
      await result.current.reorder('m1', null, ['i2', 'i1']).catch(() => {
        rejected = true
      })
    })
    expect(rejected).toBe(true)
    expect(result.current.error).not.toBeNull()
    expect(result.current.tree!.items.map((i) => i.id)).toEqual(beforeIds)
  })

  it('clears a prior error when a later optimistic mutation starts', async () => {
    const seed = treeFixture('primary')
    const getMenuTree = vi.fn(async () => seed)
    const reorder = vi
      .fn()
      .mockRejectedValueOnce(new Error('net'))
      .mockResolvedValueOnce(undefined)
    const client = { getMenuTree, reorder } as unknown as MenuClient
    const { result } = renderHook(() => useMenuTree('primary'), { wrapper: wrap(client) })
    await waitFor(() => expect(result.current.tree?.items.length).toBe(2))

    await act(async () => {
      await result.current.reorder('m1', null, ['i2', 'i1']).catch(() => {})
    })
    expect(result.current.error).not.toBeNull()

    await act(async () => {
      await result.current.reorder('m1', null, ['i2', 'i1'])
    })
    expect(result.current.error).toBeNull()
  })
})
