import { Router, type IRouter, type Request, type Response } from "express";
import { and, desc, eq, gte, inArray, lt, sql, type SQL } from "drizzle-orm";
import { db } from "@workspace/db";
import {
  rolesTable,
  userRolesTable,
  groupRolesTable,
  groupsTable,
  userGroupsTable,
  rolePermissionsTable,
  permissionsTable,
  auditLogsTable,
  rolePermissionAuditTable,
  usersTable,
} from "@workspace/db";
import { requireAdmin } from "../middlewares/auth";
import {
  CreateRoleBody,
  UpdateRoleBody,
  ReplaceRolePermissionsBody,
} from "@workspace/api-zod";
import {
  emitAppsChangedToRoleHolders,
  emitRolePermissionsChangedToHolders,
} from "../lib/realtime.js";

const router: IRouter = Router();

const PROTECTED_ROLE_NAMES = new Set(["admin", "user", "order_receiver"]);

function isSystemRole(role: { isSystem: number | boolean; name: string }): boolean {
  return Boolean(role.isSystem) || PROTECTED_ROLE_NAMES.has(role.name);
}

function serializePermission(p: typeof permissionsTable.$inferSelect) {
  return {
    id: p.id,
    name: p.name,
    descriptionAr: p.descriptionAr,
    descriptionEn: p.descriptionEn,
  };
}

function serializeRole(r: typeof rolesTable.$inferSelect) {
  return {
    id: r.id,
    name: r.name,
    descriptionAr: r.descriptionAr,
    descriptionEn: r.descriptionEn,
    isSystem: Boolean(r.isSystem),
    createdAt: r.createdAt,
  };
}

router.get("/roles", requireAdmin, async (_req, res): Promise<void> => {
  const rows = await db.select().from(rolesTable).orderBy(rolesTable.name);
  res.json(rows.map(serializeRole));
});

router.get("/permissions", requireAdmin, async (_req, res): Promise<void> => {
  const rows = await db.select().from(permissionsTable).orderBy(permissionsTable.name);
  res.json(rows.map(serializePermission));
});

const ROLE_AUDIT_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;

function parseRoleAuditUtcDate(s: string): Date | null {
  if (!ROLE_AUDIT_DATE_RE.test(s)) return null;
  const d = new Date(`${s}T00:00:00.000Z`);
  if (Number.isNaN(d.getTime())) return null;
  // Reject impossible calendar days (e.g. 2024-02-31, 2025-13-01) that
  // would otherwise silently roll forward into a different real date.
  const [y, m, day] = s.split("-").map(Number);
  if (
    d.getUTCFullYear() !== y ||
    d.getUTCMonth() + 1 !== m ||
    d.getUTCDate() !== day
  ) {
    return null;
  }
  return d;
}

type RoleAuditFilters = {
  actorUserId: number | null;
  fromDate: Date | null;
  toDateExclusive: Date | null;
};

function parseRoleAuditFilters(
  req: Request,
  res: Response,
): RoleAuditFilters | null {
  const actorUserIdStr =
    typeof req.query.actorUserId === "string" ? req.query.actorUserId.trim() : "";
  const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : "";
  const toStr = typeof req.query.to === "string" ? req.query.to.trim() : "";

  let actorUserId: number | null = null;
  if (actorUserIdStr) {
    const parsed = Number(actorUserIdStr);
    if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) {
      res
        .status(400)
        .json({ error: "Invalid 'actorUserId' (expected non-negative integer)" });
      return null;
    }
    // Treat 0 as "no filter" so callers can clear the dropdown without
    // dropping the query param entirely.
    actorUserId = parsed > 0 ? parsed : null;
  }
  let fromDate: Date | null = null;
  let toDateExclusive: Date | null = null;
  if (fromStr) {
    fromDate = parseRoleAuditUtcDate(fromStr);
    if (!fromDate) {
      res.status(400).json({ error: "Invalid 'from' date (expected YYYY-MM-DD)" });
      return null;
    }
  }
  if (toStr) {
    const toDate = parseRoleAuditUtcDate(toStr);
    if (!toDate) {
      res.status(400).json({ error: "Invalid 'to' date (expected YYYY-MM-DD)" });
      return null;
    }
    toDateExclusive = new Date(toDate.getTime() + 24 * 60 * 60 * 1000);
  }
  if (fromDate && toDateExclusive && fromDate >= toDateExclusive) {
    res.status(400).json({ error: "'from' must be on or before 'to'" });
    return null;
  }
  return { actorUserId, fromDate, toDateExclusive };
}

function buildRoleAuditWhere(roleId: number, filters: RoleAuditFilters): SQL {
  const conditions: SQL[] = [eq(rolePermissionAuditTable.roleId, roleId)];
  if (filters.actorUserId != null)
    conditions.push(eq(rolePermissionAuditTable.actorUserId, filters.actorUserId));
  if (filters.fromDate)
    conditions.push(gte(rolePermissionAuditTable.createdAt, filters.fromDate));
  if (filters.toDateExclusive)
    conditions.push(lt(rolePermissionAuditTable.createdAt, filters.toDateExclusive));
  return and(...conditions) as SQL;
}

router.get("/roles/:id/audit", requireAdmin, async (req, res): Promise<void> => {
  const id = Number(req.params.id);
  if (!Number.isInteger(id)) {
    res.status(400).json({ error: "Invalid id" });
    return;
  }
  const limitRaw = Number(req.query.limit);
  const offsetRaw = Number(req.query.offset);
  const limit =
    Number.isFinite(limitRaw) && limitRaw > 0
      ? Math.min(Math.floor(limitRaw), 200)
      : 10;
  const offset =
    Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0;

  const [role] = await db
    .select({ id: rolesTable.id })
    .from(rolesTable)
    .where(eq(rolesTable.id, id));
  if (!role) {
    res.status(404).json({ error: "Role not found" });
    return;
  }

  // Filter parsing is intentionally performed AFTER the role existence
  // check so 404 takes precedence over 400 for unknown roles, matching
  // the behaviour of the other role sub-routes.
  const filters = parseRoleAuditFilters(req, res);
  if (!filters) return;

  const whereClause = buildRoleAuditWhere(id, filters);

  const rows = await db
    .select({
      id: rolePermissionAuditTable.id,
      roleId: rolePermissionAuditTable.roleId,
      previousPermissionIds: rolePermissionAuditTable.previousPermissionIds,
      newPermissionIds: rolePermissionAuditTable.newPermissionIds,
      createdAt: rolePermissionAuditTable.createdAt,
      actorUserId: rolePermissionAuditTable.actorUserId,
      actorUsername: usersTable.username,
      actorDisplayNameAr: usersTable.displayNameAr,
      actorDisplayNameEn: usersTable.displayNameEn,
      actorAvatarUrl: usersTable.avatarUrl,
    })
    .from(rolePermissionAuditTable)
    .leftJoin(usersTable, eq(usersTable.id, rolePermissionAuditTable.actorUserId))
    .where(whereClause)
    .orderBy(desc(rolePermissionAuditTable.createdAt), desc(rolePermissionAuditTable.id))
    .limit(limit)
    .offset(offset);

  const [{ count: totalCount } = { count: 0 }] = await db
    .select({ count: sql<number>`count(*)::int` })
    .from(rolePermissionAuditTable)
    .where(whereClause);

  const entries = rows.map((r) => {
    const prev = (r.previousPermissionIds ?? []) as number[];
    const next = (r.newPermissionIds ?? []) as number[];
    const prevSet = new Set(prev);
    const nextSet = new Set(next);
    return {
      id: r.id,
      roleId: r.roleId,
      previousPermissionIds: prev,
      newPermissionIds: next,
      addedPermissionIds: next.filter((p) => !prevSet.has(p)),
      removedPermissionIds: prev.filter((p) => !nextSet.has(p)),
      createdAt: r.createdAt,
      actor:
        r.actorUserId != null && r.actorUsername != null
          ? {
              id: r.actorUserId,
              username: r.actorUsername,
              displayNameAr: r.actorDisplayNameAr,
              displayNameEn: r.actorDisplayNameEn,
              avatarUrl: r.actorAvatarUrl,
            }
          : null,
    };
  });

  const nextOffset =
    offset + rows.length < totalCount ? offset + rows.length : null;

  res.json({
    entries,
    totalCount,
    limit,
    offset,
    nextOffset,
  });
});

router.get("/roles/:id/usage", requireAdmin, async (req, res): Promise<void> => {
  const id = Number(req.params.id);
  if (!Number.isInteger(id)) {
    res.status(400).json({ error: "Invalid id" });
    return;
  }
  const [role] = await db.select({ id: rolesTable.id }).from(rolesTable).where(eq(rolesTable.id, id));
  if (!role) {
    res.status(404).json({ error: "Role not found" });
    return;
  }
  const [userCountRow] = await db
    .select({ count: sql<number>`count(*)::int` })
    .from(userRolesTable)
    .where(eq(userRolesTable.roleId, id));
  const [groupCountRow] = await db
    .select({ count: sql<number>`count(*)::int` })
    .from(groupRolesTable)
    .where(eq(groupRolesTable.roleId, id));
  res.json({
    userCount: userCountRow?.count ?? 0,
    groupCount: groupCountRow?.count ?? 0,
  });
});

router.post("/roles", requireAdmin, async (req, res): Promise<void> => {
  const parsed = CreateRoleBody.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: parsed.error.message });
    return;
  }
  const name = parsed.data.name.trim();
  if (!/^[a-z][a-z0-9_]*$/i.test(name)) {
    res.status(400).json({ error: "Role name must start with a letter and contain only letters, numbers, or underscores" });
    return;
  }
  const existing = await db
    .select({ id: rolesTable.id })
    .from(rolesTable)
    .where(eq(rolesTable.name, name));
  if (existing.length > 0) {
    res.status(409).json({ error: "Role name already taken" });
    return;
  }
  const [role] = await db
    .insert(rolesTable)
    .values({
      name,
      descriptionAr: parsed.data.descriptionAr ?? null,
      descriptionEn: parsed.data.descriptionEn ?? null,
    })
    .returning();
  await db.insert(auditLogsTable).values({
    actorUserId: req.session.userId ?? null,
    action: "role.create",
    targetType: "role",
    targetId: role.id,
    metadata: {
      name: role.name,
      descriptionAr: role.descriptionAr,
      descriptionEn: role.descriptionEn,
    },
  });
  res.status(201).json(serializeRole(role));
});

router.patch("/roles/:id", requireAdmin, async (req, res): Promise<void> => {
  const id = Number(req.params.id);
  if (!Number.isInteger(id)) {
    res.status(400).json({ error: "Invalid id" });
    return;
  }
  const parsed = UpdateRoleBody.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: parsed.error.message });
    return;
  }
  const [existing] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
  if (!existing) {
    res.status(404).json({ error: "Role not found" });
    return;
  }
  const updates: Partial<typeof rolesTable.$inferInsert> = {};
  if (parsed.data.name !== undefined) {
    const name = parsed.data.name.trim();
    if (name !== existing.name) {
      if (isSystemRole(existing)) {
        res.status(400).json({ error: "Cannot rename a system role", code: "system_role_rename" });
        return;
      }
      if (!/^[a-z][a-z0-9_]*$/i.test(name)) {
        res
          .status(400)
          .json({ error: "Role name must start with a letter and contain only letters, numbers, or underscores" });
        return;
      }
      const taken = await db
        .select({ id: rolesTable.id })
        .from(rolesTable)
        .where(eq(rolesTable.name, name));
      if (taken.some((r) => r.id !== id)) {
        res.status(409).json({ error: "Role name already taken" });
        return;
      }
      updates.name = name;
    }
  }
  if (parsed.data.descriptionAr !== undefined) updates.descriptionAr = parsed.data.descriptionAr;
  if (parsed.data.descriptionEn !== undefined) updates.descriptionEn = parsed.data.descriptionEn;
  if (Object.keys(updates).length > 0) {
    await db.update(rolesTable).set(updates).where(eq(rolesTable.id, id));
    const renamed = updates.name !== undefined && updates.name !== existing.name;
    await db.insert(auditLogsTable).values({
      actorUserId: req.session.userId ?? null,
      action: "role.update",
      targetType: "role",
      targetId: id,
      metadata: {
        previousName: existing.name,
        name: updates.name ?? existing.name,
        renamed,
        ...(renamed
          ? { renamedFrom: existing.name, renamedTo: updates.name }
          : {}),
        changes: updates,
      },
    });
  }
  const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
  res.json(serializeRole(role));
});

router.delete("/roles/:id", requireAdmin, async (req, res): Promise<void> => {
  const id = Number(req.params.id);
  if (!Number.isInteger(id)) {
    res.status(400).json({ error: "Invalid id" });
    return;
  }
  const [existing] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
  if (!existing) {
    res.status(404).json({ error: "Role not found" });
    return;
  }
  if (isSystemRole(existing)) {
    res.status(400).json({ error: "Cannot delete a system role" });
    return;
  }
  const [userCountRow] = await db
    .select({ count: sql<number>`count(*)::int` })
    .from(userRolesTable)
    .where(eq(userRolesTable.roleId, id));
  const [groupCountRow] = await db
    .select({ count: sql<number>`count(*)::int` })
    .from(groupRolesTable)
    .where(eq(groupRolesTable.roleId, id));
  const userCount = userCountRow?.count ?? 0;
  const groupCount = groupCountRow?.count ?? 0;
  if (userCount > 0 || groupCount > 0) {
    res.status(409).json({
      error: "Role is in use",
      userCount,
      groupCount,
    });
    return;
  }
  await db.transaction(async (tx) => {
    await tx.delete(rolePermissionsTable).where(eq(rolePermissionsTable.roleId, id));
    await tx.delete(rolesTable).where(eq(rolesTable.id, id));
  });
  await db.insert(auditLogsTable).values({
    actorUserId: req.session.userId ?? null,
    action: "role.delete",
    targetType: "role",
    targetId: id,
    metadata: {
      name: existing.name,
      descriptionAr: existing.descriptionAr,
      descriptionEn: existing.descriptionEn,
    },
  });
  res.sendStatus(204);
});

router.get("/roles/:id/permissions", requireAdmin, async (req, res): Promise<void> => {
  const id = Number(req.params.id);
  if (!Number.isInteger(id)) {
    res.status(400).json({ error: "Invalid id" });
    return;
  }
  const [role] = await db.select({ id: rolesTable.id }).from(rolesTable).where(eq(rolesTable.id, id));
  if (!role) {
    res.status(404).json({ error: "Role not found" });
    return;
  }
  const rows = await db
    .select({
      id: permissionsTable.id,
      name: permissionsTable.name,
      descriptionAr: permissionsTable.descriptionAr,
      descriptionEn: permissionsTable.descriptionEn,
    })
    .from(rolePermissionsTable)
    .innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id))
    .where(eq(rolePermissionsTable.roleId, id))
    .orderBy(permissionsTable.name);
  res.json(rows);
});

router.post(
  "/roles/:id/permissions/impact-preview",
  requireAdmin,
  async (req, res): Promise<void> => {
    const id = Number(req.params.id);
    if (!Number.isInteger(id)) {
      res.status(400).json({ error: "Invalid id" });
      return;
    }
    const candidate = req.body?.permissionIds;
    if (!Array.isArray(candidate) || !candidate.every((n) => Number.isInteger(n) && n > 0)) {
      res.status(400).json({ error: "permissionIds must be an array of positive integers" });
      return;
    }
    const [role] = await db
      .select({ id: rolesTable.id })
      .from(rolesTable)
      .where(eq(rolesTable.id, id));
    if (!role) {
      res.status(404).json({ error: "Role not found" });
      return;
    }

    const candidateSet = new Set<number>(candidate as number[]);

    const currentPerms = await db
      .select({ id: permissionsTable.id, name: permissionsTable.name })
      .from(rolePermissionsTable)
      .innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id))
      .where(eq(rolePermissionsTable.roleId, id));

    const removed = currentPerms.filter((p) => !candidateSet.has(p.id));

    if (removed.length === 0) {
      res.json({ removed: [], totalAffectedUsers: 0 });
      return;
    }

    const groups = await db
      .select({ id: groupsTable.id, name: groupsTable.name })
      .from(groupRolesTable)
      .innerJoin(groupsTable, eq(groupRolesTable.groupId, groupsTable.id))
      .where(eq(groupRolesTable.roleId, id))
      .orderBy(groupsTable.name);

    // Users that currently hold this role: directly OR via any group that grants it.
    const directUsers = await db
      .select({ userId: userRolesTable.userId })
      .from(userRolesTable)
      .where(eq(userRolesTable.roleId, id));

    const groupIds = groups.map((g) => g.id);
    const indirectUsers = groupIds.length
      ? await db
          .select({ userId: userGroupsTable.userId })
          .from(userGroupsTable)
          .where(inArray(userGroupsTable.groupId, groupIds))
      : [];

    const roleHolderIds = Array.from(
      new Set<number>([
        ...directUsers.map((r) => r.userId),
        ...indirectUsers.map((r) => r.userId),
      ]),
    );

    const allAffected = new Set<number>();
    const items: Array<{
      permissionId: number;
      permissionName: string;
      userCount: number;
      groupCount: number;
      groups: Array<{ id: number; name: string }>;
    }> = [];

    if (roleHolderIds.length === 0) {
      for (const p of removed) {
        items.push({
          permissionId: p.id,
          permissionName: p.name,
          userCount: 0,
          groupCount: groups.length,
          groups,
        });
      }
      res.json({ removed: items, totalAffectedUsers: 0 });
      return;
    }

    // For each removed permission, find which role-holders would still have it
    // through some OTHER role (direct or via any group).
    for (const p of removed) {
      const directOther = await db
        .selectDistinct({ userId: userRolesTable.userId })
        .from(userRolesTable)
        .innerJoin(
          rolePermissionsTable,
          eq(rolePermissionsTable.roleId, userRolesTable.roleId),
        )
        .where(
          sql`${inArray(userRolesTable.userId, roleHolderIds)} AND ${userRolesTable.roleId} <> ${id} AND ${rolePermissionsTable.permissionId} = ${p.id}`,
        );

      const indirectOther = await db
        .selectDistinct({ userId: userGroupsTable.userId })
        .from(userGroupsTable)
        .innerJoin(
          groupRolesTable,
          eq(groupRolesTable.groupId, userGroupsTable.groupId),
        )
        .innerJoin(
          rolePermissionsTable,
          eq(rolePermissionsTable.roleId, groupRolesTable.roleId),
        )
        .where(
          sql`${inArray(userGroupsTable.userId, roleHolderIds)} AND ${groupRolesTable.roleId} <> ${id} AND ${rolePermissionsTable.permissionId} = ${p.id}`,
        );

      const stillHave = new Set<number>([
        ...directOther.map((r) => r.userId),
        ...indirectOther.map((r) => r.userId),
      ]);
      let lost = 0;
      for (const uid of roleHolderIds) {
        if (!stillHave.has(uid)) {
          lost += 1;
          allAffected.add(uid);
        }
      }
      items.push({
        permissionId: p.id,
        permissionName: p.name,
        userCount: lost,
        groupCount: groups.length,
        groups,
      });
    }

    items.sort((a, b) => a.permissionName.localeCompare(b.permissionName));
    res.json({ removed: items, totalAffectedUsers: allAffected.size });
  },
);

router.put("/roles/:id/permissions", requireAdmin, async (req, res): Promise<void> => {
  const id = Number(req.params.id);
  if (!Number.isInteger(id)) {
    res.status(400).json({ error: "Invalid id" });
    return;
  }
  const parsed = ReplaceRolePermissionsBody.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: parsed.error.message });
    return;
  }
  const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
  if (!role) {
    res.status(404).json({ error: "Role not found" });
    return;
  }
  if (isSystemRole(role)) {
    res
      .status(400)
      .json({ error: "Cannot modify a system role's permissions", code: "system_role_permissions" });
    return;
  }
  if (!parsed.data.permissionIds.every((n) => Number.isInteger(n) && n > 0)) {
    res.status(400).json({ error: "permissionIds must be positive integers" });
    return;
  }
  const requestedIds = Array.from(new Set(parsed.data.permissionIds));
  if (requestedIds.length > 0) {
    const found = await db
      .select({ id: permissionsTable.id })
      .from(permissionsTable)
      .where(inArray(permissionsTable.id, requestedIds));
    if (found.length !== requestedIds.length) {
      res.status(404).json({ error: "Permission not found" });
      return;
    }
  }
  // Apply the permission replacement, the generic audit_logs row, and the
  // dedicated role_permission_audit row in a single transaction so the
  // permission set and its audit trail are guaranteed to commit together.
  // Reading the previous IDs inside the transaction also avoids racing with
  // a concurrent edit between the read and the write.
  await db.transaction(async (tx) => {
    const previousIds = (
      await tx
        .select({ permissionId: rolePermissionsTable.permissionId })
        .from(rolePermissionsTable)
        .where(eq(rolePermissionsTable.roleId, id))
    ).map((r) => r.permissionId);

    const previousSet = new Set(previousIds);
    const requestedSet = new Set(requestedIds);
    const added = requestedIds.filter((p) => !previousSet.has(p));
    const removed = previousIds.filter((p) => !requestedSet.has(p));
    const changed = added.length > 0 || removed.length > 0;

    await tx.delete(rolePermissionsTable).where(eq(rolePermissionsTable.roleId, id));
    if (requestedIds.length > 0) {
      await tx
        .insert(rolePermissionsTable)
        .values(requestedIds.map((permissionId) => ({ roleId: id, permissionId })));
    }

    // Dedicated role-permission audit row written on EVERY PUT call (per
    // task spec) so we have a complete record of who reviewed/saved the
    // permission set at any point in time, even when the resulting set
    // happens to be unchanged. The History UI uses
    // addedPermissionIds/removedPermissionIds (computed in the GET
    // handler) to surface meaningful changes vs. no-op saves.
    await tx.insert(rolePermissionAuditTable).values({
      roleId: id,
      actorUserId: req.session.userId ?? null,
      previousPermissionIds: [...previousIds].sort((a, b) => a - b),
      newPermissionIds: [...requestedIds].sort((a, b) => a - b),
    });

    // The legacy audit_logs entry is kept for backward compatibility
    // with the existing audit log UI, but only when the set actually
    // changes (matches its long-standing semantics for this action).
    if (changed) {
      await tx.insert(auditLogsTable).values({
        actorUserId: req.session.userId ?? null,
        action: "role.permissions.replace",
        targetType: "role",
        targetId: id,
        metadata: {
          roleName: role.name,
          added,
          removed,
        },
      });
    }
  });
  await emitAppsChangedToRoleHolders(id);
  await emitRolePermissionsChangedToHolders(id);
  const rows = await db
    .select({
      id: permissionsTable.id,
      name: permissionsTable.name,
      descriptionAr: permissionsTable.descriptionAr,
      descriptionEn: permissionsTable.descriptionEn,
    })
    .from(rolePermissionsTable)
    .innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id))
    .where(eq(rolePermissionsTable.roleId, id))
    .orderBy(permissionsTable.name);
  res.json(rows);
});

router.post("/roles/:id/permissions", requireAdmin, async (req, res): Promise<void> => {
  const id = Number(req.params.id);
  if (!Number.isInteger(id)) {
    res.status(400).json({ error: "Invalid id" });
    return;
  }
  const permissionId = Number(req.body?.permissionId);
  if (!Number.isInteger(permissionId)) {
    res.status(400).json({ error: "permissionId is required" });
    return;
  }
  const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
  if (!role) {
    res.status(404).json({ error: "Role not found" });
    return;
  }
  if (isSystemRole(role)) {
    res
      .status(400)
      .json({ error: "Cannot modify a system role's permissions", code: "system_role_permissions" });
    return;
  }
  const [perm] = await db
    .select({ id: permissionsTable.id, name: permissionsTable.name })
    .from(permissionsTable)
    .where(eq(permissionsTable.id, permissionId));
  if (!perm) {
    res.status(404).json({ error: "Permission not found" });
    return;
  }
  const existing = await db
    .select({ roleId: rolePermissionsTable.roleId })
    .from(rolePermissionsTable)
    .where(sql`${rolePermissionsTable.roleId} = ${id} AND ${rolePermissionsTable.permissionId} = ${permissionId}`);
  if (existing.length === 0) {
    await db.insert(rolePermissionsTable).values({ roleId: id, permissionId });
    await emitAppsChangedToRoleHolders(id);
    await emitRolePermissionsChangedToHolders(id);
    await db.insert(auditLogsTable).values({
      actorUserId: req.session.userId ?? null,
      action: "role.permission.add",
      targetType: "role",
      targetId: id,
      metadata: {
        roleName: role.name,
        permissionId,
        permissionName: perm.name,
      },
    });
  }
  res.status(201).json({ roleId: id, permissionId });
});

router.delete("/roles/:id/permissions/:permissionId", requireAdmin, async (req, res): Promise<void> => {
  const id = Number(req.params.id);
  const permissionId = Number(req.params.permissionId);
  if (!Number.isInteger(id) || !Number.isInteger(permissionId)) {
    res.status(400).json({ error: "Invalid id" });
    return;
  }
  const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
  if (!role) {
    res.status(404).json({ error: "Role not found" });
    return;
  }
  if (isSystemRole(role)) {
    res
      .status(400)
      .json({ error: "Cannot modify a system role's permissions", code: "system_role_permissions" });
    return;
  }
  const [perm] = await db
    .select({ name: permissionsTable.name })
    .from(permissionsTable)
    .where(eq(permissionsTable.id, permissionId));
  const deleted = await db
    .delete(rolePermissionsTable)
    .where(
      sql`${rolePermissionsTable.roleId} = ${id} AND ${rolePermissionsTable.permissionId} = ${permissionId}`,
    )
    .returning();
  if (deleted.length > 0) {
    await emitAppsChangedToRoleHolders(id);
    await emitRolePermissionsChangedToHolders(id);
    await db.insert(auditLogsTable).values({
      actorUserId: req.session.userId ?? null,
      action: "role.permission.remove",
      targetType: "role",
      targetId: id,
      metadata: {
        roleName: role.name,
        permissionId,
        permissionName: perm?.name ?? null,
      },
    });
  }
  res.sendStatus(204);
});

export default router;
