/**
 * Unit tests for chatSocket (U5).
 *
 * Covers the exported pure function `resolveIncomingConvId` (heuristic
 * conversation routing) and the `useChatSocket` composable's lifecycle
 * behaviors (heartbeat setup, intentional disconnect suppression, reconnect
 * scheduling). WebSocket injection is mocked at the apiClient level.
 */

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useChatSocket, resolveIncomingConvId } from '@/stores/chatSocket'
import { apiClient } from '@/api/client'
import type { WsServerMessage } from '@/api/types'

// happy-dom does not expose numeric WebSocket constants on the global, but
// chatSocket.ts references `WebSocket.OPEN` / `WebSocket.CONNECTING` /
// `WebSocket.CLOSED`. Install a minimal stub before any test runs.
;(globalThis as unknown as { WebSocket: unknown }).WebSocket = {
  CONNECTING: 0,
  OPEN: 1,
  CLOSING: 2,
  CLOSED: 3,
}

// ── Mock apiClient.createWebSocket ────────────────────────────────────

const WS_CONNECTING = 0
const WS_OPEN = 1
const WS_CLOSED = 3

interface FakeSocket {
  onopen: (() => void) | null
  onmessage: ((e: { data: string }) => void) | null
  onclose: (() => void) | null
  onerror: ((e: unknown) => void) | null
  readyState: number
  send: ReturnType<typeof vi.fn>
  close: ReturnType<typeof vi.fn>
}

function createFakeSocket(): FakeSocket {
  let state = WS_CONNECTING
  return {
    onopen: null,
    onmessage: null,
    onclose: null,
    onerror: null,
    readyState: state,
    send: vi.fn(),
    close: vi.fn(() => {
      state = WS_CLOSED
      // Reflect the new state on the returned object so callers see CLOSED.
      ;(fakeSocket as FakeSocket).readyState = WS_CLOSED
    }),
  }
}

let fakeSocket: FakeSocket

vi.mock('@/api/client', () => ({
  apiClient: {
    createWebSocket: vi.fn(() => {
      fakeSocket = createFakeSocket()
      return fakeSocket
    }),
  },
}))

// ── resolveIncomingConvId (pure) ─────────────────────────────────────

describe('resolveIncomingConvId (pure)', () => {
  it('returns currentConversationId when it is pending', () => {
    const id = resolveIncomingConvId(
      'current',
      new Set(['current', 'other']),
      new Map([
        ['current', 100],
        ['other', 200],
      ]),
    )
    expect(id).toBe('current')
  })

  it('falls back to most-recently-used pending conversation', () => {
    const id = resolveIncomingConvId(
      null,
      new Set(['a', 'b']),
      new Map([
        ['a', 100],
        ['b', 300],
      ]),
    )
    expect(id).toBe('b')
  })

  it('skips conversations that are not currently pending', () => {
    const id = resolveIncomingConvId(
      null,
      new Set(['a']),
      new Map([
        ['a', 100],
        ['stale', 999],
      ]),
    )
    expect(id).toBe('a')
  })

  it('returns currentConversationId when no pending conv exists', () => {
    const id = resolveIncomingConvId(
      'current',
      new Set(),
      new Map([['old', 100]]),
    )
    expect(id).toBe('current')
  })

  it('returns empty string when nothing resolves', () => {
    const id = resolveIncomingConvId(null, new Set(), new Map())
    expect(id).toBe('')
  })
})

// ── useChatSocket lifecycle ──────────────────────────────────────────

describe('useChatSocket', () => {
  beforeEach(() => {
    vi.useFakeTimers()
    ;(apiClient.createWebSocket as ReturnType<typeof vi.fn>).mockClear()
  })
  afterEach(() => {
    vi.useRealTimers()
  })

  function makeSocket() {
    const currentConversationId = ref<string | null>('c1')
    const pendingConversations = ref<Set<string>>(new Set())
    const pendingLastUsedAt = ref<Map<string, number>>(new Map())
    const onMessage = vi.fn()
    const onReconnect = vi.fn()
    const onDisconnect = vi.fn()

    const socket = useChatSocket({
      currentConversationId,
      pendingConversations,
      pendingLastUsedAt,
      onMessage,
      onReconnect,
      onDisconnect,
    })
    return { socket, currentConversationId, pendingConversations, pendingLastUsedAt, onMessage, onReconnect, onDisconnect }
  }

  it('connectWebSocket: opens socket and fires onReconnect on open', () => {
    const { socket, onReconnect } = makeSocket()
    socket.connectWebSocket()
    expect(fakeSocket.readyState).toBe(WS_CONNECTING)
    fakeSocket.readyState = WS_OPEN
    fakeSocket.onopen?.()
    expect(socket.isWsConnected.value).toBe(true)
    expect(onReconnect).toHaveBeenCalled()
  })

  it('heartbeat: sends ping every 30s while open', () => {
    const { socket } = makeSocket()
    socket.connectWebSocket()
    fakeSocket.readyState = WS_OPEN
    fakeSocket.onopen?.()
    expect(fakeSocket.send).not.toHaveBeenCalled()
    vi.advanceTimersByTime(30000)
    expect(fakeSocket.send).toHaveBeenCalledWith(JSON.stringify({ type: 'ping' }))
    vi.advanceTimersByTime(30000)
    expect(fakeSocket.send).toHaveBeenCalledTimes(2)
  })

  it('onmessage: parses JSON and forwards to onMessage callback', () => {
    const { socket, onMessage } = makeSocket()
    socket.connectWebSocket()
    fakeSocket.readyState = WS_OPEN
    fakeSocket.onopen?.()
    const event: WsServerMessage = { type: 'pong' }
    fakeSocket.onmessage?.({ data: JSON.stringify(event) })
    expect(onMessage).toHaveBeenCalledWith(event)
  })

  it('onmessage: swallows malformed JSON without crashing', () => {
    const { socket, onMessage } = makeSocket()
    socket.connectWebSocket()
    fakeSocket.readyState = WS_OPEN
    fakeSocket.onopen?.()
    fakeSocket.onmessage?.({ data: 'not-json' })
    expect(onMessage).not.toHaveBeenCalled()
  })

  it('onclose: schedules reconnect after 3s when not intentional', () => {
    const { socket, pendingConversations, pendingLastUsedAt, onDisconnect } = makeSocket()
    socket.connectWebSocket()
    fakeSocket.readyState = WS_OPEN
    fakeSocket.onopen?.()
    pendingConversations.value.add('c1')
    pendingLastUsedAt.value.set('c1', 1)

    fakeSocket.readyState = WS_CLOSED
    fakeSocket.onclose?.()

    expect(socket.isWsConnected.value).toBe(false)
    expect(onDisconnect).toHaveBeenCalled()
    // P2 #21 fix: pending state cleared on close so UI doesn't stay stuck.
    expect(pendingConversations.value.has('c1')).toBe(false)
    // Reconnect not yet — 3s delay.
    const createWs = apiClient.createWebSocket as ReturnType<typeof vi.fn>
    expect(createWs).toHaveBeenCalledTimes(1)
    vi.advanceTimersByTime(3000)
    expect(createWs).toHaveBeenCalledTimes(2)
  })

  it('disconnectWebSocket: suppresses auto-reconnect', () => {
    const { socket } = makeSocket()
    socket.connectWebSocket()
    fakeSocket.readyState = WS_OPEN
    fakeSocket.onopen?.()

    socket.disconnectWebSocket()
    expect(fakeSocket.close).toHaveBeenCalled()
    expect(socket.isWsConnected.value).toBe(false)

    const createWs = apiClient.createWebSocket as ReturnType<typeof vi.fn>
    const callsBefore = createWs.mock.calls.length
    // Even if onclose fires later, no reconnect should happen.
    fakeSocket.readyState = WS_CLOSED
    fakeSocket.onclose?.()
    vi.advanceTimersByTime(10000)
    expect(createWs.mock.calls.length).toBe(callsBefore)
  })

  it('connectWebSocket: skips when socket already OPEN', () => {
    const { socket } = makeSocket()
    socket.connectWebSocket()
    fakeSocket.readyState = WS_OPEN
    fakeSocket.onopen?.()
    const firstSocket = fakeSocket

    socket.connectWebSocket() // should be a no-op
    expect(fakeSocket).toBe(firstSocket)
  })

  it('connectWebSocket: skips when socket is CONNECTING', () => {
    const { socket } = makeSocket()
    socket.connectWebSocket()
    // fakeSocket starts in CONNECTING state
    const firstSocket = fakeSocket
    socket.connectWebSocket()
    expect(fakeSocket).toBe(firstSocket)
  })
})
