/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import {
  Actions,
  createEffect,
  ofType
} from '@ngrx/effects';
import { Store, type Action } from '@ngrx/store';
import {
  of,
  from,
  EMPTY
} from 'rxjs';
import {
  map,
  mergeMap,
  withLatestFrom,
  tap,
  switchMap,
  catchError
} from 'rxjs/operators';
import { RoomsActions } from './rooms.actions';
import { UsersActions } from '../users/users.actions';
import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
import {
  selectActiveChannelId,
  selectCurrentRoom,
  selectSavedRooms
} from './rooms.selectors';
import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
import { resolveRoomPermission } from '../../domains/access-control';
import type {
  ChatEvent,
  Room,
  RoomSettings,
  RoomPermissions,
  BanEntry,
  User,
  VoiceState
} from '../../shared-kernel';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { hasRoomBanForUser } from '../../domains/access-control';
import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants';
import { VoiceSessionFacade } from '../../domains/voice-session';
import {
  buildSignalingUser,
  buildKnownUserExtras,
  isWrongServer,
  resolveRoom,
  reconcileRoomSnapshotChannels,
  sanitizeRoomSnapshot,
  normalizeIncomingBans,
  getPersistedCurrentUserId
} from './rooms.helpers';
import type { RoomPresenceSignalingMessage } from './rooms.helpers';

const SERVER_ICON_SYNC_REQUEST_DELAYS_MS = [
  1_500,
  3_000,
  5_000,
  8_000
];

/**
 * NgRx effects for real-time state synchronisation: signaling presence
 * events (server_users, user_joined, user_left, access_denied), P2P
 * room-state / icon sync, and voice/screen/camera state broadcasts.
 */
@Injectable()
export class RoomStateSyncEffects {
  private actions$ = inject(Actions);
  private store = inject(Store);
  private webrtc = inject(RealtimeSessionFacade);
  private db = inject(DatabaseService);
  private audioService = inject(NotificationAudioService);
  private voiceSessionService = inject(VoiceSessionFacade);

  /**
   * Tracks user IDs we already know are in voice.  Lives outside the
   * NgRx store so it survives room switches and presence re-syncs,
   * preventing false join/leave sounds during state refreshes.
   */
  private knownVoiceUsers = new Set<string>();
  private pendingServerIconRequestsByPeer = new Map<string, Set<string>>();
  /**
   * When a user leaves (e.g. socket drops), record the timestamp so
   * that a rapid re-join (reconnect) does not trigger a false
   * join/leave sound within {@link RECONNECT_SOUND_GRACE_MS}.
   */
  private recentlyLeftVoiceTimestamps = new Map<string, number>();

  // ── Signaling presence ─────────────────────────────────────────

  /** Handles WebRTC signaling events for user presence (join, leave, server_users). */
  signalingMessages$ = createEffect(() =>
    this.webrtc.onSignalingMessage.pipe(
      withLatestFrom(
        this.store.select(selectCurrentUser),
        this.store.select(selectCurrentRoom),
        this.store.select(selectSavedRooms),
        this.store.select(selectAllUsers)
      ),
      mergeMap(([
        message,
        currentUser,
        currentRoom,
        savedRooms,
        allUsers
      ]) => {
        const signalingMessage: RoomPresenceSignalingMessage = message;
        const myId = currentUser?.oderId || currentUser?.id;
        const viewedServerId = currentRoom?.id;
        const room = resolveRoom(signalingMessage.serverId, currentRoom, savedRooms);
        const shouldClearReconnectFlag = !isWrongServer(signalingMessage.serverId, viewedServerId);

        switch (signalingMessage.type) {
          case 'server_users': {
            if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId)
              return EMPTY;

            const syncedUsers = signalingMessage.users
              .filter((user) => user.oderId !== myId)
              .map((user) =>
                buildSignalingUser(user, {
                  ...buildKnownUserExtras(room, user.oderId),
                  description: user.description,
                  profileUpdatedAt: user.profileUpdatedAt,
                  presenceServerIds: [signalingMessage.serverId],
                  ...(user.status ? { status: user.status } : {})
                })
              );
            const actions: Action[] = [
              UsersActions.syncServerPresence({
                roomId: signalingMessage.serverId,
                users: syncedUsers,
                connectedPeerIds: this.webrtc.getConnectedPeers()
              })
            ];

            if (shouldClearReconnectFlag) {
              actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
            }

            return actions;
          }

          case 'user_joined': {
            if (!signalingMessage.serverId || signalingMessage.oderId === myId)
              return EMPTY;

            if (!signalingMessage.oderId)
              return EMPTY;

            const joinedUser = {
              oderId: signalingMessage.oderId,
              displayName: signalingMessage.displayName,
              description: signalingMessage.description,
              profileUpdatedAt: signalingMessage.profileUpdatedAt,
              status: signalingMessage.status
            };
            const actions: Action[] = [
              UsersActions.userJoined({
                user: buildSignalingUser(joinedUser, {
                  ...buildKnownUserExtras(room, joinedUser.oderId),
                  description: joinedUser.description,
                  profileUpdatedAt: joinedUser.profileUpdatedAt,
                  presenceServerIds: [signalingMessage.serverId]
                })
              })
            ];

            if (shouldClearReconnectFlag) {
              actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
            }

            return actions;
          }

          case 'user_left': {
            if (!signalingMessage.oderId)
              return EMPTY;

            const remainingServerIds = Array.isArray(signalingMessage.serverIds) ? signalingMessage.serverIds : undefined;

            if (!remainingServerIds || remainingServerIds.length === 0) {
              if (this.knownVoiceUsers.has(signalingMessage.oderId)) {
                this.recentlyLeftVoiceTimestamps.set(signalingMessage.oderId, Date.now());
              }

              this.knownVoiceUsers.delete(signalingMessage.oderId);
            }

            const actions: Action[] = [
              UsersActions.userLeft({
                userId: signalingMessage.oderId,
                serverId: signalingMessage.serverId,
                serverIds: remainingServerIds,
                connectedPeerIds: this.webrtc.getConnectedPeers()
              })
            ];

            if (shouldClearReconnectFlag) {
              actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
            }

            return actions;
          }

          case 'status_update': {
            if (!signalingMessage.oderId || !signalingMessage.status)
              return EMPTY;

            const validStatuses = [
              'online',
              'away',
              'busy',
              'offline'
            ];

            if (!validStatuses.includes(signalingMessage.status))
              return EMPTY;

            // 'offline' from the server means the user chose Invisible;
            // display them as disconnected to other users.
            const mappedStatus = signalingMessage.status === 'offline' ? 'disconnected' : (signalingMessage.status as 'online' | 'away' | 'busy');

            return [
              UsersActions.updateRemoteUserStatus({
                userId: signalingMessage.oderId,
                status: mappedStatus
              })
            ];
          }

          case 'voice_state': {
            const voiceEvent = {
              ...signalingMessage,
              type: 'voice-state',
              fromPeerId: signalingMessage.oderId ?? signalingMessage.fromUserId
            } as ChatEvent;

            return this.handleVoiceOrScreenState(voiceEvent, allUsers, currentUser ?? null, 'voice');
          }

          case 'access_denied': {
            if (isWrongServer(signalingMessage.serverId, viewedServerId))
              return EMPTY;

            if (signalingMessage.reason !== 'SERVER_NOT_FOUND')
              return EMPTY;

            // When multiple signal URLs are configured, the room may already
            // be successfully joined on a different signal server.  Only show
            // the reconnect notice when the room is not reachable at all.
            if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId))
              return EMPTY;

            return [RoomsActions.setSignalServerReconnecting({ isReconnecting: true })];
          }

          case 'server_icon_sync_peers': {
            if (!signalingMessage.serverId || !Array.isArray(signalingMessage.users)) {
              return EMPTY;
            }

            const serverId = signalingMessage.serverId;

            for (const user of signalingMessage.users) {
              if (!user.oderId || user.oderId === myId) {
                continue;
              }

              this.queueServerIconSyncRequest(user.oderId, serverId);
              this.webrtc.sendRawMessage({
                type: 'server_icon_peer_request',
                targetUserId: user.oderId,
                serverId
              });
            }

            return EMPTY;
          }

          case 'server_icon_peer_request': {
            const serverId = signalingMessage.serverId;
            const targetUserId = signalingMessage.fromUserId;
            const room = resolveRoom(serverId, currentRoom, savedRooms);

            if (!serverId || !targetUserId || !room?.icon) {
              return EMPTY;
            }

            this.webrtc.sendRawMessage({
              type: 'server_icon_peer_data',
              targetUserId,
              serverId,
              icon: room.icon,
              iconUpdatedAt: room.iconUpdatedAt || 0
            });

            return EMPTY;
          }

          case 'server_icon_peer_data': {
            if (!signalingMessage.serverId || typeof signalingMessage.icon !== 'string') {
              return EMPTY;
            }

            return of(
              RoomsActions.receiveSearchServerIcon({
                roomId: signalingMessage.serverId,
                icon: signalingMessage.icon,
                iconUpdatedAt: signalingMessage.iconUpdatedAt || Date.now()
              })
            );
          }

          default:
            return EMPTY;
        }
      })
    )
  );

  // ── P2P state sync ─────────────────────────────────────────────

  /** Request a full room-state snapshot whenever a peer data channel opens. */
  peerConnectedServerStateSync$ = createEffect(
    () =>
      this.webrtc.onPeerConnected.pipe(
        withLatestFrom(this.store.select(selectCurrentRoom)),
        tap(([peerId, room]) => {
          if (!room)
            return;

          this.webrtc.sendToPeer(peerId, {
            type: 'server-state-request',
            roomId: room.id
          });
        })
      ),
    { dispatch: false }
  );

  /** Re-request the latest room-state snapshot whenever the user enters or views a server. */
  roomEntryServerStateSync$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
        tap(({ room }) => {
          if (room.iconUpdatedAt) {
            this.webrtc.sendRawMessage({
              type: 'server_icon_available',
              serverId: room.id,
              iconUpdatedAt: room.iconUpdatedAt
            });
          }

          for (const peerId of this.webrtc.getConnectedPeers()) {
            try {
              this.webrtc.sendToPeer(peerId, {
                type: 'server-state-request',
                roomId: room.id
              });
            } catch {
              /* peer may have disconnected */
            }
          }
        })
      ),
    { dispatch: false }
  );

  /** Processes incoming P2P room-state, room-sync, and icon-sync events. */
  incomingRoomEvents$ = createEffect(() =>
    this.webrtc.onMessageReceived.pipe(
      withLatestFrom(
        this.store.select(selectCurrentRoom),
        this.store.select(selectSavedRooms),
        this.store.select(selectAllUsers),
        this.store.select(selectCurrentUser),
        this.store.select(selectActiveChannelId)
      ),
      mergeMap(([
        event,
        currentRoom,
        savedRooms,
        allUsers,
        currentUser,
        activeChannelId
      ]) => {
        switch (event.type) {
          case 'voice-state':
            return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice');
          case 'voice-channel-move':
            return this.handleVoiceChannelMove(event, currentRoom, savedRooms, currentUser ?? null);
          case 'screen-state':
            return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'screen');
          case 'camera-state':
            return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'camera');
          case 'server-state-request':
            return this.handleServerStateRequest(event, currentRoom, savedRooms);
          case 'server-state-full':
            return this.handleServerStateFull(event, currentRoom, savedRooms, currentUser ?? null);
          case 'room-settings-update':
            return this.handleRoomSettingsUpdate(event, currentRoom, savedRooms);
          case 'room-permissions-update':
            return this.handleRoomPermissionsUpdate(event, currentRoom, savedRooms);
          case 'channels-update':
            return this.handleChannelsUpdate(event, currentRoom, savedRooms, activeChannelId);
          case 'server-icon-summary':
            return this.handleIconSummary(event, currentRoom, savedRooms);
          case 'server-icon-request':
            return this.handleIconRequest(event, currentRoom, savedRooms);
          case 'server-icon-full':
          case 'server-icon-update':
            return this.handleIconData(event, currentRoom, savedRooms);
          default:
            return EMPTY;
        }
      })
    )
  );

  /** Broadcasts the local server icon summary to peers when a new peer connects. */
  peerConnectedIconSync$ = createEffect(
    () =>
      this.webrtc.onPeerConnected.pipe(
        withLatestFrom(this.store.select(selectCurrentRoom)),
        tap(([_peerId, room]) => {
          if (!room)
            return;

          const iconUpdatedAt = room.iconUpdatedAt || 0;

          this.webrtc.broadcastMessage({
            type: 'server-icon-summary',
            roomId: room.id,
            iconUpdatedAt
          });
        })
      ),
    { dispatch: false }
  );

  /** Sends queued discovery icon requests as soon as a temporary peer channel opens. */
  peerConnectedDiscoveryIconSync$ = createEffect(
    () =>
      this.webrtc.onPeerConnected.pipe(
        tap((peerId) => {
          const serverIds = this.pendingServerIconRequestsByPeer.get(peerId);

          if (!serverIds)
            return;

          for (const serverId of serverIds) {
            this.sendServerIconSyncRequest(peerId, serverId);
          }
        })
      ),
    { dispatch: false }
  );

  // ── Voice / Screen / Camera handlers ───────────────────────────

  private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], currentUser: User | null, kind: 'voice' | 'screen' | 'camera') {
    const userId: string | undefined = event.fromPeerId ?? event.oderId;

    if (!userId)
      return EMPTY;

    const existingUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
    const userExists = !!existingUser;

    if (kind === 'voice') {
      const vs = event.voiceState as Partial<VoiceState> | undefined;

      if (!vs)
        return EMPTY;

      const presenceRefreshAction =
        vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId)
          ? UsersActions.userJoined({
            user: buildSignalingUser(
              { oderId: userId, displayName: event.displayName || existingUser?.displayName || 'User' },
              { presenceServerIds: [vs.serverId] }
            )
          })
          : null;
      // Detect voice-connection transitions to play join/leave sounds.
      const weAreInVoice = this.webrtc.isVoiceConnected();
      const nowConnected = vs.isConnected ?? false;
      const wasKnown = this.knownVoiceUsers.has(userId);
      const wasInCurrentVoiceRoom = this.isSameVoiceRoom(existingUser?.voiceState, currentUser?.voiceState);
      const mergedVoiceState = { ...existingUser?.voiceState, ...vs };
      const isInCurrentVoiceRoom = this.isSameVoiceRoom(mergedVoiceState, currentUser?.voiceState);

      if (weAreInVoice) {
        const isReconnect = this.consumeRecentLeave(userId);

        if (!isReconnect) {
          if (((!wasKnown && isInCurrentVoiceRoom) || (userExists && !wasInCurrentVoiceRoom && isInCurrentVoiceRoom)) && nowConnected) {
            this.audioService.play(AppSound.Joining);
          } else if (wasInCurrentVoiceRoom && !isInCurrentVoiceRoom) {
            this.audioService.play(AppSound.Leave);
          }
        }
      }

      if (nowConnected) {
        this.knownVoiceUsers.add(userId);
      } else {
        this.knownVoiceUsers.delete(userId);
      }

      if (!userExists) {
        return of(
          UsersActions.userJoined({
            user: buildSignalingUser(
              { oderId: userId, displayName: event.displayName || 'User' },
              {
                presenceServerIds: vs.serverId ? [vs.serverId] : undefined,
                voiceState: {
                  isConnected: vs.isConnected ?? false,
                  isMuted: vs.isMuted ?? false,
                  isDeafened: vs.isDeafened ?? false,
                  isSpeaking: vs.isSpeaking ?? false,
                  isMutedByAdmin: vs.isMutedByAdmin,
                  volume: vs.volume,
                  roomId: vs.roomId,
                  serverId: vs.serverId
                }
              }
            )
          })
        );
      }

      const actions: Action[] = [];

      if (presenceRefreshAction) {
        actions.push(presenceRefreshAction);
      }

      actions.push(UsersActions.updateVoiceState({ userId, voiceState: vs }));

      return actions;
    }

    if (kind === 'screen') {
      const isSharing = event.isScreenSharing as boolean | undefined;

      if (isSharing === undefined)
        return EMPTY;

      if (!userExists) {
        return of(
          UsersActions.userJoined({
            user: buildSignalingUser({ oderId: userId, displayName: event.displayName || 'User' }, { screenShareState: { isSharing } })
          })
        );
      }

      return of(
        UsersActions.updateScreenShareState({
          userId,
          screenShareState: { isSharing }
        })
      );
    }

    const isCameraEnabled = event.isCameraEnabled as boolean | undefined;

    if (isCameraEnabled === undefined)
      return EMPTY;

    if (!userExists) {
      return of(
        UsersActions.userJoined({
          user: buildSignalingUser({ oderId: userId, displayName: event.displayName || 'User' }, { cameraState: { isEnabled: isCameraEnabled } })
        })
      );
    }

    return of(
      UsersActions.updateCameraState({
        userId,
        cameraState: { isEnabled: isCameraEnabled }
      })
    );
  }

  private handleVoiceChannelMove(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: User | null) {
    const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : null;
    const serverId = typeof event.roomId === 'string' ? event.roomId : currentUser?.voiceState?.serverId;
    const nextVoiceState = event.voiceState as Partial<VoiceState> | undefined;

    if (!currentUser || !targetUserId || !serverId || !nextVoiceState?.roomId) {
      return EMPTY;
    }

    if (targetUserId !== currentUser.id && targetUserId !== currentUser.oderId) {
      return EMPTY;
    }

    const room = resolveRoom(serverId, currentRoom, savedRooms);
    const movedChannel = room?.channels?.find((channel) => channel.id === nextVoiceState.roomId && channel.type === 'voice');

    if (!room || !movedChannel) {
      return EMPTY;
    }

    const updatedVoiceState: Partial<VoiceState> = {
      isConnected: true,
      isMuted: currentUser.voiceState?.isMuted ?? false,
      isDeafened: currentUser.voiceState?.isDeafened ?? false,
      isSpeaking: currentUser.voiceState?.isSpeaking ?? false,
      isMutedByAdmin: currentUser.voiceState?.isMutedByAdmin,
      volume: currentUser.voiceState?.volume,
      roomId: movedChannel.id,
      serverId: room.id
    };
    const wasViewingVoiceServer = this.voiceSessionService.isViewingVoiceServer();

    this.webrtc.startVoiceHeartbeat(movedChannel.id, room.id);
    this.voiceSessionService.startSession({
      serverId: room.id,
      serverName: room.name,
      roomId: movedChannel.id,
      roomName: `🔊 ${movedChannel.name}`,
      serverIcon: room.icon,
      serverDescription: room.description,
      serverRoute: `/room/${room.id}`
    });

    this.voiceSessionService.setViewingVoiceServer(wasViewingVoiceServer);
    this.webrtc.broadcastMessage({
      type: 'voice-state',
      oderId: currentUser.oderId || currentUser.id,
      displayName: currentUser.displayName || 'User',
      voiceState: updatedVoiceState
    });

    return of(
      UsersActions.updateVoiceState({
        userId: currentUser.id,
        voiceState: updatedVoiceState
      })
    );
  }

  private isSameVoiceRoom(voiceState: Partial<VoiceState> | undefined, currentUserVoiceState: Partial<VoiceState> | undefined): boolean {
    return (
      !!voiceState?.isConnected &&
      !!currentUserVoiceState?.isConnected &&
      !!voiceState.roomId &&
      !!voiceState.serverId &&
      voiceState.roomId === currentUserVoiceState.roomId &&
      voiceState.serverId === currentUserVoiceState.serverId
    );
  }

  /**
   * Returns `true` and cleans up the entry if the given user left
   * recently enough to be considered a reconnect.
   */
  private consumeRecentLeave(userId: string): boolean {
    const now = Date.now();

    for (const [id, ts] of this.recentlyLeftVoiceTimestamps) {
      if (now - ts > RECONNECT_SOUND_GRACE_MS) {
        this.recentlyLeftVoiceTimestamps.delete(id);
      }
    }

    const leaveTs = this.recentlyLeftVoiceTimestamps.get(userId);

    if (leaveTs !== undefined && now - leaveTs <= RECONNECT_SOUND_GRACE_MS) {
      this.recentlyLeftVoiceTimestamps.delete(userId);
      return true;
    }

    return false;
  }

  // ── Server-state sync handlers ─────────────────────────────────

  private handleServerStateRequest(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
    const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
    const room = resolveRoom(roomId, currentRoom, savedRooms);
    const fromPeerId = event.fromPeerId;

    if (!room || !fromPeerId)
      return EMPTY;

    return from(this.db.getBansForRoom(room.id)).pipe(
      tap((bans) => {
        this.webrtc.sendToPeer(fromPeerId, {
          type: 'server-state-full',
          roomId: room.id,
          room: sanitizeRoomSnapshot(room),
          bans
        });
      }),
      mergeMap(() => EMPTY)
    );
  }

  private handleServerStateFull(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: { id: string; oderId: string } | null) {
    const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
    const room = resolveRoom(roomId, currentRoom, savedRooms);
    const incomingRoom = event.room as Partial<Room> | undefined;

    if (!room || !incomingRoom)
      return EMPTY;

    const roomChanges = {
      ...sanitizeRoomSnapshot(incomingRoom),
      channels: reconcileRoomSnapshotChannels(room.channels, incomingRoom.channels)
    };
    const bans = normalizeIncomingBans(room.id, event.bans);

    return this.syncBansToLocalRoom(room.id, bans).pipe(
      mergeMap(() => {
        const actions: (
          | ReturnType<typeof RoomsActions.updateRoom>
          | ReturnType<typeof UsersActions.loadBansSuccess>
          | ReturnType<typeof RoomsActions.forgetRoom>
        )[] = [
          RoomsActions.updateRoom({
            roomId: room.id,
            changes: roomChanges
          })
        ];
        const isCurrentUserBanned = hasRoomBanForUser(bans, currentUser, getPersistedCurrentUserId());

        if (currentRoom?.id === room.id) {
          actions.push(UsersActions.loadBansSuccess({ bans }));
        }

        if (isCurrentUserBanned) {
          actions.push(RoomsActions.forgetRoom({ roomId: room.id }));
        }

        return actions;
      }),
      catchError(() => EMPTY)
    );
  }

  private handleRoomSettingsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
    const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
    const room = resolveRoom(roomId, currentRoom, savedRooms);
    const settings = event.settings as Partial<RoomSettings> | undefined;

    if (!room || !settings)
      return EMPTY;

    return of(
      RoomsActions.updateRoom({
        roomId: room.id,
        changes: {
          name: settings.name ?? room.name,
          description: settings.description ?? room.description,
          topic: settings.topic ?? room.topic,
          isPrivate: settings.isPrivate ?? room.isPrivate,
          password: settings.password === '' ? undefined : room.password,
          hasPassword:
            typeof settings.hasPassword === 'boolean'
              ? settings.hasPassword
              : typeof room.hasPassword === 'boolean'
                ? room.hasPassword
                : !!room.password,
          maxUsers: settings.maxUsers ?? room.maxUsers
        }
      })
    );
  }

  private handleRoomPermissionsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
    const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
    const room = resolveRoom(roomId, currentRoom, savedRooms);
    const permissions = event.permissions as Partial<RoomPermissions> | undefined;
    const incomingRoom = event.room as Partial<Room> | undefined;

    if (!room || (!permissions && !incomingRoom))
      return EMPTY;

    return of(
      RoomsActions.updateRoom({
        roomId: room.id,
        changes: {
          permissions: permissions ? ({ ...(room.permissions || {}), ...permissions } as RoomPermissions) : room.permissions,
          roles: Array.isArray(incomingRoom?.roles) ? incomingRoom.roles : room.roles,
          roleAssignments: Array.isArray(incomingRoom?.roleAssignments) ? incomingRoom.roleAssignments : room.roleAssignments,
          channelPermissions: Array.isArray(incomingRoom?.channelPermissions) ? incomingRoom.channelPermissions : room.channelPermissions,
          slowModeInterval: typeof incomingRoom?.slowModeInterval === 'number' ? incomingRoom.slowModeInterval : room.slowModeInterval
        }
      })
    );
  }

  private handleChannelsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], activeChannelId: string): Action[] {
    const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
    const room = resolveRoom(roomId, currentRoom, savedRooms);
    const channels = Array.isArray(event.channels) ? event.channels : null;

    if (!room || !channels) {
      return [];
    }

    const actions: Action[] = [
      RoomsActions.updateRoom({
        roomId: room.id,
        changes: { channels }
      })
    ];

    if (!channels.some((channel) => channel.id === activeChannelId)) {
      const fallbackChannelId = channels.find((channel) => channel.type === 'text')?.id ?? 'general';

      actions.push(RoomsActions.selectChannel({ channelId: fallbackChannelId }));
    }

    return actions;
  }

  // ── Icon sync handlers ─────────────────────────────────────────

  private handleIconSummary(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
    const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
    const room = resolveRoom(roomId, currentRoom, savedRooms);

    if (!room)
      return EMPTY;

    const remoteUpdated = event.iconUpdatedAt || 0;
    const localUpdated = room.iconUpdatedAt || 0;

    if (remoteUpdated > localUpdated && event.fromPeerId) {
      this.webrtc.sendToPeer(event.fromPeerId, {
        type: 'server-icon-request',
        roomId: room.id
      });
    }

    return EMPTY;
  }

  private handleIconRequest(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
    const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
    const room = resolveRoom(roomId, currentRoom, savedRooms);

    if (!room)
      return EMPTY;

    if (event.fromPeerId) {
      this.webrtc.sendToPeer(event.fromPeerId, {
        type: 'server-icon-full',
        roomId: room.id,
        icon: room.icon,
        iconUpdatedAt: room.iconUpdatedAt || 0
      });
    }

    return EMPTY;
  }

  private handleIconData(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
    const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
    const room = resolveRoom(roomId, currentRoom, savedRooms);
    const senderId = event.fromPeerId;

    if (!room || typeof event.icon !== 'string' || !senderId)
      return this.handleSearchResultIconData(event, roomId);

    return this.store.select(selectAllUsers).pipe(
      map((users) => users.find((user) => user.id === senderId)),
      mergeMap((sender) => {
        if (!sender)
          return EMPTY;

        const isOwner = room.hostId === sender.id;
        const canByRole = resolveRoomPermission(room, sender, 'manageIcon');

        if (!isOwner && !canByRole)
          return EMPTY;

        const updates: Partial<Room> = {
          icon: event.icon,
          iconUpdatedAt: event.iconUpdatedAt || Date.now()
        };

        this.db.updateRoom(room.id, updates);
        this.webrtc.sendRawMessage({
          type: 'server_icon_available',
          serverId: room.id,
          iconUpdatedAt: updates.iconUpdatedAt
        });

        return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates }));
      })
    );
  }

  private handleSearchResultIconData(event: ChatEvent, roomId: string | undefined) {
    if (!roomId || typeof event.icon !== 'string') {
      return EMPTY;
    }

    const iconUpdatedAt = event.iconUpdatedAt || Date.now();

    return of(
      RoomsActions.receiveSearchServerIcon({
        roomId,
        icon: event.icon,
        iconUpdatedAt
      })
    );
  }

  private queueServerIconSyncRequest(peerId: string, serverId: string): void {
    const pendingServerIds = this.pendingServerIconRequestsByPeer.get(peerId) ?? new Set<string>();

    pendingServerIds.add(serverId);
    this.pendingServerIconRequestsByPeer.set(peerId, pendingServerIds);
    this.scheduleServerIconSyncRequests(peerId, serverId);
  }

  private scheduleServerIconSyncRequests(peerId: string, serverId: string): void {
    for (const delayMs of SERVER_ICON_SYNC_REQUEST_DELAYS_MS) {
      setTimeout(() => {
        this.sendServerIconSyncRequest(peerId, serverId);
      }, delayMs);
    }
  }

  private sendServerIconSyncRequest(peerId: string, serverId: string): void {
    this.webrtc.sendToPeer(peerId, {
      type: 'server-icon-request',
      roomId: serverId
    });
  }

  // ── Internal helpers ───────────────────────────────────────────

  private syncBansToLocalRoom(roomId: string, bans: BanEntry[]) {
    return from(this.db.getBansForRoom(roomId)).pipe(
      switchMap((localBans) => {
        const nextIds = new Set(bans.map((ban) => ban.oderId));
        const removals = localBans.filter((ban) => !nextIds.has(ban.oderId)).map((ban) => this.db.removeBan(ban.oderId));
        const saves = bans.map((ban) => this.db.saveBan({ ...ban, roomId }));

        return from(Promise.all([...removals, ...saves]));
      })
    );
  }
}
