import path from 'path';
import type { FastifyInstance } from 'fastify';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb, getRawDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isSpaceOwner, isBanned, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
import crypto from 'crypto';
import { connectionManager } from '../ws/handler.js';
import { deleteAttachmentFiles, deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.js';
import { resizeProfileImage } from '../utils/thumbnail.js';
import { config } from '../config.js';
import type {
  CreateSpaceRequest,
  UpdateSpaceRequest,
  JoinSpaceRequest,
  UpdateMemberRequest,
  Space,
  Channel,
  ChannelCategory,
  MemberWithUser,
  SpaceWithChannelsAndMembers,
  Role,
} from '@backspace/shared';
import { AVATAR_COLORS } from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
import { checkVoicePermissions } from '../ws/events.js';

function rowToSpace(row: typeof schema.spaces.$inferSelect): Space {
  return {
    id: row.id,
    name: row.name,
    icon: row.icon,
    banner: row.banner ?? null,
    avatarColor: (row.avatarColor as Space['avatarColor']) ?? null,
    ownerId: row.ownerId,
    inviteCode: row.inviteCode,
    visibility: (row.visibility ?? 'private') as Space['visibility'],
    description: row.description ?? null,
    createdAt: row.createdAt,
  };
}

function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
  return {
    id: row.id,
    spaceId: row.spaceId,
    name: row.name,
    type: row.type as Channel['type'],
    topic: row.topic,
    position: row.position ?? 0,
    categoryId: row.categoryId ?? null,
    createdAt: row.createdAt,
  };
}

function generateInviteCode(): string {
  return crypto.randomBytes(4).toString('hex');
}

export async function spaceRoutes(app: FastifyInstance): Promise<void> {
  // POST /api/spaces - Create a new server
  app.post<{ Body: CreateSpaceRequest }>('/api/spaces', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { name, icon, banner, avatarColor, visibility, description } = request.body;

    if (!name || typeof name !== 'string') {
      return reply.code(400).send({ error: 'Space name is required', statusCode: 400 });
    }

    const trimmedName = name.trim();
    if (trimmedName.length < 1 || trimmedName.length > 100) {
      return reply.code(400).send({ error: 'Space name must be between 1 and 100 characters', statusCode: 400 });
    }

    // Validate visibility
    const validVisibilities = ['public', 'request', 'private'];
    const safeVisibility = visibility && validVisibilities.includes(visibility) ? visibility : 'private';

    // Validate description
    const safeDescription = description ? description.trim().slice(0, 200) || null : null;

    // Validate avatarColor — assign random if not provided
    const safeAvatarColor = avatarColor && (AVATAR_COLORS as readonly string[]).includes(avatarColor)
      ? avatarColor
      : AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)];

    const db = getDb();
    const spaceId = generateSnowflake();
    const textCategoryId = generateSnowflake();
    const voiceCategoryId = generateSnowflake();
    const channelId = generateSnowflake();
    const voiceChannelId = generateSnowflake();
    const now = Date.now();
    const inviteCode = generateInviteCode();

    // Create server, owner membership, default categories + channels, and @everyone role atomically
    db.transaction((tx) => {
      tx.insert(schema.spaces).values({
        id: spaceId,
        name: trimmedName,
        icon: icon ?? null,
        banner: banner ?? null,
        avatarColor: safeAvatarColor,
        ownerId: request.userId,
        inviteCode,
        visibility: safeVisibility,
        description: safeDescription,
        createdAt: now,
      }).run();

      tx.insert(schema.spaceMembers).values({
        spaceId,
        userId: request.userId,
        joinedAt: now,
      }).run();

      // Default categories
      tx.insert(schema.channelCategories).values({
        id: textCategoryId,
        spaceId,
        name: 'text-channels',
        position: 0,
        createdAt: now,
      }).run();

      tx.insert(schema.channelCategories).values({
        id: voiceCategoryId,
        spaceId,
        name: 'voice-channels',
        position: 1,
        createdAt: now,
      }).run();

      // Default text channel in text-channels category
      tx.insert(schema.channels).values({
        id: channelId,
        spaceId,
        name: 'general',
        type: 'text',
        position: 0,
        categoryId: textCategoryId,
        createdAt: now,
      }).run();

      // Default voice channel in voice-channels category
      tx.insert(schema.channels).values({
        id: voiceChannelId,
        spaceId,
        name: 'voice',
        type: 'voice',
        position: 0,
        categoryId: voiceCategoryId,
        createdAt: now,
      }).run();

      // Auto-create @everyone role (id = spaceId)
      tx.insert(schema.roles).values({
        id: spaceId,
        spaceId,
        name: '@everyone',
        color: '#b9bbbe',
        position: 0,
        permissions: permissionsToString(DEFAULT_EVERYONE_PERMISSIONS),
        createdAt: now,
      }).run();
    });

    const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, spaceId)).get();
    if (!server) {
      return reply.code(500).send({ error: 'Failed to create space', statusCode: 500 });
    }

    // Register the creator in connectionManager so they receive WS broadcasts for this space
    connectionManager.addUserSpace(request.userId, spaceId);

    // Clean up attachment records for icon/banner — reference is now in spaces table
    if (icon && typeof icon === 'string' && icon.includes('/api/uploads/')) {
      deleteAttachmentByFilename(icon);
    }
    if (banner && typeof banner === 'string' && banner.includes('/api/uploads/')) {
      deleteAttachmentByFilename(banner);
    }

    // Resize profile images to optimal dimensions
    if (icon && typeof icon === 'string' && !icon.startsWith('http')) {
      const filePath = path.join(config.uploadDir, path.basename(icon));
      await resizeProfileImage(filePath, 'icon');
    }
    if (banner && typeof banner === 'string' && !banner.startsWith('http')) {
      const filePath = path.join(config.uploadDir, path.basename(banner));
      await resizeProfileImage(filePath, 'banner');
    }

    return reply.code(201).send(rowToSpace(server));
  });

  // GET /api/spaces - List user's servers
  app.get('/api/spaces', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const db = getDb();

    const memberships = db.select()
      .from(schema.spaceMembers)
      .where(eq(schema.spaceMembers.userId, request.userId))
      .all();

    if (memberships.length === 0) {
      return reply.code(200).send([]);
    }

    const spaceIds = memberships.map(m => m.spaceId);
    const servers = db.select()
      .from(schema.spaces)
      .where(inArray(schema.spaces.id, spaceIds))
      .all();

    return reply.code(200).send(servers.map(rowToSpace));
  });

  // GET /api/spaces/:id - Get server detail with channels and members
  app.get<{ Params: { id: string } }>('/api/spaces/:id', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id } = request.params;
    const db = getDb();

    const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
    if (!server) {
      return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
    }

    if (!isMember(id, request.userId)) {
      return reply.code(403).send({ error: 'You are not a member of this space', statusCode: 403 });
    }

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

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

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

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

    const userMap = new Map(users.map(u => [u.id, u]));

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

    const members: MemberWithUser[] = memberRows
      .map(m => {
        const user = userMap.get(m.userId);
        if (!user) 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(user),
          roles: memberRoles,
        };
      })
      .filter((m): m is MemberWithUser => m !== null);

    // Fetch categories for this space
    const categoryRows = db.select()
      .from(schema.channelCategories)
      .where(eq(schema.channelCategories.spaceId, id))
      .all();

    // Batch-fetch category overrides for @everyone to determine isPrivate
    const catEveryoneOverrides = db.select().from(schema.categoryOverrides)
      .where(and(
        eq(schema.categoryOverrides.targetType, 'role'),
        eq(schema.categoryOverrides.targetId, id),
      ))
      .all();
    const privateCategoryIds = new Set<string>();
    for (const o of catEveryoneOverrides) {
      const denyBits = BigInt(o.deny || '0');
      if ((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n) {
        privateCategoryIds.add(o.categoryId);
      }
    }

    const categories: ChannelCategory[] = categoryRows.map(c => ({
      id: c.id,
      spaceId: c.spaceId,
      name: c.name,
      position: c.position ?? 0,
      isPrivate: privateCategoryIds.has(c.id),
      createdAt: c.createdAt,
    }));

    // Compute space-level permissions for the requesting user
    const spacePerms = computePermissions(request.userId, id);

    // Batch-fetch all channel overrides for @everyone (role = spaceId) to determine isPrivate
    const everyoneOverrides = db.select().from(schema.channelOverrides)
      .where(and(
        eq(schema.channelOverrides.targetType, 'role'),
        eq(schema.channelOverrides.targetId, id),
      ))
      .all();
    const privateChannelIds = new Set<string>();
    for (const o of everyoneOverrides) {
      const denyBits = BigInt(o.deny || '0');
      if ((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n) {
        privateChannelIds.add(o.channelId);
      }
    }

    // Filter channels by VIEW_CHANNEL permission and attach per-channel myPermissions
    const visibleChannels: (Channel & { isPrivate: boolean; myPermissions: string })[] = [];
    for (const ch of channels) {
      const perms = computePermissions(request.userId, id, ch.id);
      if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
        visibleChannels.push({
          ...rowToChannel(ch),
          isPrivate: privateChannelIds.has(ch.id),
          myPermissions: permissionsToString(perms),
        });
      }
    }

    const canManageRoles = (spacePerms & PermissionBits.MANAGE_ROLES) !== 0n;

    const result: SpaceWithChannelsAndMembers = {
      ...rowToSpace(server),
      channels: visibleChannels,
      categories,
      members,
      roles: roles.map(r => ({
        id: r.id,
        spaceId: r.spaceId,
        name: r.name,
        color: r.color ?? '#b9bbbe',
        position: r.position ?? 0,
        permissions: canManageRoles ? (r.permissions ?? '0') : undefined,
        createdAt: r.createdAt,
      })),
      myPermissions: permissionsToString(spacePerms),
    };

    return reply.code(200).send(result);
  });

  // PATCH /api/spaces/:id - Update server (owner only)
  app.patch<{ Params: { id: string }; Body: UpdateSpaceRequest }>('/api/spaces/:id', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id } = request.params;
    const { name, icon, banner, avatarColor, visibility, description } = request.body;
    const db = getDb();

    const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
    if (!server) {
      return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
    }

    if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
      return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
    }

    const updates: Partial<typeof schema.spaces.$inferInsert> = {};

    if (name !== undefined) {
      const trimmedName = name.trim();
      if (trimmedName.length < 1 || trimmedName.length > 100) {
        return reply.code(400).send({ error: 'Space name must be between 1 and 100 characters', statusCode: 400 });
      }
      updates.name = trimmedName;
    }

    // Track old files for cleanup after update
    const oldIcon = server.icon;
    const oldBanner = server.banner;

    if (icon !== undefined) {
      updates.icon = icon || null;
    }

    if (banner !== undefined) {
      updates.banner = banner || null;
    }

    if (avatarColor !== undefined) {
      if (avatarColor === '') {
        updates.avatarColor = null;
      } else if ((AVATAR_COLORS as readonly string[]).includes(avatarColor)) {
        updates.avatarColor = avatarColor;
      } else {
        return reply.code(400).send({ error: 'Invalid avatar color', statusCode: 400 });
      }
    }

    if (visibility !== undefined) {
      const validVisibilities = ['public', 'request', 'private'];
      if (!validVisibilities.includes(visibility)) {
        return reply.code(400).send({ error: 'Visibility must be "public", "request", or "private"', statusCode: 400 });
      }
      updates.visibility = visibility;
    }

    if (description !== undefined) {
      const trimmed = description.trim().slice(0, 200);
      updates.description = trimmed || null;
    }

    if (Object.keys(updates).length === 0) {
      return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
    }

    db.update(schema.spaces).set(updates).where(eq(schema.spaces.id, id)).run();

    // Clean up old icon/banner files that were replaced
    if (icon !== undefined && oldIcon && oldIcon !== (icon || null) && !oldIcon.startsWith('http')) {
      deleteUploadFile(oldIcon);
      deleteAttachmentByFilename(oldIcon);
    }
    if (banner !== undefined && oldBanner && oldBanner !== (banner || null) && !oldBanner.startsWith('http')) {
      deleteUploadFile(oldBanner);
      deleteAttachmentByFilename(oldBanner);
    }
    // Clean up attachment records for newly-set profile images — the reference
    // now lives in the spaces table, so the attachment record is unnecessary
    if (icon && typeof icon === 'string' && icon.includes('/api/uploads/')) {
      deleteAttachmentByFilename(icon);
    }
    if (banner && typeof banner === 'string' && banner.includes('/api/uploads/')) {
      deleteAttachmentByFilename(banner);
    }

    // Resize profile images to optimal dimensions
    if (icon && typeof icon === 'string' && !icon.startsWith('http')) {
      const filePath = path.join(config.uploadDir, path.basename(icon));
      await resizeProfileImage(filePath, 'icon');
    }
    if (banner && typeof banner === 'string' && !banner.startsWith('http')) {
      const filePath = path.join(config.uploadDir, path.basename(banner));
      await resizeProfileImage(filePath, 'banner');
    }

    const updated = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
    if (!updated) {
      return reply.code(500).send({ error: 'Failed to update space', statusCode: 500 });
    }

    const spaceData = rowToSpace(updated);

    // Broadcast space_updated to all space members
    connectionManager.sendToSpace(id, {
      type: 'space_updated',
      space: spaceData,
    });

    return reply.code(200).send(spaceData);
  });

  // DELETE /api/spaces/:id - Delete server (owner only)
  app.delete<{ Params: { id: string } }>('/api/spaces/:id', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id } = request.params;
    const db = getDb();

    const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
    if (!server) {
      return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
    }

    if (!isSpaceOwner(id, request.userId)) {
      return reply.code(403).send({ error: 'Only the space owner can delete the space', statusCode: 403 });
    }

    // Collect all attachment files before cascade-deleting DB records
    const channelIds = db.select({ id: schema.channels.id })
      .from(schema.channels).where(eq(schema.channels.spaceId, id)).all().map(c => c.id);

    let attachmentRows: { filename: string }[] = [];
    if (channelIds.length > 0) {
      const messageIds = db.select({ id: schema.messages.id })
        .from(schema.messages).where(inArray(schema.messages.channelId, channelIds)).all().map(m => m.id);
      if (messageIds.length > 0) {
        attachmentRows = db.select({ filename: schema.attachments.filename })
          .from(schema.attachments).where(inArray(schema.attachments.messageId, messageIds)).all();
      }
    }

    // Capture space icon/banner before deletion
    const spaceIcon = server.icon;
    const spaceBanner = server.banner;

    // Delete all channels (messages cascade), members, folder refs, read states, then space atomically
    db.transaction((tx) => {
      // Clean up read_states for all channels in this space (no FK cascade — channelId is plain text)
      if (channelIds.length > 0) {
        tx.delete(schema.readStates).where(inArray(schema.readStates.channelId, channelIds)).run();
      }
      tx.delete(schema.channels).where(eq(schema.channels.spaceId, id)).run();
      tx.delete(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).run();
      tx.delete(schema.spaceFolderMembers).where(eq(schema.spaceFolderMembers.spaceId, id)).run();
      tx.delete(schema.spaces).where(eq(schema.spaces.id, id)).run();
    });

    // Clean up all attachment files from disk
    deleteAttachmentFiles(attachmentRows);

    // Clean up space icon/banner files
    if (spaceIcon && !spaceIcon.startsWith('http')) deleteUploadFile(spaceIcon);
    if (spaceBanner && !spaceBanner.startsWith('http')) deleteUploadFile(spaceBanner);

    return reply.code(200).send({ success: true });
  });

  // POST /api/spaces/:id/invite - Generate invite code (admin+)
  app.post<{ Params: { id: string } }>('/api/spaces/:id/invite', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id } = request.params;
    const db = getDb();

    const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
    if (!server) {
      return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
    }

    if (!hasPermission(request.userId, id, PermissionBits.CREATE_INVITE)) {
      return reply.code(403).send({ error: 'Missing CREATE_INVITE permission', statusCode: 403 });
    }

    // Return existing invite code if one exists, otherwise generate a new one
    if (server.inviteCode) {
      return reply.code(200).send({ inviteCode: server.inviteCode });
    }

    const inviteCode = generateInviteCode();
    db.update(schema.spaces).set({ inviteCode }).where(eq(schema.spaces.id, id)).run();

    return reply.code(200).send({ inviteCode });
  });

  // POST /api/spaces/:id/join - Join server by invite code
  app.post<{ Params: { id: string }; Body: JoinSpaceRequest }>('/api/spaces/:id/join', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id } = request.params;
    const { inviteCode } = request.body;

    if (!inviteCode || typeof inviteCode !== 'string') {
      return reply.code(400).send({ error: 'Invite code is required', statusCode: 400 });
    }

    const db = getDb();

    const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
    if (!server) {
      return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
    }

    if (server.inviteCode !== inviteCode) {
      return reply.code(400).send({ error: 'Invalid invite code', statusCode: 400 });
    }

    if (isBanned(id, request.userId)) {
      return reply.code(403).send({ error: 'You are banned from this space', statusCode: 403 });
    }

    if (isMember(id, request.userId)) {
      return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
    }

    const now = Date.now();
    db.insert(schema.spaceMembers).values({
      spaceId: id,
      userId: request.userId,
      joinedAt: now,
    }).run();

    // Register the user in connectionManager so they receive WS broadcasts for this server
    connectionManager.addUserSpace(request.userId, id);

    // Broadcast member_joined to existing server members
    const joiningUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
    if (joiningUser) {
      const memberPayload: MemberWithUser = {
        spaceId: id,
        userId: request.userId,
        nickname: null,
        joinedAt: now,
        user: sanitizeUser(joiningUser),
        roles: [],
      };
      connectionManager.sendToSpace(id, {
        type: 'member_joined',
        spaceId: id,
        member: memberPayload,
      });
    }

    return reply.code(200).send(rowToSpace(server));
  });

  // POST /api/spaces/join - Join server by invite code (no server ID needed)
  app.post<{ Body: JoinSpaceRequest }>('/api/spaces/join', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { inviteCode } = request.body;

    if (!inviteCode || typeof inviteCode !== 'string') {
      return reply.code(400).send({ error: 'Invite code is required', statusCode: 400 });
    }

    const db = getDb();

    const server = db.select().from(schema.spaces).where(eq(schema.spaces.inviteCode, inviteCode)).get();
    if (!server) {
      return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 });
    }

    if (isBanned(server.id, request.userId)) {
      return reply.code(403).send({ error: 'You are banned from this space', statusCode: 403 });
    }

    if (isMember(server.id, request.userId)) {
      return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
    }

    const now = Date.now();
    db.insert(schema.spaceMembers).values({
      spaceId: server.id,
      userId: request.userId,
      joinedAt: now,
    }).run();

    // Register the user in connectionManager so they receive WS broadcasts for this server
    connectionManager.addUserSpace(request.userId, server.id);

    // Broadcast member_joined to existing server members
    const joiningUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
    if (joiningUser) {
      const memberPayload: MemberWithUser = {
        spaceId: server.id,
        userId: request.userId,
        nickname: null,
        joinedAt: now,
        user: sanitizeUser(joiningUser),
        roles: [],
      };
      connectionManager.sendToSpace(server.id, {
        type: 'member_joined',
        spaceId: server.id,
        member: memberPayload,
      });
    }

    return reply.code(200).send(rowToSpace(server));
  });

  // GET /api/spaces/:id/members - List server members
  app.get<{ Params: { id: string } }>('/api/spaces/:id/members', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id } = request.params;
    const db = getDb();

    const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
    if (!server) {
      return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
    }

    if (!isMember(id, request.userId)) {
      return reply.code(403).send({ error: 'You are not a member of this space', statusCode: 403 });
    }

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

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

    const userMap = new Map(users.map(u => [u.id, u]));

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

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

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

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

        const assignedRoles = 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(user),
          roles: assignedRoles,
        };
      })
      .filter((m): m is MemberWithUser => m !== null);

    return reply.code(200).send(members);
  });

  // PATCH /api/spaces/:id/members/:uid - Update member roles
  app.patch<{ Params: { id: string; uid: string }; Body: UpdateMemberRequest }>('/api/spaces/:id/members/:uid', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id, uid } = request.params;
    const { roleIds } = request.body;
    const db = getDb();

    const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
    if (!server) {
      return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
    }

    if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
      return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
    }

    if (uid === request.userId) {
      return reply.code(400).send({ error: 'You cannot change your own roles', statusCode: 400 });
    }

    if (!Array.isArray(roleIds)) {
      return reply.code(400).send({ error: 'roleIds must be an array of role IDs', statusCode: 400 });
    }

    // Cannot modify the server owner's roles unless you are the owner
    if (isSpaceOwner(id, uid) && !isSpaceOwner(id, request.userId)) {
      return reply.code(403).send({ error: 'Only the space owner can modify their own roles', statusCode: 403 });
    }

    const member = db.select()
      .from(schema.spaceMembers)
      .where(and(
        eq(schema.spaceMembers.spaceId, id),
        eq(schema.spaceMembers.userId, uid),
      ))
      .get();

    if (!member) {
      return reply.code(404).send({ error: 'Member not found', statusCode: 404 });
    }

    // Validate all roleIds belong to this server and are not @everyone
    if (roleIds.length > 0) {
      const spaceRoles = db.select()
        .from(schema.roles)
        .where(eq(schema.roles.spaceId, id))
        .all();

      const spaceRoleIds = new Set(spaceRoles.map(r => r.id));

      for (const roleId of roleIds) {
        if (!spaceRoleIds.has(roleId)) {
          return reply.code(400).send({ error: `Role ${roleId} does not belong to this space`, statusCode: 400 });
        }
        if (roleId === id) {
          return reply.code(400).send({ error: '@everyone role is implicit and cannot be assigned', statusCode: 400 });
        }
      }
    }

    // Atomically replace member's role assignments
    db.transaction((tx) => {
      // Remove all existing role assignments for this member in this server
      tx.delete(schema.memberRoles)
        .where(and(
          eq(schema.memberRoles.spaceId, id),
          eq(schema.memberRoles.userId, uid),
        ))
        .run();

      // Insert new role assignments
      for (const roleId of roleIds) {
        tx.insert(schema.memberRoles).values({
          spaceId: id,
          userId: uid,
          roleId,
        }).run();
      }
    });

    // Force target user's client to re-sync with their new permissions
    connectionManager.pushReadyPayload(uid);
    checkVoicePermissions(id);

    // Build response with populated roles
    const updatedMember = db.select()
      .from(schema.spaceMembers)
      .where(and(
        eq(schema.spaceMembers.spaceId, id),
        eq(schema.spaceMembers.userId, uid),
      ))
      .get();

    if (!updatedMember) {
      return reply.code(500).send({ error: 'Failed to update member', statusCode: 500 });
    }

    const user = db.select().from(schema.users).where(eq(schema.users.id, uid)).get();
    if (!user) {
      return reply.code(500).send({ error: 'User not found', statusCode: 500 });
    }

    const updatedRoleRows = db.select()
      .from(schema.memberRoles)
      .where(and(
        eq(schema.memberRoles.spaceId, id),
        eq(schema.memberRoles.userId, uid),
      ))
      .all();

    const updatedRoleIds = updatedRoleRows.map(r => r.roleId);
    const allRoles = db.select()
      .from(schema.roles)
      .where(eq(schema.roles.spaceId, id))
      .orderBy(schema.roles.position)
      .all();

    const memberRoles = allRoles
      .filter(r => updatedRoleIds.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,
      }));

    const result: MemberWithUser = {
      spaceId: updatedMember.spaceId,
      userId: updatedMember.userId,
      nickname: updatedMember.nickname,
      joinedAt: updatedMember.joinedAt,
      user: sanitizeUser(user),
      roles: memberRoles,
    };

    return reply.code(200).send(result);
  });

  // DELETE /api/spaces/:id/members/:uid - Kick member (owner) or leave (self)
  app.delete<{ Params: { id: string; uid: string } }>('/api/spaces/:id/members/:uid', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id, uid } = request.params;
    const db = getDb();

    const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
    if (!server) {
      return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
    }

    const isSelf = uid === request.userId;
    const isOwnerUser = isSpaceOwner(id, request.userId);
    const canKick = hasPermission(request.userId, id, PermissionBits.KICK_MEMBERS);

    if (!isSelf && !canKick) {
      return reply.code(403).send({ error: 'Missing KICK_MEMBERS permission', statusCode: 403 });
    }

    // Owner cannot leave their own server - they must delete it
    if (isSelf && isOwnerUser) {
      return reply.code(400).send({ error: 'Space owner cannot leave. Transfer ownership or delete the space.', statusCode: 400 });
    }

    const member = db.select()
      .from(schema.spaceMembers)
      .where(and(
        eq(schema.spaceMembers.spaceId, id),
        eq(schema.spaceMembers.userId, uid),
      ))
      .get();

    if (!member) {
      return reply.code(404).send({ error: 'Member not found', statusCode: 404 });
    }

    // Cannot kick the owner
    if (isSpaceOwner(id, uid)) {
      return reply.code(400).send({ error: 'Cannot remove the space owner', statusCode: 400 });
    }

    db.delete(schema.spaceMembers)
      .where(and(
        eq(schema.spaceMembers.spaceId, id),
        eq(schema.spaceMembers.userId, uid),
      ))
      .run();

    // Clean up any voice restrictions for the removed member
    db.delete(schema.voiceRestrictions).where(
      and(
        eq(schema.voiceRestrictions.spaceId, id),
        eq(schema.voiceRestrictions.userId, uid),
      )
    ).run();

    // Clean up read_states for the departing user in this space's channels
    const spaceChannelIds = db.select({ id: schema.channels.id })
      .from(schema.channels).where(eq(schema.channels.spaceId, id)).all().map(c => c.id);
    if (spaceChannelIds.length > 0) {
      db.delete(schema.readStates).where(and(
        eq(schema.readStates.userId, uid),
        inArray(schema.readStates.channelId, spaceChannelIds),
      )).run();
    }

    // Broadcast member_left event
    connectionManager.sendToSpace(id, {
      type: 'member_left',
      spaceId: id,
      userId: uid,
    });

    return reply.code(200).send({ success: true });
  });

  // Role Management
  
  // POST /api/spaces/:id/roles - Create a new role
  app.post<{ Params: { id: string }; Body: { name: string; color?: string; permissions?: string } }>('/api/spaces/:id/roles', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id } = request.params;
    const { name, color, permissions } = request.body;
    const db = getDb();

    if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
      return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
    }

    // Validate permissions string is a valid bigint if provided
    let permStr: string;
    if (permissions !== undefined && permissions !== null) {
      try {
        BigInt(permissions);
        permStr = permissions;
      } catch {
        return reply.code(400).send({ error: 'Invalid permissions value', statusCode: 400 });
      }
    } else {
      // Default to @everyone baseline so new roles start functional
      permStr = permissionsToString(DEFAULT_EVERYONE_PERMISSIONS);
    }

    // Trim and validate name
    const roleName = (name || 'new role').trim() || 'new role';

    // Check for case-insensitive duplicate name within the space
    const rawDb = getRawDb();
    const duplicate = rawDb.prepare(
      'SELECT id FROM roles WHERE space_id = ? AND name COLLATE NOCASE = ?'
    ).get(id, roleName);
    if (duplicate) {
      return reply.code(409).send({ error: 'A role with this name already exists', statusCode: 409 });
    }

    const roleId = generateSnowflake();
    db.insert(schema.roles).values({
      id: roleId,
      spaceId: id,
      name: roleName,
      color: color || '#b9bbbe',
      position: 0,
      permissions: permStr,
      createdAt: Date.now(),
    }).run();

    const role = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get();

    // Broadcast updated state to all space members
    const memberRows = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).all();
    for (const m of memberRows) {
      connectionManager.pushReadyPayload(m.userId);
    }
    checkVoicePermissions(id);

    return reply.code(201).send(role);
  });

  // PATCH /api/spaces/:id/roles/:roleId - Update a role
  app.patch<{ Params: { id: string; roleId: string }; Body: { name?: string; color?: string; position?: number; permissions?: string } }>('/api/spaces/:id/roles/:roleId', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id, roleId } = request.params;
    const { name, color, position, permissions } = request.body;
    const db = getDb();

    if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
      return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
    }

    const updates: Partial<typeof schema.roles.$inferInsert> = {};
    if (name !== undefined) {
      const trimmed = name.trim();
      if (!trimmed) {
        return reply.code(400).send({ error: 'Role name cannot be empty', statusCode: 400 });
      }
      // Check for case-insensitive duplicate name within the space
      const rawDb = getRawDb();
      const duplicate = rawDb.prepare(
        'SELECT id FROM roles WHERE space_id = ? AND name COLLATE NOCASE = ? AND id != ?'
      ).get(id, trimmed, roleId);
      if (duplicate) {
        return reply.code(409).send({ error: 'A role with this name already exists', statusCode: 409 });
      }
      updates.name = trimmed;
    }
    if (color !== undefined) updates.color = color;
    if (position !== undefined) updates.position = position;

    if (permissions !== undefined) {
      try {
        BigInt(permissions);
        updates.permissions = permissions;
      } catch {
        return reply.code(400).send({ error: 'Invalid permissions value', statusCode: 400 });
      }
    }

    if (Object.keys(updates).length === 0) {
      return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
    }

    db.update(schema.roles).set(updates).where(and(eq(schema.roles.id, roleId), eq(schema.roles.spaceId, id))).run();
    const updated = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get();

    // Broadcast updated state to all space members
    const memberRows = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).all();
    for (const m of memberRows) {
      connectionManager.pushReadyPayload(m.userId);
    }
    checkVoicePermissions(id);

    return reply.code(200).send(updated);
  });

  // DELETE /api/spaces/:id/roles/:roleId - Delete a role
  app.delete<{ Params: { id: string; roleId: string } }>('/api/spaces/:id/roles/:roleId', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id, roleId } = request.params;
    const db = getDb();

    if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
      return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
    }

    // Cannot delete @everyone role
    if (roleId === id) {
      return reply.code(400).send({ error: 'Cannot delete the @everyone role', statusCode: 400 });
    }

    // Delete channel overrides referencing this role
    db.delete(schema.channelOverrides).where(
      and(eq(schema.channelOverrides.targetType, 'role'), eq(schema.channelOverrides.targetId, roleId))
    ).run();

    db.delete(schema.roles).where(and(eq(schema.roles.id, roleId), eq(schema.roles.spaceId, id))).run();

    // Broadcast updated state to all space members
    const memberRows = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).all();
    for (const m of memberRows) {
      connectionManager.pushReadyPayload(m.userId);
    }
    checkVoicePermissions(id);

    return reply.code(200).send({ success: true });
  });

  // POST /api/spaces/:id/members/:uid/roles - Add role to member
  app.post<{ Params: { id: string; uid: string }; Body: { roleId: string } }>('/api/spaces/:id/members/:uid/roles', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id, uid } = request.params;
    const { roleId } = request.body;
    const db = getDb();

    if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
      return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
    }

    db.insert(schema.memberRoles).values({
      spaceId: id,
      userId: uid,
      roleId,
    }).run();

    return reply.code(200).send({ success: true });
  });

  // DELETE /api/spaces/:id/members/:uid/roles/:roleId - Remove role from member
  app.delete<{ Params: { id: string; uid: string; roleId: string } }>('/api/spaces/:id/members/:uid/roles/:roleId', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id, uid, roleId } = request.params;
    const db = getDb();

    if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
      return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
    }

    db.delete(schema.memberRoles).where(and(
      eq(schema.memberRoles.spaceId, id),
      eq(schema.memberRoles.userId, uid),
      eq(schema.memberRoles.roleId, roleId)
    )).run();

    return reply.code(200).send({ success: true });
  });

  // PATCH /api/spaces/:id/transfer-ownership — Transfer space ownership
  app.patch<{ Params: { id: string }; Body: { newOwnerId: string } }>('/api/spaces/:id/transfer-ownership', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id } = request.params;
    const { newOwnerId } = request.body;
    const db = getDb();

    if (!newOwnerId || typeof newOwnerId !== 'string') {
      return reply.code(400).send({ error: 'newOwnerId is required', statusCode: 400 });
    }

    const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
    if (!server) {
      return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
    }

    if (!isSpaceOwner(id, request.userId)) {
      return reply.code(403).send({ error: 'Only the space owner can transfer ownership', statusCode: 403 });
    }

    if (newOwnerId === request.userId) {
      return reply.code(400).send({ error: 'You are already the owner', statusCode: 400 });
    }

    // Verify new owner is a member
    if (!isMember(id, newOwnerId)) {
      return reply.code(400).send({ error: 'New owner must be a member of the space', statusCode: 400 });
    }

    db.update(schema.spaces).set({ ownerId: newOwnerId }).where(eq(schema.spaces.id, id)).run();

    const updated = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
    if (!updated) {
      return reply.code(500).send({ error: 'Failed to transfer ownership', statusCode: 500 });
    }

    const spaceData = rowToSpace(updated);

    // Broadcast space_updated so all clients see the new owner
    connectionManager.sendToSpace(id, {
      type: 'space_updated',
      space: spaceData,
    });

    return reply.code(200).send(spaceData);
  });

  // GET /api/spaces/invite/:code/preview — Public invite preview (no auth)
  app.get<{ Params: { code: string } }>('/api/spaces/invite/:code/preview', async (request, reply) => {
    const { code } = request.params;
    const db = getDb();

    const space = db.select().from(schema.spaces).where(eq(schema.spaces.inviteCode, code)).get();
    if (!space) {
      return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 });
    }

    const memberCount = db.select().from(schema.spaceMembers)
      .where(eq(schema.spaceMembers.spaceId, space.id))
      .all().length;

    const settings = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
    const instanceName = settings?.instanceName ?? 'Backspace';

    return reply.code(200).send({
      spaceId: space.id,
      spaceName: space.name,
      description: space.description ?? null,
      icon: space.icon ?? null,
      avatarColor: space.avatarColor ?? null,
      memberCount,
      instanceName,
    });
  });

  // ─── Ban Management ───────────────────────────────────────────────────────

  // GET /api/spaces/:id/bans - List bans
  app.get<{ Params: { id: string } }>('/api/spaces/:id/bans', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id } = request.params;
    const db = getDb();

    if (!hasPermission(request.userId, id, PermissionBits.BAN_MEMBERS)) {
      return reply.code(403).send({ error: 'Missing BAN_MEMBERS permission', statusCode: 403 });
    }

    const banRows = db.select().from(schema.bans)
      .where(eq(schema.bans.spaceId, id))
      .all();

    if (banRows.length === 0) return reply.code(200).send([]);

    const userIds = [...new Set(banRows.map(b => b.userId))];
    const bannedByIds = [...new Set(banRows.map(b => b.bannedBy))];
    const allUserIds = [...new Set([...userIds, ...bannedByIds].filter((id): id is string => id !== null))];
    const users = allUserIds.length > 0
      ? db.select().from(schema.users).where(inArray(schema.users.id, allUserIds)).all()
      : [];
    const userMap = new Map(users.map(u => [u.id, u]));

    const bans = banRows.map(b => {
      const user = userMap.get(b.userId);
      const moderator = b.bannedBy ? userMap.get(b.bannedBy) : undefined;
      return {
        spaceId: b.spaceId,
        userId: b.userId,
        reason: b.reason,
        bannedBy: b.bannedBy,
        createdAt: b.createdAt,
        user: user ? sanitizeUser(user) : null,
        moderator: moderator ? sanitizeUser(moderator) : null,
      };
    });

    return reply.code(200).send(bans);
  });

  // POST /api/spaces/:id/bans - Ban a member
  app.post<{ Params: { id: string }; Body: { userId: string; reason?: string } }>('/api/spaces/:id/bans', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id } = request.params;
    const { userId: targetId, reason } = request.body;
    const db = getDb();

    if (!targetId || typeof targetId !== 'string') {
      return reply.code(400).send({ error: 'userId is required', statusCode: 400 });
    }

    if (!hasPermission(request.userId, id, PermissionBits.BAN_MEMBERS)) {
      return reply.code(403).send({ error: 'Missing BAN_MEMBERS permission', statusCode: 403 });
    }

    // Cannot ban the space owner
    if (isSpaceOwner(id, targetId)) {
      return reply.code(400).send({ error: 'Cannot ban the space owner', statusCode: 400 });
    }

    // Cannot ban yourself
    if (targetId === request.userId) {
      return reply.code(400).send({ error: 'Cannot ban yourself', statusCode: 400 });
    }

    // Check if already banned
    if (isBanned(id, targetId)) {
      return reply.code(409).send({ error: 'User is already banned', statusCode: 409 });
    }

    const now = Date.now();

    // Fetch channel IDs before the transaction for read_states cleanup
    const banChannelIds = db.select({ id: schema.channels.id })
      .from(schema.channels).where(eq(schema.channels.spaceId, id)).all().map(c => c.id);

    db.transaction((tx) => {
      // Insert ban record
      tx.insert(schema.bans).values({
        spaceId: id,
        userId: targetId,
        reason: reason?.trim() || null,
        bannedBy: request.userId,
        createdAt: now,
      }).run();

      // Remove member from space
      tx.delete(schema.spaceMembers).where(and(
        eq(schema.spaceMembers.spaceId, id),
        eq(schema.spaceMembers.userId, targetId),
      )).run();

      // Remove member's role assignments
      tx.delete(schema.memberRoles).where(and(
        eq(schema.memberRoles.spaceId, id),
        eq(schema.memberRoles.userId, targetId),
      )).run();

      // Clean up read_states for the banned user in this space's channels
      if (banChannelIds.length > 0) {
        tx.delete(schema.readStates).where(and(
          eq(schema.readStates.userId, targetId),
          inArray(schema.readStates.channelId, banChannelIds),
        )).run();
      }

      // Clean up any voice restrictions for the banned member
      tx.delete(schema.voiceRestrictions).where(and(
        eq(schema.voiceRestrictions.spaceId, id),
        eq(schema.voiceRestrictions.userId, targetId),
      )).run();
    });

    // Broadcast member_left event so other clients update their member list
    connectionManager.sendToSpace(id, {
      type: 'member_left',
      spaceId: id,
      userId: targetId,
    });

    // Notify the banned user
    connectionManager.sendToUser(targetId, {
      type: 'member_banned',
      spaceId: id,
      reason: reason?.trim() || null,
    });

    return reply.code(200).send({ success: true });
  });

  // DELETE /api/spaces/:id/bans/:uid - Unban a user
  app.delete<{ Params: { id: string; uid: string } }>('/api/spaces/:id/bans/:uid', {
    preHandler: authenticate,
  }, async (request, reply) => {
    const { id, uid } = request.params;
    const db = getDb();

    if (!hasPermission(request.userId, id, PermissionBits.BAN_MEMBERS)) {
      return reply.code(403).send({ error: 'Missing BAN_MEMBERS permission', statusCode: 403 });
    }

    const result = db.delete(schema.bans).where(and(
      eq(schema.bans.spaceId, id),
      eq(schema.bans.userId, uid),
    )).run();

    if (result.changes === 0) {
      return reply.code(404).send({ error: 'Ban not found', statusCode: 404 });
    }

    return reply.code(200).send({ success: true });
  });
}
