import { useEffect, useRef } from "react";
import { io } from "socket.io-client";
import { useQueryClient } from "@tanstack/react-query";
import {
  getListNotificationsQueryKey,
  getGetHomeStatsQueryKey,
  getListMyServiceOrdersQueryKey,
  getListIncomingServiceOrdersQueryKey,
  getListAppsQueryKey,
  getGetMeQueryKey,
  getListRolesQueryKey,
  getListPermissionsQueryKey,
  getGetRolePermissionsQueryKey,
  getGetRolePermissionAuditQueryKey,
  getGetRoleUsageQueryKey,
} from "@workspace/api-client-react";
import { useAuth } from "@/contexts/AuthContext";
import { notificationPlayer } from "@/lib/notification-sounds";
import { toast } from "@/hooks/use-toast";
import i18n from "@/i18n";
import {
  useIncomingNotePopup,
  type IncomingNotePayload,
  type IncomingReplyPayload,
} from "@/contexts/IncomingNotePopupContext";

const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");

// #628: warmup window after socket (re)connect during which chimes
// are suppressed so a page-load flush of pending events doesn't sound
// like an alarm. Lowered from 3000 → 1000 ms because on mobile the
// socket disconnects/reconnects often (background tab, tunnel hop,
// PWA wake-up); a 3-second window silently swallowed any order that
// arrived in the first few seconds after returning to the app. 1 s
// still covers the synchronous initial-burst at first connect.
const SOCKET_WARMUP_MS = 1000;
const nowMs = () =>
  typeof performance !== "undefined" ? performance.now() : Date.now();

export function useNotificationsSocket() {
  const { user } = useAuth();
  const queryClient = useQueryClient();
  const connectedAtRef = useRef<number>(0);
  const { enqueue: enqueueNotePopup, updateChecklistItems } =
    useIncomingNotePopup();
  const enqueueNotePopupRef = useRef(enqueueNotePopup);
  enqueueNotePopupRef.current = enqueueNotePopup;
  const updateChecklistItemsRef = useRef(updateChecklistItems);
  updateChecklistItemsRef.current = updateChecklistItems;
  const currentUserIdRef = useRef<number | null>(user?.id ?? null);
  currentUserIdRef.current = user?.id ?? null;
  // Dedupe note chimes by noteId so a socket reconnect that replays the
  // same `note_received` event (or a duplicate emit from the server)
  // doesn't beep twice for the same incoming note. Mirrors the
  // `playedRef` pattern used by upcoming-meeting-alert.
  const playedNoteIdsRef = useRef<Set<number>>(new Set());
  // Same dedupe shape as `playedNoteIdsRef`, but keyed on replyId so a
  // reconnect that replays a `note_replied` event doesn't re-chime.
  const playedReplyIdsRef = useRef<Set<number>>(new Set());

  useEffect(() => {
    if (!user) return;

    // Suppress chimes for the first few seconds after the socket
    // connects so any pending notifications that flush in on page
    // load don't all play sounds back-to-back.
    connectedAtRef.current = nowMs();

    // ---- Coalesced invalidations (#427) ----
    // When several socket events arrive in the same JS task (e.g. a
    // `note_received` AND a `notification_created` AND a meeting
    // alert all fire from one server fanout), invalidating each
    // query key synchronously triggers a re-render storm that
    // briefly stalls the UI on slower iPad/phone hardware — which
    // contributed to the perceived "tap hang". We collect query
    // keys in a Set keyed by JSON stringification (cheap dedupe)
    // and flush them once per animation frame so React Query batches
    // the refetches into one render pass.
    const pendingKeys = new Map<string, readonly unknown[]>();
    let rafHandle: number | null = null;
    const flushPending = () => {
      rafHandle = null;
      const keys = Array.from(pendingKeys.values());
      pendingKeys.clear();
      for (const key of keys) {
        queryClient.invalidateQueries({ queryKey: key as unknown[] });
      }
    };
    const invalidate = (key: readonly unknown[]) => {
      pendingKeys.set(JSON.stringify(key), key);
      if (rafHandle !== null) return;
      if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
        rafHandle = window.requestAnimationFrame(flushPending);
      } else {
        // SSR / test fallback: macro-task defer is still enough to
        // coalesce events arriving in the same tick.
        rafHandle = window.setTimeout(flushPending, 0);
      }
    };

    const socket = io(window.location.origin, {
      path: `${BASE}/api/socket.io`,
      // #632: Behind Tailscale / proxies on iPad PWA the WebSocket can
      // be silently dropped by NAT/idle timeouts. Tighten the
      // reconnection budget so the client retries quickly instead of
      // sitting on a dead connection while orders pile up server-side.
      reconnection: true,
      reconnectionDelay: 500,
      reconnectionDelayMax: 3000,
      reconnectionAttempts: Infinity,
      timeout: 10000,
    });

    // #632: Track whether the socket has been disconnected at any
    // point so the next successful `connect` can refetch every
    // realtime-driven query — events that fire while the socket was
    // dead are lost forever otherwise, which is exactly the bug
    // (recipients seeing orders only after force-quitting the PWA).
    let wasDisconnected = false;
    // #632: Replay every event-driven query key on reconnect. Mirrors
    // the keys touched by individual socket.on handlers below — keep
    // this list in sync when adding new realtime channels. Role and
    // permission keys are included so a `role_permissions_changed`
    // event that fired while the socket was dead is recovered on
    // reconnect (per architect review).
    const refetchRealtimeState = () => {
      invalidate(getListNotificationsQueryKey());
      invalidate(getGetHomeStatsQueryKey());
      invalidate(getListIncomingServiceOrdersQueryKey());
      invalidate(getListMyServiceOrdersQueryKey());
      invalidate(["notes"]);
      invalidate(["note-folders"]);
      invalidate(["note-folders", "shared-with-me"]);
      invalidate(["/api/executive-meetings"]);
      invalidate(["/api/executive-meetings/alert-state"]);
      invalidate(["/api/executive-meetings/notifications"]);
      invalidate(getListAppsQueryKey());
      invalidate(getGetMeQueryKey());
      invalidate(getListRolesQueryKey());
      invalidate(getListPermissionsQueryKey());
    };

    socket.on("connect", () => {
      // Reset the warmup window so a flush of pending notifications
      // delivered immediately after reconnect doesn't chime.
      connectedAtRef.current = nowMs();
      if (wasDisconnected) {
        wasDisconnected = false;
        refetchRealtimeState();
      }
    });

    socket.on("disconnect", (reason) => {
      wasDisconnected = true;
      // Surface unexpected disconnects in the console for field
      // debugging without breaking anything. `io client disconnect` is
      // the cleanup path on hook teardown — skip the noise for it.
      if (reason !== "io client disconnect") {
        // eslint-disable-next-line no-console
        console.warn("[socket] disconnected:", reason);
      }
    });

    socket.on("connect_error", (err) => {
      // eslint-disable-next-line no-console
      console.warn("[socket] connect_error:", err.message);
    });

    socket.on("notification_created", (payload?: { type?: string }) => {
      invalidate(getListNotificationsQueryKey());
      invalidate(getGetHomeStatsQueryKey());
      if (payload?.type === "order") {
        invalidate(getListMyServiceOrdersQueryKey(),);
        invalidate(getListIncomingServiceOrdersQueryKey(),);
      }

      // Per-user sound + vibration. Plays whether the tab is in the
      // foreground or background — users want to hear the chime even
      // while looking at the app. Still silenced by global mute and
      // per-channel opt-outs below.
      if (user.notificationsMuted) return;
      const isOrder = payload?.type === "order";
      const isMeeting = payload?.type === "executive_meeting";
      if (isOrder && !user.notifyOrdersEnabled) return;
      if (isMeeting && !user.notifyMeetingsEnabled) return;
      if (!isOrder && !isMeeting) return;
      // Skip audio/vibration during the warmup window — UI/badge
      // counts (above) still update, but we stay silent so a
      // page-load flush doesn't sound like an alarm.
      if (nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS) return;
      const soundId = isOrder
        ? user.notificationSoundOrder
        : user.notificationSoundMeeting;
      const vibrate = isOrder
        ? user.vibrationEnabledOrder
        : user.vibrationEnabledMeeting;
      // #628: order chimes use a tighter throttle window so a small
      // burst of incoming orders still rings for each one. Meeting
      // chimes keep the default 3s window — a single meeting event
      // can fan out to several listeners and we don't want a
      // buzzy double-chime. Buckets keep the two channels isolated
      // so an order burst can't starve a meeting alert (or vice
      // versa).
      notificationPlayer.play(soundId, {
        vibrate,
        throttleMs: isOrder ? 800 : 3000,
        throttleBucket: isOrder ? "order" : "meeting",
      });
    });

    socket.on("order_updated", () => {
      invalidate(getListMyServiceOrdersQueryKey(),);
      invalidate(getListIncomingServiceOrdersQueryKey(),);
    });

    socket.on("order_deleted", () => {
      invalidate(getListMyServiceOrdersQueryKey(),);
      invalidate(getListIncomingServiceOrdersQueryKey(),);
    });

    socket.on("order_incoming_changed", () => {
      invalidate(getListIncomingServiceOrdersQueryKey(),);
    });

    // Realtime note events: invalidate notes queries AND surface a toast so
    // the recipient (or sender, on a reply) sees an immediate, dismissible
    // confirmation regardless of which page they're on.
    socket.on("note_received", (payload?: Partial<IncomingNotePayload>) => {
      invalidate(["notes"]);
      const inWarmup = nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS;
      let enqueued = false;
      if (
        payload &&
        typeof payload.noteId === "number" &&
        typeof payload.senderUserId === "number"
      ) {
        const rawKind = (payload as { kind?: unknown }).kind;
        // Default missing/unknown kind to "text" — the legacy server
        // emit (pre-checklist) didn't include `kind`, and the popup
        // body's text branch is the safe fallback.
        const noteKind: "text" | "checklist" =
          rawKind === "checklist" ? "checklist" : "text";
        const rawItems = (payload as { items?: unknown }).items;
        const items = Array.isArray(rawItems)
          ? (rawItems as IncomingNotePayload["items"])
          : null;
        const full: IncomingNotePayload = {
          noteId: payload.noteId,
          recipientRowId: payload.recipientRowId,
          title: typeof payload.title === "string" ? payload.title : "",
          content: typeof payload.content === "string" ? payload.content : "",
          color: typeof payload.color === "string" ? payload.color : "default",
          sentAt: payload.sentAt,
          senderUserId: payload.senderUserId,
          sender: payload.sender ?? null,
          noteKind,
          items,
        };
        enqueued = enqueueNotePopupRef.current(
          full,
          currentUserIdRef.current,
        );
      }
      if (inWarmup) return;
      // Sound + vibration for incoming notes — gated by global mute and
      // the per-channel notes toggle. Deduped by noteId so a socket
      // reconnect that replays the same event doesn't re-chime. Only
      // plays when the popup actually enqueued (i.e. recipient !== sender).
      if (
        enqueued &&
        payload &&
        typeof payload.noteId === "number" &&
        !user.notificationsMuted &&
        user.notifyNotesEnabled &&
        !playedNoteIdsRef.current.has(payload.noteId)
      ) {
        playedNoteIdsRef.current.add(payload.noteId);
        notificationPlayer.play(user.notificationSoundNote, {
          vibrate: user.vibrationEnabledNote,
          throttleBucket: "note",
        });
      }
      // Only fall back to a toast when the floating popup did NOT
      // accept this event (sender's own note, duplicate, malformed,
      // etc.). When the popup IS shown, the toast is redundant — and
      // on touch devices stacking a toast over an already-stacked
      // popup + meeting alert produced extra layers that intermittently
      // intercepted the next tap (#427).
      if (!enqueued) {
        toast({
          title: i18n.t("notes.toast.received.title"),
          description: i18n.t("notes.toast.received.desc"),
        });
      }
    });

    socket.on(
      "note_replied",
      (payload?: Partial<IncomingReplyPayload> & { recipientUserId?: number }) => {
        invalidate(["notes"]);
        const inWarmup = nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS;
        let enqueued = false;
        // The server emits `note_replied` only to the *original note
        // owner* (i.e. the sender). Build a reply popup payload — the
        // queue dedupes on replyId so a socket reconnect that replays
        // the same event won't re-show or re-chime.
        if (
          payload &&
          typeof payload.noteId === "number" &&
          typeof payload.replyId === "number"
        ) {
          // The "sender" of the *original* note is the current user
          // (the owner) — we don't have their full UserSummary on the
          // payload, but the floating card never renders it for the
          // reply variant; it shows the replier instead. Use the
          // replier's id as a stand-in for senderUserId so the
          // queue's "ignore my own outgoing" guard treats this event
          // as inbound (the replier is *not* the current user).
          const replierId = payload.replier?.id;
          const senderUserId =
            typeof replierId === "number" ? replierId : -1;
          const full: IncomingReplyPayload = {
            kind: "reply",
            noteId: payload.noteId,
            replyId: payload.replyId,
            replyContent:
              typeof payload.replyContent === "string"
                ? payload.replyContent
                : "",
            replier: payload.replier ?? null,
            noteTitle:
              typeof payload.noteTitle === "string" ? payload.noteTitle : "",
            title:
              typeof payload.noteTitle === "string" ? payload.noteTitle : "",
            content:
              typeof payload.replyContent === "string"
                ? payload.replyContent
                : "",
            color:
              typeof payload.color === "string" ? payload.color : "default",
            senderUserId,
            sender: payload.replier ?? null,
          };
          enqueued = enqueueNotePopupRef.current(
            full,
            currentUserIdRef.current,
          );
        }
        if (inWarmup) return;
        // Sound + vibration mirror the new-note alert. Reuse the same
        // notes channel prefs (notificationSoundNote /
        // vibrationEnabledNote / notifyNotesEnabled) — the user
        // confirmed they want a single sensory profile for both
        // events. Dedupe by replyId via a per-session set.
        const replyId = payload?.replyId;
        if (
          enqueued &&
          typeof replyId === "number" &&
          !user.notificationsMuted &&
          user.notifyNotesEnabled &&
          !playedReplyIdsRef.current.has(replyId)
        ) {
          playedReplyIdsRef.current.add(replyId);
          notificationPlayer.play(user.notificationSoundNote, {
            vibrate: user.vibrationEnabledNote,
            throttleBucket: "note",
          });
        }
        // See `note_received` above: skip the redundant toast when the
        // floating popup is the surface (#427).
        if (!enqueued) {
          toast({
            title: i18n.t("notes.toast.replied.title"),
            description: i18n.t("notes.toast.replied.desc"),
          });
        }
      },
    );

    socket.on("note_status_changed", () => {
      invalidate(["notes"]);
    });

    // a collaborator (owner or another recipient) toggled a
    // checklist item — refetch so this user's open thread / popup /
    // cards reflect the shared state. Server already updates the
    // emitter optimistically, so it never receives its own echo. We
    // also patch any open popup payload directly because the popup
    // renders from the queued snapshot, not the React Query cache.
    socket.on(
      "note_checklist_changed",
      (payload?: {
        noteId?: number;
        items?: { id: string; text: string; done: boolean }[];
      }) => {
        invalidate(["notes"]);
        if (typeof payload?.noteId === "number") {
          invalidate(["notes", "thread", payload.noteId]);
          if (Array.isArray(payload.items)) {
            updateChecklistItemsRef.current(payload.noteId, payload.items);
          }
        }
      },
    );

    // Folder-sharing live updates (#445). Owner shares/unshares a folder
    // with this user → invalidate the rail's "Shared with me" list, and any
    // open shared-folder note view for that folder so it either refreshes
    // (still shared) or surfaces the revoked empty state (unshared) without
    // a manual reload.
    socket.on(
      "note-folder-shared",
      (payload?: { folderId?: number }) => {
        invalidate(["note-folders", "shared-with-me"]);
        if (typeof payload?.folderId === "number") {
          invalidate(["notes", "shared-folder", payload.folderId]);
        }
        // this event also fanouts to the OWNER + every other
        // recipient when an editor mutates the folder's notes (create /
        // patch / delete / checklist toggle), so refresh the owner's
        // personal notes list and the folders rail badge counts too.
        invalidate(["notes"]);
        invalidate(["note-folders"]);
      },
    );
    // dedicated event for permission-only flips on an
    // existing share (view ↔ edit). Same invalidation as `shared`,
    // but kept distinct so the contract is explicit and so future UI
    // can react with a toast like "Your access changed to Edit".
    socket.on(
      "note-folder-share-updated",
      (payload?: { folderId?: number; permission?: "view" | "edit" }) => {
        invalidate(["note-folders", "shared-with-me"]);
        if (typeof payload?.folderId === "number") {
          invalidate(["notes", "shared-folder", payload.folderId]);
        }
      },
    );
    socket.on(
      "note-folder-unshared",
      (payload?: { folderId?: number }) => {
        invalidate(["note-folders", "shared-with-me"]);
        if (typeof payload?.folderId === "number") {
          invalidate(["notes", "shared-folder", payload.folderId]);
        }
      },
    );

    socket.on("apps_changed", () => {
      invalidate(getListAppsQueryKey());
      invalidate(getGetMeQueryKey());
    });

    socket.on(
      "executive_meetings_changed",
      (payload?: { date?: string }) => {
        if (typeof payload?.date === "string" && payload.date.length > 0) {
          invalidate(["/api/executive-meetings", payload.date],);
        } else {
          invalidate(["/api/executive-meetings"],);
        }
      },
    );

    socket.on("executive_meeting_notifications_changed", () => {
      invalidate(["/api/executive-meetings/notifications"],);
    });

    // #277: Per-user push for the 5-min upcoming-meeting alert. The server
    // only emits this to `user:${userId}`, so receiving it means *this*
    // user (in this or another of their tabs / devices) acknowledged or
    // dismissed an alert. Invalidate the alert-state query so the open
    // alert card hides within ~1s instead of waiting on the 30s poll.
    // Other users do not receive this event, so their alert stays visible.
    socket.on(
      "executive_meeting_alert_state_changed",
      () => {
        invalidate(["/api/executive-meetings/alert-state"],);
      },
    );

    socket.on(
      "role_permissions_changed",
      (payload?: { roleId?: number }) => {
        invalidate(getGetMeQueryKey());
        invalidate(getListPermissionsQueryKey());
        invalidate(getListRolesQueryKey());
        const roleId = payload?.roleId;
        if (typeof roleId === "number") {
          invalidate(getGetRolePermissionsQueryKey(roleId),);
          invalidate(getGetRolePermissionAuditQueryKey(roleId),);
          invalidate(getGetRoleUsageQueryKey(roleId),);
        }
      },
    );

    // #632: When the PWA / tab comes back to the foreground, iPad's
    // background suspension may have silently killed the WebSocket
    // even though `socket.connected` is still `true` (half-open
    // socket — the client hasn't sent a ping yet). Probing with a
    // timed roundtrip is the only reliable way to tell. We use the
    // socket.io v4 ack-timeout helper to round-trip a cheap event;
    // if the server doesn't ack within 3s, force-cycle the transport
    // so missed orders surface within ~3s of returning to the app
    // instead of waiting on the ping-timeout window (~15s).
    const onVisibility = () => {
      if (typeof document === "undefined") return;
      if (document.visibilityState !== "visible") return;
      if (!socket.connected) {
        wasDisconnected = true;
        socket.connect();
        return;
      }
      // Half-open probe: round-trip a cheap event through socket.io's
      // built-in ack channel. The server doesn't need to register a
      // handler — when no handler exists, the ack still fires with
      // no args, so a missing ack within 3s means the transport is
      // dead and we should force-cycle it.
      socket.timeout(3000).emit(
        "client_health_probe",
        (err: Error | null) => {
          if (err && socket.connected) {
            wasDisconnected = true;
            socket.disconnect().connect();
          }
        },
      );
    };
    if (typeof document !== "undefined") {
      document.addEventListener("visibilitychange", onVisibility);
    }

    return () => {
      if (typeof document !== "undefined") {
        document.removeEventListener("visibilitychange", onVisibility);
      }
      if (rafHandle !== null) {
        if (typeof window !== "undefined" && typeof window.cancelAnimationFrame === "function") {
          window.cancelAnimationFrame(rafHandle);
        } else {
          window.clearTimeout(rafHandle);
        }
        rafHandle = null;
      }
      pendingKeys.clear();
      socket.disconnect();
    };
  }, [user, queryClient]);
}
