import { Router, type IRouter } from "express";
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import {
  and,
  asc,
  desc,
  eq,
  gte,
  ilike,
  inArray,
  lt,
  lte,
  ne,
  or,
  sql,
} from "drizzle-orm";
import { z, type ZodType } from "zod";
import { db } from "@workspace/db";
import {
  protocolRoomsTable,
  protocolRoomBookingsTable,
  protocolExternalMeetingsTable,
  protocolGiftsTable,
  protocolGiftIssuesTable,
  protocolPhotoAlbumsTable,
  protocolPhotosTable,
  protocolAuditLogsTable,
  rolesTable,
  permissionsTable,
  rolePermissionsTable,
  usersTable,
  PROTOCOL_GIFT_KINDS,
} from "@workspace/db";
import {
  requireProtocolAccess,
  getEffectiveRoleIds,
  userHasPermission,
} from "../middlewares/auth";
import {
  ObjectStorageService,
  extensionForContentType,
} from "../lib/objectStorage";
import { ZipArchive } from "archiver";
import { Readable } from "stream";
import type { Request, Response, NextFunction } from "express";

const router: IRouter = Router();
const objectStorage = new ObjectStorageService();

router.param("id", (req, res, next, value) => {
  if (!/^\d+$/.test(String(value))) {
    next("route");
    return;
  }
  next();
});

// ---------------------------------------------------------------------------
// Permission helpers
// ---------------------------------------------------------------------------
// Scoped protocol permissions. Access to protocol routes is guarded by these
// permissions (seeded and mapped to the five protocol roles), never by role
// names — so an admin can delegate any capability by granting the permission.
const PROTOCOL_PERMS = {
  // Read + tile visibility. Kept in lockstep with the app_permissions grant so
  // backend usability matches exactly what makes the home-screen tile visible.
  read: "protocol.access",
  // Submit booking / issuance requests (created as "pending").
  request: "protocol.request",
  // Create/edit records directly (no approval needed to author).
  mutate: "protocol.mutate",
  // Approve/reject bookings and gift issuances.
  approve: "protocol.approve",
  // Manage the rooms registry.
  rooms: "protocol.rooms.manage",
  // View the audit log.
  audit: "protocol.audit.read",
} as const;

async function getRoleNamesForUser(userId: number): Promise<Set<string>> {
  const ids = await getEffectiveRoleIds(userId);
  if (ids.length === 0) return new Set();
  const rows = await db
    .select({ name: rolesTable.name })
    .from(rolesTable)
    .where(inArray(rolesTable.id, ids));
  return new Set(rows.map((r) => r.name));
}

// Effective protocol permission names held by the user (via any effective
// role). One query, used to compute the capabilities payload for /me.
async function getProtocolPermsForUser(userId: number): Promise<Set<string>> {
  const ids = await getEffectiveRoleIds(userId);
  if (ids.length === 0) return new Set();
  const rows = await db
    .select({ name: permissionsTable.name })
    .from(rolePermissionsTable)
    .innerJoin(
      permissionsTable,
      eq(rolePermissionsTable.permissionId, permissionsTable.id),
    )
    .where(
      and(
        inArray(rolePermissionsTable.roleId, ids),
        inArray(permissionsTable.name, Object.values(PROTOCOL_PERMS)),
      ),
    );
  return new Set(rows.map((r) => r.name));
}

function makeRequirePerm(perm: string) {
  return async function (
    req: Request,
    res: Response,
    next: NextFunction,
  ): Promise<void> {
    if (!req.session.userId) {
      res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
      return;
    }
    // Mirror requireProtocolAccess: a deactivated user with a still-valid
    // session must not be able to reach mutating/approval endpoints.
    const [user] = await db
      .select({ isActive: usersTable.isActive })
      .from(usersTable)
      .where(eq(usersTable.id, req.session.userId));
    if (!user || !user.isActive) {
      res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
      return;
    }
    const ok = await userHasPermission(req.session.userId, perm);
    if (!ok) {
      res.status(403).json({ error: "Forbidden", code: "forbidden" });
      return;
    }
    next();
  };
}

const requireRequest = makeRequirePerm(PROTOCOL_PERMS.request);
const requireMutate = makeRequirePerm(PROTOCOL_PERMS.mutate);
const requireApprove = makeRequirePerm(PROTOCOL_PERMS.approve);
const requireManageRooms = makeRequirePerm(PROTOCOL_PERMS.rooms);
const requireAudit = makeRequirePerm(PROTOCOL_PERMS.audit);

// ---------------------------------------------------------------------------
// Zod schemas
// ---------------------------------------------------------------------------
const isoDateTime = z.string().datetime({ offset: true });

function parseBody<T>(
  res: Response,
  schema: ZodType<T>,
  body: unknown,
): T | null {
  const r = schema.safeParse(body ?? {});
  if (!r.success) {
    const issue = r.error.issues[0];
    const msg = issue
      ? `${issue.path.join(".") || "body"}: ${issue.message}`
      : "validation";
    res.status(400).json({ error: msg, code: "validation" });
    return null;
  }
  return r.data;
}

const roomCreateSchema = z.object({
  nameAr: z.string().trim().min(1).max(200),
  nameEn: z.string().trim().max(200).optional().default(""),
  capacity: z.number().int().positive().max(100000).nullable().optional(),
  location: z.string().trim().max(300).nullable().optional(),
  isActive: z.boolean().optional(),
  sortOrder: z.number().int().min(0).max(100000).optional(),
});
const roomPatchSchema = roomCreateSchema.partial().refine(
  (v) => Object.keys(v).length > 0,
  { message: "no fields to update" },
);

// A single "key attendee" (أبرز الحضور) row.
const keyAttendeeSchema = z.object({
  name: z.string().trim().min(1).max(300),
  position: z.string().trim().max(300).default(""),
  side: z.enum(["internal", "external"]),
});
const keyAttendeesSchema = z.array(keyAttendeeSchema).max(100);

const bookingCreateSchema = z
  .object({
    roomId: z.number().int().positive(),
    title: z.string().trim().min(1).max(500),
    requesterName: z.string().trim().max(300).nullable().optional(),
    requesterPhone: z.string().trim().max(40).nullable().optional(),
    // The external entity name (اسم الجهة); only meaningful when
    // meetingType is "external".
    requesterOrg: z.string().trim().max(300).nullable().optional(),
    department: z.string().trim().max(300).nullable().optional(),
    meetingType: z.enum(["internal", "external"]).default("internal"),
    purpose: z.string().trim().max(5000).nullable().optional(),
    attendeeCount: z.number().int().positive().max(100000).nullable().optional(),
    keyAttendees: keyAttendeesSchema.optional().default([]),
    startsAt: isoDateTime,
    endsAt: isoDateTime,
    notes: z.string().trim().max(5000).nullable().optional(),
  })
  .refine((v) => new Date(v.startsAt) < new Date(v.endsAt), {
    message: "startsAt must be before endsAt",
    path: ["endsAt"],
  });

const bookingPatchSchema = z
  .object({
    roomId: z.number().int().positive().optional(),
    title: z.string().trim().min(1).max(500).optional(),
    requesterName: z.string().trim().max(300).nullable().optional(),
    requesterPhone: z.string().trim().max(40).nullable().optional(),
    requesterOrg: z.string().trim().max(300).nullable().optional(),
    department: z.string().trim().max(300).nullable().optional(),
    meetingType: z.enum(["internal", "external"]).optional(),
    purpose: z.string().trim().max(5000).nullable().optional(),
    attendeeCount: z.number().int().positive().max(100000).nullable().optional(),
    keyAttendees: keyAttendeesSchema.optional(),
    startsAt: isoDateTime.optional(),
    endsAt: isoDateTime.optional(),
    notes: z.string().trim().max(5000).nullable().optional(),
  })
  .refine((v) => Object.keys(v).length > 0, { message: "no fields to update" });

// Public (no-login) booking request. Stricter than the internal schema:
// full name, contact phone and title are all REQUIRED, and a hidden
// honeypot field (`website`) must stay empty — a filled honeypot is a bot.
const publicBookingRequestSchema = z
  .object({
    roomId: z.number().int().positive(),
    title: z.string().trim().min(1).max(500),
    requesterName: z.string().trim().min(1).max(300),
    requesterPhone: z.string().trim().min(3).max(40),
    // The external entity name (اسم الجهة). Required when meetingType is
    // "external"; ignored/null for internal meetings.
    requesterOrg: z.string().trim().max(300).nullable().optional(),
    department: z.string().trim().max(300).nullable().optional(),
    meetingType: z.enum(["internal", "external"]).default("internal"),
    purpose: z.string().trim().max(5000).nullable().optional(),
    attendeeCount: z.number().int().positive().max(100000).nullable().optional(),
    keyAttendees: keyAttendeesSchema.optional().default([]),
    startsAt: isoDateTime,
    endsAt: isoDateTime,
    notes: z.string().trim().max(5000).nullable().optional(),
    // Honeypot: real users never see or fill this. We accept any value at
    // the schema level (so a bot gets no "invalid field" signal) and handle
    // a non-empty value in the route by silently pretending success.
    website: z.string().max(200).optional(),
  })
  .refine((v) => new Date(v.startsAt) < new Date(v.endsAt), {
    message: "startsAt must be before endsAt",
    path: ["endsAt"],
  })
  // When the meeting is with an external entity, its name (اسم الجهة) is
  // required.
  .refine(
    (v) =>
      v.meetingType !== "external" ||
      (typeof v.requesterOrg === "string" && v.requesterOrg.trim().length > 0),
    {
      message: "requesterOrg is required for external meetings",
      path: ["requesterOrg"],
    },
  );

const rejectSchema = z.object({
  reason: z.string().trim().max(1000).nullable().optional(),
});

const externalCreateSchema = z
  .object({
    title: z.string().trim().min(1).max(500),
    partyName: z.string().trim().max(300).nullable().optional(),
    location: z.string().trim().max(300).nullable().optional(),
    startsAt: isoDateTime,
    endsAt: isoDateTime.nullable().optional(),
    notes: z.string().trim().max(5000).nullable().optional(),
  })
  .refine(
    (v) => !v.endsAt || new Date(v.startsAt) < new Date(v.endsAt),
    { message: "startsAt must be before endsAt", path: ["endsAt"] },
  );

// Edits may only mark an already-decided meeting completed/cancelled; the
// pending -> approved | rejected transition goes through the dedicated
// approve/reject endpoints so it stays state-conditional.
const externalPatchSchema = z
  .object({
    title: z.string().trim().min(1).max(500).optional(),
    partyName: z.string().trim().max(300).nullable().optional(),
    location: z.string().trim().max(300).nullable().optional(),
    startsAt: isoDateTime.optional(),
    endsAt: isoDateTime.nullable().optional(),
    status: z.enum(["completed", "cancelled"]).optional(),
    notes: z.string().trim().max(5000).nullable().optional(),
  })
  .refine((v) => Object.keys(v).length > 0, { message: "no fields to update" });

const giftCreateSchema = z.object({
  nameAr: z.string().trim().min(1).max(300),
  nameEn: z.string().trim().max(300).optional().default(""),
  kind: z.enum(PROTOCOL_GIFT_KINDS).optional(),
  descriptionAr: z.string().trim().max(5000).nullable().optional(),
  descriptionEn: z.string().trim().max(5000).nullable().optional(),
  imageUrl: z.string().trim().max(500).nullable().optional(),
  quantityInStock: z.number().int().min(0).max(1000000).optional(),
  isActive: z.boolean().optional(),
});
const giftPatchSchema = giftCreateSchema.partial().refine(
  (v) => Object.keys(v).length > 0,
  { message: "no fields to update" },
);

const issueCreateSchema = z.object({
  giftId: z.number().int().positive(),
  recipientName: z.string().trim().min(1).max(300),
  occasion: z.string().trim().max(300).nullable().optional(),
  quantity: z.number().int().positive().max(100000).optional(),
  notes: z.string().trim().max(5000).nullable().optional(),
});

// ---------------------------------------------------------------------------
// Audit helper
// ---------------------------------------------------------------------------
type DbExecutor = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];

async function logAudit(
  executor: DbExecutor,
  opts: {
    action: string;
    entityType: string;
    entityId: number | null;
    oldValue?: unknown;
    newValue?: unknown;
    performedBy: number | null;
  },
): Promise<void> {
  await executor.insert(protocolAuditLogsTable).values({
    action: opts.action,
    entityType: opts.entityType,
    entityId: opts.entityId ?? null,
    oldValue: (opts.oldValue ?? null) as never,
    newValue: (opts.newValue ?? null) as never,
    performedBy: opts.performedBy,
  });
}

// Overlap / conflict rule: a booking occupies [startsAt, endsAt). Two
// bookings for the same room conflict when
//   newStart < existingEnd AND newEnd > existingStart
// Only "pending" and "approved" rows count as occupying the room.
const OCCUPYING_STATUSES = ["pending", "approved"] as const;
const CONFLICT_MESSAGE_AR =
  "لا يمكن إتمام الحجز، توجد حجز آخر لنفس القاعة في نفس الوقت.";

// Serialize all booking mutations for a given room by taking a row-level
// lock on the room inside the transaction. Any concurrent create / edit /
// approve for the same room blocks until this transaction commits, which
// makes the check-then-write conflict rule atomic without needing a
// btree_gist exclusion constraint (additive-only migration requirement).
async function lockRoomRow(
  tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
  roomId: number,
): Promise<boolean> {
  const rows = await tx
    .select({ id: protocolRoomsTable.id })
    .from(protocolRoomsTable)
    .where(eq(protocolRoomsTable.id, roomId))
    .for("update");
  return rows.length > 0;
}

// Build a human-friendly booking reference (رقم الحجز) from a row id and its
// creation date, e.g. "RB-2026-0042". Deterministic + unique because the id
// is unique.
function makeBookingReference(id: number, when: Date): string {
  return `RB-${when.getFullYear()}-${String(id).padStart(4, "0")}`;
}

// Reserve the next booking id from the table's serial sequence so we can
// compute the booking reference and store it in the same INSERT (the column
// is NOT NULL). Advancing the sequence here keeps it in sync with the
// explicit id we insert.
async function reserveBookingId(
  tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
): Promise<number> {
  const result = await tx.execute(
    sql`SELECT nextval(pg_get_serial_sequence('protocol_room_bookings', 'id')) AS id`,
  );
  const rows = (result as unknown as { rows: Array<{ id: number | string }> })
    .rows;
  return Number(rows[0].id);
}

async function hasBookingConflict(
  executor: DbExecutor,
  opts: {
    roomId: number;
    startsAt: Date;
    endsAt: Date;
    excludeId?: number;
    statuses?: ReadonlyArray<string>;
  },
): Promise<boolean> {
  const statuses = opts.statuses ?? OCCUPYING_STATUSES;
  const conds = [
    eq(protocolRoomBookingsTable.roomId, opts.roomId),
    inArray(protocolRoomBookingsTable.status, statuses as string[]),
    sql`${protocolRoomBookingsTable.startsAt} < ${opts.endsAt.toISOString()}`,
    sql`${protocolRoomBookingsTable.endsAt} > ${opts.startsAt.toISOString()}`,
  ];
  if (opts.excludeId !== undefined) {
    conds.push(ne(protocolRoomBookingsTable.id, opts.excludeId));
  }
  const rows = await executor
    .select({ id: protocolRoomBookingsTable.id })
    .from(protocolRoomBookingsTable)
    .where(and(...conds))
    .limit(1);
  return rows.length > 0;
}

// ---------------------------------------------------------------------------
// Capabilities
// ---------------------------------------------------------------------------
router.get(
  "/protocol/me",
  requireProtocolAccess,
  async (req: Request, res: Response) => {
    const userId = req.session.userId!;
    const [names, perms] = await Promise.all([
      getRoleNamesForUser(userId),
      getProtocolPermsForUser(userId),
    ]);
    res.json({
      userId,
      roles: Array.from(names),
      canRead: true,
      canRequest: perms.has(PROTOCOL_PERMS.request),
      canMutate: perms.has(PROTOCOL_PERMS.mutate),
      canApprove: perms.has(PROTOCOL_PERMS.approve),
      canManageRooms: perms.has(PROTOCOL_PERMS.rooms),
      canViewAudit: perms.has(PROTOCOL_PERMS.audit),
    });
  },
);

// ---------------------------------------------------------------------------
// Public (no-login) booking request link
//
// These two endpoints power the guest-facing room booking request form. They
// are intentionally NOT guarded by requireProtocolAccess so anyone with the
// link can pick a room and submit a request. Requests are always created as
// `pending` + `source = "public"` with no createdBy, and land in the exact
// same Protocol staff confirm/reject flow as internal pending bookings.
//
// Abuse is limited by (a) an IP rate limiter, (b) a hidden honeypot field,
// and (c) never exposing the room schedule — a guest only sees the active
// room list, never who booked what or when.
// ---------------------------------------------------------------------------
const publicBookingLimiter = rateLimit({
  windowMs: 10 * 60 * 1000,
  max: 8,
  standardHeaders: true,
  legacyHeaders: false,
  // Key on the proxy-derived client IP. The app sets `trust proxy = 1`, so
  // `req.ip` is the address inserted by the single trusted edge proxy and is
  // NOT client-spoofable via X-Forwarded-For. `ipKeyGenerator` also applies
  // the correct IPv6 subnet normalization so a single IPv6 client cannot
  // rotate through addresses within its /64 to defeat the limit.
  keyGenerator: (req: Request) => ipKeyGenerator(req.ip ?? ""),
  message: {
    error: "لقد أرسلت طلبات كثيرة، يرجى المحاولة لاحقاً.",
    code: "rate_limited",
  },
});

router.get(
  "/protocol/public/rooms",
  async (_req: Request, res: Response) => {
    // Only the minimal fields a guest needs to choose a room. No capacity /
    // location / audit fields, and only active rooms.
    const rows = await db
      .select({
        id: protocolRoomsTable.id,
        nameAr: protocolRoomsTable.nameAr,
        nameEn: protocolRoomsTable.nameEn,
      })
      .from(protocolRoomsTable)
      .where(eq(protocolRoomsTable.isActive, true))
      .orderBy(asc(protocolRoomsTable.sortOrder), asc(protocolRoomsTable.id));
    res.json(rows);
  },
);

router.post(
  "/protocol/public/booking-requests",
  publicBookingLimiter,
  async (req: Request, res: Response) => {
    const body = parseBody(res, publicBookingRequestSchema, req.body);
    if (!body) return;
    // Honeypot tripped — pretend success so bots get no signal, but never
    // write anything.
    if (body.website) {
      res.status(201).json({ ok: true });
      return;
    }
    const startsAt = new Date(body.startsAt);
    const endsAt = new Date(body.endsAt);

    const [room] = await db
      .select({ id: protocolRoomsTable.id, isActive: protocolRoomsTable.isActive })
      .from(protocolRoomsTable)
      .where(eq(protocolRoomsTable.id, body.roomId));
    if (!room || !room.isActive) {
      res.status(400).json({ error: "room not found", code: "invalid_room" });
      return;
    }

    try {
      const created = await db.transaction(async (tx) => {
        if (!(await lockRoomRow(tx, body.roomId))) {
          throw new Error("__room_missing__");
        }
        if (
          await hasBookingConflict(tx, {
            roomId: body.roomId,
            startsAt,
            endsAt,
          })
        ) {
          throw new Error("__conflict__");
        }
        const isExternal = body.meetingType === "external";
        const bookingId = await reserveBookingId(tx);
        const [row] = await tx
          .insert(protocolRoomBookingsTable)
          .values({
            id: bookingId,
            roomId: body.roomId,
            title: body.title,
            requesterName: body.requesterName,
            requesterPhone: body.requesterPhone,
            requesterOrg: isExternal ? (body.requesterOrg ?? null) : null,
            department: body.department ?? null,
            meetingType: body.meetingType,
            purpose: body.purpose ?? null,
            attendeeCount: body.attendeeCount ?? null,
            keyAttendees: (body.keyAttendees ?? []).map((a) => ({
              name: a.name,
              position: a.position ?? "",
              side: a.side,
            })),
            bookingReference: makeBookingReference(bookingId, new Date()),
            startsAt,
            endsAt,
            status: "pending",
            source: "public",
            notes: body.notes ?? null,
            createdBy: null,
          })
          .returning();
        await logAudit(tx, {
          action: "booking.public_request",
          entityType: "booking",
          entityId: row.id,
          newValue: row,
          performedBy: null,
        });
        return row;
      });
      // Deliberately return only an acknowledgement plus the booking
      // reference — never the full stored row (which could leak internal ids
      // / other data) to an anonymous caller.
      res
        .status(201)
        .json({ ok: true, id: created.id, bookingReference: created.bookingReference });
    } catch (e) {
      if (e instanceof Error && e.message === "__conflict__") {
        res
          .status(409)
          .json({ error: CONFLICT_MESSAGE_AR, code: "booking_conflict" });
        return;
      }
      if (e instanceof Error && e.message === "__room_missing__") {
        res.status(400).json({ error: "room not found", code: "invalid_room" });
        return;
      }
      throw e;
    }
  },
);

// ---------------------------------------------------------------------------
// Rooms
// ---------------------------------------------------------------------------
router.get(
  "/protocol/rooms",
  requireProtocolAccess,
  async (_req: Request, res: Response) => {
    const rows = await db
      .select()
      .from(protocolRoomsTable)
      .orderBy(asc(protocolRoomsTable.sortOrder), asc(protocolRoomsTable.id));
    res.json(rows);
  },
);

router.post(
  "/protocol/rooms",
  requireManageRooms,
  async (req: Request, res: Response) => {
    const body = parseBody(res, roomCreateSchema, req.body);
    if (!body) return;
    const [row] = await db
      .insert(protocolRoomsTable)
      .values({
        nameAr: body.nameAr,
        nameEn: body.nameEn ?? "",
        capacity: body.capacity ?? null,
        location: body.location ?? null,
        isActive: body.isActive ?? true,
        sortOrder: body.sortOrder ?? 0,
        createdBy: req.session.userId!,
      })
      .returning();
    await logAudit(db, {
      action: "room.create",
      entityType: "room",
      entityId: row.id,
      newValue: row,
      performedBy: req.session.userId!,
    });
    res.status(201).json(row);
  },
);

router.patch(
  "/protocol/rooms/:id",
  requireManageRooms,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const body = parseBody(res, roomPatchSchema, req.body);
    if (!body) return;
    const [existing] = await db
      .select()
      .from(protocolRoomsTable)
      .where(eq(protocolRoomsTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const [row] = await db
      .update(protocolRoomsTable)
      .set({
        ...(body.nameAr !== undefined ? { nameAr: body.nameAr } : {}),
        ...(body.nameEn !== undefined ? { nameEn: body.nameEn } : {}),
        ...(body.capacity !== undefined ? { capacity: body.capacity } : {}),
        ...(body.location !== undefined ? { location: body.location } : {}),
        ...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
        ...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
      })
      .where(eq(protocolRoomsTable.id, id))
      .returning();
    await logAudit(db, {
      action: "room.update",
      entityType: "room",
      entityId: id,
      oldValue: existing,
      newValue: row,
      performedBy: req.session.userId!,
    });
    res.json(row);
  },
);

router.delete(
  "/protocol/rooms/:id",
  requireManageRooms,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const [existing] = await db
      .select()
      .from(protocolRoomsTable)
      .where(eq(protocolRoomsTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    await db.delete(protocolRoomsTable).where(eq(protocolRoomsTable.id, id));
    await logAudit(db, {
      action: "room.delete",
      entityType: "room",
      entityId: id,
      oldValue: existing,
      performedBy: req.session.userId!,
    });
    res.status(204).end();
  },
);

// ---------------------------------------------------------------------------
// Bookings
// ---------------------------------------------------------------------------
router.get(
  "/protocol/bookings",
  requireProtocolAccess,
  async (req: Request, res: Response) => {
    const roomIdRaw = req.query.roomId;
    const statusRaw = req.query.status;
    const fromRaw = req.query.from;
    const toRaw = req.query.to;
    const conds = [];
    if (typeof roomIdRaw === "string" && /^\d+$/.test(roomIdRaw)) {
      conds.push(eq(protocolRoomBookingsTable.roomId, Number(roomIdRaw)));
    }
    if (
      typeof statusRaw === "string" &&
      ["pending", "approved", "rejected", "cancelled"].includes(statusRaw)
    ) {
      conds.push(eq(protocolRoomBookingsTable.status, statusRaw));
    }
    if (typeof fromRaw === "string" && !Number.isNaN(Date.parse(fromRaw))) {
      conds.push(
        gte(protocolRoomBookingsTable.startsAt, new Date(fromRaw)),
      );
    }
    if (typeof toRaw === "string" && !Number.isNaN(Date.parse(toRaw))) {
      conds.push(lte(protocolRoomBookingsTable.startsAt, new Date(toRaw)));
    }
    // Free-text staff search by booking reference (رقم الحجز) OR requester
    // phone. Both are indexed; matched case-insensitively as a substring.
    const qRaw = req.query.q;
    if (typeof qRaw === "string" && qRaw.trim() !== "") {
      const term = `%${qRaw.trim()}%`;
      conds.push(
        or(
          ilike(protocolRoomBookingsTable.bookingReference, term),
          ilike(protocolRoomBookingsTable.requesterPhone, term),
        )!,
      );
    }
    const rows = await db
      .select()
      .from(protocolRoomBookingsTable)
      .where(conds.length > 0 ? and(...conds) : undefined)
      .orderBy(desc(protocolRoomBookingsTable.startsAt));
    res.json(rows);
  },
);

router.post(
  "/protocol/bookings",
  requireRequest,
  async (req: Request, res: Response) => {
    const body = parseBody(res, bookingCreateSchema, req.body);
    if (!body) return;
    const startsAt = new Date(body.startsAt);
    const endsAt = new Date(body.endsAt);

    const [room] = await db
      .select({ id: protocolRoomsTable.id, isActive: protocolRoomsTable.isActive })
      .from(protocolRoomsTable)
      .where(eq(protocolRoomsTable.id, body.roomId));
    if (!room) {
      res.status(400).json({ error: "room not found", code: "invalid_room" });
      return;
    }

    // Non-approvers submit requests as "pending"; users with the approve
    // permission may create an already-approved booking directly. Either way
    // the slot is checked.
    const canApprove = await userHasPermission(
      req.session.userId!,
      PROTOCOL_PERMS.approve,
    );

    try {
      const created = await db.transaction(async (tx) => {
        // Lock the room to serialize concurrent bookings, then check.
        if (!(await lockRoomRow(tx, body.roomId))) {
          throw new Error("__room_missing__");
        }
        if (
          await hasBookingConflict(tx, {
            roomId: body.roomId,
            startsAt,
            endsAt,
          })
        ) {
          throw new Error("__conflict__");
        }
        const bookingId = await reserveBookingId(tx);
        const [row] = await tx
          .insert(protocolRoomBookingsTable)
          .values({
            id: bookingId,
            roomId: body.roomId,
            title: body.title,
            requesterName: body.requesterName ?? null,
            requesterPhone: body.requesterPhone ?? null,
            requesterOrg:
              body.meetingType === "external" ? (body.requesterOrg ?? null) : null,
            department: body.department ?? null,
            meetingType: body.meetingType,
            purpose: body.purpose ?? null,
            attendeeCount: body.attendeeCount ?? null,
            keyAttendees: (body.keyAttendees ?? []).map((a) => ({
              name: a.name,
              position: a.position ?? "",
              side: a.side,
            })),
            bookingReference: makeBookingReference(bookingId, new Date()),
            startsAt,
            endsAt,
            status: canApprove ? "approved" : "pending",
            notes: body.notes ?? null,
            createdBy: req.session.userId!,
            approvedBy: canApprove ? req.session.userId! : null,
            approvedAt: canApprove ? new Date() : null,
          })
          .returning();
        await logAudit(tx, {
          action: "booking.create",
          entityType: "booking",
          entityId: row.id,
          newValue: row,
          performedBy: req.session.userId!,
        });
        return row;
      });
      res.status(201).json(created);
    } catch (e) {
      if (e instanceof Error && e.message === "__conflict__") {
        res
          .status(409)
          .json({ error: CONFLICT_MESSAGE_AR, code: "booking_conflict" });
        return;
      }
      if (e instanceof Error && e.message === "__room_missing__") {
        res.status(400).json({ error: "room not found", code: "invalid_room" });
        return;
      }
      throw e;
    }
  },
);

router.patch(
  "/protocol/bookings/:id",
  requireMutate,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const body = parseBody(res, bookingPatchSchema, req.body);
    if (!body) return;
    const [existing] = await db
      .select()
      .from(protocolRoomBookingsTable)
      .where(eq(protocolRoomBookingsTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const roomId = body.roomId ?? existing.roomId;
    const startsAt = body.startsAt ? new Date(body.startsAt) : existing.startsAt;
    const endsAt = body.endsAt ? new Date(body.endsAt) : existing.endsAt;
    if (startsAt >= endsAt) {
      res
        .status(400)
        .json({ error: "startsAt must be before endsAt", code: "validation" });
      return;
    }
    try {
      const updated = await db.transaction(async (tx) => {
        // Lock the (possibly new) room to serialize against concurrent
        // bookings before the overlap re-check.
        if (!(await lockRoomRow(tx, roomId))) {
          throw new Error("__room_missing__");
        }
        // Only re-check the slot if the booking still occupies the room.
        if (
          (existing.status === "pending" || existing.status === "approved") &&
          (await hasBookingConflict(tx, {
            roomId,
            startsAt,
            endsAt,
            excludeId: id,
          }))
        ) {
          throw new Error("__conflict__");
        }
        const [row] = await tx
          .update(protocolRoomBookingsTable)
          .set({
            roomId,
            ...(body.title !== undefined ? { title: body.title } : {}),
            ...(body.requesterName !== undefined
              ? { requesterName: body.requesterName }
              : {}),
            ...(body.requesterPhone !== undefined
              ? { requesterPhone: body.requesterPhone }
              : {}),
            ...(body.meetingType !== undefined
              ? { meetingType: body.meetingType }
              : {}),
            ...(body.requesterOrg !== undefined || body.meetingType !== undefined
              ? {
                  requesterOrg:
                    (body.meetingType ?? existing.meetingType) === "external"
                      ? (body.requesterOrg !== undefined
                          ? body.requesterOrg
                          : existing.requesterOrg)
                      : null,
                }
              : {}),
            ...(body.department !== undefined
              ? { department: body.department }
              : {}),
            ...(body.purpose !== undefined ? { purpose: body.purpose } : {}),
            ...(body.attendeeCount !== undefined
              ? { attendeeCount: body.attendeeCount }
              : {}),
            ...(body.keyAttendees !== undefined
              ? {
                  keyAttendees: body.keyAttendees.map((a) => ({
                    name: a.name,
                    position: a.position ?? "",
                    side: a.side,
                  })),
                }
              : {}),
            startsAt,
            endsAt,
            ...(body.notes !== undefined ? { notes: body.notes } : {}),
          })
          .where(eq(protocolRoomBookingsTable.id, id))
          .returning();
        await logAudit(tx, {
          action: "booking.update",
          entityType: "booking",
          entityId: id,
          oldValue: existing,
          newValue: row,
          performedBy: req.session.userId!,
        });
        return row;
      });
      res.json(updated);
    } catch (e) {
      if (e instanceof Error && e.message === "__conflict__") {
        res
          .status(409)
          .json({ error: CONFLICT_MESSAGE_AR, code: "booking_conflict" });
        return;
      }
      if (e instanceof Error && e.message === "__room_missing__") {
        res.status(400).json({ error: "room not found", code: "invalid_room" });
        return;
      }
      throw e;
    }
  },
);

router.post(
  "/protocol/bookings/:id/approve",
  requireApprove,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    try {
      const updated = await db.transaction(async (tx) => {
        // Re-read the booking inside the transaction so status checks and
        // the conflict guard see a consistent snapshot.
        const [existing] = await tx
          .select()
          .from(protocolRoomBookingsTable)
          .where(eq(protocolRoomBookingsTable.id, id));
        if (!existing) throw new Error("__not_found__");
        // Lock the room to serialize against concurrent approvals/bookings.
        await lockRoomRow(tx, existing.roomId);
        if (existing.status !== "pending") {
          throw new Error("__invalid_status__");
        }
        // Guard against approving two overlapping pending requests: only
        // other APPROVED bookings block the approval here.
        if (
          await hasBookingConflict(tx, {
            roomId: existing.roomId,
            startsAt: existing.startsAt,
            endsAt: existing.endsAt,
            excludeId: id,
            statuses: ["approved"],
          })
        ) {
          throw new Error("__conflict__");
        }
        // State-conditional update: only flip a still-pending row.
        const [row] = await tx
          .update(protocolRoomBookingsTable)
          .set({
            status: "approved",
            approvedBy: req.session.userId!,
            approvedAt: new Date(),
            rejectionReason: null,
          })
          .where(
            and(
              eq(protocolRoomBookingsTable.id, id),
              eq(protocolRoomBookingsTable.status, "pending"),
            ),
          )
          .returning();
        if (!row) throw new Error("__invalid_status__");
        await logAudit(tx, {
          action: "booking.approve",
          entityType: "booking",
          entityId: id,
          oldValue: existing,
          newValue: row,
          performedBy: req.session.userId!,
        });
        return row;
      });
      res.json(updated);
    } catch (e) {
      if (e instanceof Error) {
        if (e.message === "__conflict__") {
          res
            .status(409)
            .json({ error: CONFLICT_MESSAGE_AR, code: "booking_conflict" });
          return;
        }
        if (e.message === "__not_found__") {
          res.status(404).json({ error: "Not found", code: "not_found" });
          return;
        }
        if (e.message === "__invalid_status__") {
          res
            .status(409)
            .json({ error: "booking is not pending", code: "invalid_status" });
          return;
        }
      }
      throw e;
    }
  },
);

router.post(
  "/protocol/bookings/:id/reject",
  requireApprove,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const body = parseBody(res, rejectSchema, req.body);
    if (!body) return;
    const [existing] = await db
      .select()
      .from(protocolRoomBookingsTable)
      .where(eq(protocolRoomBookingsTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    // Only a pending booking can be rejected; the state-conditional update
    // makes this atomic against a concurrent approve/cancel.
    const [row] = await db
      .update(protocolRoomBookingsTable)
      .set({
        status: "rejected",
        approvedBy: req.session.userId!,
        approvedAt: new Date(),
        rejectionReason: body.reason ?? null,
      })
      .where(
        and(
          eq(protocolRoomBookingsTable.id, id),
          eq(protocolRoomBookingsTable.status, "pending"),
        ),
      )
      .returning();
    if (!row) {
      res
        .status(409)
        .json({ error: "booking is not pending", code: "invalid_status" });
      return;
    }
    await logAudit(db, {
      action: "booking.reject",
      entityType: "booking",
      entityId: id,
      oldValue: existing,
      newValue: row,
      performedBy: req.session.userId!,
    });
    res.json(row);
  },
);

router.post(
  "/protocol/bookings/:id/cancel",
  requireMutate,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const [existing] = await db
      .select()
      .from(protocolRoomBookingsTable)
      .where(eq(protocolRoomBookingsTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const [row] = await db
      .update(protocolRoomBookingsTable)
      .set({ status: "cancelled" })
      .where(eq(protocolRoomBookingsTable.id, id))
      .returning();
    await logAudit(db, {
      action: "booking.cancel",
      entityType: "booking",
      entityId: id,
      oldValue: existing,
      newValue: row,
      performedBy: req.session.userId!,
    });
    res.json(row);
  },
);

router.delete(
  "/protocol/bookings/:id",
  requireMutate,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const [existing] = await db
      .select()
      .from(protocolRoomBookingsTable)
      .where(eq(protocolRoomBookingsTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    await db
      .delete(protocolRoomBookingsTable)
      .where(eq(protocolRoomBookingsTable.id, id));
    await logAudit(db, {
      action: "booking.delete",
      entityType: "booking",
      entityId: id,
      oldValue: existing,
      performedBy: req.session.userId!,
    });
    res.status(204).end();
  },
);

// Bulk delete. One transaction: fetch existing rows (for audit), delete them
// all, and write one audit row per deleted booking. Ids that don't exist are
// simply skipped and reported back so the client can toast "deleted X of Y".
const bulkDeleteSchema = z.object({
  ids: z.array(z.number().int().positive()).min(1).max(500),
});

router.post(
  "/protocol/bookings/bulk-delete",
  requireMutate,
  async (req: Request, res: Response) => {
    const body = parseBody(res, bulkDeleteSchema, req.body);
    if (!body) return;
    const ids = Array.from(new Set(body.ids));
    const deletedIds = await db.transaction(async (tx) => {
      const existing = await tx
        .select()
        .from(protocolRoomBookingsTable)
        .where(inArray(protocolRoomBookingsTable.id, ids));
      if (existing.length === 0) return [] as number[];
      const foundIds = existing.map((r) => r.id);
      await tx
        .delete(protocolRoomBookingsTable)
        .where(inArray(protocolRoomBookingsTable.id, foundIds));
      for (const row of existing) {
        await logAudit(tx, {
          action: "booking.delete",
          entityType: "booking",
          entityId: row.id,
          oldValue: row,
          performedBy: req.session.userId!,
        });
      }
      return foundIds;
    });
    res.json({ requested: ids.length, deleted: deletedIds.length, deletedIds });
  },
);

// ---------------------------------------------------------------------------
// External meetings
// ---------------------------------------------------------------------------
router.get(
  "/protocol/external-meetings",
  requireProtocolAccess,
  async (_req: Request, res: Response) => {
    const rows = await db
      .select()
      .from(protocolExternalMeetingsTable)
      .orderBy(desc(protocolExternalMeetingsTable.startsAt));
    res.json(rows);
  },
);

router.post(
  "/protocol/external-meetings",
  requireMutate,
  async (req: Request, res: Response) => {
    const body = parseBody(res, externalCreateSchema, req.body);
    if (!body) return;
    const [row] = await db
      .insert(protocolExternalMeetingsTable)
      .values({
        title: body.title,
        partyName: body.partyName ?? null,
        location: body.location ?? null,
        startsAt: new Date(body.startsAt),
        endsAt: body.endsAt ? new Date(body.endsAt) : null,
        status: "pending",
        notes: body.notes ?? null,
        createdBy: req.session.userId!,
      })
      .returning();
    await logAudit(db, {
      action: "external_meeting.create",
      entityType: "external_meeting",
      entityId: row.id,
      newValue: row,
      performedBy: req.session.userId!,
    });
    res.status(201).json(row);
  },
);

router.patch(
  "/protocol/external-meetings/:id",
  requireMutate,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const body = parseBody(res, externalPatchSchema, req.body);
    if (!body) return;
    const [existing] = await db
      .select()
      .from(protocolExternalMeetingsTable)
      .where(eq(protocolExternalMeetingsTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const [row] = await db
      .update(protocolExternalMeetingsTable)
      .set({
        ...(body.title !== undefined ? { title: body.title } : {}),
        ...(body.partyName !== undefined ? { partyName: body.partyName } : {}),
        ...(body.location !== undefined ? { location: body.location } : {}),
        ...(body.startsAt !== undefined
          ? { startsAt: new Date(body.startsAt) }
          : {}),
        ...(body.endsAt !== undefined
          ? { endsAt: body.endsAt ? new Date(body.endsAt) : null }
          : {}),
        ...(body.status !== undefined ? { status: body.status } : {}),
        ...(body.notes !== undefined ? { notes: body.notes } : {}),
      })
      .where(eq(protocolExternalMeetingsTable.id, id))
      .returning();
    await logAudit(db, {
      action: "external_meeting.update",
      entityType: "external_meeting",
      entityId: id,
      oldValue: existing,
      newValue: row,
      performedBy: req.session.userId!,
    });
    res.json(row);
  },
);

router.delete(
  "/protocol/external-meetings/:id",
  requireMutate,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const [existing] = await db
      .select()
      .from(protocolExternalMeetingsTable)
      .where(eq(protocolExternalMeetingsTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    await db
      .delete(protocolExternalMeetingsTable)
      .where(eq(protocolExternalMeetingsTable.id, id));
    await logAudit(db, {
      action: "external_meeting.delete",
      entityType: "external_meeting",
      entityId: id,
      oldValue: existing,
      performedBy: req.session.userId!,
    });
    res.status(204).end();
  },
);

router.post(
  "/protocol/external-meetings/:id/approve",
  requireApprove,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const [existing] = await db
      .select()
      .from(protocolExternalMeetingsTable)
      .where(eq(protocolExternalMeetingsTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    // State-conditional claim: only a pending meeting flips to approved.
    const [row] = await db
      .update(protocolExternalMeetingsTable)
      .set({
        status: "approved",
        approvedBy: req.session.userId!,
        approvedAt: new Date(),
        rejectionReason: null,
      })
      .where(
        and(
          eq(protocolExternalMeetingsTable.id, id),
          eq(protocolExternalMeetingsTable.status, "pending"),
        ),
      )
      .returning();
    if (!row) {
      res
        .status(409)
        .json({ error: "meeting is not pending", code: "invalid_status" });
      return;
    }
    await logAudit(db, {
      action: "external_meeting.approve",
      entityType: "external_meeting",
      entityId: id,
      oldValue: existing,
      newValue: row,
      performedBy: req.session.userId!,
    });
    res.json(row);
  },
);

router.post(
  "/protocol/external-meetings/:id/reject",
  requireApprove,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const body = parseBody(res, rejectSchema, req.body);
    if (!body) return;
    const [existing] = await db
      .select()
      .from(protocolExternalMeetingsTable)
      .where(eq(protocolExternalMeetingsTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const [row] = await db
      .update(protocolExternalMeetingsTable)
      .set({
        status: "rejected",
        approvedBy: req.session.userId!,
        approvedAt: new Date(),
        rejectionReason: body.reason ?? null,
      })
      .where(
        and(
          eq(protocolExternalMeetingsTable.id, id),
          eq(protocolExternalMeetingsTable.status, "pending"),
        ),
      )
      .returning();
    if (!row) {
      res
        .status(409)
        .json({ error: "meeting is not pending", code: "invalid_status" });
      return;
    }
    await logAudit(db, {
      action: "external_meeting.reject",
      entityType: "external_meeting",
      entityId: id,
      oldValue: existing,
      newValue: row,
      performedBy: req.session.userId!,
    });
    res.json(row);
  },
);

// ---------------------------------------------------------------------------
// Gifts & shields catalogue
// ---------------------------------------------------------------------------
router.get(
  "/protocol/gifts",
  requireProtocolAccess,
  async (req: Request, res: Response) => {
    const kindRaw = req.query.kind;
    const conds = [];
    if (
      typeof kindRaw === "string" &&
      (PROTOCOL_GIFT_KINDS as readonly string[]).includes(kindRaw)
    ) {
      conds.push(eq(protocolGiftsTable.kind, kindRaw));
    }
    const rows = await db
      .select()
      .from(protocolGiftsTable)
      .where(conds.length > 0 ? and(...conds) : undefined)
      .orderBy(asc(protocolGiftsTable.nameAr));
    res.json(rows);
  },
);

router.post(
  "/protocol/gifts",
  requireMutate,
  async (req: Request, res: Response) => {
    const body = parseBody(res, giftCreateSchema, req.body);
    if (!body) return;
    const [row] = await db
      .insert(protocolGiftsTable)
      .values({
        nameAr: body.nameAr,
        nameEn: body.nameEn ?? "",
        kind: body.kind ?? "gift",
        descriptionAr: body.descriptionAr ?? null,
        descriptionEn: body.descriptionEn ?? null,
        imageUrl: body.imageUrl ?? null,
        quantityInStock: body.quantityInStock ?? 0,
        isActive: body.isActive ?? true,
        createdBy: req.session.userId!,
      })
      .returning();
    await logAudit(db, {
      action: "gift.create",
      entityType: "gift",
      entityId: row.id,
      newValue: row,
      performedBy: req.session.userId!,
    });
    res.status(201).json(row);
  },
);

router.patch(
  "/protocol/gifts/:id",
  requireMutate,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const body = parseBody(res, giftPatchSchema, req.body);
    if (!body) return;
    const [existing] = await db
      .select()
      .from(protocolGiftsTable)
      .where(eq(protocolGiftsTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const [row] = await db
      .update(protocolGiftsTable)
      .set({
        ...(body.nameAr !== undefined ? { nameAr: body.nameAr } : {}),
        ...(body.nameEn !== undefined ? { nameEn: body.nameEn } : {}),
        ...(body.kind !== undefined ? { kind: body.kind } : {}),
        ...(body.descriptionAr !== undefined
          ? { descriptionAr: body.descriptionAr }
          : {}),
        ...(body.descriptionEn !== undefined
          ? { descriptionEn: body.descriptionEn }
          : {}),
        ...(body.imageUrl !== undefined ? { imageUrl: body.imageUrl } : {}),
        ...(body.quantityInStock !== undefined
          ? { quantityInStock: body.quantityInStock }
          : {}),
        ...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
      })
      .where(eq(protocolGiftsTable.id, id))
      .returning();
    await logAudit(db, {
      action: "gift.update",
      entityType: "gift",
      entityId: id,
      oldValue: existing,
      newValue: row,
      performedBy: req.session.userId!,
    });
    res.json(row);
  },
);

router.delete(
  "/protocol/gifts/:id",
  requireMutate,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const [existing] = await db
      .select()
      .from(protocolGiftsTable)
      .where(eq(protocolGiftsTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    // Refuse to delete a gift that has issuance history — the FK is
    // `on delete restrict`, so return a clean 409 instead of a raw 500.
    const [used] = await db
      .select({ id: protocolGiftIssuesTable.id })
      .from(protocolGiftIssuesTable)
      .where(eq(protocolGiftIssuesTable.giftId, id))
      .limit(1);
    if (used) {
      res.status(409).json({
        error: "gift has issuance history",
        code: "gift_in_use",
      });
      return;
    }
    await db.delete(protocolGiftsTable).where(eq(protocolGiftsTable.id, id));
    await logAudit(db, {
      action: "gift.delete",
      entityType: "gift",
      entityId: id,
      oldValue: existing,
      performedBy: req.session.userId!,
    });
    res.status(204).end();
  },
);

// ---------------------------------------------------------------------------
// Gift issuances
// ---------------------------------------------------------------------------
router.get(
  "/protocol/gift-issues",
  requireProtocolAccess,
  async (req: Request, res: Response) => {
    const statusRaw = req.query.status;
    const giftIdRaw = req.query.giftId;
    const conds = [];
    if (
      typeof statusRaw === "string" &&
      ["pending", "approved", "rejected", "issued"].includes(statusRaw)
    ) {
      conds.push(eq(protocolGiftIssuesTable.status, statusRaw));
    }
    if (typeof giftIdRaw === "string" && /^\d+$/.test(giftIdRaw)) {
      conds.push(eq(protocolGiftIssuesTable.giftId, Number(giftIdRaw)));
    }
    const rows = await db
      .select()
      .from(protocolGiftIssuesTable)
      .where(conds.length > 0 ? and(...conds) : undefined)
      .orderBy(desc(protocolGiftIssuesTable.createdAt));
    res.json(rows);
  },
);

router.post(
  "/protocol/gift-issues",
  requireRequest,
  async (req: Request, res: Response) => {
    const body = parseBody(res, issueCreateSchema, req.body);
    if (!body) return;
    const [gift] = await db
      .select()
      .from(protocolGiftsTable)
      .where(eq(protocolGiftsTable.id, body.giftId));
    if (!gift) {
      res.status(400).json({ error: "gift not found", code: "invalid_gift" });
      return;
    }
    const [row] = await db
      .insert(protocolGiftIssuesTable)
      .values({
        giftId: body.giftId,
        recipientName: body.recipientName,
        occasion: body.occasion ?? null,
        quantity: body.quantity ?? 1,
        status: "pending",
        notes: body.notes ?? null,
        createdBy: req.session.userId!,
      })
      .returning();
    await logAudit(db, {
      action: "gift_issue.create",
      entityType: "gift_issue",
      entityId: row.id,
      newValue: row,
      performedBy: req.session.userId!,
    });
    res.status(201).json(row);
  },
);

router.post(
  "/protocol/gift-issues/:id/approve",
  requireApprove,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    try {
      const updated = await db.transaction(async (tx) => {
        const [existing] = await tx
          .select()
          .from(protocolGiftIssuesTable)
          .where(eq(protocolGiftIssuesTable.id, id));
        if (!existing) throw new Error("__not_found__");
        // Authoritatively claim the pending issue exactly once with a
        // state-conditional update. Two concurrent approvals of the same
        // issue race here: only one flips pending -> issued and gets a row
        // back; the loser fails with invalid_status and never decrements.
        const [row] = await tx
          .update(protocolGiftIssuesTable)
          .set({
            status: "issued",
            approvedBy: req.session.userId!,
            approvedAt: new Date(),
            issuedAt: new Date(),
            rejectionReason: null,
          })
          .where(
            and(
              eq(protocolGiftIssuesTable.id, id),
              eq(protocolGiftIssuesTable.status, "pending"),
            ),
          )
          .returning();
        if (!row) throw new Error("__invalid_status__");
        const [gift] = await tx
          .select()
          .from(protocolGiftsTable)
          .where(eq(protocolGiftsTable.id, existing.giftId));
        if (!gift) throw new Error("__invalid_gift__");
        // Conditional decrement: matches only when enough stock remains.
        // A failure here throws and rolls back the status claim above, so
        // an issue never ends up "issued" without stock being consumed.
        const decremented = await tx
          .update(protocolGiftsTable)
          .set({
            quantityInStock: sql`${protocolGiftsTable.quantityInStock} - ${existing.quantity}`,
          })
          .where(
            and(
              eq(protocolGiftsTable.id, gift.id),
              gte(protocolGiftsTable.quantityInStock, existing.quantity),
            ),
          )
          .returning({ id: protocolGiftsTable.id });
        if (decremented.length === 0) {
          throw new Error("__insufficient_stock__");
        }
        await logAudit(tx, {
          action: "gift_issue.approve",
          entityType: "gift_issue",
          entityId: id,
          oldValue: existing,
          newValue: row,
          performedBy: req.session.userId!,
        });
        return row;
      });
      res.json(updated);
    } catch (e) {
      if (e instanceof Error) {
        if (e.message === "__not_found__") {
          res.status(404).json({ error: "Not found", code: "not_found" });
          return;
        }
        if (e.message === "__invalid_status__") {
          res
            .status(409)
            .json({ error: "issue is not pending", code: "invalid_status" });
          return;
        }
        if (e.message === "__invalid_gift__") {
          res.status(400).json({ error: "gift not found", code: "invalid_gift" });
          return;
        }
        if (e.message === "__insufficient_stock__") {
          res.status(409).json({
            error: "الكمية المتوفرة غير كافية",
            code: "insufficient_stock",
          });
          return;
        }
      }
      throw e;
    }
  },
);

router.post(
  "/protocol/gift-issues/:id/reject",
  requireApprove,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const body = parseBody(res, rejectSchema, req.body);
    if (!body) return;
    const [existing] = await db
      .select()
      .from(protocolGiftIssuesTable)
      .where(eq(protocolGiftIssuesTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    // Only a pending issue can be rejected; the state-conditional update
    // makes this atomic against a concurrent approve.
    const [row] = await db
      .update(protocolGiftIssuesTable)
      .set({
        status: "rejected",
        approvedBy: req.session.userId!,
        approvedAt: new Date(),
        rejectionReason: body.reason ?? null,
      })
      .where(
        and(
          eq(protocolGiftIssuesTable.id, id),
          eq(protocolGiftIssuesTable.status, "pending"),
        ),
      )
      .returning();
    if (!row) {
      res
        .status(409)
        .json({ error: "issue is not pending", code: "invalid_status" });
      return;
    }
    await logAudit(db, {
      action: "gift_issue.reject",
      entityType: "gift_issue",
      entityId: id,
      oldValue: existing,
      newValue: row,
      performedBy: req.session.userId!,
    });
    res.json(row);
  },
);

router.delete(
  "/protocol/gift-issues/:id",
  requireMutate,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const [existing] = await db
      .select()
      .from(protocolGiftIssuesTable)
      .where(eq(protocolGiftIssuesTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    await db
      .delete(protocolGiftIssuesTable)
      .where(eq(protocolGiftIssuesTable.id, id));
    await logAudit(db, {
      action: "gift_issue.delete",
      entityType: "gift_issue",
      entityId: id,
      oldValue: existing,
      performedBy: req.session.userId!,
    });
    res.status(204).end();
  },
);

// ---------------------------------------------------------------------------
// Dashboard
// ---------------------------------------------------------------------------
router.get(
  "/protocol/dashboard",
  requireProtocolAccess,
  async (_req: Request, res: Response) => {
    const now = new Date();
    const startOfDay = new Date(
      now.getFullYear(),
      now.getMonth(),
      now.getDate(),
    );
    const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
    const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
    const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);

    const [roomCount] = await db
      .select({ c: sql<number>`count(*)::int` })
      .from(protocolRoomsTable)
      .where(eq(protocolRoomsTable.isActive, true));

    const [pendingBookings] = await db
      .select({ c: sql<number>`count(*)::int` })
      .from(protocolRoomBookingsTable)
      .where(eq(protocolRoomBookingsTable.status, "pending"));

    const [todayBookings] = await db
      .select({ c: sql<number>`count(*)::int` })
      .from(protocolRoomBookingsTable)
      .where(
        and(
          inArray(protocolRoomBookingsTable.status, ["pending", "approved"]),
          gte(protocolRoomBookingsTable.startsAt, startOfDay),
          lte(protocolRoomBookingsTable.startsAt, endOfDay),
        ),
      );

    const [upcomingExternal] = await db
      .select({ c: sql<number>`count(*)::int` })
      .from(protocolExternalMeetingsTable)
      .where(
        and(
          inArray(protocolExternalMeetingsTable.status, ["pending", "approved"]),
          gte(protocolExternalMeetingsTable.startsAt, now),
        ),
      );

    const [giftStock] = await db
      .select({
        kinds: sql<number>`count(*)::int`,
        totalStock: sql<number>`coalesce(sum(${protocolGiftsTable.quantityInStock}), 0)::int`,
      })
      .from(protocolGiftsTable)
      .where(eq(protocolGiftsTable.isActive, true));

    const [pendingIssues] = await db
      .select({ c: sql<number>`count(*)::int` })
      .from(protocolGiftIssuesTable)
      .where(eq(protocolGiftIssuesTable.status, "pending"));

    // Gifts/shields actually issued during the current calendar month.
    const [issuedThisMonth] = await db
      .select({
        count: sql<number>`count(*)::int`,
        qty: sql<number>`coalesce(sum(${protocolGiftIssuesTable.quantity}), 0)::int`,
      })
      .from(protocolGiftIssuesTable)
      .where(
        and(
          eq(protocolGiftIssuesTable.status, "issued"),
          gte(protocolGiftIssuesTable.issuedAt, startOfMonth),
          lt(protocolGiftIssuesTable.issuedAt, startOfNextMonth),
        ),
      );

    // Rooms free right now = active rooms with no pending/approved booking
    // currently spanning [startsAt, endsAt).
    const [availableNow] = await db
      .select({ c: sql<number>`count(*)::int` })
      .from(protocolRoomsTable)
      .where(
        and(
          eq(protocolRoomsTable.isActive, true),
          sql`not exists (
            select 1 from ${protocolRoomBookingsTable} b
            where b.room_id = ${protocolRoomsTable.id}
              and b.status in ('pending', 'approved')
              and b.starts_at <= ${now.toISOString()}
              and b.ends_at > ${now.toISOString()}
          )`,
        ),
      );

    res.json({
      activeRooms: roomCount?.c ?? 0,
      roomsAvailableNow: availableNow?.c ?? 0,
      pendingBookings: pendingBookings?.c ?? 0,
      todayBookings: todayBookings?.c ?? 0,
      upcomingExternalMeetings: upcomingExternal?.c ?? 0,
      giftCatalogCount: giftStock?.kinds ?? 0,
      totalGiftStock: giftStock?.totalStock ?? 0,
      pendingGiftIssues: pendingIssues?.c ?? 0,
      giftsIssuedThisMonth: issuedThisMonth?.count ?? 0,
      giftsIssuedThisMonthQty: issuedThisMonth?.qty ?? 0,
    });
  },
);

// ---------------------------------------------------------------------------
// Reports
// ---------------------------------------------------------------------------
router.get(
  "/protocol/reports",
  requireProtocolAccess,
  async (req: Request, res: Response) => {
    const fromRaw = req.query.from;
    const toRaw = req.query.to;
    const from =
      typeof fromRaw === "string" && !Number.isNaN(Date.parse(fromRaw))
        ? new Date(fromRaw)
        : null;
    const to =
      typeof toRaw === "string" && !Number.isNaN(Date.parse(toRaw))
        ? new Date(toRaw)
        : null;
    const bookingRange = [];
    if (from) bookingRange.push(gte(protocolRoomBookingsTable.startsAt, from));
    if (to) bookingRange.push(lte(protocolRoomBookingsTable.startsAt, to));

    const bookingsByRoom = await db
      .select({
        roomId: protocolRoomBookingsTable.roomId,
        roomNameAr: protocolRoomsTable.nameAr,
        roomNameEn: protocolRoomsTable.nameEn,
        total: sql<number>`count(*)::int`,
        approved: sql<number>`count(*) filter (where ${protocolRoomBookingsTable.status} = 'approved')::int`,
        pending: sql<number>`count(*) filter (where ${protocolRoomBookingsTable.status} = 'pending')::int`,
        rejected: sql<number>`count(*) filter (where ${protocolRoomBookingsTable.status} = 'rejected')::int`,
        cancelled: sql<number>`count(*) filter (where ${protocolRoomBookingsTable.status} = 'cancelled')::int`,
      })
      .from(protocolRoomBookingsTable)
      .innerJoin(
        protocolRoomsTable,
        eq(protocolRoomsTable.id, protocolRoomBookingsTable.roomId),
      )
      .where(bookingRange.length > 0 ? and(...bookingRange) : undefined)
      .groupBy(
        protocolRoomBookingsTable.roomId,
        protocolRoomsTable.nameAr,
        protocolRoomsTable.nameEn,
      )
      .orderBy(desc(sql`count(*)`));

    const issueRange = [];
    if (from) issueRange.push(gte(protocolGiftIssuesTable.createdAt, from));
    if (to) issueRange.push(lte(protocolGiftIssuesTable.createdAt, to));

    const issuesByGift = await db
      .select({
        giftId: protocolGiftIssuesTable.giftId,
        giftNameAr: protocolGiftsTable.nameAr,
        giftNameEn: protocolGiftsTable.nameEn,
        kind: protocolGiftsTable.kind,
        totalRequests: sql<number>`count(*)::int`,
        issued: sql<number>`count(*) filter (where ${protocolGiftIssuesTable.status} = 'issued')::int`,
        issuedQty: sql<number>`coalesce(sum(${protocolGiftIssuesTable.quantity}) filter (where ${protocolGiftIssuesTable.status} = 'issued'), 0)::int`,
        pending: sql<number>`count(*) filter (where ${protocolGiftIssuesTable.status} = 'pending')::int`,
        rejected: sql<number>`count(*) filter (where ${protocolGiftIssuesTable.status} = 'rejected')::int`,
      })
      .from(protocolGiftIssuesTable)
      .innerJoin(
        protocolGiftsTable,
        eq(protocolGiftsTable.id, protocolGiftIssuesTable.giftId),
      )
      .where(issueRange.length > 0 ? and(...issueRange) : undefined)
      .groupBy(
        protocolGiftIssuesTable.giftId,
        protocolGiftsTable.nameAr,
        protocolGiftsTable.nameEn,
        protocolGiftsTable.kind,
      )
      .orderBy(desc(sql`count(*)`));

    const externalRange = [];
    if (from)
      externalRange.push(gte(protocolExternalMeetingsTable.startsAt, from));
    if (to) externalRange.push(lte(protocolExternalMeetingsTable.startsAt, to));

    const [externalMeetings] = await db
      .select({
        total: sql<number>`count(*)::int`,
        pending: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'pending')::int`,
        approved: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'approved')::int`,
        rejected: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'rejected')::int`,
        completed: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'completed')::int`,
        cancelled: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'cancelled')::int`,
      })
      .from(protocolExternalMeetingsTable)
      .where(externalRange.length > 0 ? and(...externalRange) : undefined);

    res.json({
      bookingsByRoom,
      issuesByGift,
      externalMeetings: externalMeetings ?? {
        total: 0,
        pending: 0,
        approved: 0,
        rejected: 0,
        completed: 0,
        cancelled: 0,
      },
    });
  },
);

// ---------------------------------------------------------------------------
// Audit log
// ---------------------------------------------------------------------------
router.get(
  "/protocol/audit",
  requireAudit,
  async (req: Request, res: Response) => {
    const limitRaw = req.query.limit;
    const limit =
      typeof limitRaw === "string" && /^\d+$/.test(limitRaw)
        ? Math.min(Number(limitRaw), 500)
        : 200;
    const rows = await db
      .select({
        id: protocolAuditLogsTable.id,
        action: protocolAuditLogsTable.action,
        entityType: protocolAuditLogsTable.entityType,
        entityId: protocolAuditLogsTable.entityId,
        oldValue: protocolAuditLogsTable.oldValue,
        newValue: protocolAuditLogsTable.newValue,
        performedBy: protocolAuditLogsTable.performedBy,
        performedAt: protocolAuditLogsTable.performedAt,
        actorUsername: usersTable.username,
        actorNameAr: usersTable.displayNameAr,
        actorNameEn: usersTable.displayNameEn,
      })
      .from(protocolAuditLogsTable)
      .leftJoin(
        usersTable,
        eq(usersTable.id, protocolAuditLogsTable.performedBy),
      )
      .orderBy(desc(protocolAuditLogsTable.performedAt))
      .limit(limit);
    res.json(rows);
  },
);

// ---------------------------------------------------------------------------
// Visit photo albums (تصوير) — album + photo CRUD.
//
// Reads are gated by requireProtocolAccess; every mutation by requireMutate
// (both also enforce the user is active). Photos reference private object-
// storage paths served through GET /api/storage/objects/*, authorized per
// entity in lib/objectAuthz.ts.
// ---------------------------------------------------------------------------
const albumCreateSchema = z.object({
  name: z.string().trim().min(1).max(300),
  externalMeetingId: z.number().int().positive().nullable().optional(),
});
const albumPatchSchema = z
  .object({
    name: z.string().trim().min(1).max(300).optional(),
    externalMeetingId: z.number().int().positive().nullable().optional(),
  })
  .refine((v) => Object.keys(v).length > 0, {
    message: "no fields to update",
  });
const photoCreateSchema = z.object({
  objectPath: z.string().trim().min(1).max(500),
});

async function externalMeetingExists(id: number): Promise<boolean> {
  const [m] = await db
    .select({ id: protocolExternalMeetingsTable.id })
    .from(protocolExternalMeetingsTable)
    .where(eq(protocolExternalMeetingsTable.id, id));
  return !!m;
}

router.get(
  "/protocol/photo-albums",
  requireProtocolAccess,
  async (req: Request, res: Response) => {
    const meetingIdRaw = req.query.meetingId;
    const meetingId =
      typeof meetingIdRaw === "string" && /^\d+$/.test(meetingIdRaw)
        ? Number(meetingIdRaw)
        : undefined;
    const rows = await db
      .select({
        id: protocolPhotoAlbumsTable.id,
        name: protocolPhotoAlbumsTable.name,
        externalMeetingId: protocolPhotoAlbumsTable.externalMeetingId,
        meetingTitle: protocolExternalMeetingsTable.title,
        createdBy: protocolPhotoAlbumsTable.createdBy,
        createdAt: protocolPhotoAlbumsTable.createdAt,
        updatedAt: protocolPhotoAlbumsTable.updatedAt,
        photoCount: sql<number>`count(${protocolPhotosTable.id})::int`,
        coverPath: sql<
          string | null
        >`(array_agg(${protocolPhotosTable.objectPath} order by ${protocolPhotosTable.sortOrder} asc, ${protocolPhotosTable.id} asc) filter (where ${protocolPhotosTable.id} is not null))[1]`,
      })
      .from(protocolPhotoAlbumsTable)
      .leftJoin(
        protocolExternalMeetingsTable,
        eq(
          protocolExternalMeetingsTable.id,
          protocolPhotoAlbumsTable.externalMeetingId,
        ),
      )
      .leftJoin(
        protocolPhotosTable,
        eq(protocolPhotosTable.albumId, protocolPhotoAlbumsTable.id),
      )
      .where(
        meetingId !== undefined
          ? eq(protocolPhotoAlbumsTable.externalMeetingId, meetingId)
          : undefined,
      )
      .groupBy(
        protocolPhotoAlbumsTable.id,
        protocolExternalMeetingsTable.title,
      )
      .orderBy(desc(protocolPhotoAlbumsTable.createdAt));
    res.json(rows);
  },
);

router.post(
  "/protocol/photo-albums",
  requireMutate,
  async (req: Request, res: Response) => {
    const body = parseBody(res, albumCreateSchema, req.body);
    if (!body) return;
    if (
      body.externalMeetingId != null &&
      !(await externalMeetingExists(body.externalMeetingId))
    ) {
      res
        .status(400)
        .json({ error: "external_meeting not found", code: "validation" });
      return;
    }
    const [row] = await db
      .insert(protocolPhotoAlbumsTable)
      .values({
        name: body.name,
        externalMeetingId: body.externalMeetingId ?? null,
        createdBy: req.session.userId!,
      })
      .returning();
    await logAudit(db, {
      action: "photo_album.create",
      entityType: "photo_album",
      entityId: row.id,
      newValue: row,
      performedBy: req.session.userId!,
    });
    res.status(201).json(row);
  },
);

router.patch(
  "/protocol/photo-albums/:id",
  requireMutate,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const body = parseBody(res, albumPatchSchema, req.body);
    if (!body) return;
    const [existing] = await db
      .select()
      .from(protocolPhotoAlbumsTable)
      .where(eq(protocolPhotoAlbumsTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    if (
      body.externalMeetingId != null &&
      !(await externalMeetingExists(body.externalMeetingId))
    ) {
      res
        .status(400)
        .json({ error: "external_meeting not found", code: "validation" });
      return;
    }
    const [row] = await db
      .update(protocolPhotoAlbumsTable)
      .set({
        ...(body.name !== undefined ? { name: body.name } : {}),
        ...(body.externalMeetingId !== undefined
          ? { externalMeetingId: body.externalMeetingId }
          : {}),
      })
      .where(eq(protocolPhotoAlbumsTable.id, id))
      .returning();
    await logAudit(db, {
      action: "photo_album.update",
      entityType: "photo_album",
      entityId: id,
      oldValue: existing,
      newValue: row,
      performedBy: req.session.userId!,
    });
    res.json(row);
  },
);

router.delete(
  "/protocol/photo-albums/:id",
  requireMutate,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const [existing] = await db
      .select()
      .from(protocolPhotoAlbumsTable)
      .where(eq(protocolPhotoAlbumsTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    // Photos cascade-delete via the FK; audit the album deletion.
    await db
      .delete(protocolPhotoAlbumsTable)
      .where(eq(protocolPhotoAlbumsTable.id, id));
    await logAudit(db, {
      action: "photo_album.delete",
      entityType: "photo_album",
      entityId: id,
      oldValue: existing,
      performedBy: req.session.userId!,
    });
    res.status(204).end();
  },
);

router.get(
  "/protocol/photo-albums/:id/photos",
  requireProtocolAccess,
  async (req: Request, res: Response) => {
    const albumId = Number(req.params.id);
    const [album] = await db
      .select({ id: protocolPhotoAlbumsTable.id })
      .from(protocolPhotoAlbumsTable)
      .where(eq(protocolPhotoAlbumsTable.id, albumId));
    if (!album) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const rows = await db
      .select()
      .from(protocolPhotosTable)
      .where(eq(protocolPhotosTable.albumId, albumId))
      .orderBy(asc(protocolPhotosTable.sortOrder), asc(protocolPhotosTable.id));
    res.json(rows);
  },
);

// Stream every photo of an album as a single ZIP. Guarded by protocol
// read access; each entry is named sequentially with an extension derived
// from the stored object's content type. Missing/unreadable objects are
// skipped rather than failing the whole archive.
router.get(
  "/protocol/photo-albums/:id/download",
  requireProtocolAccess,
  async (req: Request, res: Response) => {
    const albumId = Number(req.params.id);
    const [album] = await db
      .select({
        id: protocolPhotoAlbumsTable.id,
        name: protocolPhotoAlbumsTable.name,
      })
      .from(protocolPhotoAlbumsTable)
      .where(eq(protocolPhotoAlbumsTable.id, albumId));
    if (!album) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const rows = await db
      .select()
      .from(protocolPhotosTable)
      .where(eq(protocolPhotosTable.albumId, albumId))
      .orderBy(asc(protocolPhotosTable.sortOrder), asc(protocolPhotosTable.id));
    if (rows.length === 0) {
      res.status(404).json({ error: "album is empty", code: "not_found" });
      return;
    }

    const safeName =
      album.name
        .replace(/[\r\n"\\/]/g, "")
        .replace(/[\u0000-\u001f]/g, "")
        .trim()
        .slice(0, 180) || "album";
    const asciiName = safeName.replace(/[^\x20-\x7e]/g, "_") || "album";
    res.setHeader("Content-Type", "application/zip");
    res.setHeader(
      "Content-Disposition",
      `attachment; filename="${asciiName}.zip"; filename*=UTF-8''${encodeURIComponent(
        `${safeName}.zip`,
      )}`,
    );

    const archive = new ZipArchive({ zlib: { level: 6 } });
    archive.on("error", (err: Error) => {
      req.log.error({ err }, "Error building album zip");
      if (!res.headersSent) {
        res.status(500).json({ error: "Failed to build archive" });
      } else {
        res.destroy();
      }
    });
    archive.pipe(res);

    let index = 0;
    for (const photo of rows) {
      index += 1;
      try {
        const file = await objectStorage.getObjectEntityFile(photo.objectPath);
        const meta = await file.getMetadata();
        const ext = extensionForContentType(meta.contentType);
        const entryName = `${String(index).padStart(3, "0")}.${ext}`;
        archive.append(file.createReadStream() as Readable, {
          name: entryName,
        });
      } catch (err) {
        req.log.warn(
          { err, photoId: photo.id },
          "Skipping unreadable photo in album zip",
        );
      }
    }

    await archive.finalize();
  },
);

router.post(
  "/protocol/photo-albums/:id/photos",
  requireMutate,
  async (req: Request, res: Response) => {
    const albumId = Number(req.params.id);
    const body = parseBody(res, photoCreateSchema, req.body);
    if (!body) return;
    const [album] = await db
      .select({ id: protocolPhotoAlbumsTable.id })
      .from(protocolPhotoAlbumsTable)
      .where(eq(protocolPhotoAlbumsTable.id, albumId));
    if (!album) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    // Normalize any signed URL / legacy form back to the canonical
    // /objects/<id> path and reject anything that is not a private object.
    const objectPath = objectStorage.normalizeObjectEntityPath(body.objectPath);
    if (!objectPath.startsWith("/objects/")) {
      res.status(400).json({ error: "invalid objectPath", code: "validation" });
      return;
    }
    // Verify the object was actually uploaded before storing a reference to
    // it — this rejects dangling/guessed paths.
    try {
      await objectStorage.getObjectEntityFile(objectPath);
    } catch {
      res.status(400).json({ error: "object not found", code: "validation" });
      return;
    }
    const [agg] = await db
      .select({
        maxOrder: sql<
          number
        >`coalesce(max(${protocolPhotosTable.sortOrder}), -1)::int`,
      })
      .from(protocolPhotosTable)
      .where(eq(protocolPhotosTable.albumId, albumId));
    const [row] = await db
      .insert(protocolPhotosTable)
      .values({
        albumId,
        objectPath,
        sortOrder: (agg?.maxOrder ?? -1) + 1,
        uploadedBy: req.session.userId!,
      })
      .returning();
    await logAudit(db, {
      action: "photo.create",
      entityType: "photo",
      entityId: row.id,
      newValue: row,
      performedBy: req.session.userId!,
    });
    res.status(201).json(row);
  },
);

router.delete(
  "/protocol/photos/:id",
  requireMutate,
  async (req: Request, res: Response) => {
    const id = Number(req.params.id);
    const [existing] = await db
      .select()
      .from(protocolPhotosTable)
      .where(eq(protocolPhotosTable.id, id));
    if (!existing) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    await db.delete(protocolPhotosTable).where(eq(protocolPhotosTable.id, id));
    await logAudit(db, {
      action: "photo.delete",
      entityType: "photo",
      entityId: id,
      oldValue: existing,
      performedBy: req.session.userId!,
    });
    res.status(204).end();
  },
);

export default router;
