import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type { ServerFrame } from '@platform-modules/realtime'

/**
 * `@platform-modules/realtime-react` — headless client adapter for the `@platform-modules/realtime` wire contract.
 *
 * Depends only on the isomorphic frame types from `@platform-modules/realtime` (type-only) plus a
 * `react` peer. Deliberately small: each hook owns ONE WebSocket to one URL and surfaces
 * its status. Sharing a connection across a subtree, multiplexing rooms over one socket,
 * and reconnect/backoff policy are the HOST's concern (to reconnect, re-key the hook by
 * changing `url`) — see the realtime module spec. Adapters stay headless: hook +
 * render-prop, the host owns all markup.
 */

/** Lifecycle of a channel's socket. `closed` is terminal — re-key (`url`) to reconnect. */
export type ConnectionStatus = 'idle' | 'connecting' | 'open' | 'closed'

/**
 * Structural guard: a frame must be a JSON object with a string `type`. Payload shape is
 * the host's contract — the generic `F` is a compile-time type, not a runtime schema, so
 * this intentionally does not validate `v` or payload fields. Non-string (binary) and
 * non-JSON messages are dropped.
 */
function parseFrame<F extends ServerFrame>(data: unknown): F | 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
  if (typeof (parsed as { type?: unknown }).type !== 'string') return null
  return parsed as F
}

export interface UseChannelOptions<F extends ServerFrame> {
  /** Invoked for every parsed frame. Stored in a ref — changing its identity does NOT reconnect. */
  onFrame?: (frame: F) => void
  /** Invoked on every status transition. Ref-stable like `onFrame`. */
  onStatus?: (status: ConnectionStatus) => void
  /** WebSocket subprotocol(s), forwarded verbatim to the constructor. */
  protocols?: string | string[]
}

export interface Channel<F extends ServerFrame> {
  /** Current connection state. */
  status: ConnectionStatus
  /** Last parsed frame, or `null` before the first valid message. */
  lastFrame: F | null
  /** Send to the server. Returns `false` (no-op) unless the socket is OPEN. */
  send: (data: string | ArrayBufferLike | ArrayBufferView | Blob) => boolean
}

/**
 * Subscribe to a realtime channel: open ONE WebSocket to `url`, parse {@link ServerFrame}s,
 * and surface connection status + the last frame. Pass `url = null` to stay idle (e.g.
 * before authentication). Closes the socket (code 1000) on unmount or when `url`/`protocols`
 * change; StrictMode-safe via a per-effect disposal guard.
 */
export function useChannel<F extends ServerFrame = ServerFrame>(
  url: string | null,
  options: UseChannelOptions<F> = {},
): Channel<F> {
  const [status, setStatus] = useState<ConnectionStatus>(url ? 'connecting' : 'idle')
  const [lastFrame, setLastFrame] = useState<F | null>(null)

  const onFrameRef = useRef(options.onFrame)
  const onStatusRef = useRef(options.onStatus)
  const protocolsRef = useRef(options.protocols)
  const socketRef = useRef<WebSocket | null>(null)

  // Keep callbacks/protocols current without forcing a reconnect. Declared before the
  // connection effect so the refs are set before that effect reads them on mount.
  useEffect(() => {
    onFrameRef.current = options.onFrame
    onStatusRef.current = options.onStatus
    protocolsRef.current = options.protocols
  })

  // Stable identity for the protocols dep so an inline array literal doesn't reconnect.
  const protocolsKey = Array.isArray(options.protocols)
    ? options.protocols.join(',')
    : options.protocols ?? ''

  useEffect(() => {
    if (url === null) {
      setStatus('idle')
      return
    }

    let disposed = false
    const transition = (next: ConnectionStatus) => {
      if (disposed) return
      setStatus(next)
      onStatusRef.current?.(next)
    }

    transition('connecting')
    const ws = new WebSocket(url, protocolsRef.current)
    socketRef.current = ws

    ws.onopen = () => transition('open')
    ws.onclose = () => transition('closed')
    ws.onmessage = (event: MessageEvent) => {
      if (disposed) return
      const frame = parseFrame<F>(event.data)
      if (frame === null) return
      setLastFrame(frame)
      onFrameRef.current?.(frame)
    }

    return () => {
      disposed = true
      ws.onopen = ws.onclose = ws.onmessage = ws.onerror = null
      if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
        ws.close(1000)
      }
      if (socketRef.current === ws) socketRef.current = null
    }
    // Reconnect only when the endpoint identity changes; callbacks live in refs above.
  }, [url, protocolsKey])

  const send = useCallback<Channel<F>['send']>((data) => {
    const ws = socketRef.current
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(data)
      return true
    }
    return false
  }, [])

  return { status, lastFrame, send }
}

export interface UsePresenceOptions<F extends ServerFrame> {
  /** Frame `type` that carries a presence roster. Default `'presence'`. */
  presenceType?: string
  /** Extract the online-id list from a presence frame. Default reads a string[] `online` field. */
  select?: (frame: F) => readonly string[]
  /** WebSocket subprotocol(s), forwarded to the underlying channel. */
  protocols?: string | string[]
}

export interface Presence {
  /** Ids reported online by the most recent presence frame (empty until one arrives). */
  online: readonly string[]
  /** Underlying channel status. */
  status: ConnectionStatus
}

const defaultPresenceSelect = (frame: ServerFrame): readonly string[] => {
  const online = (frame as { online?: unknown }).online
  return Array.isArray(online) ? online.filter((id): id is string => typeof id === 'string') : []
}

/**
 * Derive an online roster from presence frames on a channel, built on {@link useChannel}.
 * Presence is a single-source convention, so the frame `type` and the roster
 * extractor are configurable rather than hard-coded. The roster resets when `url` changes
 * (a new channel starts empty).
 */
export function usePresence<F extends ServerFrame = ServerFrame>(
  url: string | null,
  options: UsePresenceOptions<F> = {},
): Presence {
  const {
    presenceType = 'presence',
    select = defaultPresenceSelect as (frame: F) => readonly string[],
    protocols,
  } = options
  const [online, setOnline] = useState<readonly string[]>([])

  // New channel ⇒ stale roster cleared before any frame from the new connection.
  useEffect(() => {
    setOnline([])
  }, [url])

  // Inline closure is refreshed each render and stored in useChannel's ref, so the latest
  // presenceType/select are used without reconnecting.
  const { status } = useChannel<F>(url, {
    protocols,
    onFrame: (frame) => {
      if (frame.type === presenceType) setOnline(select(frame))
    },
  })

  return { online, status }
}

export interface ChannelProps<F extends ServerFrame> extends UseChannelOptions<F> {
  /** Channel URL, or `null` to stay idle. */
  url: string | null
  /** Headless render-prop: receives channel state, returns host markup. The host owns all DOM. */
  render: (channel: Channel<F>) => ReactNode
}

/**
 * Headless render-prop wrapper over {@link useChannel} for hosts that prefer a component
 * to a hook. Renders nothing of its own — the host owns every element via `render`.
 */
export function Channel<F extends ServerFrame = ServerFrame>({
  url,
  render,
  ...options
}: ChannelProps<F>): ReactNode {
  return render(useChannel<F>(url, options))
}
