import type { ServerFrame } from './index.js'

export type RealtimeClientStatus =
  | 'idle'
  | 'connecting'
  | 'open'
  | 'reconnecting'
  | 'closed'

export interface ReconnectPolicy {
  initialDelayMs?: number
  maxDelayMs?: number
  factor?: number
  /** Symmetric jitter fraction in the range 0..1. `0.2` means ±20%. */
  jitter?: number
}

export interface RealtimeClientOptions<F extends ServerFrame> {
  url: string
  protocols?: string | string[]
  reconnect?: false | ReconnectPolicy
  parse?: (data: unknown) => F | null
  createWebSocket?: (url: string, protocols?: string | string[]) => WebSocket
  /** Injectable random source for deterministic jitter tests. Must return a value in [0, 1]. */
  random?: () => number
}

export type RealtimeSendData = string | ArrayBuffer | ArrayBufferView<ArrayBuffer> | Blob

export interface RealtimeClient<F extends ServerFrame> {
  /** Acquire connection ownership. First owner connects; returned release is idempotent. */
  retain(): () => void
  /** Local frame fan-out subscription. Does not define any wire-level subscription protocol. */
  subscribe(listener: (frame: F) => void): () => void
  subscribeStatus(listener: (status: RealtimeClientStatus) => void): () => void
  getStatus(): RealtimeClientStatus
  getLastFrame(): F | null
  /** Returns false unless the underlying socket is OPEN. */
  send(data: RealtimeSendData): boolean
  /** Terminal shutdown. A closed client instance never reconnects or reopens. */
  close(): void
}

const WS_CONNECTING = 0
const WS_OPEN = 1

const DEFAULT_RECONNECT = {
  initialDelayMs: 500,
  maxDelayMs: 30_000,
  factor: 2,
  jitter: 0.2,
} as const

function defaultParse(data: unknown): ServerFrame | null {
  if (typeof data !== 'string') return null

  let parsed: unknown
  try {
    parsed = JSON.parse(data)
  } catch {
    return null
  }

  if (typeof parsed !== 'object' || parsed === null) return null
  const candidate = parsed as { v?: unknown; type?: unknown }
  if (typeof candidate.v !== 'number' || !Number.isFinite(candidate.v)) return null
  if (typeof candidate.type !== 'string') return null
  return parsed as ServerFrame
}

function normalizeReconnect(policy: ReconnectPolicy | undefined) {
  const initialDelayMs = Math.max(0, policy?.initialDelayMs ?? DEFAULT_RECONNECT.initialDelayMs)
  const maxDelayMs = Math.max(initialDelayMs, policy?.maxDelayMs ?? DEFAULT_RECONNECT.maxDelayMs)
  const factor = Math.max(1, policy?.factor ?? DEFAULT_RECONNECT.factor)
  const jitter = Math.min(1, Math.max(0, policy?.jitter ?? DEFAULT_RECONNECT.jitter))
  return { initialDelayMs, maxDelayMs, factor, jitter }
}

export function createRealtimeClient(
  options: RealtimeClientOptions<ServerFrame> & { parse?: undefined },
): RealtimeClient<ServerFrame>
export function createRealtimeClient<F extends ServerFrame>(
  options: RealtimeClientOptions<F> & { parse: (data: unknown) => F | null },
): RealtimeClient<F>
export function createRealtimeClient<F extends ServerFrame = ServerFrame>(
  options: RealtimeClientOptions<F>,
): RealtimeClient<F> {
  const parse: (data: unknown) => F | null = options.parse
    ?? ((data: unknown) => defaultParse(data) as F | null)
  const createSocket =
    options.createWebSocket ??
    ((url: string, protocols?: string | string[]) => new WebSocket(url, protocols))
  const random = options.random ?? Math.random
  const reconnectPolicy = options.reconnect === false ? null : normalizeReconnect(options.reconnect)

  let status: RealtimeClientStatus = 'idle'
  let lastFrame: F | null = null
  let socket: WebSocket | null = null
  let retainCount = 0
  let reconnectAttempt = 0
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null
  let terminal = false
  let generation = 0

  const frameListeners = new Set<(frame: F) => void>()
  const statusListeners = new Set<(status: RealtimeClientStatus) => void>()

  const transition = (next: RealtimeClientStatus) => {
    if (status === next) return
    status = next
    for (const listener of [...statusListeners]) listener(next)
  }

  const cancelReconnect = () => {
    if (reconnectTimer !== null) {
      clearTimeout(reconnectTimer)
      reconnectTimer = null
    }
  }

  const detachAndCloseSocket = () => {
    const current = socket
    socket = null
    generation += 1
    if (!current) return

    current.onopen = null
    current.onclose = null
    current.onmessage = null
    current.onerror = null
    if (current.readyState === WS_OPEN || current.readyState === WS_CONNECTING) current.close(1000)
  }

  const reconnectDelay = () => {
    if (!reconnectPolicy) return 0
    const base = Math.min(
      reconnectPolicy.maxDelayMs,
      reconnectPolicy.initialDelayMs * reconnectPolicy.factor ** reconnectAttempt,
    )
    reconnectAttempt += 1
    const spread = base * reconnectPolicy.jitter
    const sample = Math.min(1, Math.max(0, random()))
    return Math.max(0, base - spread + 2 * spread * sample)
  }

  const scheduleReconnect = () => {
    if (terminal || retainCount === 0 || reconnectPolicy === null || reconnectTimer !== null) return
    transition('reconnecting')
    const delay = reconnectDelay()
    reconnectTimer = setTimeout(() => {
      reconnectTimer = null
      connect(true)
    }, delay)
  }

  const connect = (isReconnect = false) => {
    if (terminal || retainCount === 0 || socket !== null) return
    cancelReconnect()
    transition(isReconnect ? 'reconnecting' : 'connecting')

    const currentGeneration = ++generation
    const ws = createSocket(options.url, options.protocols)
    socket = ws

    ws.onopen = () => {
      if (terminal || socket !== ws || generation !== currentGeneration) return
      reconnectAttempt = 0
      transition('open')
    }

    ws.onmessage = (event: MessageEvent) => {
      if (terminal || socket !== ws || generation !== currentGeneration) return
      const frame = parse(event.data)
      if (frame === null) return
      lastFrame = frame
      for (const listener of [...frameListeners]) listener(frame)
    }

    ws.onclose = () => {
      if (socket !== ws || generation !== currentGeneration) return
      socket = null
      if (terminal) {
        transition('closed')
        return
      }
      if (retainCount === 0) {
        transition('idle')
        return
      }
      if (reconnectPolicy === null) {
        transition('closed')
        return
      }
      scheduleReconnect()
    }

    // Browsers normally follow `error` with `close`; the close event owns retry scheduling
    // so one failure cannot create two reconnect attempts.
    ws.onerror = () => {}
  }

  return {
    retain() {
      if (terminal) return () => {}
      retainCount += 1
      if (retainCount === 1) connect(false)

      let released = false
      return () => {
        if (released) return
        released = true
        retainCount = Math.max(0, retainCount - 1)
        if (retainCount !== 0 || terminal) return
        cancelReconnect()
        reconnectAttempt = 0
        detachAndCloseSocket()
        transition('idle')
      }
    },

    subscribe(listener) {
      frameListeners.add(listener)
      return () => frameListeners.delete(listener)
    },

    subscribeStatus(listener) {
      statusListeners.add(listener)
      return () => statusListeners.delete(listener)
    },

    getStatus() {
      return status
    },

    getLastFrame() {
      return lastFrame
    },

    send(data) {
      if (!socket || socket.readyState !== WS_OPEN) return false
      socket.send(data)
      return true
    },

    close() {
      if (terminal) return
      terminal = true
      retainCount = 0
      cancelReconnect()
      frameListeners.clear()
      detachAndCloseSocket()
      transition('closed')
      statusListeners.clear()
    },
  }
}
