/**
 * use-live-notifications — singleton WebSocket manager + React hooks.
 *
 * Architecture:
 *  - Module-level singleton: one WebSocket per tab, shared across all React roots
 *    that call `useLiveNotifications()`. A mount counter keeps it open as long as
 *    at least one root is mounted; closes cleanly when the last root unmounts.
 *  - Exponential backoff reconnect (500ms → 30s).
 *  - msgId-keyed deduplication (5-minute TTL) guards against server retransmits.
 *  - BroadcastChannel cross-tab dedup: a tab that receives an inbox event posts to
 *    all other tabs so they also invalidate their query caches — one WS does the
 *    work for multiple tabs.
 *  - notif-bus: per-channel pub/sub so individual components can `useLive(channel, fn)`
 *    for real-time updates without polling.
 *
 * Exports:
 *  - `useLiveNotifications()` — mount once in app root (HydratedIsland). Manages
 *    the WebSocket lifecycle. Returns `connected` boolean for LiveBadge.
 *  - `useLive(channel, handler)` — subscribe to a specific DO topic channel.
 *
 * @wave 7 — client hook + bell wire-up
 */

import { useEffect, useRef, useState } from 'react';
import { useQueryClient, type QueryClient } from '@tanstack/react-query';
import { emit, on, channels } from '@/lib/notif-bus';
import { captureCaught } from '@/lib/observability';

const SEEN_TTL_MS = 5 * 60_000;
const MAX_BACKOFF_MS = 30_000;
const BC_NAME = 'multideal-notif';

// ─── singleton state ──────────────────────────────────────────────────────────

let socket: WebSocket | null = null;
let backoff = 500;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
const seen = new Map<string, number>();
let bc: BroadcastChannel | null = null;
let qcRef: QueryClient | null = null;

/** Number of mounted useLiveNotifications() consumers. */
let mountCount = 0;

/** Registered setConnected callbacks for LiveBadge state propagation. */
const connectedListeners = new Set<(v: boolean) => void>();

// ─── helpers ─────────────────────────────────────────────────────────────────

function evictSeen(): void {
  const cutoff = Date.now() - SEEN_TTL_MS;
  for (const [k, t] of seen) if (t < cutoff) seen.delete(k);
}

function notifyConnected(value: boolean): void {
  connectedListeners.forEach((fn) => fn(value));
}

function open(): void {
  if (
    socket &&
    (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)
  ) {
    return;
  }
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
  const ws = new WebSocket(`${proto}//${location.host}/api/ws`);
  socket = ws;

  ws.addEventListener('open', () => {
    backoff = 500;
    notifyConnected(true);
    const lastInboxTs = Number(sessionStorage.getItem('notif.lastInboxTs') ?? 0);
    ws.send(JSON.stringify({ op: 'hello', lastInboxTs }));
    // Re-subscribe to all channels registered before this (re)connect.
    for (const ch of channels()) {
      ws.send(JSON.stringify({ op: 'sub', ch }));
    }
  });

  ws.addEventListener('message', (e) => {
    let env: { ch: string; kind: string; ts?: number; msgId?: string };
    try {
      env = JSON.parse(e.data as string) as typeof env;
    } catch (err) {
      captureCaught(err, {
        scope: 'lib.hooks.use-live-notifications.message.parse',
        severity: 'warning',
      });
      return;
    }

    // Dedup by msgId.
    if (env.msgId) {
      if (seen.has(env.msgId)) return;
      seen.set(env.msgId, Date.now());
      evictSeen();
    }

    // Inbox / read-sync / prefs events invalidate the notifications query and
    // broadcast to other tabs so they also refresh.
    if (env.kind === 'inbox_delivery' || env.kind === 'read_sync' || env.kind === 'prefs_refresh') {
      if (qcRef) void qcRef.invalidateQueries({ queryKey: ['notifications'] });
      if (env.ts) sessionStorage.setItem('notif.lastInboxTs', String(env.ts));
      bc?.postMessage({ type: env.kind, ts: env.ts });
    }

    emit(env.ch, env);
  });

  ws.addEventListener('close', scheduleReconnect);
  ws.addEventListener('error', scheduleReconnect);
}

function scheduleReconnect(): void {
  notifyConnected(false);
  if (reconnectTimer) return;
  reconnectTimer = setTimeout(() => {
    reconnectTimer = null;
    backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
    if (mountCount > 0) open();
  }, backoff);
}

// ─── hooks ────────────────────────────────────────────────────────────────────

/**
 * Mount once inside the app's shared QueryClientProvider tree (e.g. HydratedIsland).
 * Manages the WebSocket lifecycle and returns the current connection state.
 */
export function useLiveNotifications(): boolean {
  const qc = useQueryClient();
  const [connected, setConnected] = useState(false);

  useEffect(() => {
    qcRef = qc;
    mountCount += 1;

    // Register this component's setConnected for live updates.
    connectedListeners.add(setConnected);

    if (mountCount === 1) {
      // First mount: open BC + WS.
      bc = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(BC_NAME) : null;
      if (bc) {
        bc.onmessage = () => {
          void qc.invalidateQueries({ queryKey: ['notifications'] });
        };
      }
      open();
    } else {
      // Subsequent mounts: sync to current WS state.
      // Deferred via queueMicrotask to avoid calling setState synchronously
      // inside the effect body (lint: react-hooks/set-state-in-effect).
      const isOpen = socket?.readyState === WebSocket.OPEN;
      queueMicrotask(() => setConnected(isOpen));
    }

    return () => {
      connectedListeners.delete(setConnected);
      mountCount = Math.max(0, mountCount - 1);
      if (mountCount === 0) {
        socket?.close();
        socket = null;
        bc?.close();
        bc = null;
        if (reconnectTimer) {
          clearTimeout(reconnectTimer);
          reconnectTimer = null;
        }
        qcRef = null;
      }
    };
  }, [qc]);

  return connected;
}

/**
 * Subscribe to a specific DO topic channel.
 * Sends sub/unsub ops to the WS so the server knows which topics to forward.
 */
export function useLive(channel: string, handler: (env: unknown) => void): void {
  // Stable ref so handler identity changes don't cause churn.
  const handlerRef = useRef(handler);

  useEffect(() => {
    // Update ref inside effect — not during render (lint: react-hooks/refs).
    handlerRef.current = handler;
  });

  useEffect(() => {
    const stableHandler = (env: unknown) => handlerRef.current(env);
    const off = on(channel, stableHandler);

    if (socket?.readyState === WebSocket.OPEN) {
      socket.send(JSON.stringify({ op: 'sub', ch: channel }));
    }

    return () => {
      off();
      if (socket?.readyState === WebSocket.OPEN) {
        socket.send(JSON.stringify({ op: 'unsub', ch: channel }));
      }
    };
    // channel is stable; handler changes handled via ref above.
  }, [channel]);
}
