import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render, renderHook } from '@testing-library/react'
import { Channel, useChannel, usePresence } from './index'

/** Minimal WebSocket double — jsdom ships none. Drives lifecycle from the test. */
class MockWebSocket {
  static readonly CONNECTING = 0
  static readonly OPEN = 1
  static readonly CLOSING = 2
  static readonly CLOSED = 3
  static instances: MockWebSocket[] = []

  readyState: number = MockWebSocket.CONNECTING
  onopen: (() => void) | null = null
  onmessage: ((event: { data: unknown }) => void) | null = null
  onclose: (() => void) | null = null
  onerror: (() => void) | null = null
  sent: unknown[] = []
  closedCode: number | undefined

  constructor(
    public url: string,
    public protocols?: string | string[],
  ) {
    MockWebSocket.instances.push(this)
  }

  open() {
    this.readyState = MockWebSocket.OPEN
    this.onopen?.()
  }
  emit(data: unknown) {
    this.onmessage?.({ data })
  }
  send(data: unknown) {
    this.sent.push(data)
  }
  close(code?: number) {
    this.readyState = MockWebSocket.CLOSED
    this.closedCode = code
    this.onclose?.()
  }
}

const last = (): MockWebSocket =>
  MockWebSocket.instances[MockWebSocket.instances.length - 1]!

beforeEach(() => {
  MockWebSocket.instances = []
  vi.stubGlobal('WebSocket', MockWebSocket)
})
afterEach(() => {
  vi.unstubAllGlobals()
})

describe('useChannel', () => {
  it('connects to the url and reports open', () => {
    const { result } = renderHook(() => useChannel('wss://x/room'))
    expect(result.current.status).toBe('connecting')
    expect(last().url).toBe('wss://x/room')
    act(() => last().open())
    expect(result.current.status).toBe('open')
  })

  it('parses valid frames and drops junk', () => {
    const onFrame = vi.fn()
    const { result } = renderHook(() => useChannel('wss://x', { onFrame }))
    act(() => last().open())
    act(() => last().emit(JSON.stringify({ v: 1, type: 'ping' })))
    expect(result.current.lastFrame).toEqual({ v: 1, type: 'ping' })
    expect(onFrame).toHaveBeenCalledWith({ v: 1, type: 'ping' })

    act(() => last().emit('not-json'))
    act(() => last().emit(JSON.stringify({ v: 1 }))) // no `type`
    act(() => last().emit(JSON.stringify(['array'])))
    expect(result.current.lastFrame).toEqual({ v: 1, type: 'ping' }) // unchanged
    expect(onFrame).toHaveBeenCalledTimes(1)
  })

  it('guards send by OPEN state', () => {
    const { result } = renderHook(() => useChannel('wss://x'))
    expect(result.current.send('early')).toBe(false)
    act(() => last().open())
    let ok = false
    act(() => {
      ok = result.current.send('hi')
    })
    expect(ok).toBe(true)
    expect(last().sent).toEqual(['hi'])
  })

  it('closes the socket (code 1000) on unmount', () => {
    const { unmount } = renderHook(() => useChannel('wss://x'))
    const ws = last()
    act(() => ws.open())
    unmount()
    expect(ws.closedCode).toBe(1000)
  })

  it('reconnects when the url changes', () => {
    const { result, rerender } = renderHook(({ url }) => useChannel(url), {
      initialProps: { url: 'wss://x/a' },
    })
    const first = last()
    act(() => first.open())
    rerender({ url: 'wss://x/b' })
    expect(first.closedCode).toBe(1000)
    expect(MockWebSocket.instances).toHaveLength(2)
    expect(last().url).toBe('wss://x/b')
    expect(result.current.status).toBe('connecting')
  })

  it('stays idle and opens no socket when url is null', () => {
    const { result } = renderHook(() => useChannel(null))
    expect(result.current.status).toBe('idle')
    expect(MockWebSocket.instances).toHaveLength(0)
  })
})

describe('usePresence', () => {
  it('tracks the online roster from presence frames', () => {
    const { result } = renderHook(() => usePresence('wss://x/room'))
    act(() => last().open())
    act(() => last().emit(JSON.stringify({ v: 1, type: 'presence', online: ['a', 'b'] })))
    expect(result.current.online).toEqual(['a', 'b'])
    expect(result.current.status).toBe('open')
  })

  it('ignores non-presence frames and resets the roster on url change', () => {
    const { result, rerender } = renderHook(({ url }) => usePresence(url), {
      initialProps: { url: 'wss://x/a' },
    })
    act(() => last().open())
    act(() => last().emit(JSON.stringify({ v: 1, type: 'presence', online: ['a'] })))
    act(() => last().emit(JSON.stringify({ v: 1, type: 'message' })))
    expect(result.current.online).toEqual(['a'])
    rerender({ url: 'wss://x/b' })
    expect(result.current.online).toEqual([])
  })

  it('honors a custom presenceType and select', () => {
    const { result } = renderHook(() =>
      usePresence('wss://x', {
        presenceType: 'roster',
        select: (f) => (f as { ids?: string[] }).ids ?? [],
      }),
    )
    act(() => last().open())
    act(() => last().emit(JSON.stringify({ v: 1, type: 'roster', ids: ['z'] })))
    expect(result.current.online).toEqual(['z'])
  })
})

describe('Channel', () => {
  it('passes channel state to the render prop and renders no DOM of its own', () => {
    const { container } = render(
      <Channel url="wss://x" render={(c) => <span data-testid="s">{c.status}</span>} />,
    )
    expect(container.querySelector('[data-testid="s"]')?.textContent).toBe('connecting')
    act(() => last().open())
    expect(container.querySelector('[data-testid="s"]')?.textContent).toBe('open')
  })
})
