import { describe, it, expect, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useNotifications } from './useNotifications.js'
import type { NotificationClient } from './client.js'
import type { NotificationRecord } from '@platform-modules/notifications/inbox'

function rec(id: string, readAt: string | null = null): NotificationRecord {
  return { id, userId: 'me', title: id, createdAt: '2026-01-01T00:00:00.000Z', readAt }
}
function fakeClient(over: Partial<NotificationClient> = {}): NotificationClient {
  return {
    list: vi.fn(async () => ({ items: [rec('a'), rec('b')] })),
    unreadCount: vi.fn(async () => 2),
    markRead: vi.fn(async () => {}),
    ...over,
  }
}

describe('useNotifications', () => {
  it('loads items + unread on mount', async () => {
    const { result } = renderHook(() => useNotifications(fakeClient()))
    await waitFor(() => expect(result.current.loading).toBe(false))
    expect(result.current.items.map((i) => i.id)).toEqual(['a', 'b'])
    expect(result.current.unread).toBe(2)
    expect(result.current.error).toBeNull()
  })

  it('captures list error WITHOUT rejecting; reload retries', async () => {
    const list = vi.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValue({ items: [rec('a')] })
    const { result } = renderHook(() => useNotifications(fakeClient({ list, unreadCount: vi.fn(async () => 0) })))
    await waitFor(() => expect(result.current.error).not.toBeNull())
    expect(result.current.loading).toBe(false)
    act(() => result.current.reload())
    // error clears synchronously at reload start — wait on the retried data, not on error
    await waitFor(() => expect(result.current.items.map((i) => i.id)).toEqual(['a']))
    expect(result.current.error).toBeNull()
  })

  it('loadMore appends using nextCursor', async () => {
    const list = vi.fn()
      .mockResolvedValueOnce({ items: [rec('a')], nextCursor: 'c1' })
      .mockResolvedValueOnce({ items: [rec('b')] })
    const { result } = renderHook(() => useNotifications(fakeClient({ list })))
    await waitFor(() => expect(result.current.items.length).toBe(1))
    await act(async () => { await result.current.loadMore() })
    expect(result.current.items.map((i) => i.id)).toEqual(['a', 'b'])
    expect(list).toHaveBeenLastCalledWith(expect.objectContaining({ cursor: 'c1' }))
  })

  it('hasMore reflects nextCursor presence and clears on the last page', async () => {
    const list = vi.fn()
      .mockResolvedValueOnce({ items: [rec('a')], nextCursor: 'c1' })
      .mockResolvedValueOnce({ items: [rec('b')] })
    const { result } = renderHook(() => useNotifications(fakeClient({ list })))
    await waitFor(() => expect(result.current.hasMore).toBe(true))
    await act(async () => { await result.current.loadMore() })
    expect(result.current.hasMore).toBe(false)
  })

  it('hasMore is false when the first page has no cursor', async () => {
    const { result } = renderHook(() => useNotifications(fakeClient()))
    await waitFor(() => expect(result.current.loading).toBe(false))
    expect(result.current.hasMore).toBe(false)
  })

  it('markRead is optimistic and rolls back on rejection (awaitable reject)', async () => {
    const markRead = vi.fn().mockRejectedValue(new Error('net'))
    const { result } = renderHook(() => useNotifications(fakeClient({ markRead })))
    await waitFor(() => expect(result.current.unread).toBe(2))
    let rejected = false
    await act(async () => {
      await result.current.markRead(['a']).catch(() => { rejected = true })
    })
    // mutation error-split: markRead REJECTS (awaitable) ...
    expect(rejected).toBe(true)
    // ... AND captures (sets error), not only rejects
    expect(result.current.error).not.toBeNull()
    // rolled back — unread restored, item not stuck-read
    expect(result.current.unread).toBe(2)
  })

  it('markRead all zeroes unread optimistically on success', async () => {
    const { result } = renderHook(() => useNotifications(fakeClient()))
    await waitFor(() => expect(result.current.unread).toBe(2))
    await act(async () => { await result.current.markRead('all') })
    expect(result.current.unread).toBe(0)
  })

  it('polling OFF by default — no extra list calls over time', async () => {
    vi.useFakeTimers()
    const client = fakeClient()
    renderHook(() => useNotifications(client))
    await vi.advanceTimersByTimeAsync(10_000)
    expect((client.list as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1)
    vi.useRealTimers()
  })

  it('polling opt-in fires on interval and clears on unmount (no leak)', async () => {
    vi.useFakeTimers()
    const client = fakeClient()
    const { unmount } = renderHook(() => useNotifications(client, { pollMs: 1000 }))
    await vi.advanceTimersByTimeAsync(2500)
    const calls = (client.unreadCount as ReturnType<typeof vi.fn>).mock.calls.length
    expect(calls).toBeGreaterThanOrEqual(2)
    unmount()
    await vi.advanceTimersByTimeAsync(5000)
    expect((client.unreadCount as ReturnType<typeof vi.fn>).mock.calls.length).toBe(calls)
    vi.useRealTimers()
  })
})
