// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import React from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'

import { NotificationBell, type NotificationsApi } from '../../components/NotificationBell.js'
import type {
  Notification,
  NotificationOutcome,
  NotificationStreamHandle,
  NotificationStreamOptions,
} from '../../lib/notifications-client.js'

function ok<T>(value: T): NotificationOutcome<T> {
  return { ok: true, result: value }
}

function err(error = 'boom', status = 500): NotificationOutcome<never> {
  return { ok: false, error, status }
}

function row(overrides: Partial<Notification> = {}): Notification {
  return {
    id: 'n-1',
    userId: 'user-1',
    kind: 'comment_reply',
    documentId: 'doc-1',
    commentId: 'c-1',
    payload: {
      kind: 'comment_reply',
      actorId: 'someone',
      actorName: 'Someone',
      excerpt: 'hi',
    },
    readAt: null,
    createdAt: '2026-01-01T00:00:00.000Z',
    ...overrides,
  }
}

function createFakeApi() {
  let rows: Notification[] = []
  const calls: { name: string; args: unknown[] }[] = []
  const api: NotificationsApi = {
    list: vi.fn(async (opts) => {
      calls.push({ name: 'list', args: [opts] })
      return ok(opts.includeRead ? rows : rows.filter((r) => r.readAt === null))
    }),
    unread: vi.fn(async () => {
      calls.push({ name: 'unread', args: [] })
      return ok(rows.filter((r) => r.readAt === null).length)
    }),
    markRead: vi.fn(async (id) => {
      calls.push({ name: 'markRead', args: [id] })
      const target = rows.find((r) => r.id === id)
      if (!target) return err('not found', 404)
      target.readAt = '2026-01-02T00:00:00.000Z'
      return ok(target)
    }),
    markAllRead: vi.fn(async () => {
      calls.push({ name: 'markAllRead', args: [] })
      let n = 0
      for (const r of rows) {
        if (r.readAt === null) {
          r.readAt = '2026-01-02T00:00:00.000Z'
          n++
        }
      }
      return ok(n)
    }),
  }
  return {
    api,
    calls,
    setRows: (next: Notification[]) => {
      rows = next
    },
    rows: () => rows,
  }
}

describe('NotificationBell — badge', () => {
  it('shows the unread badge from the unread() call', async () => {
    const fake = createFakeApi()
    fake.setRows([row(), row({ id: 'n-2' })])
    render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
    await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('2'))
  })

  it('hides the badge when count is 0', async () => {
    const fake = createFakeApi()
    render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
    // Let the initial unread() settle.
    await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
    expect(screen.queryByTestId('bell-badge')).toBeNull()
  })

  it('clamps the badge at "99+"', async () => {
    const fake = createFakeApi()
    fake.setRows(Array.from({ length: 150 }, (_, i) => row({ id: `n-${i}` })))
    render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
    await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('99+'))
  })

  it('polls unread() at pollIntervalMs', async () => {
    vi.useFakeTimers({ shouldAdvanceTime: true })
    try {
      const fake = createFakeApi()
      render(<NotificationBell api={fake.api} pollIntervalMs={5000} />)
      await waitFor(() => expect(fake.api.unread).toHaveBeenCalledTimes(1))
      await act(async () => {
        vi.advanceTimersByTime(5000)
      })
      expect(fake.api.unread).toHaveBeenCalledTimes(2)
    } finally {
      vi.useRealTimers()
    }
  })
})

describe('NotificationBell — dropdown', () => {
  it('shows the empty state when there are no rows', async () => {
    const fake = createFakeApi()
    render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
    await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
    fireEvent.click(screen.getByTestId('bell-button'))
    await waitFor(() => expect(screen.getByTestId('bell-dropdown')).toBeTruthy())
    await waitFor(() => expect(screen.getByText('All caught up.')).toBeTruthy())
  })

  it('renders one button per row with the formatted summary', async () => {
    const fake = createFakeApi()
    fake.setRows([
      row({
        id: 'n-a',
        payload: {
          kind: 'comment_reply',
          actorId: 'x',
          actorName: 'Alice',
          excerpt: 'good',
        },
      }),
      row({
        id: 'n-b',
        kind: 'comment_resolved',
        payload: {
          kind: 'comment_resolved',
          actorId: 'y',
          actorName: 'Bob',
          rootExcerpt: 'fix',
        },
      }),
    ])
    render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
    await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
    fireEvent.click(screen.getByTestId('bell-button'))
    await waitFor(() => expect(screen.getByTestId('bell-row-n-a')).toBeTruthy())
    expect(screen.getByTestId('bell-row-n-a').textContent).toContain('Alice replied')
    expect(screen.getByTestId('bell-row-n-b').textContent).toContain('Bob resolved')
  })

  it('marks a row read optimistically, then via the API', async () => {
    const fake = createFakeApi()
    const onSelect = vi.fn()
    fake.setRows([row()])
    render(<NotificationBell api={fake.api} pollIntervalMs={0} onSelect={onSelect} />)
    await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
    fireEvent.click(screen.getByTestId('bell-button'))
    await waitFor(() => expect(screen.getByTestId('bell-row-n-1')).toBeTruthy())
    await act(async () => {
      fireEvent.click(screen.getByTestId('bell-row-n-1'))
    })
    expect(fake.api.markRead).toHaveBeenCalledWith('n-1')
    expect(onSelect).toHaveBeenCalledTimes(1)
    expect(screen.queryByTestId('bell-badge')).toBeNull()
  })

  it('rolls back the optimistic update when markRead fails', async () => {
    const fake = createFakeApi()
    ;(fake.api.markRead as ReturnType<typeof vi.fn>).mockResolvedValueOnce(err('server', 500))
    const onError = vi.fn()
    fake.setRows([row()])
    render(<NotificationBell api={fake.api} pollIntervalMs={0} onError={onError} />)
    await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
    fireEvent.click(screen.getByTestId('bell-button'))
    await waitFor(() => expect(screen.getByTestId('bell-row-n-1')).toBeTruthy())
    await act(async () => {
      fireEvent.click(screen.getByTestId('bell-row-n-1'))
    })
    expect(onError).toHaveBeenCalledWith('server')
    // The badge should bounce back to 1.
    await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
  })

  it('marks all read in one click and disables the button after', async () => {
    const fake = createFakeApi()
    fake.setRows([row(), row({ id: 'n-2' })])
    render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
    await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('2'))
    fireEvent.click(screen.getByTestId('bell-button'))
    await waitFor(() => expect(screen.getByTestId('bell-mark-all')).toBeTruthy())
    await act(async () => {
      fireEvent.click(screen.getByTestId('bell-mark-all'))
    })
    expect(fake.api.markAllRead).toHaveBeenCalled()
    expect((screen.getByTestId('bell-mark-all') as HTMLButtonElement).disabled).toBe(true)
  })

  it('surfaces list() errors via onError', async () => {
    const fake = createFakeApi()
    ;(fake.api.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(err('CSRF', 403))
    const onError = vi.fn()
    render(<NotificationBell api={fake.api} pollIntervalMs={0} onError={onError} />)
    await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
    fireEvent.click(screen.getByTestId('bell-button'))
    await waitFor(() => expect(onError).toHaveBeenCalledWith('CSRF'))
  })
})

// ---------------------------------------------------------------------------
// SSE push channel (Phase 7) — verify the bell wires up subscribe(),
// processes incoming notifications, and tears down on unmount.
// ---------------------------------------------------------------------------
describe('NotificationBell — SSE push', () => {
  function withSubscribe(api: NotificationsApi) {
    let captured: NotificationStreamOptions | undefined
    let closed = false
    const handle: NotificationStreamHandle = {
      close: () => {
        closed = true
      },
    }
    const subscribe = vi.fn((opts: NotificationStreamOptions) => {
      captured = opts
      return handle
    })
    return {
      api: { ...api, subscribe },
      push: (n: Notification) => captured?.onNotification(n),
      pushError: (e: Event) => captured?.onError?.(e),
      isClosed: () => closed,
    }
  }

  it('subscribes on mount and bumps the badge when a new notification arrives', async () => {
    const fake = createFakeApi()
    const wired = withSubscribe(fake.api)
    render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
    await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
    await act(async () => {
      wired.push(row({ id: 'sse-1' }))
    })
    await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
  })

  it('does not double-count a notification that arrives via both SSE and REST', async () => {
    const fake = createFakeApi()
    const wired = withSubscribe(fake.api)
    fake.setRows([row({ id: 'race' })])
    render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
    // Open the dropdown so the row is in `items` from the REST list.
    await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
    fireEvent.click(screen.getByTestId('bell-button'))
    await waitFor(() => expect(screen.getByTestId('bell-row-race')).toBeTruthy())
    // Now push the same row via SSE — must NOT add a duplicate or
    // bump the badge above 1.
    await act(async () => {
      wired.push(row({ id: 'race' }))
    })
    expect(screen.getAllByTestId('bell-row-race')).toHaveLength(1)
    expect(screen.getByTestId('bell-badge').textContent).toBe('1')
  })

  it('prepends new pushed rows to the open dropdown', async () => {
    const fake = createFakeApi()
    const wired = withSubscribe(fake.api)
    fake.setRows([row({ id: 'old' })])
    render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
    await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
    fireEvent.click(screen.getByTestId('bell-button'))
    await waitFor(() => expect(screen.getByTestId('bell-row-old')).toBeTruthy())
    await act(async () => {
      wired.push(row({ id: 'fresh' }))
    })
    const rows = screen.getAllByTestId(/^bell-row-/)
    expect(rows[0]!.getAttribute('data-testid')).toBe('bell-row-fresh')
  })

  it('does NOT bump the badge when the pushed notification is already read', async () => {
    const fake = createFakeApi()
    const wired = withSubscribe(fake.api)
    render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
    await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
    await act(async () => {
      wired.push(row({ id: 'already-read', readAt: '2026-01-02T00:00:00.000Z' }))
    })
    // Already-read notifications still appear in the list when the
    // user opens the dropdown with includeRead=true, but they must
    // NOT register a fresh unread bump.
    expect(screen.queryByTestId('bell-badge')).toBeNull()
  })

  it('closes the SSE subscription when the bell unmounts', async () => {
    const fake = createFakeApi()
    const wired = withSubscribe(fake.api)
    const { unmount } = render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
    await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
    unmount()
    expect(wired.isClosed()).toBe(true)
  })

  it('does not surface transient SSE errors via onError (poll loop handles convergence)', async () => {
    const fake = createFakeApi()
    const wired = withSubscribe(fake.api)
    const onError = vi.fn()
    render(<NotificationBell api={wired.api} pollIntervalMs={0} onError={onError} />)
    await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
    await act(async () => {
      wired.pushError(new Event('error'))
    })
    // Deliberately quiet — see NotificationBell SSE handler comment.
    expect(onError).not.toHaveBeenCalled()
  })

  it('mark-all-read failure does not drop SSE rows that arrived mid-flight', async () => {
    const fake = createFakeApi()
    const wired = withSubscribe(fake.api)
    fake.setRows([row({ id: 'before' })])
    // Hold the markAllRead promise so we can interleave an SSE push.
    let resolveMark: (v: NotificationOutcome<number>) => void = () => undefined
    ;(wired.api.markAllRead as ReturnType<typeof vi.fn>).mockImplementationOnce(
      () =>
        new Promise((resolve) => {
          resolveMark = resolve
        }),
    )
    const onError = vi.fn()
    render(<NotificationBell api={wired.api} pollIntervalMs={0} onError={onError} />)
    await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
    fireEvent.click(screen.getByTestId('bell-button'))
    await waitFor(() => expect(screen.getByTestId('bell-row-before')).toBeTruthy())

    // Click mark-all — handler optimistically marks 'before' read,
    // but the API promise is still pending.
    await act(async () => {
      fireEvent.click(screen.getByTestId('bell-mark-all'))
    })
    // While in flight, a fresh notification arrives via SSE.
    await act(async () => {
      wired.push(row({ id: 'mid-flight' }))
    })

    // Now resolve the API with a failure.
    await act(async () => {
      resolveMark({ ok: false, error: 'server', status: 500 })
      // flush promise chain
      await Promise.resolve()
    })

    expect(onError).toHaveBeenCalledWith('server')
    // The original row was rolled back to unread...
    expect(screen.getByTestId('bell-row-before').getAttribute('data-read')).toBe('false')
    // ...and the SSE arrival is still present (not dropped by the
    // legacy snapshot-replace rollback).
    expect(screen.getByTestId('bell-row-mid-flight')).toBeTruthy()
  })

  it('still mounts cleanly when api.subscribe is undefined (no SSE support)', async () => {
    const fake = createFakeApi()
    // Strip the `subscribe` member entirely — defaultApi has it,
    // but consumer-provided test APIs may not.
    const apiWithoutSubscribe: NotificationsApi = { ...fake.api }
    delete (apiWithoutSubscribe as { subscribe?: unknown }).subscribe
    render(<NotificationBell api={apiWithoutSubscribe} pollIntervalMs={0} />)
    await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
    // No crash, badge from unread() shows nothing, no items.
    expect(screen.queryByTestId('bell-badge')).toBeNull()
  })
})
