import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ServerFrame } from './index.js'
import { createRealtimeClient } from './client.js'

interface TestFrame extends ServerFrame<'ping'> {
  v: 1
  value: number
}

const parseTestFrame = (data: unknown): TestFrame | null => {
  if (typeof data !== 'string') return null
  try {
    const parsed = JSON.parse(data) as Partial<TestFrame>
    return parsed.v === 1 && parsed.type === 'ping' && typeof parsed.value === 'number'
      ? parsed as TestFrame
      : null
  } catch {
    return null
  }
}

if (false) {
  // @ts-expect-error A narrowed frame type must be backed by an explicit runtime parser.
  createRealtimeClient<TestFrame>({ url: 'wss://example.test/realtime' })
}

class FakeWebSocket {
  static readonly CONNECTING = 0
  static readonly OPEN = 1
  static readonly CLOSING = 2
  static readonly CLOSED = 3

  readyState = FakeWebSocket.CONNECTING
  sent: unknown[] = []
  closeCalls: Array<{ code?: number }> = []
  onopen: ((event: Event) => void) | null = null
  onclose: ((event: CloseEvent) => void) | null = null
  onmessage: ((event: MessageEvent) => void) | null = null
  onerror: ((event: Event) => void) | null = null

  open() {
    this.readyState = FakeWebSocket.OPEN
    this.onopen?.(new Event('open'))
  }

  message(data: unknown) {
    this.onmessage?.({ data } as MessageEvent)
  }

  remoteClose() {
    this.readyState = FakeWebSocket.CLOSED
    this.onclose?.({ code: 1006 } as CloseEvent)
  }

  send(data: unknown) {
    this.sent.push(data)
  }

  close(code?: number) {
    this.closeCalls.push({ code })
    this.readyState = FakeWebSocket.CLOSED
  }
}

function harness(options: { reconnect?: false | { initialDelayMs: number; maxDelayMs: number; factor: number; jitter: number }; random?: () => number } = {}) {
  const sockets: FakeWebSocket[] = []
  const client = createRealtimeClient<TestFrame>({
    url: 'wss://example.test/realtime',
    reconnect: options.reconnect,
    random: options.random,
    parse: parseTestFrame,
    createWebSocket: () => {
      const socket = new FakeWebSocket()
      sockets.push(socket)
      return socket as unknown as WebSocket
    },
  })
  return { client, sockets }
}

afterEach(() => vi.useRealTimers())

describe('createRealtimeClient', () => {
  it('shares one socket across retains and closes on the last idempotent release', () => {
    const { client, sockets } = harness()
    const releaseA = client.retain()
    const releaseB = client.retain()
    expect(sockets).toHaveLength(1)

    sockets[0]!.open()
    expect(client.getStatus()).toBe('open')

    releaseA()
    releaseA()
    expect(sockets[0]!.closeCalls).toHaveLength(0)

    releaseB()
    expect(sockets[0]!.closeCalls).toEqual([{ code: 1000 }])
    expect(client.getStatus()).toBe('idle')
  })

  it('fans one parsed frame out to all listeners and supports unsubscribe', () => {
    const { client, sockets } = harness()
    client.retain()
    const a = vi.fn()
    const b = vi.fn()
    const unsubscribeA = client.subscribe(a)
    client.subscribe(b)

    sockets[0]!.message('{"v":1,"type":"ping","value":7}')
    expect(a).toHaveBeenCalledWith({ v: 1, type: 'ping', value: 7 })
    expect(b).toHaveBeenCalledWith({ v: 1, type: 'ping', value: 7 })
    expect(client.getLastFrame()).toEqual({ v: 1, type: 'ping', value: 7 })

    unsubscribeA()
    sockets[0]!.message('{"v":1,"type":"ping","value":8}')
    expect(a).toHaveBeenCalledOnce()
    expect(b).toHaveBeenCalledTimes(2)
  })

  it('drops malformed, binary, and structurally invalid custom frames', () => {
    const { client, sockets } = harness()
    client.retain()
    const listener = vi.fn()
    client.subscribe(listener)

    sockets[0]!.message('not-json')
    sockets[0]!.message(new ArrayBuffer(2))
    sockets[0]!.message('{"v":1,"type":"ping"}')
    sockets[0]!.message('{"v":2,"type":"ping","value":1}')
    expect(listener).not.toHaveBeenCalled()
  })

  it('default parser validates the full ServerFrame trust-boundary floor', () => {
    const sockets: FakeWebSocket[] = []
    const client = createRealtimeClient({
      url: 'wss://example.test/realtime',
      reconnect: false,
      createWebSocket: () => {
        const socket = new FakeWebSocket()
        sockets.push(socket)
        return socket as unknown as WebSocket
      },
    })
    const listener = vi.fn()
    client.subscribe(listener)
    client.retain()

    sockets[0]!.message('{"type":"ping"}')
    sockets[0]!.message('{"v":"1","type":"ping"}')
    sockets[0]!.message('{"v":1e999,"type":"ping"}')
    sockets[0]!.message('{"v":1,"type":7}')
    expect(listener).not.toHaveBeenCalled()

    sockets[0]!.message('{"v":1,"type":"ping"}')
    expect(listener).toHaveBeenCalledOnce()
    expect(listener).toHaveBeenCalledWith({ v: 1, type: 'ping' })
  })

  it('sends only while open', () => {
    const { client, sockets } = harness()
    client.retain()
    expect(client.send('before')).toBe(false)
    sockets[0]!.open()
    expect(client.send('after')).toBe(true)
    expect(sockets[0]!.sent).toEqual(['after'])
  })

  it('reconnects after abnormal close with deterministic jitter and stops after release', () => {
    vi.useFakeTimers()
    const { client, sockets } = harness({
      reconnect: { initialDelayMs: 100, maxDelayMs: 1000, factor: 2, jitter: 0.2 },
      random: () => 0,
    })
    const release = client.retain()
    sockets[0]!.open()
    sockets[0]!.remoteClose()
    expect(client.getStatus()).toBe('reconnecting')

    vi.advanceTimersByTime(79)
    expect(sockets).toHaveLength(1)
    vi.advanceTimersByTime(1)
    expect(sockets).toHaveLength(2)

    release()
    vi.advanceTimersByTime(10_000)
    expect(sockets).toHaveLength(2)
    expect(client.getStatus()).toBe('idle')
  })

  it('resets backoff after a successful reconnect', () => {
    vi.useFakeTimers()
    const { client, sockets } = harness({
      reconnect: { initialDelayMs: 100, maxDelayMs: 1000, factor: 2, jitter: 0 },
    })
    client.retain()
    sockets[0]!.open()
    sockets[0]!.remoteClose()
    vi.advanceTimersByTime(100)
    expect(sockets).toHaveLength(2)

    sockets[1]!.open()
    sockets[1]!.remoteClose()
    vi.advanceTimersByTime(99)
    expect(sockets).toHaveLength(2)
    vi.advanceTimersByTime(1)
    expect(sockets).toHaveLength(3)
  })

  it('can disable reconnect', () => {
    vi.useFakeTimers()
    const { client, sockets } = harness({ reconnect: false })
    client.retain()
    sockets[0]!.open()
    sockets[0]!.remoteClose()
    expect(client.getStatus()).toBe('closed')
    vi.advanceTimersByTime(60_000)
    expect(sockets).toHaveLength(1)
  })

  it('terminal close cancels resources and prevents reopen', () => {
    vi.useFakeTimers()
    const { client, sockets } = harness({
      reconnect: { initialDelayMs: 100, maxDelayMs: 1000, factor: 2, jitter: 0 },
    })
    client.retain()
    sockets[0]!.remoteClose()
    client.close()
    expect(client.getStatus()).toBe('closed')
    client.retain()
    vi.advanceTimersByTime(10_000)
    expect(sockets).toHaveLength(1)
  })
})
