import type { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws';
import { verifyJwt } from '../utils/auth.js';
import { getDb, schema } from '../db/index.js';
import { eq, and, or, inArray, isNull, desc, sql } from 'drizzle-orm';
import { handleClientEvent } from './events.js';
import { computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js';
import type {
  User,
  Space,
  SpaceWithChannelsAndMembers,
  MemberWithUser,
  Channel,
  ChannelCategory,
  DmChannel,
  ServerEvent,
  SpaceFolder,
  SpaceLayoutItem,
  ReadState,
  ActiveCallInfo,
  Activity,
} from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';

// ─── Heartbeat State ──────────────────────────────────────────────────────────
const wsIsAlive: WeakMap<WebSocket, boolean> = new WeakMap();
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;

// SQLite's SQLITE_MAX_VARIABLE_NUMBER default is 999.
// Chunk inArray() calls to stay safely under this limit.
const BATCH_CHUNK_SIZE = 500;

function batchInArray<TId, TResult>(ids: TId[], queryFn: (chunk: TId[]) => TResult[]): TResult[] {
  if (ids.length <= BATCH_CHUNK_SIZE) return queryFn(ids);
  const results: TResult[] = [];
  for (let i = 0; i < ids.length; i += BATCH_CHUNK_SIZE) {
    results.push(...queryFn(ids.slice(i, i + BATCH_CHUNK_SIZE)));
  }
  return results;
}

export interface AuthenticatedSocket {
  ws: WebSocket;
  userId: string;
  username: string;
}

// ─── VoiceRoom Abstraction ─────────────────────────────────────────────────

export interface SpaceRoomMeta {
  type: 'space';
  spaceId: string;
}

export interface DmRoomMeta {
  type: 'dm';
  callerId: string;
  state: 'ringing' | 'active';
}

/** In-memory registry for federated calls on REMOTE instances. */
export interface FederatedCallEntry {
  dmChannelId: string | null;     // null for Path B (no local DM), late-bound when DM created mid-call
  federatedId: string;            // primary key — cross-instance stable
  callerId: string;               // local stub userId of the caller
  callerHomeUserId: string;
  federatedCallHost: string;      // peer origin of the host instance
  livekitUrl: string;
  tokens: Map<string, string>;    // homeUserId → LiveKit token
  ringedUserIds: string[];        // local userIds that received dm_call_incoming
  state: 'ringing' | 'active';
  startedAt: number;
}

export interface VoiceRoom {
  roomId: string;
  roomType: 'space' | 'dm';
  participants: Set<string>;
  metadata: SpaceRoomMeta | DmRoomMeta;
  startedAt: number;
}

// ─── ConnectionManager ─────────────────────────────────────────────────────

class ConnectionManager {
  // userId → Set of WebSocket connections (multiple tabs)
  private connections: Map<string, Set<WebSocket>> = new Map();
  // userId → Set of space IDs the user belongs to
  private userSpaces: Map<string, Set<string>> = new Map();
  // ws → userId (reverse lookup)
  private wsToUser: Map<WebSocket, string> = new Map();
  // Unified voice room tracking (replaces voiceStates + activeCalls)
  private voiceRooms: Map<string, VoiceRoom> = new Map();
  // O(1) reverse index: userId → roomId
  private userToRoom: Map<string, string> = new Map();
  // userId → { isMuted, isDeafened, isCameraOn, isScreenSharing } — voice user status
  private voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = new Map();
  // userId → Timeout
  private pendingOfflineTimeouts: Map<string, NodeJS.Timeout> = new Map();
  // roomId → Timeout for ringing DM rooms (60s auto-cleanup)
  private ringingTimeouts: Map<string, NodeJS.Timeout> = new Map();
  // Callback registered by events.ts to fan dm_call_end out to peers on ring timeout.
  // Null during startup — ring timeouts that fire before registration simply no-op (there are no peers to notify before boot completes).
  private ringTimeoutFanoutHook: ((dmChannelId: string, callerId: string) => Promise<void>) | null = null;
  /** Federated calls where this instance is NOT the host. Keyed by federatedId. */
  private federatedCalls: Map<string, FederatedCallEntry> = new Map();
  private federatedCallTimeouts: Map<string, NodeJS.Timeout> = new Map();
  // Space-muted/deafened users (moderator action)
  private spaceMutedUsers: Set<string> = new Set(); // Stores spaceId:userId
  private spaceDeafenedUsers: Set<string> = new Set(); // Stores spaceId:userId
  // Permission-muted users (SPEAK permission revoked while in voice)
  private permissionMutedUsers: Set<string> = new Set(); // Stores spaceId:userId
  // The specific WebSocket that initiated voice_join / DM call for this user.
  // When THIS socket closes, voice state is cleaned up immediately.
  private voiceWs: Map<string, WebSocket> = new Map();
  // Per-user WebSocket rate limiters (shared across all tabs/connections)
  private userRateLimiters: Map<string, WsRateLimiter> = new Map();

  // ─── Rich Presence ──────────────────────────────────────────────────────
  // userId → Activity[] (ephemeral, same lifecycle as voiceUserStates)
  private userActivities: Map<string, Activity[]> = new Map();
  // userId → boolean (cached from DB at auth time, updated via REST)
  private userShowActivity: Map<string, boolean> = new Map();
  // userId → status string (cached at auth, updated on presence_update)
  private userStatuses: Map<string, string> = new Map();
  // userId → timestamp of last activity_update (rate limiting)
  private lastActivityUpdate: Map<string, number> = new Map();

  addConnection(userId: string, ws: WebSocket): void {
    if (!this.connections.has(userId)) {
      this.connections.set(userId, new Set());
    }
    this.connections.get(userId)!.add(ws);
    this.wsToUser.set(ws, userId);

    // If they were pending offline, cancel it!
    this.cancelDisconnect(userId);
  }

  removeConnection(ws: WebSocket): string | undefined {
    const userId = this.wsToUser.get(ws);
    if (!userId) return undefined;

    this.wsToUser.delete(ws);
    const userConnections = this.connections.get(userId);
    if (userConnections) {
      userConnections.delete(ws);

      // ── Immediate voice cleanup if this was the voice-active socket ──
      if (this.voiceWs.get(userId) === ws) {
        this.voiceWs.delete(userId);

        // Leave voice room (space or DM)
        const left = this.leaveCurrentRoom(userId);
        this.clearVoiceUserStatus(userId);
        if (left) {
          if (left.room.roomType === 'space') {
            const meta = left.room.metadata as SpaceRoomMeta;
            this.sendToSpace(meta.spaceId, {
              type: 'voice_state_update',
              channelId: left.roomId,
              userId,
              action: 'leave',
            });
          } else {
            this.sendToDmMembers(left.roomId, {
              type: 'voice_state_update',
              channelId: left.roomId,
              userId,
              action: 'leave',
            });
            // Auto-end empty active DM calls
            const updatedRoom = this.voiceRooms.get(left.roomId);
            if (updatedRoom && updatedRoom.participants.size === 0
                && (updatedRoom.metadata as DmRoomMeta).state === 'active') {
              this.destroyRoom(left.roomId);
              this.sendToDmMembers(left.roomId, {
                type: 'dm_call_ended',
                dmChannelId: left.roomId,
              });
            }
          }
        }

        // Clean up ringing DM rooms where this user is the caller
        for (const [roomId, room] of this.voiceRooms) {
          if (room.roomType === 'dm') {
            const meta = room.metadata as DmRoomMeta;
            if (meta.state === 'ringing' && meta.callerId === userId) {
              this.destroyRoom(roomId);
              this.sendToDmMembers(roomId, {
                type: 'dm_call_ended',
                dmChannelId: roomId,
              });
            }
          }
        }

        // Notify the user's remaining tabs so their UI updates
        if (userConnections.size > 0 && left) {
          this.sendToUser(userId, {
            type: 'voice_disconnected',
            userId,
            channelId: left.roomId,
            reason: 'session_closed',
          });
        }
      }

      if (userConnections.size === 0) {
        this.connections.delete(userId);
        // Schedule disconnect cleanup (presence/offline, NOT voice — already handled above)
        this.scheduleDisconnect(userId);
      }
    }
    return userId;
  }

  private scheduleDisconnect(userId: string) {
    if (this.pendingOfflineTimeouts.has(userId)) return;

    const timeout = setTimeout(() => {
      this.finalizeDisconnect(userId);
      this.pendingOfflineTimeouts.delete(userId);
    }, 5000); // 5 second grace period

    this.pendingOfflineTimeouts.set(userId, timeout);
  }

  private cancelDisconnect(userId: string) {
    const timeout = this.pendingOfflineTimeouts.get(userId);
    if (timeout) {
      clearTimeout(timeout);
      this.pendingOfflineTimeouts.delete(userId);
      console.log(`[ConnectionManager] Rescued session for user ${userId}`);
    }
  }

  private finalizeDisconnect(userId: string) {
    // Double check they are still offline
    if (this.isUserOnline(userId)) return;

    console.log(`[ConnectionManager] Finalizing disconnect for user ${userId}`);
    const db = getDb();
    db.update(schema.users).set({ status: 'offline' }).where(eq(schema.users.id, userId)).run();

    // Leave voice room if in one (handles both space and DM rooms)
    const left = this.leaveCurrentRoom(userId);
    this.clearVoiceUserStatus(userId);
    this.voiceWs.delete(userId);
    if (left) {
      if (left.room.roomType === 'space') {
        const meta = left.room.metadata as SpaceRoomMeta;
        this.sendToSpace(meta.spaceId, {
          type: 'voice_state_update',
          channelId: left.roomId,
          userId: userId,
          action: 'leave',
        });
      } else {
        // DM room — broadcast leave and auto-end if empty
        this.sendToDmMembers(left.roomId, {
          type: 'voice_state_update',
          channelId: left.roomId,
          userId: userId,
          action: 'leave',
        });
        const updatedRoom = this.voiceRooms.get(left.roomId);
        if (updatedRoom && updatedRoom.participants.size === 0 && (updatedRoom.metadata as DmRoomMeta).state === 'active') {
          this.destroyRoom(left.roomId);
          this.sendToDmMembers(left.roomId, {
            type: 'dm_call_ended',
            dmChannelId: left.roomId,
          });
        }
      }
    }

    // Destroy any ringing DM rooms where this user is the caller
    for (const [roomId, room] of this.voiceRooms) {
      if (room.roomType === 'dm') {
        const meta = room.metadata as DmRoomMeta;
        if (meta.state === 'ringing' && meta.callerId === userId) {
          this.destroyRoom(roomId);
          this.sendToDmMembers(roomId, {
            type: 'dm_call_ended',
            dmChannelId: roomId,
          });
        }
      }
    }

    // Clear activity state
    this.clearUserActivities(userId);
    this.userShowActivity.delete(userId);
    this.userStatuses.delete(userId);
    this.lastActivityUpdate.delete(userId);

    // Broadcast offline to all spaces
    const userSpaces = this.getUserSpaces(userId);
    for (const spaceId of userSpaces) {
      this.sendToSpace(spaceId, {
        type: 'presence_update',
        userId: userId,
        status: 'offline',
        activities: [] as Activity[],
      });
    }

    // Clean up userSpaces (re-populated on next connect via setUserSpaces)
    this.userSpaces.delete(userId);

    // Clean up per-user rate limiter
    this.userRateLimiters.delete(userId);
  }

  getUserConnections(userId: string): Set<WebSocket> {
    return this.connections.get(userId) ?? new Set();
  }

  isUserOnline(userId: string): boolean {
    const conns = this.connections.get(userId);
    return conns !== undefined && conns.size > 0;
  }

  getUserRateLimiter(userId: string): WsRateLimiter {
    let limiter = this.userRateLimiters.get(userId);
    if (!limiter) {
      limiter = new WsRateLimiter();
      this.userRateLimiters.set(userId, limiter);
    }
    return limiter;
  }

  // ─── Activity accessors ─────────────────────────────────────────────────

  setUserActivities(userId: string, activities: Activity[]): void {
    if (activities.length === 0) {
      this.userActivities.delete(userId);
    } else {
      this.userActivities.set(userId, activities);
    }
  }

  getUserActivities(userId: string): Activity[] {
    return this.userActivities.get(userId) ?? [];
  }

  clearUserActivities(userId: string): void {
    this.userActivities.delete(userId);
  }

  setUserShowActivity(userId: string, show: boolean): void {
    this.userShowActivity.set(userId, show);
  }

  getUserShowActivity(userId: string): boolean {
    return this.userShowActivity.get(userId) ?? true;
  }

  setUserStatus(userId: string, status: string): void {
    this.userStatuses.set(userId, status);
  }

  getUserStatus(userId: string): string {
    return this.userStatuses.get(userId) ?? 'offline';
  }

  checkActivityRateLimit(userId: string): boolean {
    const now = Date.now();
    const last = this.lastActivityUpdate.get(userId) ?? 0;
    if (now - last < 3000) return false;
    this.lastActivityUpdate.set(userId, now);
    return true;
  }

  setUserSpaces(userId: string, spaceIds: string[]): void {
    this.userSpaces.set(userId, new Set(spaceIds));
  }

  addUserSpace(userId: string, spaceId: string): void {
    if (!this.userSpaces.has(userId)) {
      this.userSpaces.set(userId, new Set());
    }
    this.userSpaces.get(userId)!.add(spaceId);
  }

  getUserSpaces(userId: string): Set<string> {
    return this.userSpaces.get(userId) ?? new Set();
  }

  // ─── Unified VoiceRoom API ─────────────────────────────────────────────────

  /** Create a room. Returns false if room already exists. */
  createRoom(roomId: string, roomType: 'space' | 'dm', metadata: SpaceRoomMeta | DmRoomMeta): boolean {
    if (this.voiceRooms.has(roomId)) return false;
    this.voiceRooms.set(roomId, {
      roomId,
      roomType,
      participants: new Set(),
      metadata,
      startedAt: Date.now(),
    });
    return true;
  }

  /** Register a fan-out callback invoked when a ringing DM room hits its 60s timeout. */
  setRingTimeoutFanoutHook(fn: (dmChannelId: string, callerId: string) => Promise<void>): void {
    this.ringTimeoutFanoutHook = fn;
  }

  /** Create a DM room in ringing state with 60s auto-cleanup. */
  createDmRoom(dmChannelId: string, callerId: string): boolean {
    const created = this.createRoom(dmChannelId, 'dm', {
      type: 'dm',
      callerId,
      state: 'ringing',
    });
    if (!created) return false;

    // 60s ringing timeout — auto-destroy if still ringing
    const timeout = setTimeout(() => {
      this.ringingTimeouts.delete(dmChannelId);
      const room = this.voiceRooms.get(dmChannelId);
      if (room && room.roomType === 'dm' && (room.metadata as DmRoomMeta).state === 'ringing') {
        const ringedCallerId = (room.metadata as DmRoomMeta).callerId;
        this.destroyRoom(dmChannelId);
        this.sendToDmMembers(dmChannelId, {
          type: 'dm_call_ended',
          dmChannelId,
        });
        // Fan dm_call_end out to remote peers so stranded Path-A/B ringees exit the ring.
        // Without this, an accept-relay failure → Alice's 60s auto-clean leaves Bob's FederatedCallEntry lingering with no terminal event.
        if (this.ringTimeoutFanoutHook) {
          this.ringTimeoutFanoutHook(dmChannelId, ringedCallerId).catch(err =>
            console.error('[ws] ring-timeout fan-out error:', err),
          );
        }
      }
    }, 60_000);
    this.ringingTimeouts.set(dmChannelId, timeout);

    return true;
  }

  /** Transition a DM room from ringing → active. Returns false if not found or not ringing. */
  activateDmRoom(dmChannelId: string): boolean {
    const room = this.voiceRooms.get(dmChannelId);
    if (!room || room.roomType !== 'dm') return false;
    const meta = room.metadata as DmRoomMeta;
    if (meta.state !== 'ringing') return false;
    meta.state = 'active';

    // Clear ringing timeout
    const timeout = this.ringingTimeouts.get(dmChannelId);
    if (timeout) {
      clearTimeout(timeout);
      this.ringingTimeouts.delete(dmChannelId);
    }
    return true;
  }

  /** Register a federated call received via S2S. Adds 60s ringing timeout. */
  createFederatedCall(entry: FederatedCallEntry): void {
    this.clearFederatedCall(entry.federatedId);
    this.federatedCalls.set(entry.federatedId, entry);

    const timeout = setTimeout(() => {
      this.federatedCallTimeouts.delete(entry.federatedId);
      const call = this.federatedCalls.get(entry.federatedId);
      if (call && call.state === 'ringing') {
        this.federatedCalls.delete(entry.federatedId);
        const endEvent = {
          type: 'dm_call_ended',
          dmChannelId: call.dmChannelId,
          federatedCallId: call.federatedId,
        };
        for (const uid of call.ringedUserIds) {
          this.sendToUser(uid, endEvent as ServerEvent);
        }
      }
    }, 60_000);
    this.federatedCallTimeouts.set(entry.federatedId, timeout);
  }

  /** Get a federated call entry by federatedId (primary lookup). */
  getFederatedCall(federatedId: string): FederatedCallEntry | undefined {
    return this.federatedCalls.get(federatedId);
  }

  /** Get a federated call entry by local dmChannelId (convenience reverse lookup). */
  getFederatedCallByDmChannel(dmChannelId: string): FederatedCallEntry | undefined {
    for (const entry of this.federatedCalls.values()) {
      if (entry.dmChannelId === dmChannelId) return entry;
    }
    return undefined;
  }

  /** Transition a federated call from ringing → active. */
  activateFederatedCall(federatedId: string): boolean {
    const call = this.federatedCalls.get(federatedId);
    if (!call || call.state !== 'ringing') return false;
    call.state = 'active';
    const timeout = this.federatedCallTimeouts.get(federatedId);
    if (timeout) {
      clearTimeout(timeout);
      this.federatedCallTimeouts.delete(federatedId);
    }
    return true;
  }

  /** Remove a federated call entry and clear its timeout. */
  clearFederatedCall(federatedId: string): void {
    this.federatedCalls.delete(federatedId);
    const timeout = this.federatedCallTimeouts.get(federatedId);
    if (timeout) {
      clearTimeout(timeout);
      this.federatedCallTimeouts.delete(federatedId);
    }
  }

  /**
   * Evict all FederatedCallEntry objects whose federatedCallHost matches the given peer origin.
   * Emits dm_call_undeliverable { phase: 'host_unreachable', terminal: true } to each entry's
   * ringedUserIds, then clears the entry (and its 60s ring timer if still armed).
   *
   * Idempotent: re-invocation with an already-evicted host returns 0.
   * Called from onPeerDeactivated (signal 1) and the 30s sentinel (signal 2 / backstop).
   */
  evictFederatedCallsForHost(
    peerOrigin: string,
    ctx: {
      reason: 'peer_transient_failure' | 'peer_rejected';
      peerLabel?: string;
    },
  ): number {
    const matches: FederatedCallEntry[] = [];
    for (const entry of this.federatedCalls.values()) {
      if (entry.federatedCallHost === peerOrigin) matches.push(entry);
    }
    if (matches.length === 0) return 0;

    let evicted = 0;
    for (const entry of matches) {
      // Re-check — concurrent teardown may have removed it between collect and broadcast.
      if (!this.federatedCalls.has(entry.federatedId)) continue;

      const event: ServerEvent = {
        type: 'dm_call_undeliverable',
        dmChannelId: entry.dmChannelId,
        federatedCallId: entry.federatedId,
        terminal: true,
        phase: 'host_unreachable',
        failures: [{
          reason: ctx.reason,
          peerOrigin,
          peerLabel: ctx.peerLabel,
        }],
      };

      for (const uid of entry.ringedUserIds) {
        this.sendToUser(uid, event);
      }

      this.clearFederatedCall(entry.federatedId);
      evicted += 1;
    }

    return evicted;
  }

  /** Late-bind a dmChannelId onto a Path B FederatedCallEntry. */
  lateBindFederatedCall(federatedId: string, dmChannelId: string): void {
    const call = this.federatedCalls.get(federatedId);
    if (call && call.dmChannelId === null) {
      call.dmChannelId = dmChannelId;
    }
  }

  /** Expose federated calls for ready payload assembly. */
  getAllFederatedCalls(): Map<string, FederatedCallEntry> {
    return this.federatedCalls;
  }

  /** Add a user to a room. Enforces one-room-per-user invariant. Returns the room or null if room doesn't exist. */
  joinRoom(roomId: string, userId: string): VoiceRoom | null {
    const room = this.voiceRooms.get(roomId);
    if (!room) return null;

    // Enforce one-room-per-user invariant: silently remove from old room
    const currentRoomId = this.userToRoom.get(userId);
    if (currentRoomId && currentRoomId !== roomId) {
      const oldRoom = this.voiceRooms.get(currentRoomId);
      if (oldRoom) {
        oldRoom.participants.delete(userId);
        if (oldRoom.participants.size === 0 && oldRoom.roomType === 'space') {
          this.voiceRooms.delete(currentRoomId);
        }
      }
    }

    room.participants.add(userId);
    this.userToRoom.set(userId, roomId);
    return room;
  }

  /** Remove a user from a specific room. Returns the room or null if not found. */
  leaveRoom(roomId: string, userId: string): VoiceRoom | null {
    const room = this.voiceRooms.get(roomId);
    if (!room || !room.participants.has(userId)) return null;

    room.participants.delete(userId);
    this.userToRoom.delete(userId);
    
    if (room.roomType === 'space') {
      const meta = room.metadata as SpaceRoomMeta;
      this.clearSpaceVoiceState(meta.spaceId, userId);
    }

    // Auto-cleanup empty space rooms (they're lazy-created)
    if (room.participants.size === 0 && room.roomType === 'space') {
      this.voiceRooms.delete(roomId);
    }

    return room;
  }

  /** Leave whatever room the user is in. Returns { roomId, room } or null. */
  leaveCurrentRoom(userId: string): { roomId: string; room: VoiceRoom } | null {
    const roomId = this.userToRoom.get(userId);
    if (!roomId) return null;

    const room = this.leaveRoom(roomId, userId);
    if (!room) return null;

    return { roomId, room };
  }

  /** Destroy a room entirely. Returns displaced userIds. */
  destroyRoom(roomId: string): string[] {
    const room = this.voiceRooms.get(roomId);
    if (!room) return [];

    const displaced: string[] = [];
    for (const userId of room.participants) {
      this.userToRoom.delete(userId);
      displaced.push(userId);
    }

    this.voiceRooms.delete(roomId);

    // Clear ringing timeout if any
    const timeout = this.ringingTimeouts.get(roomId);
    if (timeout) {
      clearTimeout(timeout);
      this.ringingTimeouts.delete(roomId);
    }

    return displaced;
  }

  /** Get a room by ID. */
  getRoom(roomId: string): VoiceRoom | undefined {
    return this.voiceRooms.get(roomId);
  }

  /** Get participants in a room. */
  getRoomParticipants(roomId: string): Set<string> {
    return this.voiceRooms.get(roomId)?.participants ?? new Set();
  }

  /** Get the room a user is currently in. Returns { roomId, room } or null. */
  getUserRoom(userId: string): { roomId: string; room: VoiceRoom } | null {
    const roomId = this.userToRoom.get(userId);
    if (!roomId) return null;
    const room = this.voiceRooms.get(roomId);
    if (!room) return null;
    return { roomId, room };
  }

  /** Read-only access to all rooms. */
  getAllRooms(): Map<string, VoiceRoom> {
    return this.voiceRooms;
  }

  // ─── Voice User Status (unchanged) ────────────────────────────────────────

  setVoiceUserStatus(userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean): void {
    this.voiceUserStates.set(userId, { isMuted, isDeafened, isCameraOn, isScreenSharing });
  }

  getVoiceUserStatus(userId: string): { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean } | undefined {
    return this.voiceUserStates.get(userId);
  }

  clearVoiceUserStatus(userId: string): void {
    this.voiceUserStates.delete(userId);
  }

  // ─── Voice WebSocket Binding ───────────────────────────────────────────────

  /** Store which ws owns the voice session for this user. */
  setVoiceWs(userId: string, ws: WebSocket): void {
    this.voiceWs.set(userId, ws);
  }

  /** Get the voice-owning ws for this user. */
  getVoiceWs(userId: string): WebSocket | undefined {
    return this.voiceWs.get(userId);
  }

  /** Clear the voice ws binding for this user. */
  clearVoiceWs(userId: string): void {
    this.voiceWs.delete(userId);
  }

  setSpaceMuted(spaceId: string, userId: string, muted: boolean): void {
    const key = `${spaceId}:${userId}`;
    if (muted) this.spaceMutedUsers.add(key);
    else this.spaceMutedUsers.delete(key);
  }

  isSpaceMuted(spaceId: string, userId: string): boolean {
    return this.spaceMutedUsers.has(`${spaceId}:${userId}`);
  }

  setSpaceDeafened(spaceId: string, userId: string, deafened: boolean): void {
    const key = `${spaceId}:${userId}`;
    if (deafened) this.spaceDeafenedUsers.add(key);
    else this.spaceDeafenedUsers.delete(key);
  }

  isSpaceDeafened(spaceId: string, userId: string): boolean {
    return this.spaceDeafenedUsers.has(`${spaceId}:${userId}`);
  }

  clearSpaceVoiceState(spaceId: string, userId: string): void {
    this.spaceMutedUsers.delete(`${spaceId}:${userId}`);
    this.spaceDeafenedUsers.delete(`${spaceId}:${userId}`);
    this.permissionMutedUsers.delete(`${spaceId}:${userId}`);
  }

  setPermissionMuted(spaceId: string, userId: string, muted: boolean): void {
    const key = `${spaceId}:${userId}`;
    if (muted) this.permissionMutedUsers.add(key);
    else this.permissionMutedUsers.delete(key);
  }

  isPermissionMuted(spaceId: string, userId: string): boolean {
    return this.permissionMutedUsers.has(`${spaceId}:${userId}`);
  }

  getAllVoiceUserStates(): Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> {
    return this.voiceUserStates;
  }

  // ─── Broadcasting ─────────────────────────────────────────────────────────

  /** Send to a specific user (all their connections). */
  sendToUser(userId: string, event: ServerEvent): void {
    const connections = this.getUserConnections(userId);
    const message = JSON.stringify(event);
    for (const ws of connections) {
      if (ws.readyState === 1) { // WebSocket.OPEN
        ws.send(message);
      }
    }
  }

  /** Send to all members of a space. */
  sendToSpace(spaceId: string, event: ServerEvent, excludeUserId?: string): void {
    const message = JSON.stringify(event);
    for (const [userId, spaceIds] of this.userSpaces) {
      if (spaceIds.has(spaceId) && userId !== excludeUserId) {
        const connections = this.getUserConnections(userId);
        for (const ws of connections) {
          if (ws.readyState === 1) {
            ws.send(message);
          }
        }
      }
    }
  }

  /** Send to space members who have VIEW_CHANNEL on the given channel. */
  sendToChannel(spaceId: string, channelId: string, event: ServerEvent, excludeUserId?: string): void {
    const message = JSON.stringify(event);
    for (const [userId, spaceIds] of this.userSpaces) {
      if (spaceIds.has(spaceId) && userId !== excludeUserId) {
        const perms = computePermissions(userId, spaceId, channelId);
        if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
          const connections = this.getUserConnections(userId);
          for (const ws of connections) {
            if (ws.readyState === 1) {
              ws.send(message);
            }
          }
        }
      }
    }
  }

  /** Expose userSpaces iterator for pre-delete viewer collection. */
  getUserSpaceEntries(): IterableIterator<[string, Set<string>]> {
    return this.userSpaces.entries();
  }

  /** Send to all DM channel members (queries dm_members table). */
  sendToDmMembers(dmChannelId: string, event: ServerEvent, excludeUserId?: string): void {
    const db = getDb();
    const dmMembers = db.select()
      .from(schema.dmMembers)
      .where(eq(schema.dmMembers.dmChannelId, dmChannelId))
      .all();

    for (const member of dmMembers) {
      if (member.userId !== excludeUserId) {
        this.sendToUser(member.userId, event);
      }
    }
  }

  /** Send event to users who were ringed for a federated call.
   *  ALWAYS uses ringedUserIds, never sendToDmMembers — sendToDmMembers would
   *  also reach the caller's replicated stub, causing cross-instance event contamination
   *  (the caller's multi-instance WS gets dm_call_accepted with the wrong dmChannelId). */
  sendToFederatedCallUsers(federatedId: string, event: ServerEvent, excludeUserId?: string): void {
    const call = this.federatedCalls.get(federatedId);
    if (!call) return;
    for (const uid of call.ringedUserIds) {
      if (uid !== excludeUserId) {
        this.sendToUser(uid, event);
      }
    }
  }

  /** Send to a room — routes to sendToSpace (space rooms) or sendToDmMembers (DM rooms). */
  sendToRoom(roomId: string, event: ServerEvent, excludeUserId?: string): void {
    const room = this.voiceRooms.get(roomId);
    if (!room) return;

    if (room.roomType === 'space') {
      const meta = room.metadata as SpaceRoomMeta;
      this.sendToSpace(meta.spaceId, event, excludeUserId);
    } else {
      this.sendToDmMembers(roomId, event, excludeUserId);
    }
  }

  /** Send to all connections of all online users. */
  sendToAll(event: ServerEvent, excludeUserId?: string): void {
    const message = JSON.stringify(event);
    for (const [userId, connections] of this.connections) {
      if (userId !== excludeUserId) {
        for (const ws of connections) {
          if (ws.readyState === 1) {
            ws.send(message);
          }
        }
      }
    }
  }

  /** Send to a specific WebSocket instance (not all of a user's connections). */
  sendToWs(ws: WebSocket, event: ServerEvent): void {
    if (ws.readyState === 1) { // WebSocket.OPEN
      ws.send(JSON.stringify(event));
    }
  }

  /** Force-disconnect all WebSocket connections for a user (e.g. account deletion). */
  forceDisconnectUser(userId: string): void {
    // Cancel any pending offline timeout
    const timeout = this.pendingOfflineTimeouts.get(userId);
    if (timeout) {
      clearTimeout(timeout);
      this.pendingOfflineTimeouts.delete(userId);
    }

    // Leave voice room if in one
    const left = this.leaveCurrentRoom(userId);
    this.clearVoiceUserStatus(userId);
    this.voiceWs.delete(userId);
    if (left) {
      if (left.room.roomType === 'space') {
        const meta = left.room.metadata as SpaceRoomMeta;
        this.sendToSpace(meta.spaceId, {
          type: 'voice_state_update',
          channelId: left.roomId,
          userId,
          action: 'leave',
        });
      } else {
        this.sendToDmMembers(left.roomId, {
          type: 'voice_state_update',
          channelId: left.roomId,
          userId,
          action: 'leave',
        });
      }
    }

    // Destroy any ringing DM rooms where this user is the caller
    for (const [roomId, room] of this.voiceRooms) {
      if (room.roomType === 'dm') {
        const meta = room.metadata as DmRoomMeta;
        if (meta.state === 'ringing' && meta.callerId === userId) {
          this.destroyRoom(roomId);
          this.sendToDmMembers(roomId, {
            type: 'dm_call_ended',
            dmChannelId: roomId,
          });
        }
      }
    }

    // Clear activity state
    this.clearUserActivities(userId);
    this.userShowActivity.delete(userId);
    this.userStatuses.delete(userId);
    this.lastActivityUpdate.delete(userId);

    // Close all WebSocket connections
    const connections = this.connections.get(userId);
    if (connections) {
      for (const ws of connections) {
        this.wsToUser.delete(ws);
        try { ws.close(4001, 'Account deleted'); } catch { /* ignore */ }
      }
      this.connections.delete(userId);
    }

    // Clean up user spaces
    this.userSpaces.delete(userId);
  }

  getAllOnlineUserIds(): string[] {
    return Array.from(this.connections.keys());
  }

  getAllConnections(): Map<string, Set<WebSocket>> {
    return this.connections;
  }

  /** Send an event to all connected admin users. */
  sendToAdmins(event: ServerEvent): void {
    const db = getDb();
    for (const userId of this.connections.keys()) {
      const user = db.select({ isAdmin: schema.users.isAdmin })
        .from(schema.users).where(eq(schema.users.id, userId)).get();
      if (user?.isAdmin === 1) {
        this.sendToUser(userId, event);
      }
    }
  }

  /** Push a fresh ready payload to a specific user, forcing full store re-sync. */
  pushReadyPayload(userId: string): void {
    const connections = this.getUserConnections(userId);
    if (connections.size === 0) return;

    const readyData = buildReadyPayload(userId);
    const message = JSON.stringify({ type: 'ready', ...readyData });
    for (const ws of connections) {
      if (ws.readyState === 1) {
        ws.send(message);
      }
    }
  }
}

export const connectionManager = new ConnectionManager();

// ─── WebSocket Rate Limiter (Token Bucket) ─────────────────────────────────

class WsRateLimiter {
  private tokens: number;
  private readonly maxTokens: number;
  private readonly refillRate: number; // tokens per second
  private lastRefill: number;

  constructor(maxTokens = 30, refillRate = 2) {
    this.maxTokens = maxTokens;
    this.tokens = maxTokens;
    this.refillRate = refillRate;
    this.lastRefill = Date.now();
  }

  consume(): boolean {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
    this.lastRefill = now;

    if (this.tokens >= 1) {
      this.tokens -= 1;
      return true;
    }
    return false;
  }
}

function buildReadyPayload(userId: string): {
  user: User;
  spaces: SpaceWithChannelsAndMembers[];
  dmChannels: DmChannel[];
  folders: SpaceFolder[];
  spaceLayout: SpaceLayoutItem[] | null;
  layoutUpdatedAt: number | null;
  voiceStates: Record<string, string[]>;
  voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
  spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
  readStates: ReadState[];
  activeCalls: ActiveCallInfo[];
  userActivities: Record<string, Activity[]>;
  rejectedPeerOrigins: string[];
  awaitingApprovalPeerOrigins: string[];
  activePeerOrigins: string[];
  pendingApprovalCount: number;
} {
  const db = getDb();

  // Get user
  const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
  if (!userRow) {
    throw new Error('User not found');
  }
  const user = sanitizeUser(userRow, true);
  const isFederated = !!userRow.homeInstance;

  // Cache showActivity and status for Rich Presence
  connectionManager.setUserShowActivity(userId, userRow.showActivity !== 0);
  connectionManager.setUserStatus(userId, (userRow.status ?? 'offline') as string);

  // Get user's space memberships
  const memberships = db.select()
    .from(schema.spaceMembers)
    .where(eq(schema.spaceMembers.userId, userId))
    .all();

  const spaceIds = memberships.map(m => m.spaceId);

  const visibleChannelIdSet = new Set<string>();
  const spaces: SpaceWithChannelsAndMembers[] = [];

  if (spaceIds.length > 0) {
    const spaceRows = db.select()
      .from(schema.spaces)
      .where(inArray(schema.spaces.id, spaceIds))
      .all();

    // Batch: all channels for all spaces (1 query instead of N)
    const allChannels = batchInArray(
      spaceIds,
      ids => db.select().from(schema.channels).where(inArray(schema.channels.spaceId, ids)).all(),
    );
    const channelsBySpace = new Map<string, (typeof allChannels)>();
    for (const ch of allChannels) {
      let arr = channelsBySpace.get(ch.spaceId);
      if (!arr) { arr = []; channelsBySpace.set(ch.spaceId, arr); }
      arr.push(ch);
    }

    // Batch: determine which channels are private (VIEW_CHANNEL denied on @everyone)
    // @everyone role ID equals the space ID, so we query for overrides targeting role = spaceId
    const allEveroneOverrides = batchInArray(
      spaceIds,
      ids => db.select().from(schema.channelOverrides).where(
        and(
          eq(schema.channelOverrides.targetType, 'role'),
          inArray(schema.channelOverrides.targetId, ids),
        )
      ).all(),
    );
    const privateChannelIds = new Set<string>();
    for (const o of allEveroneOverrides) {
      const denyBits = BigInt(o.deny || '0');
      if ((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n) {
        privateChannelIds.add(o.channelId);
      }
    }

    // Batch: all categories for all spaces (1 query instead of N)
    const allCategories = batchInArray(
      spaceIds,
      ids => db.select().from(schema.channelCategories).where(inArray(schema.channelCategories.spaceId, ids)).all(),
    );
    const categoriesBySpace = new Map<string, ChannelCategory[]>();
    for (const cat of allCategories) {
      let arr = categoriesBySpace.get(cat.spaceId);
      if (!arr) { arr = []; categoriesBySpace.set(cat.spaceId, arr); }
      arr.push({
        id: cat.id,
        spaceId: cat.spaceId,
        name: cat.name,
        position: cat.position ?? 0,
        createdAt: cat.createdAt,
      });
    }

    // Batch: last message ID per channel (1 query instead of N×C)
    const allChannelIds = allChannels.map(ch => ch.id);
    const lastMsgMap = new Map<string, string>();
    if (allChannelIds.length > 0) {
      const lastMsgRows = batchInArray(
        allChannelIds,
        ids => db.select({
          channelId: schema.messages.channelId,
          lastId: sql<string>`max(${schema.messages.id})`,
        }).from(schema.messages).where(inArray(schema.messages.channelId, ids)).groupBy(schema.messages.channelId).all(),
      );
      for (const row of lastMsgRows) {
        if (row.lastId) lastMsgMap.set(row.channelId, row.lastId);
      }
    }

    for (const spaceRow of spaceRows) {
      const channels = channelsBySpace.get(spaceRow.id) ?? [];

      const roles = db.select()
        .from(schema.roles)
        .where(eq(schema.roles.spaceId, spaceRow.id))
        .orderBy(schema.roles.position)
        .all();

      const memberRows = db.select()
        .from(schema.spaceMembers)
        .where(eq(schema.spaceMembers.spaceId, spaceRow.id))
        .all();

      const memberUserIds = memberRows.map(m => m.userId);
      const users = memberUserIds.length > 0
        ? batchInArray(memberUserIds, ids => db.select().from(schema.users).where(inArray(schema.users.id, ids)).all())
        : [];
      const userMap = new Map(users.map(u => [u.id, u]));

      const memberRoleRows = db.select()
        .from(schema.memberRoles)
        .where(eq(schema.memberRoles.spaceId, spaceRow.id))
        .all();

      const members: MemberWithUser[] = memberRows
        .map(m => {
          const u = userMap.get(m.userId);
          if (!u) return null;

          const assignedRoleIds = memberRoleRows
            .filter(mr => mr.userId === m.userId)
            .map(mr => mr.roleId);

          const memberRoles = roles
            .filter(r => assignedRoleIds.includes(r.id))
            .map(r => ({
              id: r.id,
              spaceId: r.spaceId,
              name: r.name,
              color: r.color ?? '#b9bbbe',
              position: r.position ?? 0,
              createdAt: r.createdAt,
            }));

          return {
            spaceId: m.spaceId,
            userId: m.userId,
            nickname: m.nickname,
            joinedAt: m.joinedAt,
            user: sanitizeUser(u),
            roles: memberRoles,
          };
        })
        .filter((m): m is MemberWithUser => m !== null);

      // Compute space-level permissions for this user
      const spacePerms = computePermissions(userId, spaceRow.id);

      // Filter channels by VIEW_CHANNEL and attach per-channel permissions
      const visibleChannels: Channel[] = [];
      for (const ch of channels) {
        const chPerms = computePermissions(userId, spaceRow.id, ch.id);
        const hasView = (chPerms & PermissionBits.VIEW_CHANNEL) !== 0n || (chPerms & PermissionBits.ADMINISTRATOR) !== 0n;
        if (hasView) {
          visibleChannelIdSet.add(ch.id);
          visibleChannels.push({
            id: ch.id,
            spaceId: ch.spaceId,
            name: ch.name,
            type: ch.type as Channel['type'],
            topic: ch.topic,
            position: ch.position ?? 0,
            categoryId: ch.categoryId ?? null,
            isPrivate: privateChannelIds.has(ch.id),
            createdAt: ch.createdAt,
            lastMessageId: lastMsgMap.get(ch.id) ?? null,
            myPermissions: permissionsToString(chPerms),
          });
        }
      }

      spaces.push({
        id: spaceRow.id,
        name: spaceRow.name,
        icon: spaceRow.icon,
        banner: spaceRow.banner ?? null,
        avatarColor: (spaceRow.avatarColor as Space['avatarColor']) ?? null,
        ownerId: spaceRow.ownerId,
        inviteCode: spaceRow.inviteCode,
        visibility: (spaceRow.visibility ?? 'private') as SpaceWithChannelsAndMembers['visibility'],
        description: spaceRow.description ?? null,
        createdAt: spaceRow.createdAt,
        channels: visibleChannels,
        categories: categoriesBySpace.get(spaceRow.id) ?? [],
        members,
        roles: roles.map(r => ({
          id: r.id,
          spaceId: r.spaceId,
          name: r.name,
          color: r.color ?? '#b9bbbe',
          position: r.position ?? 0,
          permissions: r.permissions ?? undefined,
          isEveryone: r.id === spaceRow.id,
          createdAt: r.createdAt,
        })),
        myPermissions: permissionsToString(spacePerms),
      });
    }
  }

  // Store user's space IDs for broadcasting
  connectionManager.setUserSpaces(userId, spaceIds);

  // Get DM channels
  const dmMemberships = db.select()
    .from(schema.dmMembers)
    .where(and(
      eq(schema.dmMembers.userId, userId),
      eq(schema.dmMembers.closed, 0),
    ))
    .all();

  const dmChannelIds = dmMemberships.map(dm => dm.dmChannelId);
  const dmChannels: DmChannel[] = [];

  if (dmChannelIds.length > 0) {
    // Batch: all DM channels (1 query, exclude soft-deleted)
    const allDmChannelRows = batchInArray(
      dmChannelIds,
      ids => db.select().from(schema.dmChannels).where(and(inArray(schema.dmChannels.id, ids), isNull(schema.dmChannels.deletedAt))).all(),
    );
    const dmChannelMap = new Map(allDmChannelRows.map(c => [c.id, c]));

    // Batch: all DM members across all channels (1 query)
    const allDmMemberRows = batchInArray(
      dmChannelIds,
      ids => db.select().from(schema.dmMembers).where(inArray(schema.dmMembers.dmChannelId, ids)).all(),
    );

    // Batch: all unique users from DM members (1 query)
    const allDmUserIds = [...new Set(allDmMemberRows.map(m => m.userId))];
    const allDmUsers = allDmUserIds.length > 0
      ? batchInArray(allDmUserIds, ids => db.select().from(schema.users).where(inArray(schema.users.id, ids)).all())
      : [];
    const dmUserMap = new Map(allDmUsers.map(u => [u.id, u]));

    // Batch: last message per DM channel.
    // Two-step approach (same as GET /api/dm): get MAX(created_at) per channel,
    // then fetch the actual message rows matching those timestamps.
    const dmMaxTimestamps = batchInArray(
      dmChannelIds,
      ids => db.select({
        dmChannelId: schema.dmMessages.dmChannelId,
        maxCreatedAt: sql<number>`MAX(${schema.dmMessages.createdAt})`.as('max_created_at'),
      }).from(schema.dmMessages).where(inArray(schema.dmMessages.dmChannelId, ids)).groupBy(schema.dmMessages.dmChannelId).all(),
    );
    const dmLastMsgMap = new Map<string, typeof schema.dmMessages.$inferSelect>();
    if (dmMaxTimestamps.length > 0) {
      const conditions = dmMaxTimestamps.map(t =>
        and(eq(schema.dmMessages.dmChannelId, t.dmChannelId), eq(schema.dmMessages.createdAt, t.maxCreatedAt!))
      );
      const dmLastMessages = db.select().from(schema.dmMessages).where(or(...conditions)).all();
      for (const m of dmLastMessages) {
        if (!dmLastMsgMap.has(m.dmChannelId)) {
          dmLastMsgMap.set(m.dmChannelId, m);
        }
      }
    }
    const dmLastMsgIds = [...dmLastMsgMap.values()].map(m => m.id);

    // Batch: attachments for last messages (1 query)
    const dmLastMsgAttachments = dmLastMsgIds.length > 0
      ? batchInArray(dmLastMsgIds, ids =>
          db.select({
            dmMessageId: schema.attachments.dmMessageId,
            type: schema.attachments.mimetype,
            filename: schema.attachments.originalName,
          }).from(schema.attachments).where(inArray(schema.attachments.dmMessageId, ids)).all()
        )
      : [];
    const dmLastMsgAttachmentMap = new Map<string, Array<{ type: string; filename: string }>>();
    for (const a of dmLastMsgAttachments) {
      if (!a.dmMessageId) continue;
      const arr = dmLastMsgAttachmentMap.get(a.dmMessageId) ?? [];
      arr.push({ type: a.type, filename: a.filename });
      dmLastMsgAttachmentMap.set(a.dmMessageId, arr);
    }

    // Assemble DM channels with zero additional queries
    for (const dm of dmMemberships) {
      const dmChannel = dmChannelMap.get(dm.dmChannelId);
      if (!dmChannel) continue;

      const memberRows = allDmMemberRows.filter(m => m.dmChannelId === dm.dmChannelId);
      const members = memberRows
        .map(m => dmUserMap.get(m.userId))
        .filter((u): u is NonNullable<typeof u> => u != null)
        .map(u => sanitizeUser(u));

      const last = dmLastMsgMap.get(dm.dmChannelId) ?? null;

      dmChannels.push({
        id: dmChannel.id,
        federatedId: dmChannel.federatedId ?? null,
        ownerId: dmChannel.ownerId ?? null,
        createdAt: dmChannel.createdAt,
        members,
        lastMessage: last ? {
          id: last.id,
          dmChannelId: last.dmChannelId,
          userId: last.userId,
          content: last.content,
          createdAt: last.createdAt,
          attachments: dmLastMsgAttachmentMap.get(last.id) ?? [],
        } : null,
      });
    }

  }

  // Include DM channel IDs in the visible set for read state filtering
  for (const dm of dmChannels) {
    visibleChannelIdSet.add(dm.id);
  }

  // Seed read states for federated users' DM channels that have no existing read state.
  // This handles the bootstrap: DMs existed before cross-instance access was enabled,
  // so the remote instance has no read state history. Mark as read (latest message).
  // Going forward, the S2S read_state_update relay keeps things in sync.
  if (isFederated && dmChannels.length > 0) {
    const dmIds = dmChannels.map(dm => dm.id);
    const existingDmReadStates = batchInArray(
      dmIds,
      ids => db.select({ channelId: schema.readStates.channelId })
        .from(schema.readStates)
        .where(and(eq(schema.readStates.userId, userId), inArray(schema.readStates.channelId, ids)))
        .all(),
    );
    const hasReadState = new Set(existingDmReadStates.map(rs => rs.channelId));
    const now = Date.now();
    for (const dm of dmChannels) {
      if (!hasReadState.has(dm.id) && dm.lastMessage) {
        db.insert(schema.readStates).values({
          userId,
          channelId: dm.id,
          lastReadMessageId: dm.lastMessage.id,
          updatedAt: now,
        }).run();
      }
    }
  }

  // Get Space Folders
  const folderRows = db.select()
    .from(schema.spaceFolders)
    .where(eq(schema.spaceFolders.userId, userId))
    .orderBy(schema.spaceFolders.position)
    .all();

  const folders: SpaceFolder[] = [];
  for (const folder of folderRows) {
    const folderSpaceIds = db.select()
      .from(schema.spaceFolderMembers)
      .where(eq(schema.spaceFolderMembers.folderId, folder.id))
      .orderBy(schema.spaceFolderMembers.position)
      .all()
      .map(m => m.spaceId);

    folders.push({
      id: folder.id,
      userId: folder.userId,
      name: folder.name,
      color: folder.color,
      position: folder.position ?? 0,
      spaceIds: folderSpaceIds,
    });
  }

  // Get user space layout
  const layoutRow = db.select().from(schema.userSpaceLayout)
    .where(eq(schema.userSpaceLayout.userId, userId)).get();
  const spaceLayout: SpaceLayoutItem[] | null = layoutRow ? JSON.parse(layoutRow.layout) : null;
  const layoutUpdatedAt: number | null = layoutRow?.updatedAt ?? null;

  // Build voice states — tell the client who is currently in voice channels
  // across all their spaces
  const voiceStates: Record<string, string[]> = {};
  for (const space of spaces) {
    for (const ch of space.channels) {
      if (ch.type === 'voice') {
        const participants = connectionManager.getRoomParticipants(ch.id);
        if (participants.size > 0) {
          voiceStates[ch.id] = Array.from(participants);
        }
      }
    }
  }

  // Build active calls from user's DM memberships
  const activeCalls: ActiveCallInfo[] = [];
  for (const dm of dmMemberships) {
    const room = connectionManager.getRoom(dm.dmChannelId);
    if (room && room.roomType === 'dm') {
      const dmMeta = room.metadata as DmRoomMeta;
      activeCalls.push({
        dmChannelId: dm.dmChannelId,
        callerId: dmMeta.callerId,
        participants: Array.from(room.participants),
        startedAt: room.startedAt,
        state: dmMeta.state,
      });
      // Inject DM call participants into voiceStates so frontend's generic handler works
      if (room.participants.size > 0) {
        voiceStates[dm.dmChannelId] = Array.from(room.participants);
      }
    }
  }

  // Resolve this user's homeUserId for token lookup
  const readyUser = db.select({ homeUserId: schema.users.homeUserId })
    .from(schema.users)
    .where(eq(schema.users.id, userId))
    .get();
  const myHomeUserId = readyUser?.homeUserId || userId;

  // Also include federated calls (this instance is NOT the host)
  for (const [_fedId, fedCall] of connectionManager.getAllFederatedCalls()) {
    const isParticipant = fedCall.ringedUserIds.includes(userId);
    const isDmMember = fedCall.dmChannelId && dmMemberships.some(dm => dm.dmChannelId === fedCall.dmChannelId);
    if (isParticipant || isDmMember) {
      activeCalls.push({
        dmChannelId: fedCall.dmChannelId,
        federatedCallId: fedCall.federatedId,
        callerId: fedCall.callerId,
        participants: [],
        startedAt: fedCall.startedAt,
        state: fedCall.state,
        federatedCallHost: fedCall.federatedCallHost,
        livekitUrl: fedCall.livekitUrl,
        livekitToken: fedCall.tokens.get(myHomeUserId),
      });
    }
  }

  // Build voice user states — includes both space and DM participants now
  const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {};
  for (const chId of Object.keys(voiceStates)) {
    const usersInChannel = voiceStates[chId];
    if (usersInChannel) {
      for (const uid of usersInChannel) {
        const status = connectionManager.getVoiceUserStatus(uid);
        if (status) {
          voiceUserStates[uid] = status;
        }
      }
    }
  }

  // Build space mute/deafen states from DB (authoritative source for all spaces the user belongs to)
  // Also includes ephemeral permission-mute state from in-memory Set
  const spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> = {};
  if (spaceIds.length > 0) {
    const allRestrictions = db.select()
      .from(schema.voiceRestrictions)
      .where(inArray(schema.voiceRestrictions.spaceId, spaceIds))
      .all();
    for (const r of allRestrictions) {
      const key = `${r.spaceId}:${r.userId}`;
      const existing = spaceVoiceStates[key] ?? { spaceMuted: false, spaceDeafened: false, permissionMuted: false };
      if (r.restrictionType === 'mute') existing.spaceMuted = true;
      if (r.restrictionType === 'deafen') existing.spaceDeafened = true;
      spaceVoiceStates[key] = existing;
    }
    // Include ephemeral permission-mute state for all voice participants in user's spaces
    for (const [roomId, room] of connectionManager.getAllRooms()) {
      if (room.roomType !== 'space') continue;
      const meta = room.metadata as SpaceRoomMeta;
      if (!spaceIds.includes(meta.spaceId)) continue;
      for (const participantId of room.participants) {
        if (connectionManager.isPermissionMuted(meta.spaceId, participantId)) {
          const key = `${meta.spaceId}:${participantId}`;
          const existing = spaceVoiceStates[key] ?? { spaceMuted: false, spaceDeafened: false, permissionMuted: false };
          existing.permissionMuted = true;
          spaceVoiceStates[key] = existing;
        }
      }
    }
  }

  // Fetch read states for unread tracking
  const readStateRows = db.select()
    .from(schema.readStates)
    .where(eq(schema.readStates.userId, userId))
    .all();

  const readStates: ReadState[] = readStateRows
    .filter(rs => !isFederated || visibleChannelIdSet.has(rs.channelId))
    .map(rs => ({
      channelId: rs.channelId,
      lastReadMessageId: rs.lastReadMessageId,
    }));

  // Build user activities snapshot for all visible users
  // Auto-inject customStatus as a 'custom' activity for users with no ephemeral activities
  const userActivities: Record<string, Activity[]> = {};
  const seenUserIds = new Set<string>();

  function collectUserActivities(uid: string, customStatus: string | null) {
    if (seenUserIds.has(uid)) return;
    seenUserIds.add(uid);
    let acts = connectionManager.getUserActivities(uid);
    if (acts.length === 0 && customStatus) {
      acts = [{ type: 'custom', name: customStatus }];
    }
    if (acts.length > 0) {
      userActivities[uid] = acts;
    }
  }

  for (const space of spaces) {
    for (const member of space.members) {
      collectUserActivities(member.userId, member.user?.customStatus ?? null);
    }
  }
  for (const dm of dmChannels) {
    for (const member of dm.members) {
      collectUserActivities(member.id, member.customStatus ?? null);
    }
  }

  // Rejected peer origins for unreachable member indicators
  const rejectedPeers = db
    .select({ origin: schema.federationPeers.origin })
    .from(schema.federationPeers)
    .where(eq(schema.federationPeers.status, 'rejected'))
    .all();
  const rejectedPeerOrigins = rejectedPeers.map(p => p.origin);

  // Awaiting-approval peer origins for softer unreachable indicators
  const awaitingApprovalPeers = db
    .select({ origin: schema.federationPeers.origin })
    .from(schema.federationPeers)
    .where(eq(schema.federationPeers.status, 'awaiting_approval'))
    .all();
  const awaitingApprovalPeerOrigins = awaitingApprovalPeers.map(p => p.origin);

  // Active peer origins — client uses this allowlist to gate DM events from remote instances
  const activePeers = db
    .select({ origin: schema.federationPeers.origin })
    .from(schema.federationPeers)
    .where(eq(schema.federationPeers.status, 'active'))
    .all();
  const activePeerOrigins = activePeers.map(p => p.origin);

  // Pending approval count for admin notification
  let pendingApprovalCount = 0;
  if (userRow?.isAdmin === 1) {
    const countResult = db
      .select({ count: sql<number>`count(*)` })
      .from(schema.peerApprovalRequests)
      .get();
    pendingApprovalCount = countResult?.count ?? 0;
  }

  return { user, spaces, dmChannels, folders, spaceLayout, layoutUpdatedAt, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls, userActivities, rejectedPeerOrigins, awaitingApprovalPeerOrigins, activePeerOrigins, pendingApprovalCount };
}

export async function registerWebSocket(app: FastifyInstance): Promise<void> {
  app.get('/ws', { websocket: true }, (socket, request) => {
    const ws = socket as unknown as WebSocket;
    let authenticated = false;
    let userId: string | undefined;
    let username: string | undefined;
    let isFederated = false;

    // Set auth timeout - must authenticate within 10 seconds
    const authTimeout = setTimeout(() => {
      if (!authenticated) {
        ws.send(JSON.stringify({ type: 'error', message: 'Authentication timeout' }));
        ws.close();
      }
    }, 10000);

    ws.on('message', (data: Buffer | string) => {
      let parsed: Record<string, unknown>;
      try {
        const raw = typeof data === 'string' ? data : data.toString('utf-8');
        parsed = JSON.parse(raw) as Record<string, unknown>;
      } catch {
        ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
        return;
      }

      // Any received message proves liveness
      wsIsAlive.set(ws, true);

      if (!authenticated) {
        // First message must be auth
        if (parsed.type !== 'auth' || typeof parsed.token !== 'string') {
          ws.send(JSON.stringify({ type: 'error', message: 'First message must be auth' }));
          ws.close();
          return;
        }

        try {
          const payload = verifyJwt(parsed.token);
          userId = payload.userId;
          username = payload.username;

          // Reject deleted users and revoked tokens
          const db = getDb();
          const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
          if (!userRow || userRow.isDeleted) {
            ws.send(JSON.stringify({ type: 'error', message: 'This account has been deleted' }));
            ws.close();
            return;
          }
          // Token revocation: reject tokens issued before last password change
          if (userRow.passwordChangedAt && payload.iat) {
            if (payload.iat < Math.floor(userRow.passwordChangedAt / 1000)) {
              ws.send(JSON.stringify({ type: 'error', message: 'Token has been revoked' }));
              ws.close();
              return;
            }
          }

          authenticated = true;
          isFederated = !!userRow.homeInstance;
          clearTimeout(authTimeout);

          // Update user status to online
          db.update(schema.users).set({ status: 'online' }).where(eq(schema.users.id, userId)).run();

          // Add connection
          connectionManager.addConnection(userId, ws);

          // Mark alive for heartbeat detection; browsers auto-respond to ping frames (RFC 6455)
          wsIsAlive.set(ws, true);
          ws.on('pong', () => { wsIsAlive.set(ws, true); });

          // Build and send ready payload
          const readyData = buildReadyPayload(userId);
          ws.send(JSON.stringify({
            type: 'ready',
            ...readyData,
          }));

          // Broadcast presence update to all spaces
          const userSpaces = connectionManager.getUserSpaces(userId);
          for (const spaceId of userSpaces) {
            connectionManager.sendToSpace(spaceId, {
              type: 'presence_update',
              userId,
              status: 'online',
            }, userId);
          }
        } catch {
          ws.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
          ws.close();
        }
        return;
      }

      // Fast-path heartbeat — never reaches business logic
      if (parsed.type === 'ping') {
        ws.send(JSON.stringify({ type: 'pong' }));
        return;
      }

      // Rate limit all post-auth, non-ping messages (per-user, shared across tabs)
      if (!connectionManager.getUserRateLimiter(userId!).consume()) {
        ws.send(JSON.stringify({ type: 'error', message: 'Rate limited' }));
        return;
      }

      // Handle authenticated events
      if (userId && username) {
        try {
          handleClientEvent(parsed, userId, username, ws, isFederated);
        } catch (err) {
          app.log.error({ err, eventType: parsed.type, userId }, 'Unhandled error in WS event handler');
          try {
            ws.send(JSON.stringify({ type: 'error', message: 'Internal server error' }));
          } catch { /* ws may already be closed */ }
        }
      }
    });

    ws.on('close', () => {
      clearTimeout(authTimeout);
      if (userId) {
        connectionManager.removeConnection(ws);
      }
    });

    ws.on('error', () => {
      clearTimeout(authTimeout);
    });
  });

  // ─── Heartbeat Sweep ──────────────────────────────────────────────────────
  // Detect dead connections (e.g. PC shut off without TCP FIN).
  // Sends protocol-level ping frames; browsers auto-respond with pong (RFC 6455).
  // Worst-case detection: 30s + 30s + 5s grace = ~65s.
  const HEARTBEAT_INTERVAL_MS = 30_000;

  heartbeatInterval = setInterval(() => {
    for (const [, userConnections] of connectionManager.getAllConnections()) {
      for (const ws of userConnections) {
        if (wsIsAlive.get(ws) === false) {
          ws.terminate(); // Emits 'close' → removeConnection → scheduleDisconnect → finalizeDisconnect
          continue;
        }
        wsIsAlive.set(ws, false);
        if (ws.readyState === 1) ws.ping();
      }
    }
  }, HEARTBEAT_INTERVAL_MS);

  app.addHook('onClose', async () => {
    if (heartbeatInterval) {
      clearInterval(heartbeatInterval);
      heartbeatInterval = null;
    }
  });
}
