// Step 2.26 — WebSocket Dispatcher
// Wires WS client events to store updates.
// Each server message type maps to one or more store actions.

import type { WsClient } from "./ws";
import { toConnectionStatus } from "./ws";
import { authStore, setAuth, clearAuth } from "@stores/auth.store";
import { setTransientError, setConnectionStatus } from "@stores/ui.store";
import {
  setChannels,
  setRoles,
  setActiveChannel,
  addChannel,
  updateChannel,
  removeChannel,
  incrementUnread,
} from "@stores/channels.store";
import { channelsStore } from "@stores/channels.store";
import {
  addMessage,
  editMessage,
  deleteMessage,
  updateReaction,
  confirmSend,
  markSendFailed,
  messagesStore,
} from "@stores/messages.store";
import {
  setMembers,
  addMember,
  removeMember,
  updateMemberRole,
  updateMemberProfile,
  updatePresence,
  setTyping,
} from "@stores/members.store";
import {
  voiceStore,
  setVoiceStates,
  updateVoiceState,
  removeVoiceUser,
  setVoiceConfig,
  setSpeakers,
  joinVoiceChannel,
  leaveVoiceChannel,
} from "@stores/voice.store";
import {
  dmStore,
  setDmChannels,
  addDmChannel,
  removeDmChannel,
  updateDmLastMessage,
  updateDmLastMessagePreview,
} from "@stores/dm.store";
import type { DmChannel } from "@stores/dm.store";
import { setBlockedByMe, setUserBlockedByThem, clearBlockedByThem } from "@stores/blocks.store";
import type { DmChannelPayload } from "./types";
import type { ApiClient } from "./api";
import { notifyIncomingMessage } from "./notifications";
import { ensureIdentityKeyPublished } from "@lib/identity";
import { createLogger } from "./logger";
import { ServerMessageType as S } from "./protocolTypes";

const log = createLogger("dispatcher");

/** Lazily import the LiveKit session module. livekit-client (~1.3 MB) is kept
 *  out of the entry chunk; voice handlers load it on first use. Once a voice
 *  flow has started the module is cached, so this resolves in a microtask. */
function livekitSession(): Promise<typeof import("@lib/livekitSession")> {
  return import("@lib/livekitSession");
}

/** Map a server DM channel payload to the client DmChannel type. */
function mapDmPayload(p: DmChannelPayload): DmChannel {
  return {
    channelId: p.channel_id,
    recipient: {
      id: p.recipient.id,
      username: p.recipient.username,
      avatar: p.recipient.avatar,
      status: p.recipient.status,
    },
    lastMessageId: p.last_message_id,
    lastMessage: p.last_message,
    lastMessageAt: p.last_message_at,
    unreadCount: p.unread_count,
  };
}

/** Unsubscribe all listeners. */
export type DispatcherCleanup = () => void;

/**
 * The single writer for ui.store.connectionStatus (UX spec §3): collapses the
 * ws client's internal state machine onto the 3-state status. Wired once at
 * startup and kept for the app's lifetime — deliberately separate from
 * wireDispatcher, whose listeners are torn down per connection.
 */
export function wireConnectionStatus(ws: Pick<WsClient, "onStateChange">): () => void {
  return ws.onStateChange((s) => setConnectionStatus(toConnectionStatus(s)));
}

/**
 * Wire a WsClient to all domain stores.
 * Returns a cleanup function that removes all listeners.
 *
 * `api` is optional so tests can wire the dispatcher without a client; when
 * present it is used to refresh DM block state (GET /blocks) on ready.
 */
export function wireDispatcher(
  ws: WsClient,
  api?: Pick<ApiClient, "listBlocks"> & Partial<Pick<ApiClient, "updateProfile" | "getConfig">>,
): DispatcherCleanup {
  const unsubs: Array<() => void> = [];

  // ── Auth ──────────────────────────────────────────────

  unsubs.push(
    ws.on(S.AUTH_OK, (payload) => {
      setAuth(authStore.getState().token ?? "", payload.user, payload.server_name, payload.motd);
    }),
  );

  unsubs.push(
    ws.on(S.AUTH_ERROR, (payload) => {
      log.error("Auth failed", { message: payload.message });
      setTransientError(payload.message);
      clearAuth();
    }),
  );

  // ── Ready (initial state dump) ────────────────────────

  unsubs.push(
    ws.on(S.READY, (payload) => {
      setChannels(payload.channels);
      setRoles(payload.roles ?? []);
      setMembers(payload.members);
      setVoiceStates(payload.voice_states);

      // Defense-in-depth: if the ready payload shows us in a voice channel
      // but we have no LiveKit session (e.g. after F5 reload), send
      // voice_leave to clean up the stale state. The server should have
      // already cleaned this up, but this handles edge cases.
      //
      // livekitSession is lazily imported, so instead of the synchronous
      // isVoiceConnected() the check reads the voice store's lifecycle
      // status: "idle" means no live or pending LiveKit session (a fresh
      // reload always starts idle — exactly the stale case), while any other
      // status means livekitSession is driving a session right now.
      const currentUserId = authStore.getState().user?.id ?? 0;
      const inVoicePerReady =
        currentUserId !== 0 && payload.voice_states.some((vs) => vs.user_id === currentUserId);
      const voiceSessionActive = voiceStore.getState().voiceStatus !== "idle";
      if (inVoicePerReady && !voiceSessionActive) {
        log.warn("Stale voice state detected in ready payload — sending voice_leave");
        ws.send({ type: "voice_leave", payload: {} });
        leaveVoiceChannel();
      }

      // F3: publish our long-term identity public key so peers can pin+verify
      // us in voice. Idempotent (no PATCH when the server copy already matches)
      // and fire-and-forget — never block the ready flow. Username is required
      // by the server's profile update, so it rides along with the key.
      const self = payload.members.find((m) => m.id === currentUserId);
      const host = api?.getConfig?.().host;
      if (self !== undefined && currentUserId !== 0 && host && api?.updateProfile) {
        const updateProfile = api.updateProfile;
        void ensureIdentityKeyPublished(
          host,
          self.username,
          self.identity_public_key ?? null,
          (data) => updateProfile(data),
        );
      }

      // Auto-select the first text channel if none is active
      const currentActive = channelsStore.select((s) => s.activeChannelId);
      if (currentActive === null && payload.channels.length > 0) {
        const firstText = payload.channels.find((ch) => ch.type === "text");
        if (firstText !== undefined) {
          setActiveChannel(firstText.id);
        }
      }

      // Populate DM channels if present in the ready payload
      const dmPayloads = payload.dm_channels ?? [];
      if (dmPayloads.length > 0) {
        setDmChannels(dmPayloads.map(mapDmPayload));
      }

      // Refresh DM block state (channels-members-dms.md §3.2). "Being blocked"
      // is only known from a refused send, so it's stale after a reconnect —
      // clear it and re-fetch our own outgoing blocks authoritatively.
      clearBlockedByThem();
      if (api !== undefined) {
        api
          .listBlocks()
          .then((r) => setBlockedByMe(r.blocked_user_ids))
          .catch((err) => log.warn("Failed to load block list", { error: String(err) }));
      }

      log.info("Ready payload applied", {
        channels: payload.channels.length,
        members: payload.members.length,
        voiceStates: payload.voice_states.length,
        dmChannels: dmPayloads.length,
      });
    }),
  );

  // ── DM Channels ─────────────────────────────────────

  unsubs.push(
    ws.on(S.DM_CHANNEL_OPEN, (payload) => {
      log.info("DM channel opened", { channelId: payload.channel_id });
      addDmChannel(mapDmPayload(payload));
    }),
  );

  unsubs.push(
    ws.on(S.DM_CHANNEL_CLOSE, (payload) => {
      log.info("DM channel closed", { channelId: payload.channel_id });
      removeDmChannel(payload.channel_id);
    }),
  );

  // ── Chat Messages ─────────────────────────────────────

  unsubs.push(
    ws.on(S.CHAT_MESSAGE, (payload) => {
      log.debug("chat_message received", {
        id: payload.id,
        channelId: payload.channel_id,
        user: payload.user.username,
      });
      addMessage(payload);
      const activeId = channelsStore.select((s) => s.activeChannelId);

      // Check if this is a DM channel and whether the message is from self.
      const dmChannels = dmStore.getState().channels;
      const isDm = dmChannels.some((c) => c.channelId === payload.channel_id);
      const currentUserId = authStore.getState().user?.id ?? null;
      const isOwnMessage = currentUserId !== null && payload.user.id === currentUserId;

      // Increment channel-level unread for non-active, non-own-message channels.
      // Skip during reconnection replay to avoid inflating counts — the
      // server's ready payload already contains accurate unread_count values.
      // DM channel IDs are not in channelsStore (they use dmStore), so
      // incrementUnread is a no-op for DMs, but the own-message guard is
      // applied here for defence-in-depth.
      if (payload.channel_id !== activeId && !isOwnMessage && !ws.isReplaying()) {
        incrementUnread(payload.channel_id);
      }

      // Update DM store last message if this message belongs to a DM channel.
      // Skip unread increment for own messages, currently focused DM, and replay.
      if (isDm) {
        const isDmActive = payload.channel_id === activeId;
        if (isOwnMessage || isDmActive || ws.isReplaying()) {
          // Update last message preview but don't increment unread count.
          updateDmLastMessagePreview(
            payload.channel_id,
            payload.id,
            payload.content,
            payload.timestamp,
          );
        } else {
          updateDmLastMessage(payload.channel_id, payload.id, payload.content, payload.timestamp);
        }
      }

      // Fire desktop notification, taskbar flash, and sound
      notifyIncomingMessage(payload);
    }),
  );

  unsubs.push(
    ws.on(S.CHAT_EDITED, (payload) => {
      editMessage(payload);
    }),
  );

  unsubs.push(
    ws.on(S.CHAT_DELETED, (payload) => {
      deleteMessage(payload);
    }),
  );

  unsubs.push(
    ws.on(S.CHAT_SEND_OK, (payload, id) => {
      if (id) {
        confirmSend(id, payload.message_id, payload.timestamp);
      }
    }),
  );

  // ── Reactions ───────────────────────────────────────────

  unsubs.push(
    ws.on(S.REACTION_UPDATE, (payload) => {
      const userId = authStore.getState().user?.id ?? 0;
      updateReaction(payload, userId);
    }),
  );

  // ── Typing ────────────────────────────────────────────

  unsubs.push(
    ws.on(S.TYPING, (payload) => {
      setTyping(payload.channel_id, payload.user_id);
    }),
  );

  // ── Presence ──────────────────────────────────────────

  unsubs.push(
    ws.on(S.PRESENCE, (payload) => {
      updatePresence(payload.user_id, payload.status);
    }),
  );

  // ── Channels ──────────────────────────────────────────

  unsubs.push(
    ws.on(S.CHANNEL_CREATE, (payload) => {
      addChannel(payload);
    }),
  );

  unsubs.push(
    ws.on(S.CHANNEL_UPDATE, (payload) => {
      updateChannel(payload);
    }),
  );

  unsubs.push(
    ws.on(S.CHANNEL_DELETE, (payload) => {
      // If the deleted channel is the active one, redirect to the first text channel.
      const activeId = channelsStore.select((s) => s.activeChannelId);
      removeChannel(payload.id);
      if (payload.id === activeId) {
        const remaining = channelsStore.select((s) => s.channels);
        const sorted = [...remaining.values()]
          .filter((ch) => ch.type === "text")
          .toSorted((a, b) => a.position - b.position);
        const firstTextId = sorted.length > 0 ? sorted[0]!.id : null;
        setActiveChannel(firstTextId);
        log.info("Active channel deleted, redirected", { deletedId: payload.id });
      }
    }),
  );

  // ── Members ───────────────────────────────────────────

  unsubs.push(
    ws.on(S.MEMBER_JOIN, (payload) => {
      log.info("Member joined", { userId: payload.user.id, username: payload.user.username });
      addMember(payload);
    }),
  );

  unsubs.push(
    ws.on(S.MEMBER_LEAVE, (payload) => {
      log.info("Member left", { userId: payload.user_id });
      removeMember(payload.user_id);
    }),
  );

  unsubs.push(
    ws.on(S.MEMBER_BAN, (payload) => {
      log.info("Member banned", { userId: payload.user_id });
      removeMember(payload.user_id);
    }),
  );

  unsubs.push(
    ws.on(S.MEMBER_UPDATE, (payload) => {
      log.info("Member role updated", { userId: payload.user_id, role: payload.role });
      updateMemberRole(payload.user_id, payload.role);
    }),
  );

  unsubs.push(
    ws.on(S.USER_UPDATE, (payload) => {
      log.info("User profile updated", { userId: payload.user_id, username: payload.username });
      updateMemberProfile(
        payload.user_id,
        payload.username,
        payload.avatar,
        payload.identity_public_key,
      );

      // Update auth store if the current user changed their own profile.
      const currentUser = authStore.getState().user;
      if (currentUser && payload.user_id === currentUser.id) {
        setAuth(
          authStore.getState().token ?? "",
          { ...currentUser, username: payload.username, avatar: payload.avatar },
          authStore.getState().serverName ?? "",
          authStore.getState().motd ?? "",
        );
      }
    }),
  );

  // ── Voice ─────────────────────────────────────────────

  unsubs.push(
    ws.on(S.VOICE_STATE, (payload) => {
      updateVoiceState(payload);
      // Auto-join voice channel if the event is for the current user
      const currentUserId = authStore.getState().user?.id ?? 0;
      if (payload.user_id === currentUserId) {
        joinVoiceChannel(payload.channel_id);
      }
    }),
  );

  unsubs.push(
    ws.on(S.VOICE_LEAVE, (payload) => {
      removeVoiceUser(payload);
      // Notify E2EE state machine so key holder can rotate the room key.
      void livekitSession().then(({ handleParticipantLeft }) =>
        handleParticipantLeft(payload.user_id),
      );
      // Clear local voice state if the current user was removed (kick/disconnect)
      const currentUserId = authStore.getState().user?.id ?? 0;
      if (payload.user_id === currentUserId) {
        leaveVoiceChannel();
      }
    }),
  );

  unsubs.push(
    ws.on(S.VOICE_CONFIG, (payload) => {
      setVoiceConfig(payload);
    }),
  );

  unsubs.push(
    ws.on(S.VOICE_SPEAKERS, (payload) => {
      setSpeakers(payload);
    }),
  );

  unsubs.push(
    ws.on(S.VOICE_TOKEN, (payload) => {
      void livekitSession().then(({ handleVoiceToken }) =>
        handleVoiceToken(
          payload.token,
          payload.url,
          payload.channel_id,
          payload.direct_url,
          payload.is_key_holder,
        ),
      );
    }),
  );

  // ── Voice E2EE (client-side ECDH key exchange) ────────

  unsubs.push(
    ws.on(S.VOICE_E2EE_ANNOUNCE, (payload) => {
      void livekitSession().then(({ handleE2EEAnnounce }) =>
        handleE2EEAnnounce(payload.user_id, payload.public_key, payload.signature),
      );
    }),
  );

  unsubs.push(
    ws.on(S.VOICE_E2EE_OFFER, (payload) => {
      void livekitSession().then(({ handleE2EEOffer }) =>
        handleE2EEOffer(payload.from_user_id, payload.encrypted_key, payload.iv),
      );
    }),
  );

  // ── Server Events ─────────────────────────────────────

  unsubs.push(
    ws.on(S.SERVER_RESTART, (payload) => {
      log.warn("Server restarting", {
        reason: payload.reason,
        delaySeconds: payload.delay_seconds,
      });
      setTransientError(`Server is restarting: ${payload.reason ?? "maintenance"}`);
    }),
  );

  // Local transport failures (proxy not open, outbound channel full/closed):
  // fail the matching optimistic row exactly like a server error reply would.
  // Fire-and-forget sends (typing, presence, voice) have no pendingSends entry
  // and stay logged-only.
  unsubs.push(
    ws.onSendFailure((id, code) => {
      if (messagesStore.getState().pendingSends.has(id)) {
        markSendFailed(id, code);
      }
    }),
  );

  unsubs.push(
    ws.on(S.ERROR, (payload, id) => {
      log.error("Server error", {
        code: payload.code,
        message: payload.message,
        id,
      });
      if (payload.code === "BANNED") {
        // Banned users must not reconnect — show error and force logout.
        setTransientError(payload.message || "You have been banned");
        clearAuth();
        return;
      }
      // If the error carries the request id of a pending optimistic send, mark
      // that specific row failed (with retry) instead of a global toast. This
      // covers SLOW_MODE, FORBIDDEN, RATE_LIMITED, BAD_REQUEST, etc. on send.
      if (id && messagesStore.getState().pendingSends.has(id)) {
        // A FORBIDDEN on a DM send is the server's generic block refusal
        // (ErrBlocked → FORBIDDEN, bidirectional). Gate the composer with the
        // neutral "being blocked" reason; blocks.store precedence still shows
        // the explicit reason if we are the blocker. Read the channel before
        // markSendFailed clears the pending row.
        if (payload.code === "FORBIDDEN") {
          const chId = messagesStore.getState().pendingSends.get(id);
          const dm =
            chId === undefined
              ? undefined
              : dmStore.getState().channels.find((c) => c.channelId === chId);
          if (dm !== undefined) setUserBlockedByThem(dm.recipient.id, true);
        }
        markSendFailed(id, payload.code);
        return;
      }
      if (payload.code === "RATE_LIMITED" || payload.code === "FORBIDDEN") {
        setTransientError(payload.message || "Server error");
      }
    }),
  );

  return () => {
    for (const unsub of unsubs) {
      unsub();
    }
  };
}
