"use client";

import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { io, type Socket } from "socket.io-client";
import type { Call, CalendarEvent, Message, NotificationPreferences, Task } from "@slackwsh/contracts";
import { describeNotification, type NotifiableEvent } from "@slackwsh/core";
import { api, getCurrentUserId, getValidAccessToken } from "../lib/api";
import { registerWebPush } from "../lib/web-push";
import { playNotifySound, bindNotifySoundUnlock } from "../lib/notify-sound";
import { createPlatformAdapter, ensureDesktopNotificationPermission, checkDesktopUpdates, NOTIFICATION_ROUTE_EVENT } from "../platform/adapter";
import { getUnreadTotal, incrementUnread, setChannelUnread, shouldNotifyForChannel } from "../lib/unread-store";
import { getNotifyPrefs, hydrateNotifyPrefs } from "../lib/notify-prefs";
import { wasSelfAction } from "../lib/self-echo";
import { normalizeChannelRows } from "../lib/conversations";
import { gatewaySocketOptions, gatewayUrl } from "../lib/gateway-socket";
import { pushFlashToast } from "./FlashToastHost";

const platform = createPlatformAdapter();

interface WorkspaceDirectory {
  /** userId → display name, for notification copy. */
  names: Map<string, string>;
  /** channelId → what kind of conversation it is, to tell a DM from a room. */
  conversations: Map<string, { type: string; name: string | null }>;
  /** My own @handle, needed to match mentions. */
  myUsername: string | null;
}

/**
 * Keeps a personal socket alive across the app so DM activity published to
 * `u:{userId}` reaches the user even when they are not on that conversation.
 */
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
  const router = useRouter();

  // Clicking a notification navigates here rather than in the adapter, because
  // routing has to go through the Next router: inside the Tauri shell the
  // frontend is a static export, so a hard navigation to "/channel?…" would
  // have to be resolved against "channel.html" by the asset protocol, which it
  // does not do. Mounted in the root layout, so this is always listening.
  useEffect(() => {
    function onRoute(event: Event) {
      const route = (event as CustomEvent<{ route?: string }>).detail?.route;
      if (route) router.push(route);
    }
    function onSwMessage(event: MessageEvent) {
      if (event.data?.type === "notification-route" && typeof event.data.route === "string") {
        router.push(event.data.route);
      }
    }
    window.addEventListener(NOTIFICATION_ROUTE_EVENT, onRoute);
    navigator.serviceWorker?.addEventListener("message", onSwMessage);
    return () => {
      window.removeEventListener(NOTIFICATION_ROUTE_EVENT, onRoute);
      navigator.serviceWorker?.removeEventListener("message", onSwMessage);
    };
  }, [router]);

  useEffect(() => {
    bindNotifySoundUnlock();
    void ensureDesktopNotificationPermission();
    void checkDesktopUpdates();
  }, []);

  // Last-known state per row, so a `*:updated` event can be diffed to work out
  // what actually changed — the payloads carry no "what kind of change" flag.
  const previousTasks = useRef(new Map<number, Task>());
  const previousEvents = useRef(new Map<number, CalendarEvent>());
  const previousCalls = useRef(new Map<number, Call>());
  // Names and channel kinds, fetched once per workspace on first need.
  const directories = useRef(new Map<string, WorkspaceDirectory>());
  const conversationsRef = useRef(new Map<string, { type: string; name: string | null }>());
  const statusCache = useRef(new Map<string, { dndActive: boolean; fetchedAt: number }>());

  useEffect(() => {
    let socket: Socket | null = null;
    let cancelled = false;

    /**
     * Notification copy needs names and channel kinds that the event payloads
     * don't carry. Fetched lazily on the first event for a workspace rather
     * than eagerly on mount: this provider wraps every route, including login,
     * where there is no workspace to fetch and no session to fetch it with.
     */
    async function directoryFor(workspaceId: string | number): Promise<WorkspaceDirectory> {
      const key = String(workspaceId);
      const cached = directories.current.get(key);
      if (cached) return cached;
      const blank: WorkspaceDirectory = { names: new Map(), conversations: new Map(), myUsername: null };
      directories.current.set(key, blank);
      try {
        const me = await getCurrentUserId();
        const [membersRes, channelsRes] = await Promise.all([api.members(key), api.listChannels(key)]);
        for (const row of membersRes.members) {
          blank.names.set(String(row.user.id), row.user.name);
          if (me != null && Number(row.user.id) === me) blank.myUsername = row.user.username ?? null;
        }
        for (const row of normalizeChannelRows(channelsRes.channels)) {
          const entry = { type: row.channel.type, name: row.channel.name ?? null };
          blank.conversations.set(String(row.channel.id), entry);
          conversationsRef.current.set(String(row.channel.id), entry);
        }
      } catch {
        // A failed lookup only costs notification polish (no display name, a
        // DM treated as a room) — never the notification itself.
      }
      return blank;
    }

    /**
     * The single funnel from realtime event to OS notification: the router
     * decides whether and what, this decides how loudly.
     */
    async function raise(event: NotifiableEvent, me: number | null, workspaceId?: string | number | null) {
      if (cancelled) return;
      if (workspaceId != null) {
        const key = String(workspaceId);
        const cached = statusCache.current.get(key);
        let dndActive = cached?.dndActive ?? false;
        if (!cached || Date.now() - cached.fetchedAt > 30_000) {
          try {
            const status = await api.myStatus(key);
            dndActive = status.effective.dndActive;
            statusCache.current.set(key, { dndActive, fetchedAt: Date.now() });
          } catch {
            dndActive = false;
          }
        }
        if (dndActive) return;
      }
      const prefs = getNotifyPrefs(workspaceId);
      const directory = workspaceId != null ? await directoryFor(workspaceId) : undefined;
      const description = describeNotification(event, {
        myUserId: me,
        myUsername: directory?.myUsername ?? null,
        prefs,
        currentRoute: `${window.location.pathname}${window.location.search}`,
        hidden: document.hidden,
        nameFor: (userId) => directory?.names.get(String(userId)),
        previousTask:
          event.type === "task:created" || event.type === "task:updated"
            ? previousTasks.current.get(Number(event.task.id))
            : null,
        previousEvent: event.type.startsWith("event:")
          ? previousEvents.current.get((event as { event: CalendarEvent }).event.id)
          : null,
        previousCall: event.type.startsWith("call:") ? previousCalls.current.get((event as { call: Call }).call.id) : null,
        isSelfEcho: wasSelfAction,
      });
      if (!description) return;
      if (prefs.sound && !description.silent) playNotifySound(description.sound);
      // In-app flash is independent of OS toasts — still shows when the user
      // is already looking (skipToast), so they get a brief right-side cue.
      if (prefs.inAppFlash) {
        pushFlashToast({
          title: description.title,
          body: description.body,
          route: description.route,
          tag: description.tag,
        });
      }
      if (description.skipToast) return;
      await platform.notifications
        .notify(description.title, description.body, {
          tag: description.tag,
          route: description.route,
          group: description.group,
          urgent: description.urgent,
          silent: prefs.sound || Boolean(description.silent),
        })
        .catch(() => undefined);
    }

    async function connect() {
      const token = await getValidAccessToken();
      if (!token || cancelled) return;
      void registerWebPush();

      const workspaceIds: string[] = [];
      const workspaceJoins: Array<{ workspaceId: string; channelId: string }> = [];
      try {
        const mine = await api.myWorkspaces();
        await Promise.all(
          mine.workspaces.map(async (workspace) => {
            const workspaceId = String(workspace.id);
            workspaceIds.push(workspaceId);
            const [channels, preferenceResult] = await Promise.all([
              api.listChannels(workspaceId),
              api.notificationPreferences(workspaceId),
            ]);
            const preferences = preferenceResult.preferences.updatedAt == null
              ? (await api.updateNotificationPreferences(workspaceId, getNotifyPrefs(workspaceId))).preferences
              : preferenceResult.preferences;
            hydrateNotifyPrefs(workspaceId, preferences);
            const first = channels.channels.find((row) => row.channel?.id != null);
            if (first) workspaceJoins.push({ workspaceId, channelId: String(first.channel.id) });
          }),
        );
      } catch {
        // Presence is best-effort; the app remains usable if hydration fails.
      }
      const joinedWorkspaceIds = new Set(workspaceIds);

      socket = io(gatewayUrl(), gatewaySocketOptions((cb) => getValidAccessToken().then((t) => cb({ accessToken: t }))));

      let lastActivityAt = Date.now();
      let lastActive = true;
      const isActive = () => !document.hidden && Date.now() - lastActivityAt < 10 * 60_000;
      const heartbeat = () => {
        lastActive = isActive();
        socket?.emit("presence:heartbeat", { active: lastActive });
      };
      const noteActivity = () => {
        const wasActive = isActive();
        lastActivityAt = Date.now();
        if (!wasActive || !lastActive) heartbeat();
      };
      const onVisibility = () => {
        if (!document.hidden) lastActivityAt = Date.now();
        heartbeat();
      };
      window.addEventListener("pointerdown", noteActivity);
      window.addEventListener("keydown", noteActivity);
      window.addEventListener("mousemove", noteActivity);
      window.addEventListener("touchstart", noteActivity);
      window.addEventListener("focus", noteActivity);
      document.addEventListener("visibilitychange", onVisibility);
      const heartbeatTimer = window.setInterval(heartbeat, 20_000);

      const onLocalStatusChanged = (event: Event) => {
        const changedWorkspaceId = (event as CustomEvent<{ workspaceId?: string }>).detail?.workspaceId;
        if (changedWorkspaceId) statusCache.current.delete(String(changedWorkspaceId));
        heartbeat();
      };
      window.addEventListener("connecthub:status-changed", onLocalStatusChanged);

      socket.on("connect", () => {
        // Mark online for every workspace (even with zero channels). Channel
        // joins still happen for message rooms; presence no longer depends on them.
        for (const workspaceId of workspaceIds) socket?.emit("workspace:join", { workspaceId });
        for (const join of workspaceJoins) socket?.emit("channel:join", join);
        heartbeat();
      });

      socket.on("member:status_changed", (payload: { workspaceId: string | number; userId: string | number }) => {
        statusCache.current.delete(String(payload.workspaceId));
        window.dispatchEvent(new CustomEvent("connecthub:member-status-changed", { detail: payload }));
      });

      socket.on("message:created", async (payload: { message: Message; workspaceId?: string | number; channelId?: string | number }) => {
        if (cancelled) return;
        const me = await getCurrentUserId();
        if (!me) return;

        const channelId = payload.channelId ?? payload.message.channelId;
        const workspaceId = payload.workspaceId ?? payload.message.workspaceId;

        // Thread room broadcasts are consumed by the open channel's own
        // socket. The always-on provider handles only personal thread fanout,
        // which the server restricts to followers/mentioned participants.
        if (payload.message.parentId != null && payload.workspaceId == null) return;

        window.dispatchEvent(
          new CustomEvent("slackwsh:message", {
            detail: {
              message: payload.message,
              workspaceId: workspaceId ?? null,
              channelId,
            },
          }),
        );

        // Still dispatch self-authored replies so another tab/device refreshes
        // its Threads list, but never notify or count your own message unread.
        if (String(payload.message.authorId) === String(me)) return;

        // Prime the directory before describing, so the very first message of a
        // session still gets a name and knows whether it's a DM.
        if (workspaceId != null) await directoryFor(workspaceId);
        const conversation = conversationsRef.current.get(String(channelId));
        const channelType = conversation?.type ?? null;
        const channelName = conversation?.name ?? null;
        const isDm = channelType === "dm" || channelType === "group_dm";
        const event = {
          type: "message:created" as const,
          message: payload.message,
          workspaceId,
          channelId,
          channelType,
          channelName,
        };

        if (payload.message.parentId != null) {
          await raise(event, me, workspaceId);
          return;
        }

        // Unread bookkeeping is separate from notifying: a message you can see
        // still shouldn't be counted, but the count is not the same decision as
        // whether to interrupt (which the router owns).
        const viewingThisChannel = !shouldNotifyForChannel(channelId);
        if (!viewingThisChannel && workspaceId != null && channelId != null) {
          incrementUnread(workspaceId, channelId);
          const total = getUnreadTotal(String(workspaceId));
          await platform.badge.setBadge(total).catch(() => undefined);
        }

        if (viewingThisChannel) {
          // OS toast is suppressed while you're looking at the thread, but DMs
          // still get an SMS-style ping — otherwise an open conversation is silent.
          if (isDm) {
            const prefs = getNotifyPrefs(workspaceId);
            if (prefs.sound && prefs.messages !== "off") {
              let dndActive = false;
              if (workspaceId != null) {
                const key = String(workspaceId);
                const cached = statusCache.current.get(key);
                dndActive = cached?.dndActive ?? false;
              }
              if (!dndActive) playNotifySound();
            }
          }
          return;
        }

        await raise(event, me, workspaceId);

      });

      socket.on("read:updated", (payload: { channelMember?: { channelId?: string | number; unreadCount?: number } }) => {
        const member = payload.channelMember;
        if (member?.channelId == null) return;
        for (const workspaceId of joinedWorkspaceIds) {
          setChannelUnread(workspaceId, member.channelId, member.unreadCount ?? 0);
        }
      });

      socket.on("saved:updated", (payload: unknown) => {
        window.dispatchEvent(new CustomEvent("slackwsh:saved-updated", { detail: payload }));
      });

      socket.on("draft:updated", (payload: unknown) => {
        window.dispatchEvent(new CustomEvent("slackwsh:draft-updated", { detail: payload }));
      });

      socket.on("notification-preferences:updated", (payload: { preferences?: NotificationPreferences }) => {
        const preferences = payload.preferences;
        if (preferences?.workspaceId == null) return;
        hydrateNotifyPrefs(String(preferences.workspaceId), preferences);
      });

      socket.on("activity:read-updated", (payload: unknown) => {
        window.dispatchEvent(new CustomEvent("slackwsh:activity-read-updated", { detail: payload }));
      });

      socket.on("thread:subscription-updated", (payload: unknown) => {
        window.dispatchEvent(new CustomEvent("slackwsh:thread-subscription-updated", { detail: payload }));
      });

      // Tasks are workspace-wide, fanned out to every active member's own
      // u:{userId} room (see libs/messaging/src/tasks.ts) rather than a
      // ws:{workspaceId} room this always-on socket never joins. Re-dispatch
      // as a plain window event so whichever page has a task list open
      // (currently just /tasks) can merge it into local state — same
      // pattern as "slackwsh:message" above.
      const forward =
        (type: "task:created" | "task:updated" | "task:deleted") =>
        async (payload: {
          task?: Task;
          taskId?: number;
          workspaceId?: number;
          title?: string;
          createdBy?: number;
          deletedByUserId?: number;
        }) => {
          if (cancelled) return;
          window.dispatchEvent(new CustomEvent("slackwsh:task", { detail: { type, ...payload } }));
          if (type === "task:deleted") {
            const me = await getCurrentUserId();
            if (payload.taskId != null && payload.title != null && payload.createdBy != null && payload.deletedByUserId != null) {
              await raise(
                {
                  type: "task:deleted",
                  workspaceId: payload.workspaceId ?? 0,
                  taskId: payload.taskId,
                  title: payload.title,
                  createdBy: payload.createdBy,
                  deletedByUserId: payload.deletedByUserId,
                },
                me,
                payload.workspaceId,
              );
            }
            if (payload.taskId != null) previousTasks.current.delete(Number(payload.taskId));
            return;
          }
          if (!payload.task) return;
          const me = await getCurrentUserId();
          await raise({ type, task: payload.task }, me, payload.task.workspaceId);
          previousTasks.current.set(Number(payload.task.id), payload.task);
        };
      socket.on("task:created", forward("task:created"));
      socket.on("task:updated", forward("task:updated"));
      socket.on("task:deleted", forward("task:deleted"));

      // Calendar events ride the same personal-room fanout as tasks. The
      // payload carries the stored series row, not an expanded occurrence, so
      // the calendar page refetches its visible window rather than merging —
      // it can't know whether a changed series touches the week on screen.
      const forwardCalendar =
        (type: "event:created" | "event:updated" | "event:deleted") =>
        async (payload: { event?: CalendarEvent; eventId?: number; workspaceId?: number }) => {
          if (cancelled) return;
          window.dispatchEvent(new CustomEvent("slackwsh:calendar", { detail: { type, ...payload } }));
          if (type === "event:deleted" || !payload.event) return;
          const me = await getCurrentUserId();
          await raise({ type, event: payload.event }, me, payload.event.workspaceId);
          previousEvents.current.set(payload.event.id, payload.event);
        };
      socket.on("event:created", forwardCalendar("event:created"));
      socket.on("event:updated", forwardCalendar("event:updated"));
      socket.on("event:deleted", forwardCalendar("event:deleted"));

      // Calls ride the same personal-room fanout. Unlike the calendar's, these
      // payloads carry the complete row with no expansion behind it, so
      // listeners merge them instead of refetching — which matters mid-call,
      // where the participant list drives who the WebRTC mesh connects to.
      //
      // `call:signal` (SDP/ICE) is handled by CallOverlay's own socket, not here.
      const forwardCall =
        (type: "call:started" | "call:updated" | "call:ended") =>
        async (payload: { call?: Call }) => {
          if (cancelled) return;
          window.dispatchEvent(new CustomEvent("slackwsh:call", { detail: { type, ...payload } }));
          if (!payload.call) return;
          const me = await getCurrentUserId();
          if (me != null) {
            const mine = payload.call.participants.find((participant) => String(participant.userId) === String(me));
            const inConnect = payload.call.status !== "ended" && mine?.state === "joined";
            void api
              .setStatusContext(String(payload.call.workspaceId), { inConnect })
              .then(() => {
                statusCache.current.delete(String(payload.call!.workspaceId));
                window.dispatchEvent(
                  new CustomEvent("connecthub:status-changed", {
                    detail: { workspaceId: String(payload.call!.workspaceId) },
                  }),
                );
              })
              .catch(() => undefined);
          }
          await raise({ type, call: payload.call }, me, payload.call.workspaceId);
          if (type === "call:ended") previousCalls.current.delete(payload.call.id);
          else previousCalls.current.set(payload.call.id, payload.call);
        };
      socket.on("call:started", forwardCall("call:started"));
      socket.on("call:updated", forwardCall("call:updated"));
      socket.on("call:ended", forwardCall("call:ended"));

      // After workspace:join we are in ws:{id}, so presence fanout reaches us.
      socket.on(
        "presence:changed",
        (payload: { workspaceId?: string | number; userId: string | number; status: string; lastSeen?: string }) => {
          if (cancelled) return;
          window.dispatchEvent(new CustomEvent("slackwsh:presence", { detail: payload }));
        },
      );

      (socket as Socket & { __presenceCleanup?: () => void }).__presenceCleanup = () => {
        window.clearInterval(heartbeatTimer);
        window.removeEventListener("pointerdown", noteActivity);
        window.removeEventListener("keydown", noteActivity);
        window.removeEventListener("mousemove", noteActivity);
        window.removeEventListener("touchstart", noteActivity);
        window.removeEventListener("focus", noteActivity);
        document.removeEventListener("visibilitychange", onVisibility);
        window.removeEventListener("connecthub:status-changed", onLocalStatusChanged);
      };
    }

    connect().catch(() => undefined);

    return () => {
      cancelled = true;
      (socket as (Socket & { __presenceCleanup?: () => void }) | null)?.__presenceCleanup?.();
      socket?.disconnect();
    };
  }, []);

  return <>{children}</>;
}
