'use client';

/**
 * Doctor-portal realtime client. Wraps a socket.io connection to the backend
 * gateway at `/realtime` and exposes:
 *
 *  • `useRealtimeNotifications()` — React hook for components that need to
 *    consume `notification:new` events (toaster, lobby badge, etc).
 *  • `subscribeRealtime()` — non-React subscription helper used by the hook.
 *
 * Auth: the doctor's JWT lives in an httpOnly cookie so we fetch it via the
 * `/api/realtime/token` server route once on connect. If the token is null
 * (i.e. not signed in) we silently skip connecting — toasts must still render
 * without errors per the spec.
 *
 * Reconnection: socket.io handles reconnection automatically. We re-read the
 * token only on full hook remount (e.g. login state change) — keeps the
 * implementation small while still being correct for typical session lengths.
 */

import { useEffect, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';

export interface RealtimeNotification {
  id: string;
  kind: string;
  title: string;
  body: string;
  deepLink?: string | null;
  metadata?: Record<string, unknown> | null;
  createdAt: string;
}

const REALTIME_URL =
  process.env.NEXT_PUBLIC_REALTIME_URL ?? 'http://localhost:3000';

type Listener = (n: RealtimeNotification) => void;

interface RealtimeBus {
  socket: Socket | null;
  connected: boolean;
  listeners: Set<Listener>;
  connectListeners: Set<(c: boolean) => void>;
}

// Module-level singleton so multiple components share one socket connection.
const bus: RealtimeBus = {
  socket: null,
  connected: false,
  listeners: new Set(),
  connectListeners: new Set(),
};

let initPromise: Promise<void> | null = null;

async function fetchToken(): Promise<string | null> {
  try {
    const res = await fetch('/api/realtime/token', { cache: 'no-store' });
    if (!res.ok) return null;
    const data = (await res.json()) as { token: string | null };
    return data.token ?? null;
  } catch {
    return null;
  }
}

function setConnected(value: boolean): void {
  if (bus.connected === value) return;
  bus.connected = value;
  bus.connectListeners.forEach((fn) => fn(value));
}

/**
 * Lazily establishes the socket connection (idempotent). Resolves when the
 * first connect attempt has been kicked off — not when actually connected.
 */
async function ensureConnected(): Promise<void> {
  if (bus.socket || initPromise) {
    return initPromise ?? Promise.resolve();
  }
  initPromise = (async () => {
    const token = await fetchToken();
    if (!token) {
      // Not signed in — leave the bus inert. Toaster will simply never fire.
      return;
    }
    const socket = io(`${REALTIME_URL}/realtime`, {
      path: '/socket.io',
      transports: ['websocket', 'polling'],
      auth: { token },
      reconnection: true,
      reconnectionAttempts: Infinity,
      reconnectionDelay: 1_000,
      reconnectionDelayMax: 10_000,
    });
    bus.socket = socket;

    socket.on('connect', () => setConnected(true));
    socket.on('disconnect', () => setConnected(false));
    socket.on('connect_error', () => setConnected(false));

    socket.on('notification:new', (payload: RealtimeNotification) => {
      // Defensive — make sure we always have something to render.
      if (!payload || typeof payload !== 'object') return;
      bus.listeners.forEach((fn) => {
        try {
          fn(payload);
        } catch (err) {
          // Never let one bad listener kill the others.
          // eslint-disable-next-line no-console
          console.error('[realtime] listener threw', err);
        }
      });
    });
  })();
  return initPromise;
}

/** Imperative subscribe — returns an unsubscribe fn. */
export function subscribeRealtime(listener: Listener): () => void {
  bus.listeners.add(listener);
  void ensureConnected();
  return () => {
    bus.listeners.delete(listener);
  };
}

interface UseRealtimeOptions {
  /** Called for every new notification. Stable refs preferred. */
  onEvent?: Listener;
}

interface UseRealtimeResult {
  connected: boolean;
  lastEvent: RealtimeNotification | null;
}

/**
 * React hook that subscribes the calling component to the realtime stream.
 * Tracks `connected` for status indicators and `lastEvent` for components
 * that only care about the most recent payload (e.g. the lobby's peer-joined
 * badge).
 */
export function useRealtimeNotifications(
  options: UseRealtimeOptions = {},
): UseRealtimeResult {
  const { onEvent } = options;
  const [connected, setConnectedState] = useState<boolean>(bus.connected);
  const [lastEvent, setLastEvent] = useState<RealtimeNotification | null>(null);

  useEffect(() => {
    const handler: Listener = (n) => {
      setLastEvent(n);
      if (onEvent) onEvent(n);
    };
    const unsubscribe = subscribeRealtime(handler);

    const connectHandler = (c: boolean): void => setConnectedState(c);
    bus.connectListeners.add(connectHandler);

    return () => {
      unsubscribe();
      bus.connectListeners.delete(connectHandler);
    };
    // We intentionally exclude `onEvent` from deps; consumers can wrap with
    // useCallback if they need stable identity. Re-subscribing on every
    // render would cause us to miss events fired between renders.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return { connected, lastEvent };
}

/**
 * Subscribe to an arbitrary backend event name (eg. `call.extended`,
 * `call.notes-updated`).
 *
 * We subscribe **once per eventName** and dispatch through a ref that
 * always points at the latest handler. Without the ref the effect would
 * resubscribe on every render (inline arrow handlers have unstable
 * identity), and any event firing during the teardown → ensureConnected
 * → resubscribe window — wide because of the async connect — would be
 * silently dropped. The parent call page re-renders every 500 ms for the
 * timer tick, so that gap was hitting `call.ended` reliably in practice.
 */
export function useSocketEvent<T = unknown>(
  eventName: string,
  handler: (payload: T) => void,
): void {
  const handlerRef = useRef(handler);
  handlerRef.current = handler;

  useEffect(() => {
    let cancelled = false;
    const dispatch = (payload: T): void => {
      try {
        handlerRef.current(payload);
      } catch {
        // Listener errors must not break the socket.
      }
    };
    void (async () => {
      await ensureConnected();
      if (cancelled || !bus.socket) return;
      bus.socket.on(eventName, dispatch);
    })();
    return () => {
      cancelled = true;
      bus.socket?.off(eventName, dispatch);
    };
  }, [eventName]);
}

/** Test/util — disconnects and clears the singleton. */
export function disconnectRealtime(): void {
  if (bus.socket) {
    bus.socket.removeAllListeners();
    bus.socket.disconnect();
    bus.socket = null;
  }
  bus.listeners.clear();
  bus.connectListeners.clear();
  bus.connected = false;
  initPromise = null;
}
