import { Router, type IRouter } from "express";
import { eq, and, asc, inArray, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
  appsTable,
  appPermissionsTable,
  userRolesTable,
  rolePermissionsTable,
  rolesTable,
  userAppOrdersTable,
  appOpensTable,
  userGroupsTable,
  groupAppsTable,
  groupRolesTable,
  groupsTable,
  auditLogsTable,
  permissionsTable,
  usersTable,
} from "@workspace/db";
import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth";
import {
  listPermissionAudit,
  parsePermissionAuditFilters,
  recordPermissionAudit,
} from "../lib/permission-audit";
import {
  CreateAppBody,
  UpdateAppBody,
  GetAppParams,
  UpdateAppParams,
  DeleteAppParams,
  UpdateMyAppOrderBody,
} from "@workspace/api-zod";

const router: IRouter = Router();

async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$inferSelect[]> {
  const effectiveRoleIds = await getEffectiveRoleIds(userId);

  const adminRoleRows = effectiveRoleIds.length > 0
    ? await db
        .select({ id: rolesTable.id })
        .from(rolesTable)
        .where(and(inArray(rolesTable.id, effectiveRoleIds), eq(rolesTable.name, "admin")))
    : [];

  const isAdmin = adminRoleRows.length > 0;

  // Pull the user's preferred order, then order by COALESCE(user_order, app.sort_order, name).
  // Admins receive ALL apps (including inactive ones) so they can re-enable them.
  // Non-admins only receive active apps.
  const baseQuery = db
    .select({
      app: appsTable,
      userSort: userAppOrdersTable.sortOrder,
    })
    .from(appsTable)
    .leftJoin(
      userAppOrdersTable,
      sql`${userAppOrdersTable.appId} = ${appsTable.id} and ${userAppOrdersTable.userId} = ${userId}`,
    )
    .$dynamic();

  const orderedRows = await (isAdmin
    ? baseQuery
    : baseQuery.where(eq(appsTable.isActive, true))
  ).orderBy(
    sql`COALESCE(${userAppOrdersTable.sortOrder}, ${appsTable.sortOrder})`,
    asc(appsTable.nameEn),
  );

  const apps = orderedRows.map((r) => r.app);

  if (isAdmin) return apps;

  const userRoleIds = effectiveRoleIds;
  const userPermissionIds = userRoleIds.length > 0
    ? (await db
        .select({ permissionId: rolePermissionsTable.permissionId })
        .from(rolePermissionsTable)
        .where(inArray(rolePermissionsTable.roleId, userRoleIds))
      ).map((r) => r.permissionId)
    : [];

  const appsWithPermissions = await db
    .select({ appId: appPermissionsTable.appId })
    .from(appPermissionsTable);
  const restrictedAppIds = new Set(appsWithPermissions.map((r) => r.appId));

  if (restrictedAppIds.size === 0) return apps;

  const allowedRestrictedAppIds = userPermissionIds.length > 0
    ? (await db
        .select({ appId: appPermissionsTable.appId })
        .from(appPermissionsTable)
        .where(inArray(appPermissionsTable.permissionId, userPermissionIds))
      ).map((r) => r.appId)
    : [];

  // Group-granted apps: any app explicitly assigned to one of the user's groups
  // is visible regardless of the legacy permission gating.
  const groupGrantedAppIds = (
    await db
      .select({ appId: groupAppsTable.appId })
      .from(groupAppsTable)
      .innerJoin(
        userGroupsTable,
        eq(userGroupsTable.groupId, groupAppsTable.groupId),
      )
      .where(eq(userGroupsTable.userId, userId))
  ).map((r) => r.appId);
  const groupGrantedSet = new Set(groupGrantedAppIds);

  const allowedAppIdSet = new Set(allowedRestrictedAppIds);
  return apps.filter(
    (app) =>
      !restrictedAppIds.has(app.id) ||
      allowedAppIdSet.has(app.id) ||
      groupGrantedSet.has(app.id),
  );
}

router.get("/apps", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const visible = await getVisibleAppsForUser(userId);
  res.json(visible);
});

router.get("/admin/apps", requireAdmin, async (_req, res): Promise<void> => {
  const allApps = await db
    .select()
    .from(appsTable)
    .orderBy(asc(appsTable.sortOrder), asc(appsTable.nameEn));

  if (allApps.length === 0) {
    res.json([]);
    return;
  }

  // Compute dependency counts so the admin delete dialog can warn before
  // the first click (mirrors the GET /groups behavior). The lazy 409
  // fallback in DELETE /apps/:id remains as a safety net.
  const appIds = allApps.map((a) => a.id);

  const groupRows = await db
    .select({
      appId: groupAppsTable.appId,
      count: sql<number>`count(*)::int`,
    })
    .from(groupAppsTable)
    .where(inArray(groupAppsTable.appId, appIds))
    .groupBy(groupAppsTable.appId);
  const restrictionRows = await db
    .select({
      appId: appPermissionsTable.appId,
      count: sql<number>`count(*)::int`,
    })
    .from(appPermissionsTable)
    .where(inArray(appPermissionsTable.appId, appIds))
    .groupBy(appPermissionsTable.appId);
  const openRows = await db
    .select({
      appId: appOpensTable.appId,
      count: sql<number>`count(*)::int`,
    })
    .from(appOpensTable)
    .where(inArray(appOpensTable.appId, appIds))
    .groupBy(appOpensTable.appId);

  const groupMap = new Map(groupRows.map((r) => [r.appId, r.count]));
  const restrictionMap = new Map(
    restrictionRows.map((r) => [r.appId, r.count]),
  );
  const openMap = new Map(openRows.map((r) => [r.appId, r.count]));

  res.json(
    allApps.map((a) => ({
      ...a,
      groupCount: groupMap.get(a.id) ?? 0,
      restrictionCount: restrictionMap.get(a.id) ?? 0,
      openCount: openMap.get(a.id) ?? 0,
    })),
  );
});

router.put("/me/app-order", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const parsed = UpdateMyAppOrderBody.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: parsed.error.message });
    return;
  }

  const { order } = parsed.data;

  const seen = new Set<number>();
  for (const id of order) {
    if (seen.has(id)) {
      res.status(400).json({ error: `Duplicate app id in order: ${id}` });
      return;
    }
    seen.add(id);
  }

  const visible = await getVisibleAppsForUser(userId);
  const visibleIds = new Set(visible.map((a) => a.id));
  const invalid = order.filter((id) => !visibleIds.has(id));
  if (invalid.length > 0) {
    res.status(400).json({
      error: `Unknown or unauthorized app id(s): ${invalid.join(", ")}`,
    });
    return;
  }

  await db.transaction(async (tx) => {
    await tx.delete(userAppOrdersTable).where(eq(userAppOrdersTable.userId, userId));
    if (order.length > 0) {
      await tx.insert(userAppOrdersTable).values(
        order.map((appId, idx) => ({ userId, appId, sortOrder: idx })),
      );
    }
  });

  const updated = await getVisibleAppsForUser(userId);
  res.json(updated);
});

router.post("/apps", requireAdmin, async (req, res): Promise<void> => {
  const parsed = CreateAppBody.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: parsed.error.message });
    return;
  }

  // Accept an optional permissionIds[] alongside the app fields so admins
  // can gate the app at creation time. Pulling it out before the insert
  // keeps appsTable.values strictly typed against the Drizzle schema.
  const { permissionIds: rawPermissionIds, ...appValues } = parsed.data;
  // The zod schema already restricts permissionIds to numbers, but we
  // explicitly reject non-integer / non-positive values with 400 here so
  // a malformed request never silently drops ids — the admin should know
  // their gate request was wrong instead of getting a partially gated app.
  if (
    rawPermissionIds !== undefined &&
    rawPermissionIds.some((n) => !Number.isInteger(n) || n <= 0)
  ) {
    res.status(400).json({
      error: "permissionIds must be an array of positive integers",
    });
    return;
  }
  const requestedPermissionIds = Array.from(new Set(rawPermissionIds ?? []));

  // If permission ids were supplied, look them up upfront so we can 404
  // before creating the app row instead of leaving an orphaned app behind.
  // We also need the names later for the per-permission audit entries.
  const requestedPermissions = requestedPermissionIds.length > 0
    ? await db
        .select({ id: permissionsTable.id, name: permissionsTable.name })
        .from(permissionsTable)
        .where(inArray(permissionsTable.id, requestedPermissionIds))
    : [];
  if (requestedPermissions.length !== requestedPermissionIds.length) {
    const foundIds = new Set(requestedPermissions.map((p) => p.id));
    const missing = requestedPermissionIds.filter((id) => !foundIds.has(id));
    res.status(404).json({
      error: `Permission(s) not found: ${missing.join(", ")}`,
    });
    return;
  }

  // Create the app + insert its permission rows in a single transaction so
  // the app never exists in an "unrestricted" state when the admin asked
  // for permission gating. onConflictDoNothing keeps it idempotent against
  // the (app_id, permission_id) primary key.
  const { app, insertedPermissionIds } = await db.transaction(async (tx) => {
    const [created] = await tx.insert(appsTable).values(appValues).returning();
    let inserted: number[] = [];
    if (requestedPermissionIds.length > 0) {
      const ins = await tx
        .insert(appPermissionsTable)
        .values(
          requestedPermissionIds.map((permissionId) => ({
            appId: created.id,
            permissionId,
          })),
        )
        .onConflictDoNothing()
        .returning({ permissionId: appPermissionsTable.permissionId });
      inserted = ins.map((r) => r.permissionId);
      // Brand-new app starts with no permissions, so previousIds=[] and the
      // newIds is the sorted set of what we just attached. This matches the
      // single-add endpoint's audit semantics so the history view renders
      // a normal "added X" entry instead of a synthetic create marker.
      await recordPermissionAudit(tx, {
        targetKind: "app",
        targetId: created.id,
        changeKind: "app.permissions",
        actorUserId: req.session.userId ?? null,
        previousIds: [],
        newIds: requestedPermissionIds,
      });
    }
    return { app: created, insertedPermissionIds: inserted };
  });

  await db.insert(auditLogsTable).values({
    actorUserId: req.session.userId ?? null,
    action: "app.create",
    targetType: "app",
    targetId: app.id,
    metadata: {
      slug: app.slug,
      nameAr: app.nameAr,
      nameEn: app.nameEn,
      route: app.route,
      isActive: app.isActive,
      permissionIds: requestedPermissionIds,
    },
  });
  // Mirror the per-permission audit rows POST /apps/:id/permissions writes
  // so the admin log shows the same "permission added" entries whether the
  // gate was set at create time or in a follow-up dialog. We only emit
  // entries for rows the transaction actually inserted.
  if (insertedPermissionIds.length > 0) {
    const nameById = new Map(requestedPermissions.map((p) => [p.id, p.name]));
    await db.insert(auditLogsTable).values(
      insertedPermissionIds.map((permissionId) => ({
        actorUserId: req.session.userId ?? null,
        action: "app.permission.add",
        targetType: "app" as const,
        targetId: app.id,
        metadata: {
          slug: app.slug,
          nameEn: app.nameEn,
          permissionId,
          permissionName: nameById.get(permissionId) ?? null,
        },
      })),
    );
  }
  res.status(201).json(app);
});

router.get("/apps/:id", requireAuth, async (req, res): Promise<void> => {
  const params = GetAppParams.safeParse(req.params);
  if (!params.success) {
    res.status(400).json({ error: params.error.message });
    return;
  }

  const [app] = await db
    .select()
    .from(appsTable)
    .where(eq(appsTable.id, params.data.id));

  if (!app) {
    res.status(404).json({ error: "App not found" });
    return;
  }

  res.json(app);
});

router.patch("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
  const params = UpdateAppParams.safeParse(req.params);
  if (!params.success) {
    res.status(400).json({ error: params.error.message });
    return;
  }

  const parsed = UpdateAppBody.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: parsed.error.message });
    return;
  }

  const [previous] = await db
    .select()
    .from(appsTable)
    .where(eq(appsTable.id, params.data.id));

  const [app] = await db
    .update(appsTable)
    .set(parsed.data)
    .where(eq(appsTable.id, params.data.id))
    .returning();

  if (!app) {
    res.status(404).json({ error: "App not found" });
    return;
  }

  if (previous) {
    const changes: Record<string, { from: unknown; to: unknown }> = {};
    for (const [key, value] of Object.entries(parsed.data)) {
      const prevValue = (previous as Record<string, unknown>)[key];
      if (prevValue !== value) {
        changes[key] = { from: prevValue ?? null, to: value ?? null };
      }
    }
    if (Object.keys(changes).length > 0) {
      await db.insert(auditLogsTable).values({
        actorUserId: req.session.userId ?? null,
        action: "app.update",
        targetType: "app",
        targetId: app.id,
        metadata: {
          slug: app.slug,
          nameEn: app.nameEn,
          changes,
        },
      });
    }
  }

  res.json(app);
});

router.get("/apps/:id/permissions", requireAdmin, async (req, res): Promise<void> => {
  const params = GetAppParams.safeParse(req.params);
  if (!params.success) {
    res.status(400).json({ error: params.error.message });
    return;
  }

  const [app] = await db
    .select({ id: appsTable.id })
    .from(appsTable)
    .where(eq(appsTable.id, params.data.id));
  if (!app) {
    res.status(404).json({ error: "App not found" });
    return;
  }

  const rows = await db
    .select({
      id: permissionsTable.id,
      name: permissionsTable.name,
      descriptionAr: permissionsTable.descriptionAr,
      descriptionEn: permissionsTable.descriptionEn,
    })
    .from(appPermissionsTable)
    .innerJoin(
      permissionsTable,
      eq(appPermissionsTable.permissionId, permissionsTable.id),
    )
    .where(eq(appPermissionsTable.appId, app.id))
    .orderBy(permissionsTable.name);

  res.json(rows);
});

router.post(
  "/apps/: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 [app] = await db
      .select({ id: appsTable.id })
      .from(appsTable)
      .where(eq(appsTable.id, id));
    if (!app) {
      res.status(404).json({ error: "App not found" });
      return;
    }

    const candidateIds = Array.from(new Set<number>(candidate as number[]));
    const candidateSet = new Set<number>(candidateIds);

    const currentRows = await db
      .select({ permissionId: appPermissionsTable.permissionId })
      .from(appPermissionsTable)
      .where(eq(appPermissionsTable.appId, id));
    const currentIds = currentRows.map((r) => r.permissionId);
    const currentSet = new Set<number>(currentIds);

    // Groups that grant this app via group_apps. These groups offset the
    // permission gate: members of any of these groups always see the app
    // regardless of permission changes, so we surface them so the admin
    // knows which populations are protected.
    const groupGrantRows = await db
      .select({ id: groupsTable.id, name: groupsTable.name })
      .from(groupAppsTable)
      .innerJoin(groupsTable, eq(groupAppsTable.groupId, groupsTable.id))
      .where(eq(groupAppsTable.appId, id))
      .orderBy(groupsTable.name);

    const sameSize = candidateSet.size === currentSet.size;
    const noChange =
      sameSize && currentIds.every((p) => candidateSet.has(p));

    const groupGrantUserRows = await db
      .selectDistinct({ userId: userGroupsTable.userId })
      .from(userGroupsTable)
      .innerJoin(
        groupAppsTable,
        eq(groupAppsTable.groupId, userGroupsTable.groupId),
      )
      .where(eq(groupAppsTable.appId, id));
    const groupGrantUserSet = new Set<number>(
      groupGrantUserRows.map((r) => r.userId),
    );

    // Admins are excluded from the affected/visible counts since they always
    // see every app via the short-circuit in getVisibleAppsForUser. We treat
    // an admin as anyone holding the "admin" role directly OR via a group.
    const adminRoleRows = await db
      .select({ id: rolesTable.id })
      .from(rolesTable)
      .where(eq(rolesTable.name, "admin"));
    const adminUserSet = new Set<number>();
    if (adminRoleRows.length > 0) {
      const adminRoleId = adminRoleRows[0].id;
      const directAdmins = await db
        .select({ userId: userRolesTable.userId })
        .from(userRolesTable)
        .where(eq(userRolesTable.roleId, adminRoleId));
      for (const r of directAdmins) adminUserSet.add(r.userId);

      const adminGroupRows = await db
        .select({ groupId: groupRolesTable.groupId })
        .from(groupRolesTable)
        .where(eq(groupRolesTable.roleId, adminRoleId));
      const adminGroupIds = adminGroupRows.map((r) => r.groupId);
      if (adminGroupIds.length > 0) {
        const indirectAdmins = await db
          .select({ userId: userGroupsTable.userId })
          .from(userGroupsTable)
          .where(inArray(userGroupsTable.groupId, adminGroupIds));
        for (const r of indirectAdmins) adminUserSet.add(r.userId);
      }
    }

    // Returns the set of user IDs that currently hold AT LEAST ONE of `perms`
    // through any role they have (direct or via a group). Mirrors the OR
    // semantics getVisibleAppsForUser uses for app permission gating.
    const usersWithAnyPermission = async (
      perms: number[],
    ): Promise<Set<number>> => {
      const out = new Set<number>();
      if (perms.length === 0) return out;
      const directRows = await db
        .selectDistinct({ userId: userRolesTable.userId })
        .from(userRolesTable)
        .innerJoin(
          rolePermissionsTable,
          eq(rolePermissionsTable.roleId, userRolesTable.roleId),
        )
        .where(inArray(rolePermissionsTable.permissionId, perms));
      for (const r of directRows) out.add(r.userId);
      const indirectRows = await db
        .selectDistinct({ userId: userGroupsTable.userId })
        .from(userGroupsTable)
        .innerJoin(
          groupRolesTable,
          eq(groupRolesTable.groupId, userGroupsTable.groupId),
        )
        .innerJoin(
          rolePermissionsTable,
          eq(rolePermissionsTable.roleId, groupRolesTable.roleId),
        )
        .where(inArray(rolePermissionsTable.permissionId, perms));
      for (const r of indirectRows) out.add(r.userId);
      return out;
    };

    // Helper to materialise "all non-admin users" only when one of the
    // candidate or current sets is empty (the unrestricted path). Skipping
    // this query in the common restricted-to-restricted case keeps the
    // endpoint cheap on installs with many users.
    let allNonAdminUserIds: number[] | null = null;
    const getAllNonAdminUsers = async (): Promise<Set<number>> => {
      if (allNonAdminUserIds === null) {
        const allUsers = await db
          .select({ id: usersTable.id })
          .from(usersTable);
        allNonAdminUserIds = allUsers
          .map((u) => u.id)
          .filter((uid) => !adminUserSet.has(uid));
      }
      return new Set<number>(allNonAdminUserIds);
    };

    const computeVisible = async (perms: number[]): Promise<Set<number>> => {
      if (perms.length === 0) {
        // Unrestricted: every non-admin user can see the app.
        return await getAllNonAdminUsers();
      }
      const visible = await usersWithAnyPermission(perms);
      for (const uid of groupGrantUserSet) visible.add(uid);
      for (const uid of adminUserSet) visible.delete(uid);
      return visible;
    };

    const currentlyVisible = await computeVisible(currentIds);
    // When the candidate set is identical to the current set we skip the
    // second visibility query (it's guaranteed to be the same) and report
    // the real currentlyVisibleUserCount so consumers reading a noChange
    // response still get an accurate audience size for the app.
    const futureVisible = noChange
      ? currentlyVisible
      : await computeVisible(candidateIds);

    let affected = 0;
    if (!noChange) {
      for (const uid of currentlyVisible) {
        if (!futureVisible.has(uid)) affected += 1;
      }
    }

    res.json({
      affectedUserCount: affected,
      currentlyVisibleUserCount: currentlyVisible.size,
      groups: groupGrantRows,
      noChange,
    });
  },
);

router.post("/apps/:id/permissions", requireAdmin, async (req, res): Promise<void> => {
  const params = GetAppParams.safeParse(req.params);
  if (!params.success) {
    res.status(400).json({ error: params.error.message });
    return;
  }
  const permissionId = Number(req.body?.permissionId);
  if (!Number.isInteger(permissionId)) {
    res.status(400).json({ error: "permissionId is required" });
    return;
  }

  const [app] = await db
    .select({
      id: appsTable.id,
      slug: appsTable.slug,
      nameEn: appsTable.nameEn,
    })
    .from(appsTable)
    .where(eq(appsTable.id, params.data.id));
  if (!app) {
    res.status(404).json({ error: "App not found" });
    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;
  }

  // The composite primary key on (app_id, permission_id) means a duplicate
  // insert would otherwise fail with 23505. onConflictDoNothing makes the
  // endpoint idempotent so the UI can safely re-add an existing pair.
  // .returning() lets us tell a real insert from a no-op so the audit log
  // only records actual permission tightening, not retry clicks.
  const inserted = await db.transaction(async (tx) => {
    const previousIds = (
      await tx
        .select({ permissionId: appPermissionsTable.permissionId })
        .from(appPermissionsTable)
        .where(eq(appPermissionsTable.appId, app.id))
    ).map((r) => r.permissionId);
    const ins = await tx
      .insert(appPermissionsTable)
      .values({ appId: app.id, permissionId })
      .onConflictDoNothing()
      .returning({ appId: appPermissionsTable.appId });
    if (ins.length > 0) {
      await recordPermissionAudit(tx, {
        targetKind: "app",
        targetId: app.id,
        changeKind: "app.permissions",
        actorUserId: req.session.userId ?? null,
        previousIds,
        newIds: previousIds.includes(permissionId)
          ? previousIds
          : [...previousIds, permissionId],
      });
    }
    return ins;
  });

  if (inserted.length > 0) {
    await db.insert(auditLogsTable).values({
      actorUserId: req.session.userId ?? null,
      action: "app.permission.add",
      targetType: "app",
      targetId: app.id,
      metadata: {
        slug: app.slug,
        nameEn: app.nameEn,
        permissionId: perm.id,
        permissionName: perm.name,
      },
    });
  }

  res.status(201).json({ appId: app.id, permissionId });
});

router.delete(
  "/apps/:id/permissions/:permissionId",
  requireAdmin,
  async (req, res): Promise<void> => {
    const appId = Number(req.params.id);
    const permissionId = Number(req.params.permissionId);
    if (!Number.isInteger(appId) || !Number.isInteger(permissionId)) {
      res.status(400).json({ error: "Invalid id" });
      return;
    }

    const [app] = await db
      .select({
        id: appsTable.id,
        slug: appsTable.slug,
        nameEn: appsTable.nameEn,
      })
      .from(appsTable)
      .where(eq(appsTable.id, appId));
    if (!app) {
      res.status(404).json({ error: "App not found" });
      return;
    }

    // Look up the permission name BEFORE deletion so the audit entry can
    // identify the permission even if the row (or the permission itself)
    // is removed later. If the permission no longer exists we still emit
    // an audit row with the id so the trail isn't lost.
    const [perm] = await db
      .select({ name: permissionsTable.name })
      .from(permissionsTable)
      .where(eq(permissionsTable.id, permissionId));

    const deleted = await db.transaction(async (tx) => {
      const previousIds = (
        await tx
          .select({ permissionId: appPermissionsTable.permissionId })
          .from(appPermissionsTable)
          .where(eq(appPermissionsTable.appId, appId))
      ).map((r) => r.permissionId);
      const del = await tx
        .delete(appPermissionsTable)
        .where(
          and(
            eq(appPermissionsTable.appId, appId),
            eq(appPermissionsTable.permissionId, permissionId),
          ),
        )
        .returning({ appId: appPermissionsTable.appId });
      if (del.length > 0) {
        await recordPermissionAudit(tx, {
          targetKind: "app",
          targetId: app.id,
          changeKind: "app.permissions",
          actorUserId: req.session.userId ?? null,
          previousIds,
          newIds: previousIds.filter((pid) => pid !== permissionId),
        });
      }
      return del;
    });

    if (deleted.length > 0) {
      await db.insert(auditLogsTable).values({
        actorUserId: req.session.userId ?? null,
        action: "app.permission.remove",
        targetType: "app",
        targetId: app.id,
        metadata: {
          slug: app.slug,
          nameEn: app.nameEn,
          permissionId,
          permissionName: perm?.name ?? null,
        },
      });
    }

    res.sendStatus(204);
  },
);

router.get(
  "/apps/: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 [app] = await db
      .select({ id: appsTable.id })
      .from(appsTable)
      .where(eq(appsTable.id, id));
    if (!app) {
      res.status(404).json({ error: "App not found" });
      return;
    }
    const filters = parsePermissionAuditFilters(req, res);
    if (!filters) return;
    const result = await listPermissionAudit({
      targetKind: "app",
      targetId: id,
      limit,
      offset,
      filters,
    });
    res.json(result);
  },
);

router.post("/apps/:id/open", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const params = GetAppParams.safeParse(req.params);
  if (!params.success) {
    res.status(400).json({ error: params.error.message });
    return;
  }

  const [app] = await db
    .select({ id: appsTable.id })
    .from(appsTable)
    .where(eq(appsTable.id, params.data.id));

  if (!app) {
    res.status(404).json({ error: "App not found" });
    return;
  }

  await db.insert(appOpensTable).values({ userId, appId: app.id });
  res.sendStatus(204);
});

router.delete("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
  const params = DeleteAppParams.safeParse(req.params);
  if (!params.success) {
    res.status(400).json({ error: params.error.message });
    return;
  }

  const appId = params.data.id;

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

  const force = req.query.force === "true" || req.query.force === "1";

  const [groupRow] = await db
    .select({ count: sql<number>`count(*)::int` })
    .from(groupAppsTable)
    .where(eq(groupAppsTable.appId, appId));
  const [restrictionRow] = await db
    .select({ count: sql<number>`count(*)::int` })
    .from(appPermissionsTable)
    .where(eq(appPermissionsTable.appId, appId));
  const [openRow] = await db
    .select({ count: sql<number>`count(*)::int` })
    .from(appOpensTable)
    .where(eq(appOpensTable.appId, appId));

  const groupCount = groupRow?.count ?? 0;
  const restrictionCount = restrictionRow?.count ?? 0;
  const openCount = openRow?.count ?? 0;
  const hasDeps = groupCount > 0 || restrictionCount > 0 || openCount > 0;

  if (hasDeps && !force) {
    res.status(409).json({
      error: "App has dependent records",
      groupCount,
      restrictionCount,
      openCount,
    });
    return;
  }

  await db.delete(appsTable).where(eq(appsTable.id, appId));

  await db.insert(auditLogsTable).values({
    actorUserId: req.session.userId ?? null,
    action: "app.delete",
    targetType: "app",
    targetId: appId,
    metadata: {
      slug: existing.slug,
      nameAr: existing.nameAr,
      nameEn: existing.nameEn,
      force,
      groupCount,
      restrictionCount,
      openCount,
    },
  });

  res.sendStatus(204);
});

export default router;
