/**
 * Tests for useSSE — SSE connection lifecycle, back-off, event parsing, and cleanup.
 *
 * jsdom does not include EventSource, so we mock it completely.
 */
import { renderHook, act, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useSSE } from './useSSE'

// ---------------------------------------------------------------------------
// Mock EventSource — defined as a plain class so `new EventSource()` works
// ---------------------------------------------------------------------------
class MockEventSource {
  url: string
  onopen: (() => void) | null = null
  onerror: ((evt: Event) => void) | null = null
  onmessage: ((evt: MessageEvent) => void) | null = null
  private listeners: Map<string, Array<(evt: Event) => void>> = new Map()
  readyState: number = 0

  constructor(url: string) {
    this.url = url
  }

  addEventListener(type: string, handler: (evt: Event) => void) {
    if (!this.listeners.has(type)) this.listeners.set(type, [])
    this.listeners.get(type)!.push(handler)
  }

  removeEventListener() { /* no-op for tests */ }

  close() {
    this.readyState = 2
    this.onopen = null
    this.onerror = null
    this.onmessage = null
    this.listeners.clear()
  }

  // Test helpers
  _simulateOpen() { this.onopen?.() }
  _simulateError() { this.onerror?.(new Event('error')) }
  _simulateNamedEvent(type: string, data: string) {
    const handlers = this.listeners.get(type)
    if (handlers) {
      const evt = new MessageEvent(type, { data }) as Event
      handlers.forEach((h) => h(evt))
    }
  }
  _simulateMessage(data: string) {
    this.onmessage?.(new MessageEvent('message', { data }) as MessageEvent)
  }

  static readonly CONNECTING = 0
  static readonly OPEN = 1
  static readonly CLOSED = 2
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
let esInstances: MockEventSource[]

describe('useSSE', () => {
  beforeEach(() => {
    esInstances = []
    // Replace global EventSource with our mock class
    Object.defineProperty(globalThis, 'EventSource', {
      // The mock must use a class for `new EventSource()` to work
      value: class extends MockEventSource {
        constructor(url: string) {
          super(url)
          esInstances.push(this)
        }
      },
      writable: true,
      configurable: true,
    })
    vi.useFakeTimers({ shouldAdvanceTime: true })
  })

  afterEach(() => {
    vi.restoreAllMocks()
    vi.useRealTimers()
  })

  // ── Initial connection ──────────────────────────────────────────────────
  it('starts in "connecting" state and creates an EventSource', () => {
    const { result } = renderHook(() => useSSE({ url: '/api/events' }))

    expect(result.current.status).toBe('connecting')
    expect(esInstances.length).toBeGreaterThanOrEqual(1)
    expect(esInstances[0].url).toBe('/api/events')
  })

  it('transitions to "connected" on open', async () => {
    const onOpen = vi.fn()
    const { result } = renderHook(() => useSSE({ url: '/api/events', onOpen }))

    act(() => { esInstances[0]._simulateOpen() })

    await waitFor(() => {
      expect(result.current.status).toBe('connected')
    })
    expect(onOpen).toHaveBeenCalledTimes(1)
  })

  // ── Reconnection with exponential back-off ──────────────────────────────
  it('retries after error with exponential back-off', async () => {
    const { result } = renderHook(() =>
      useSSE({ url: '/api/events', reconnectBaseMs: 1000, reconnectMaxMs: 30000 }),
    )

    // First error → reconnecting, retry at 1s
    act(() => { esInstances[0]._simulateError() })
    await waitFor(() => { expect(result.current.status).toBe('reconnecting') })
    expect(esInstances).toHaveLength(1)

    // Advance 1000ms → second EventSource created
    act(() => { vi.advanceTimersByTime(1000) })
    expect(esInstances).toHaveLength(2)

    // Second error → reconnecting, retry at 2s
    act(() => { esInstances[1]._simulateError() })
    await waitFor(() => { expect(result.current.status).toBe('reconnecting') })
    act(() => { vi.advanceTimersByTime(2000) })
    expect(esInstances).toHaveLength(3)

    // Third error → reconnecting, retry at 4s
    act(() => { esInstances[2]._simulateError() })
    act(() => { vi.advanceTimersByTime(4000) })
    expect(esInstances).toHaveLength(4)
  })

  it('caps reconnect delay at reconnectMaxMs', async () => {
    renderHook(() =>
      useSSE({ url: '/api/events', reconnectBaseMs: 1000, reconnectMaxMs: 10000 }),
    )

    // Force 5 errors to push the exponent past the cap
    for (let i = 0; i < 5; i++) {
      act(() => { esInstances[i]._simulateError() })
      const expectedDelay = Math.min(1000 * 2 ** i, 10000)
      act(() => { vi.advanceTimersByTime(expectedDelay) })
    }

    // 6 ES instances created (initial + 5 retries)
    expect(esInstances).toHaveLength(6)
  })

  // ── Circuit-breaker (max retries) ───────────────────────────────────────
  it('transitions to "error" after reconnectLimit is exceeded', async () => {
    const { result } = renderHook(() =>
      useSSE({ url: '/api/events', reconnectBaseMs: 100, reconnectLimit: 2 }),
    )

    // First error → reconnecting
    act(() => { esInstances[0]._simulateError() })
    await waitFor(() => { expect(result.current.status).toBe('reconnecting') })

    // Advance → retry
    act(() => { vi.advanceTimersByTime(100) })

    // Second error → reconnecting (attempt 2, still ≤ limit)
    act(() => { esInstances[1]._simulateError() })
    await waitFor(() => { expect(result.current.status).toBe('reconnecting') })
    act(() => { vi.advanceTimersByTime(200) })

    // Third error → limit exceeded (3 > 2) → error
    act(() => { esInstances[2]._simulateError() })
    await waitFor(() => { expect(result.current.status).toBe('error') })
  })

  it('resets reconnect counter on successful connection', async () => {
    const { result } = renderHook(() =>
      useSSE({ url: '/api/events', reconnectBaseMs: 100, reconnectLimit: 3 }),
    )

    // Two errors then a successful connect
    act(() => { esInstances[0]._simulateError() })
    act(() => { vi.advanceTimersByTime(100) })

    act(() => { esInstances[1]._simulateOpen() })
    await waitFor(() => { expect(result.current.status).toBe('connected') })

    // Now error again — counter should be reset, so we get fresh attempts
    act(() => { esInstances[1]._simulateError() })
    await waitFor(() => { expect(result.current.status).toBe('reconnecting') })
    expect(result.current.status).toBe('reconnecting')
  })

  // ── Cleanup on unmount ───────────────────────────────────────────────────
  it('closes EventSource on unmount', () => {
    const closeSpy = vi.spyOn(MockEventSource.prototype, 'close')
    const { unmount } = renderHook(() => useSSE({ url: '/api/events' }))

    unmount()
    expect(closeSpy).toHaveBeenCalled()
  })

  it('does not update state after unmount', async () => {
    const { result, unmount } = renderHook(() => useSSE({ url: '/api/events' }))

    unmount()

    // These should be no-ops after unmount (mountedRef guards)
    act(() => { esInstances[0]._simulateOpen() })
    act(() => { esInstances[0]._simulateError() })

    // State should not have changed
    expect(result.current.status).toBe('connecting')
  })

  // ── Event parsing ───────────────────────────────────────────────────────
  it('parses valid JSON data into objects', async () => {
    const onMessage = vi.fn()
    renderHook(() => useSSE({ url: '/api/events', onMessage }))

    act(() => {
      esInstances[0]._simulateNamedEvent('agent.status', JSON.stringify({ agentId: 'a1', status: 'active' }))
    })

    expect(onMessage).toHaveBeenCalledWith({
      type: 'agent.status',
      data: { agentId: 'a1', status: 'active' },
    })
  })

  it('passes invalid JSON through as raw string', async () => {
    const onMessage = vi.fn()
    renderHook(() => useSSE({ url: '/api/events', onMessage }))

    act(() => {
      esInstances[0]._simulateNamedEvent('agent.status', 'not valid json {{{')
    })

    expect(onMessage).toHaveBeenCalledWith({
      type: 'agent.status',
      data: 'not valid json {{{',
    })
  })

  // ── enabled=false skips connection ──────────────────────────────────────
  it('does not create EventSource when enabled=false', () => {
    const { result } = renderHook(() => useSSE({ url: '/api/events', enabled: false }))

    expect(esInstances).toHaveLength(0)
    expect(result.current.status).toBe('connecting')
  })

  // ── onError callback ────────────────────────────────────────────────────
  it('calls onError on connection failure', async () => {
    const onError = vi.fn()
    renderHook(() =>
      useSSE({ url: '/api/events', onError, reconnectBaseMs: 100 }),
    )

    act(() => { esInstances[0]._simulateError() })
    expect(onError).toHaveBeenCalledTimes(1)
  })

  // ── Default URL ─────────────────────────────────────────────────────────
  it('uses /api/events as default URL', () => {
    renderHook(() => useSSE())
    expect(esInstances[0].url).toBe('/api/events')
  })
})
