import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { CompetitionCategory } from '@prisma/client'
import { router, adminProcedure, protectedProcedure, publicProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import {
  createPendingConfirmation,
  promoteNextWaitlistEntry,
} from '../services/finalist-confirmation'
import {
  createNotification,
  notifyAdmins,
  NotificationTypes,
} from '../services/in-app-notification'
import { sendFinalistConfirmationEmail } from '@/lib/email'
import { verifyFinalistToken } from '@/lib/finalist-token'
import { ensureLunchPickForAttendingMember } from '../services/lunch-pick-sync'
import {
  resetOrCreatePendingConfirmation,
  confirmAttendanceInTx,
} from '../services/finalist-enrollment'
import { sendManualFinalDocReminders, listFinalistDocumentsForReview, userCanReviewFinals, listReviewVisibilityOptions, reviewVisibleRequirementIds } from '../services/final-documents'

export const finalistRouter = router({
  /** List all per-category finalist slot quotas for a program. */
  listQuotas: adminProcedure
    .input(z.object({ programId: z.string() }))
    .query(async ({ ctx, input }) => {
      return ctx.prisma.finalistSlotQuota.findMany({
        where: { programId: input.programId },
        orderBy: { category: 'asc' },
      })
    }),

  /**
   * Aggregate counts of confirmations per category for a program. Used by the
   * admin slot card to show "X confirmed / Y pending" alongside the quota
   * editor.
   */
  listCategoryCounts: adminProcedure
    .input(z.object({ programId: z.string() }))
    .query(async ({ ctx, input }) => {
      const grouped = await ctx.prisma.finalistConfirmation.groupBy({
        by: ['category', 'status'],
        where: { project: { programId: input.programId } },
        _count: { _all: true },
      })
      const byCategory = new Map<string, { confirmed: number; pending: number }>()
      for (const g of grouped) {
        const slot = byCategory.get(g.category) ?? { confirmed: 0, pending: 0 }
        if (g.status === 'CONFIRMED') slot.confirmed = g._count._all
        if (g.status === 'PENDING') slot.pending = g._count._all
        byCategory.set(g.category, slot)
      }
      return Array.from(byCategory.entries()).map(([category, counts]) => ({
        category: category as CompetitionCategory,
        confirmed: counts.confirmed,
        pending: counts.pending,
      }))
    }),

  /** List the per-category waitlist for a program (rank-ordered). */
  listWaitlist: adminProcedure
    .input(z.object({ programId: z.string() }))
    .query(async ({ ctx, input }) => {
      return ctx.prisma.waitlistEntry.findMany({
        where: { programId: input.programId },
        orderBy: [{ category: 'asc' }, { rank: 'asc' }],
        include: {
          project: { select: { id: true, title: true, country: true } },
        },
      })
    }),

  /**
   * Set the finalist slot quota for a category in a program. Mutable mid-flight,
   * but blocked when reducing below the count of already-CONFIRMED finalists in
   * that category — admin must un-confirm a team first.
   */
  setQuota: adminProcedure
    .input(
      z.object({
        programId: z.string(),
        category: z.nativeEnum(CompetitionCategory),
        quota: z.number().int().min(0).max(100),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const confirmedCount = await ctx.prisma.finalistConfirmation.count({
        where: {
          project: { programId: input.programId },
          category: input.category,
          status: 'CONFIRMED',
        },
      })
      if (input.quota < confirmedCount) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: `Cannot reduce ${input.category} quota to ${input.quota} — ${confirmedCount} teams have already confirmed. Un-confirm one team first, then retry.`,
        })
      }
      const quota = await ctx.prisma.finalistSlotQuota.upsert({
        where: {
          programId_category: {
            programId: input.programId,
            category: input.category,
          },
        },
        create: {
          programId: input.programId,
          category: input.category,
          quota: input.quota,
        },
        update: { quota: input.quota },
      })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'FINALIST_QUOTA_SET',
        entityType: 'FinalistSlotQuota',
        entityId: quota.id,
        detailsJson: {
          programId: input.programId,
          category: input.category,
          quota: input.quota,
          previousConfirmedCount: confirmedCount,
        },
      })
      return quota
    }),

  /**
   * Send finalist confirmation emails to a set of selected projects in a
   * category. Reads the confirmation window from the round's configJson.
   * Validates category match + quota before creating any rows.
   */
  selectFinalists: adminProcedure
    .input(
      z.object({
        programId: z.string(),
        category: z.nativeEnum(CompetitionCategory),
        projectIds: z.array(z.string()).min(1),
        roundId: z.string(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const round = await ctx.prisma.round.findUniqueOrThrow({
        where: { id: input.roundId },
        select: { id: true, configJson: true },
      })
      const cfg = (round.configJson ?? {}) as { confirmationWindowHours?: number }
      const windowHours = cfg.confirmationWindowHours ?? 24

      const projects = await ctx.prisma.project.findMany({
        where: { id: { in: input.projectIds }, programId: input.programId },
        select: {
          id: true,
          title: true,
          competitionCategory: true,
          teamMembers: {
            where: { role: 'LEAD' },
            take: 1,
            select: { user: { select: { email: true, name: true } } },
          },
        },
      })
      if (projects.length !== input.projectIds.length) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'One or more project IDs not found in this program',
        })
      }
      const mismatched = projects.filter((p) => p.competitionCategory !== input.category)
      if (mismatched.length > 0) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: `Category mismatch: ${mismatched
            .map((p) => p.title)
            .join(', ')} are not in ${input.category}`,
        })
      }
      const quota = await ctx.prisma.finalistSlotQuota.findUnique({
        where: {
          programId_category: {
            programId: input.programId,
            category: input.category,
          },
        },
      })
      if (quota && input.projectIds.length > quota.quota) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: `Selection exceeds quota: ${input.projectIds.length} selected, ${quota.quota} available in ${input.category}`,
        })
      }

      const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
      let created = 0
      for (const project of projects) {
        const { token, deadline } = await createPendingConfirmation(ctx.prisma, {
          projectId: project.id,
          category: input.category,
          windowHours,
        })
        created++

        // Send notification email — never throw inside the loop; log failures.
        const lead = project.teamMembers[0]?.user
        if (lead?.email) {
          const confirmUrl = `${baseUrl}/finalist/confirm/${token}`
          try {
            await sendFinalistConfirmationEmail(
              lead.email,
              lead.name ?? null,
              project.title,
              deadline,
              confirmUrl,
            )
          } catch (err) {
            console.error(
              `[finalist.selectFinalists] failed to send email to ${lead.email} for project ${project.id}:`,
              err,
            )
          }
        }
      }

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'FINALIST_SELECT',
        entityType: 'Program',
        entityId: input.programId,
        detailsJson: {
          category: input.category,
          projectIds: input.projectIds,
          windowHours,
          roundId: input.roundId,
        },
      })
      return { created }
    }),

  /**
   * Look up a confirmation by its public token. Surface the data needed to
   * render the confirmation page: project, team members, current state.
   */
  getByToken: publicProcedure
    .input(z.object({ token: z.string() }))
    .query(async ({ ctx, input }) => {
      const payload = verifyFinalistToken(input.token) // throws on bad sig / expired
      const confirmation = await ctx.prisma.finalistConfirmation.findUnique({
        where: { id: payload.confirmationId },
        include: {
          project: {
            select: {
              id: true,
              title: true,
              programId: true,
              competitionCategory: true,
              program: { select: { defaultAttendeeCap: true, name: true } },
              teamMembers: {
                select: {
                  userId: true,
                  role: true,
                  user: { select: { id: true, name: true, email: true } },
                },
              },
            },
          },
          attendingMembers: { select: { userId: true, needsVisa: true } },
        },
      })
      if (!confirmation) throw new TRPCError({ code: 'NOT_FOUND' })
      if (confirmation.token !== input.token) {
        throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Token mismatch' })
      }
      return confirmation
    }),

  /**
   * Public confirm. Validates that all selected userIds are team members of
   * the project, that the count is within the program's defaultAttendeeCap,
   * and that the confirmation is still PENDING. Atomically writes
   * status=CONFIRMED + AttendingMember rows.
   */
  confirm: publicProcedure
    .input(
      z.object({
        token: z.string(),
        attendingUserIds: z.array(z.string()).min(1),
        visaFlags: z.record(z.string(), z.boolean()).default({}),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const payload = verifyFinalistToken(input.token)
      const confirmation = await ctx.prisma.finalistConfirmation.findUnique({
        where: { id: payload.confirmationId },
        include: {
          project: {
            select: {
              id: true,
              title: true,
              programId: true,
              program: { select: { defaultAttendeeCap: true } },
              teamMembers: { select: { userId: true } },
            },
          },
        },
      })
      if (!confirmation) throw new TRPCError({ code: 'NOT_FOUND' })
      if (confirmation.token !== input.token) {
        throw new TRPCError({ code: 'UNAUTHORIZED' })
      }
      if (confirmation.status !== 'PENDING') {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: `Confirmation is ${confirmation.status}, not PENDING`,
        })
      }
      const cap = confirmation.project.program.defaultAttendeeCap
      if (input.attendingUserIds.length > cap) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: `Selection exceeds attendee cap of ${cap}`,
        })
      }
      const teamUserIds = new Set(confirmation.project.teamMembers.map((tm) => tm.userId))
      for (const uid of input.attendingUserIds) {
        if (!teamUserIds.has(uid)) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: `User ${uid} is not a team member of this project`,
          })
        }
      }

      await ctx.prisma.$transaction(async (tx) => {
        await tx.finalistConfirmation.update({
          where: { id: confirmation.id },
          data: { status: 'CONFIRMED', confirmedAt: new Date() },
        })
        await tx.attendingMember.createMany({
          data: input.attendingUserIds.map((userId) => ({
            confirmationId: confirmation.id,
            userId,
            needsVisa: input.visaFlags[userId] ?? false,
          })),
        })
        const visaUsers = input.attendingUserIds.filter(
          (uid) => input.visaFlags[uid] === true,
        )
        if (visaUsers.length > 0) {
          const created = await tx.attendingMember.findMany({
            where: { confirmationId: confirmation.id, userId: { in: visaUsers } },
            select: { id: true },
          })
          await tx.visaApplication.createMany({
            data: created.map((m) => ({ attendingMemberId: m.id, status: 'REQUESTED' })),
          })
        }
        const allMembers = await tx.attendingMember.findMany({
          where: { confirmationId: confirmation.id, userId: { in: input.attendingUserIds } },
          select: { id: true },
        })
        for (const m of allMembers) {
          await ensureLunchPickForAttendingMember(tx, m.id)
        }
      })
      await logAudit({
        prisma: ctx.prisma,
        action: 'FINALIST_CONFIRMED',
        entityType: 'FinalistConfirmation',
        entityId: confirmation.id,
        detailsJson: {
          projectId: confirmation.projectId,
          attendingUserIds: input.attendingUserIds,
        },
      })
      // Admin alert — best-effort, never throws
      try {
        await notifyAdmins({
          type: NotificationTypes.FINALIST_CONFIRMED,
          title: 'Finalist confirmed',
          message: `"${confirmation.project.title}" (${confirmation.category}) has confirmed grand-finale attendance.`,
          linkUrl: '/admin/logistics',
          metadata: {
            projectId: confirmation.projectId,
            projectTitle: confirmation.project.title,
            category: confirmation.category,
          },
        })
      } catch (err) {
        console.error('[finalist.confirm] failed to send admin notification:', err)
      }
      return { ok: true }
    }),

  /**
   * Public decline. Captures an optional reason. Triggers waitlist promotion
   * for the same category. The freshly-promoted waitlist team gets its own
   * fresh 24h-ish window (read from the round configJson; the round id is
   * resolved via the project's most-recent grand-finale round, since the
   * decliner won't pass it back).
   */
  decline: publicProcedure
    .input(z.object({ token: z.string(), reason: z.string().max(500).optional() }))
    .mutation(async ({ ctx, input }) => {
      const payload = verifyFinalistToken(input.token)
      const confirmation = await ctx.prisma.finalistConfirmation.findUnique({
        where: { id: payload.confirmationId },
        include: { project: { select: { programId: true, title: true } } },
      })
      if (!confirmation) throw new TRPCError({ code: 'NOT_FOUND' })
      if (confirmation.token !== input.token) {
        throw new TRPCError({ code: 'UNAUTHORIZED' })
      }
      if (confirmation.status !== 'PENDING') {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: `Confirmation is ${confirmation.status}, not PENDING`,
        })
      }
      await ctx.prisma.finalistConfirmation.update({
        where: { id: confirmation.id },
        data: {
          status: 'DECLINED',
          declinedAt: new Date(),
          declineReason: input.reason ?? null,
        },
      })
      await logAudit({
        prisma: ctx.prisma,
        action: 'FINALIST_DECLINED',
        entityType: 'FinalistConfirmation',
        entityId: confirmation.id,
        detailsJson: {
          projectId: confirmation.projectId,
          reason: input.reason ?? null,
        },
      })
      // Admin alert — best-effort, never throws
      try {
        await notifyAdmins({
          type: NotificationTypes.FINALIST_DECLINED,
          title: 'Finalist declined',
          message: `"${confirmation.project.title}" (${confirmation.category}) has declined grand-finale attendance.`,
          linkUrl: '/admin/logistics',
          metadata: {
            projectId: confirmation.projectId,
            projectTitle: confirmation.project.title,
            category: confirmation.category,
          },
        })
      } catch (err) {
        console.error('[finalist.decline] failed to send admin notification:', err)
      }

      // Promote next waitlist entry in same category. windowHours pulled from
      // the live grand-finale round in the program (LIVE_FINAL roundType).
      const round = await ctx.prisma.round.findFirst({
        where: {
          competition: { programId: confirmation.project.programId },
          roundType: 'LIVE_FINAL',
        },
        orderBy: { sortOrder: 'desc' },
        select: { configJson: true },
      })
      const cfg = (round?.configJson ?? {}) as { confirmationWindowHours?: number }
      const windowHours = cfg.confirmationWindowHours ?? 24
      await promoteNextWaitlistEntry(ctx.prisma, {
        programId: confirmation.project.programId,
        category: confirmation.category,
        windowHours,
      })

      return { ok: true }
    }),

  /**
   * Admin override: mark a PENDING finalist confirmation as CONFIRMED on
   * behalf of the team. Used when teams reply by email instead of clicking
   * the magic link. Same validation as the public `confirm` (cap, team
   * membership) but bypasses token verification.
   */
  adminConfirm: adminProcedure
    .input(
      z.object({
        confirmationId: z.string(),
        attendingUserIds: z.array(z.string()).min(1),
        visaFlags: z.record(z.string(), z.boolean()).default({}),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const confirmation = await ctx.prisma.finalistConfirmation.findUniqueOrThrow({
        where: { id: input.confirmationId },
        include: {
          project: {
            select: {
              id: true,
              programId: true,
              program: { select: { defaultAttendeeCap: true } },
              teamMembers: { select: { userId: true } },
            },
          },
        },
      })
      if (confirmation.status !== 'PENDING') {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: `Confirmation is ${confirmation.status}, not PENDING`,
        })
      }
      const cap = confirmation.project.program.defaultAttendeeCap
      if (input.attendingUserIds.length > cap) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: `Selection exceeds attendee cap of ${cap}`,
        })
      }
      const teamUserIds = new Set(confirmation.project.teamMembers.map((tm) => tm.userId))
      for (const id of input.attendingUserIds) {
        if (!teamUserIds.has(id)) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: `User ${id} is not a team member of this project`,
          })
        }
      }

      await ctx.prisma.$transaction(async (tx) => {
        await confirmAttendanceInTx(tx, {
          confirmationId: confirmation.id,
          attendingUserIds: input.attendingUserIds,
          visaFlags: input.visaFlags,
        })
      })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'FINALIST_ADMIN_CONFIRM',
        entityType: 'FinalistConfirmation',
        entityId: confirmation.id,
        detailsJson: {
          projectId: confirmation.projectId,
          attendingUserIds: input.attendingUserIds,
          visaFlags: input.visaFlags,
        },
      })
      return { ok: true }
    }),

  /**
   * Admin override: mark a PENDING finalist confirmation as DECLINED on
   * behalf of the team and trigger waitlist promotion. Same effect as the
   * public `decline` but bypasses token verification.
   */
  adminDecline: adminProcedure
    .input(z.object({ confirmationId: z.string(), reason: z.string().max(500).optional() }))
    .mutation(async ({ ctx, input }) => {
      const confirmation = await ctx.prisma.finalistConfirmation.findUniqueOrThrow({
        where: { id: input.confirmationId },
        include: {
          project: {
            select: {
              programId: true,
              title: true,
              teamMembers: {
                where: { role: 'LEAD' },
                take: 1,
                select: { userId: true },
              },
            },
          },
        },
      })
      if (confirmation.status !== 'PENDING') {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: `Confirmation is ${confirmation.status}, not PENDING`,
        })
      }
      await ctx.prisma.finalistConfirmation.update({
        where: { id: confirmation.id },
        data: {
          status: 'DECLINED',
          declinedAt: new Date(),
          declineReason: input.reason ?? null,
        },
      })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'FINALIST_ADMIN_DECLINE',
        entityType: 'FinalistConfirmation',
        entityId: confirmation.id,
        detailsJson: {
          projectId: confirmation.projectId,
          reason: input.reason ?? null,
        },
      })
      // Admin alert — best-effort, never throws
      try {
        await notifyAdmins({
          type: NotificationTypes.FINALIST_DECLINED,
          title: 'Finalist declined (admin)',
          message: `"${confirmation.project.title}" (${confirmation.category}) was declined by an admin.`,
          linkUrl: '/admin/logistics',
          metadata: {
            projectId: confirmation.projectId,
            projectTitle: confirmation.project.title,
            category: confirmation.category,
          },
        })
      } catch (err) {
        console.error('[finalist.adminDecline] failed to send admin notification:', err)
      }

      // Withdrawal notification to team lead — best-effort, never throws
      try {
        const lead = confirmation.project.teamMembers[0]
        if (lead) {
          const projectTitle = confirmation.project.title
          const reason = input.reason
          await createNotification({
            userId: lead.userId,
            type: NotificationTypes.FINALIST_WITHDRAWN,
            title: 'Grand finale slot withdrawn',
            message: `Your team "${projectTitle}" is no longer a confirmed finalist.${reason ? ' Reason: ' + reason : ''}`,
            linkUrl: '/applicant',
            metadata: { projectTitle, reason },
          })
        }
      } catch (err) {
        console.error('[finalist.adminDecline] failed to send withdrawal notification to team:', err)
      }

      const round = await ctx.prisma.round.findFirst({
        where: {
          competition: { programId: confirmation.project.programId },
          roundType: 'LIVE_FINAL',
        },
        orderBy: { sortOrder: 'desc' },
        select: { configJson: true },
      })
      const cfg = (round?.configJson ?? {}) as { confirmationWindowHours?: number }
      const windowHours = cfg.confirmationWindowHours ?? 24
      await promoteNextWaitlistEntry(ctx.prisma, {
        programId: confirmation.project.programId,
        category: confirmation.category,
        windowHours,
      })

      return { ok: true }
    }),

  /**
   * Returns the team-member roster for a given confirmation so the admin
   * UI can render an attendee picker. Filtered by program scope so admins
   * can only inspect confirmations in programs they manage.
   */
  getConfirmationDetail: adminProcedure
    .input(z.object({ confirmationId: z.string() }))
    .query(async ({ ctx, input }) => {
      return ctx.prisma.finalistConfirmation.findUniqueOrThrow({
        where: { id: input.confirmationId },
        include: {
          project: {
            select: {
              id: true,
              title: true,
              program: { select: { defaultAttendeeCap: true } },
              teamMembers: {
                include: {
                  user: { select: { id: true, name: true, email: true } },
                },
                orderBy: { joinedAt: 'asc' },
              },
            },
          },
          attendingMembers: { select: { userId: true, needsVisa: true } },
        },
      })
    }),

  /**
   * Add a project to the waitlist at a specific rank. Existing entries at
   * rank >= input.rank shift down by one to make room.
   */
  addToWaitlist: adminProcedure
    .input(
      z.object({
        programId: z.string(),
        category: z.nativeEnum(CompetitionCategory),
        projectId: z.string(),
        rank: z.number().int().min(1),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const project = await ctx.prisma.project.findUniqueOrThrow({
        where: { id: input.projectId },
        select: { competitionCategory: true, programId: true },
      })
      if (project.programId !== input.programId) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Project does not belong to this program',
        })
      }
      if (project.competitionCategory !== input.category) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: `Project is in ${project.competitionCategory}, not ${input.category}`,
        })
      }

      // Use a transaction: shift existing entries first, then insert.
      const entry = await ctx.prisma.$transaction(async (tx) => {
        // Shift entries at >= input.rank down by 1 in reverse rank order to
        // avoid violating the unique constraint mid-update.
        const toShift = await tx.waitlistEntry.findMany({
          where: {
            programId: input.programId,
            category: input.category,
            rank: { gte: input.rank },
          },
          orderBy: { rank: 'desc' },
          select: { id: true, rank: true },
        })
        for (const e of toShift) {
          await tx.waitlistEntry.update({
            where: { id: e.id },
            data: { rank: e.rank + 1 },
          })
        }
        return tx.waitlistEntry.create({
          data: {
            programId: input.programId,
            category: input.category,
            projectId: input.projectId,
            rank: input.rank,
            status: 'WAITING',
          },
        })
      })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'WAITLIST_ADD',
        entityType: 'WaitlistEntry',
        entityId: entry.id,
        detailsJson: {
          programId: input.programId,
          category: input.category,
          projectId: input.projectId,
          rank: input.rank,
        },
      })
      return entry
    }),

  /**
   * Replace the rank order for a category's waitlist with the given list.
   * orderedProjectIds[0] becomes rank 1, etc.
   */
  reorderWaitlist: adminProcedure
    .input(
      z.object({
        programId: z.string(),
        category: z.nativeEnum(CompetitionCategory),
        orderedProjectIds: z.array(z.string()),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      await ctx.prisma.$transaction(async (tx) => {
        // Move each entry to a temporary very-large rank to avoid unique
        // constraint conflicts during the in-place rewrite.
        const TEMP_OFFSET = 100_000
        for (let i = 0; i < input.orderedProjectIds.length; i++) {
          await tx.waitlistEntry.updateMany({
            where: {
              programId: input.programId,
              category: input.category,
              projectId: input.orderedProjectIds[i],
            },
            data: { rank: TEMP_OFFSET + i + 1 },
          })
        }
        // Now write the final ranks
        for (let i = 0; i < input.orderedProjectIds.length; i++) {
          await tx.waitlistEntry.updateMany({
            where: {
              programId: input.programId,
              category: input.category,
              projectId: input.orderedProjectIds[i],
            },
            data: { rank: i + 1 },
          })
        }
      })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'WAITLIST_REORDER',
        entityType: 'Program',
        entityId: input.programId,
        detailsJson: {
          category: input.category,
          orderedProjectIds: input.orderedProjectIds,
        },
      })
      return { ok: true }
    }),

  /**
   * Admin un-confirm: flips a CONFIRMED finalist back to SUPERSEDED. Cascades
   * to drop the active mentor assignment (if any), notifies the mentor, and
   * audit-logs the override. Used to allow a category quota decrease when
   * the new quota would otherwise be below the confirmed count.
   */
  unconfirm: adminProcedure
    .input(
      z.object({
        confirmationId: z.string(),
        reason: z.string().min(5).max(500),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const confirmation = await ctx.prisma.finalistConfirmation.findUniqueOrThrow({
        where: { id: input.confirmationId },
        include: {
          project: {
            select: {
              id: true,
              title: true,
              mentorAssignments: {
                where: { droppedAt: null, completionStatus: { not: 'completed' } },
                select: {
                  id: true,
                  completionStatus: true,
                  droppedAt: true,
                  mentorId: true,
                },
              },
              teamMembers: {
                where: { role: 'LEAD' },
                take: 1,
                select: { userId: true },
              },
            },
          },
        },
      })
      if (confirmation.status !== 'CONFIRMED') {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: `Confirmation is not in CONFIRMED status (current: ${confirmation.status})`,
        })
      }

      await ctx.prisma.finalistConfirmation.update({
        where: { id: confirmation.id },
        data: { status: 'SUPERSEDED' },
      })

      // Cascade: drop ALL active mentor assignments (skip dropped/completed —
      // those were filtered out by the include `where` above). With multi-mentor
      // (PR8) we propagate the cascade to every active assignment.
      const activeAssignments = confirmation.project.mentorAssignments
      let cascadedMentorAssignment = false
      for (const ma of activeAssignments) {
        await ctx.prisma.mentorAssignment.update({
          where: { id: ma.id },
          data: {
            droppedAt: new Date(),
            droppedReason: `Finalist un-confirmed: ${input.reason}`,
            droppedBy: 'finalist_unconfirmed',
          },
        })
        cascadedMentorAssignment = true
        // Notify mentor — best-effort
        try {
          await createNotification({
            userId: ma.mentorId,
            type: NotificationTypes.MENTEE_DROPPED,
            title: 'Mentee finalist slot withdrawn',
            message: `Your mentee "${confirmation.project.title}" is no longer a confirmed finalist. Your assignment has ended.`,
            priority: 'high',
          })
        } catch (err) {
          console.error('[finalist.unconfirm] notify mentor failed:', err)
        }
      }

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'FINALIST_UNCONFIRM',
        entityType: 'FinalistConfirmation',
        entityId: confirmation.id,
        detailsJson: {
          reason: input.reason,
          projectId: confirmation.projectId,
          cascadedMentorAssignment,
          cascadedAssignmentCount: activeAssignments.length,
        },
      })

      // Withdrawal notification to team lead — best-effort, never throws
      try {
        const lead = confirmation.project.teamMembers[0]
        if (lead) {
          const projectTitle = confirmation.project.title
          await createNotification({
            userId: lead.userId,
            type: NotificationTypes.FINALIST_WITHDRAWN,
            title: 'Grand finale slot withdrawn',
            message: `Your team "${projectTitle}" is no longer a confirmed finalist.`,
            linkUrl: '/applicant',
            metadata: { projectTitle },
          })
        }
      } catch (err) {
        console.error('[finalist.unconfirm] failed to send withdrawal notification to team:', err)
      }

      return { ok: true, cascadedMentorAssignment }
    }),

  /**
   * Manually promote a specific waitlist entry out of rank order. Sends a
   * fresh confirmation email + audit-logs the override (separate from
   * automatic cascade).
   */
  manualPromote: adminProcedure
    .input(
      z.object({
        waitlistEntryId: z.string(),
        windowHours: z.number().int().min(1).max(168).default(24),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const entry = await ctx.prisma.waitlistEntry.findUniqueOrThrow({
        where: { id: input.waitlistEntryId },
        select: {
          id: true,
          projectId: true,
          category: true,
          status: true,
          programId: true,
        },
      })
      if (entry.status !== 'WAITING') {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: `Waitlist entry is ${entry.status}, not WAITING`,
        })
      }
      await ctx.prisma.waitlistEntry.update({
        where: { id: entry.id },
        data: { status: 'PROMOTED' },
      })
      const { id: confirmationId, token, deadline } = await createPendingConfirmation(
        ctx.prisma,
        {
          projectId: entry.projectId,
          category: entry.category,
          windowHours: input.windowHours,
          promotedFromWaitlistEntryId: entry.id,
        },
      )
      // Email send (best-effort)
      const project = await ctx.prisma.project.findUnique({
        where: { id: entry.projectId },
        select: {
          title: true,
          teamMembers: {
            where: { role: 'LEAD' },
            take: 1,
            select: { user: { select: { email: true, name: true } } },
          },
        },
      })
      const lead = project?.teamMembers[0]?.user
      if (lead?.email && project) {
        const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
        const confirmUrl = `${baseUrl}/finalist/confirm/${token}`
        try {
          await sendFinalistConfirmationEmail(
            lead.email,
            lead.name ?? null,
            project.title,
            deadline,
            confirmUrl,
          )
        } catch (err) {
          console.error(
            `[finalist.manualPromote] failed to send email for project ${entry.projectId}:`,
            err,
          )
        }
      }
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'FINALIST_MANUAL_PROMOTE',
        entityType: 'WaitlistEntry',
        entityId: entry.id,
        detailsJson: {
          programId: entry.programId,
          category: entry.category,
          projectId: entry.projectId,
          confirmationId,
          windowHours: input.windowHours,
        },
      })
      // Admin alert — best-effort, never throws
      try {
        const projectTitle = project?.title ?? entry.projectId
        await notifyAdmins({
          type: NotificationTypes.FINALIST_WAITLIST_PROMOTED,
          title: 'Waitlist entry promoted',
          message: `"${projectTitle}" (${entry.category}) was manually promoted from the waitlist.`,
          linkUrl: '/admin/logistics',
          metadata: {
            projectId: entry.projectId,
            projectTitle,
            category: entry.category,
          },
        })
      } catch (err) {
        console.error('[finalist.manualPromote] failed to send admin notification:', err)
      }
      return { confirmationId }
    }),

  /**
   * Team lead replaces the AttendingMember roster for a CONFIRMED finalist
   * confirmation. Diff-based: rows for users who stay are kept (preserving
   * their FlightDetail); removed users are deleted (cascading FlightDetail);
   * added users get fresh rows. Closed once `attendeeEditCutoffHours` (default
   * 48) before the LIVE_FINAL round's `windowOpenAt`.
   */
  editAttendees: protectedProcedure
    .input(
      z.object({
        confirmationId: z.string(),
        attendingUserIds: z.array(z.string()).min(1),
        visaFlags: z.record(z.string(), z.boolean()).default({}),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const confirmation = await ctx.prisma.finalistConfirmation.findUniqueOrThrow({
        where: { id: input.confirmationId },
        include: {
          project: {
            select: {
              id: true,
              programId: true,
              program: { select: { defaultAttendeeCap: true } },
              teamMembers: { select: { userId: true, role: true } },
            },
          },
          attendingMembers: {
            select: {
              id: true,
              userId: true,
              needsVisa: true,
              visaApplication: { select: { id: true } },
            },
          },
        },
      })

      const callerMembership = confirmation.project.teamMembers.find(
        (tm) => tm.userId === ctx.user.id,
      )
      if (!callerMembership) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You are not a team member of this project',
        })
      }
      if (callerMembership.role !== 'LEAD') {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only the team lead can edit attendees',
        })
      }

      if (confirmation.status !== 'CONFIRMED') {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Confirmation must be in CONFIRMED status to edit attendees',
        })
      }

      // Cap check
      const cap = confirmation.project.program.defaultAttendeeCap
      if (input.attendingUserIds.length > cap) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: `Selection exceeds attendee cap of ${cap}`,
        })
      }

      // Team membership check for the new roster
      const teamUserIds = new Set(confirmation.project.teamMembers.map((tm) => tm.userId))
      for (const id of input.attendingUserIds) {
        if (!teamUserIds.has(id)) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: `User ${id} is not a team member`,
          })
        }
      }

      // Cutoff check — uses the LIVE_FINAL round's windowOpenAt + cfg.attendeeEditCutoffHours
      const round = await ctx.prisma.round.findFirst({
        where: {
          competition: { programId: confirmation.project.programId },
          roundType: 'LIVE_FINAL',
        },
        orderBy: { sortOrder: 'desc' },
        select: { windowOpenAt: true, configJson: true },
      })
      if (round?.windowOpenAt) {
        const cfg = (round.configJson ?? {}) as { attendeeEditCutoffHours?: number }
        const cutoffHours = cfg.attendeeEditCutoffHours ?? 48
        const cutoffAt = new Date(round.windowOpenAt.getTime() - cutoffHours * 3_600_000)
        if (Date.now() > cutoffAt.getTime()) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: `Attendee edits closed ${cutoffHours}h before the grand finale (cutoff was ${cutoffAt.toISOString()})`,
          })
        }
      }

      // Diff: keep users in both, delete removed, create added
      const desiredIds = new Set(input.attendingUserIds)
      const existingByUser = new Map(
        confirmation.attendingMembers.map((m) => [m.userId, m] as const),
      )
      const toDelete = confirmation.attendingMembers.filter((m) => !desiredIds.has(m.userId))
      const toCreate = input.attendingUserIds.filter((id) => !existingByUser.has(id))
      const toUpdate = input.attendingUserIds.filter((id) => existingByUser.has(id))

      await ctx.prisma.$transaction(async (tx) => {
        if (toDelete.length > 0) {
          // FK cascade removes any VisaApplication rows tied to deleted attendees
          await tx.attendingMember.deleteMany({
            where: { id: { in: toDelete.map((m) => m.id) } },
          })
        }

        // Diff visa flips for users that stay
        const visaToDelete: string[] = []
        for (const userId of toUpdate) {
          const existing = existingByUser.get(userId)!
          const wantsVisa = input.visaFlags[userId] === true
          await tx.attendingMember.update({
            where: { id: existing.id },
            data: { needsVisa: wantsVisa },
          })
          if (existing.visaApplication && !wantsVisa) {
            visaToDelete.push(existing.visaApplication.id)
          } else if (!existing.visaApplication && wantsVisa) {
            await tx.visaApplication.create({
              data: { attendingMemberId: existing.id, status: 'REQUESTED' },
            })
          }
        }
        if (visaToDelete.length > 0) {
          await tx.visaApplication.deleteMany({ where: { id: { in: visaToDelete } } })
        }

        if (toCreate.length > 0) {
          await tx.attendingMember.createMany({
            data: toCreate.map((userId) => ({
              confirmationId: confirmation.id,
              userId,
              needsVisa: input.visaFlags[userId] ?? false,
            })),
          })
          const newVisaUsers = toCreate.filter((id) => input.visaFlags[id] === true)
          if (newVisaUsers.length > 0) {
            const created = await tx.attendingMember.findMany({
              where: { confirmationId: confirmation.id, userId: { in: newVisaUsers } },
              select: { id: true },
            })
            await tx.visaApplication.createMany({
              data: created.map((m) => ({ attendingMemberId: m.id, status: 'REQUESTED' })),
            })
          }
          const newMembers = await tx.attendingMember.findMany({
            where: { confirmationId: confirmation.id, userId: { in: toCreate } },
            select: { id: true },
          })
          for (const m of newMembers) {
            await ensureLunchPickForAttendingMember(tx, m.id)
          }
        }
      })

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'FINALIST_EDIT_ATTENDEES',
        entityType: 'FinalistConfirmation',
        entityId: confirmation.id,
        detailsJson: {
          attendingUserIds: input.attendingUserIds,
          visaFlags: input.visaFlags,
          added: toCreate,
          removed: toDelete.map((m) => m.userId),
        },
      })

      return { ok: true }
    }),

  /**
   * List all MENTORING-round projects as enrollment candidates, grouped by
   * competitionCategory. Each candidate includes team members, inLiveFinal flag,
   * confirmationStatus, and per-category quota + confirmed/pending counts.
   * Drives the Finalist Enrollment Card on the LIVE_FINAL round Overview page.
   */
  listEnrollmentCandidates: adminProcedure
    .input(z.object({ programId: z.string() }))
    .query(async ({ ctx, input }) => {
      // Resolve program (for attendeeCap) and its competitions' rounds
      const program = await ctx.prisma.program.findUniqueOrThrow({
        where: { id: input.programId },
        select: { defaultAttendeeCap: true },
      })

      // Find the MENTORING and LIVE_FINAL rounds within this program's competitions
      const rounds = await ctx.prisma.round.findMany({
        where: {
          competition: { programId: input.programId },
          roundType: { in: ['MENTORING', 'LIVE_FINAL'] },
        },
        select: { id: true, roundType: true },
        orderBy: { sortOrder: 'asc' },
      })

      const mentoringRound = rounds.find((r) => r.roundType === 'MENTORING') ?? null
      const liveFinalRound = rounds.find((r) => r.roundType === 'LIVE_FINAL') ?? null

      if (!mentoringRound) {
        return {
          liveFinalRoundId: liveFinalRound?.id ?? null,
          attendeeCap: program.defaultAttendeeCap,
          categories: [],
        }
      }

      // Load all PRS in the MENTORING round with full project + team data
      const states = await ctx.prisma.projectRoundState.findMany({
        where: { roundId: mentoringRound.id },
        select: {
          project: {
            select: {
              id: true,
              title: true,
              teamName: true,
              country: true,
              competitionCategory: true,
              finalistConfirmation: { select: { status: true } },
              projectRoundStates: {
                where: { roundId: liveFinalRound?.id ?? '' },
                select: { projectId: true },
                take: 1,
              },
              teamMembers: {
                select: {
                  userId: true,
                  role: true,
                  user: { select: { name: true, email: true } },
                },
              },
            },
          },
        },
        orderBy: [{ project: { title: 'asc' } }],
      })

      // Aggregate confirmed/pending counts per category (mirror listCategoryCounts)
      const grouped = await ctx.prisma.finalistConfirmation.groupBy({
        by: ['category', 'status'],
        where: { project: { programId: input.programId } },
        _count: { _all: true },
      })
      const countsByCategory = new Map<string, { confirmed: number; pending: number }>()
      for (const g of grouped) {
        const slot = countsByCategory.get(g.category) ?? { confirmed: 0, pending: 0 }
        if (g.status === 'CONFIRMED') slot.confirmed = g._count._all
        if (g.status === 'PENDING') slot.pending = g._count._all
        countsByCategory.set(g.category, slot)
      }

      // Load quotas for this program
      const quotas = await ctx.prisma.finalistSlotQuota.findMany({
        where: { programId: input.programId },
        select: { category: true, quota: true },
      })
      const quotaByCategory = new Map(quotas.map((q) => [q.category as string, q.quota]))

      // Group candidates by competitionCategory
      const categoryMap = new Map<
        string,
        {
          category: string
          quota: number | null
          confirmedCount: number
          pendingCount: number
          candidates: Array<{
            projectId: string
            title: string
            teamName: string | null
            country: string | null
            inLiveFinal: boolean
            confirmationStatus: string | null
            teamMembers: Array<{ userId: string; name: string | null; role: string; email: string }>
          }>
        }
      >()

      for (const s of states) {
        const p = s.project
        const cat = (p.competitionCategory as string) ?? 'UNKNOWN'

        if (!categoryMap.has(cat)) {
          const counts = countsByCategory.get(cat) ?? { confirmed: 0, pending: 0 }
          categoryMap.set(cat, {
            category: cat,
            quota: quotaByCategory.get(cat) ?? null,
            confirmedCount: counts.confirmed,
            pendingCount: counts.pending,
            candidates: [],
          })
        }

        const inLiveFinal = p.projectRoundStates.length > 0

        categoryMap.get(cat)!.candidates.push({
          projectId: p.id,
          title: p.title,
          teamName: p.teamName,
          country: p.country,
          inLiveFinal,
          confirmationStatus: p.finalistConfirmation?.status ?? null,
          teamMembers: p.teamMembers.map((tm) => ({
            userId: tm.userId,
            name: tm.user.name,
            role: tm.role,
            email: tm.user.email,
          })),
        })
      }

      return {
        liveFinalRoundId: liveFinalRound?.id ?? null,
        attendeeCap: program.defaultAttendeeCap,
        categories: Array.from(categoryMap.values()),
      }
    }),

  /**
   * Unified finalist enrollment: advances a set of projects into the LIVE_FINAL
   * round (creates ProjectRoundState, skipDuplicates) AND creates/resets their
   * FinalistConfirmation in one atomic step.
   *
   * Two attendee modes per project:
   *  - EMAIL: sends the self-confirm link to the team lead (never throws in loop)
   *  - ADMIN_CONFIRM: validates + writes attendees immediately (CONFIRMED status)
   *
   * Returns { enrolled, emailed, adminConfirmed, skipped }.
   */
  enrollFinalists: adminProcedure
    .input(
      z.object({
        programId: z.string(),
        roundId: z.string(), // the LIVE_FINAL round
        enrollments: z
          .array(
            z.object({
              projectId: z.string(),
              mode: z.enum(['EMAIL', 'ADMIN_CONFIRM']),
              attendingUserIds: z.array(z.string()).optional(),
              visaFlags: z.record(z.string(), z.boolean()).optional(),
            }),
          )
          .min(1),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      // Resolve the LIVE_FINAL round + confirmationWindowHours. Validate the
      // round belongs to the target program so an admin can't inject projects
      // into another edition's round.
      const round = await ctx.prisma.round.findUniqueOrThrow({
        where: { id: input.roundId },
        select: {
          id: true,
          configJson: true,
          competition: { select: { programId: true } },
        },
      })
      if (round.competition.programId !== input.programId) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Round does not belong to this program',
        })
      }
      const cfg = (round.configJson ?? {}) as { confirmationWindowHours?: number }
      const windowHours = cfg.confirmationWindowHours ?? 24

      // Validate all projects belong to this program
      const projectIds = input.enrollments.map((e) => e.projectId)
      const projects = await ctx.prisma.project.findMany({
        where: { id: { in: projectIds }, programId: input.programId },
        select: {
          id: true,
          title: true,
          competitionCategory: true,
          program: { select: { defaultAttendeeCap: true } },
          teamMembers: {
            select: {
              userId: true,
              role: true,
              user: { select: { email: true, name: true } },
            },
          },
        },
      })
      if (projects.length !== projectIds.length) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'One or more project IDs not found in this program',
        })
      }

      const projectMap = new Map(projects.map((p) => [p.id, p]))
      const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')

      // Pre-validate every ADMIN_CONFIRM enrollment up front so a bad entry in
      // a multi-team batch fails before any project is partially written.
      for (const enrollment of input.enrollments) {
        if (enrollment.mode !== 'ADMIN_CONFIRM') continue
        const project = projectMap.get(enrollment.projectId)!
        const cap = project.program.defaultAttendeeCap
        const attendingUserIds = enrollment.attendingUserIds ?? []
        if (attendingUserIds.length === 0) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: `ADMIN_CONFIRM mode requires attendingUserIds for project ${project.id}`,
          })
        }
        if (attendingUserIds.length > cap) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: `Selection exceeds attendee cap of ${cap} for project ${project.id}`,
          })
        }
        const teamUserIds = new Set(project.teamMembers.map((tm) => tm.userId))
        for (const uid of attendingUserIds) {
          if (!teamUserIds.has(uid)) {
            throw new TRPCError({
              code: 'BAD_REQUEST',
              message: `User ${uid} is not a team member of project ${project.id}`,
            })
          }
        }
      }

      let enrolled = 0
      let emailed = 0
      let adminConfirmed = 0
      const skipped: Array<{ projectId: string; reason: string }> = []

      for (const enrollment of input.enrollments) {
        const project = projectMap.get(enrollment.projectId)!

        // Step 1: Create ProjectRoundState in LIVE_FINAL round (idempotent)
        await ctx.prisma.projectRoundState.createMany({
          data: [{ projectId: enrollment.projectId, roundId: input.roundId }],
          skipDuplicates: true,
        })

        // Step 2: Create or reset the finalist confirmation
        const category = project.competitionCategory as CompetitionCategory
        const confirmResult = await resetOrCreatePendingConfirmation(ctx.prisma, {
          projectId: enrollment.projectId,
          category,
          windowHours,
        })

        if (confirmResult.alreadyConfirmed) {
          skipped.push({ projectId: enrollment.projectId, reason: 'ALREADY_CONFIRMED' })
          enrolled++
          continue
        }

        enrolled++

        // Step 3: Mode-specific handling
        if (enrollment.mode === 'EMAIL') {
          // Send confirmation email to team lead (best-effort — never throw in loop)
          const lead = project.teamMembers.find((tm) => tm.role === 'LEAD')?.user
          if (lead?.email) {
            const confirmUrl = `${baseUrl}/finalist/confirm/${confirmResult.token}`
            try {
              await sendFinalistConfirmationEmail(
                lead.email,
                lead.name ?? null,
                project.title,
                confirmResult.deadline,
                confirmUrl,
              )
              emailed++
            } catch (err) {
              console.error(
                `[finalist.enrollFinalists] failed to send email to ${lead.email} for project ${enrollment.projectId}:`,
                err,
              )
            }
          }
        } else {
          // ADMIN_CONFIRM: write attendees + visa + lunch rows immediately
          const attendingUserIds = enrollment.attendingUserIds!
          const visaFlags = enrollment.visaFlags ?? {}

          await ctx.prisma.$transaction(async (tx) => {
            await confirmAttendanceInTx(tx, {
              confirmationId: confirmResult.id,
              attendingUserIds,
              visaFlags,
            })
          })
          adminConfirmed++
        }

        // Step 4: Audit per enrollment
        await logAudit({
          prisma: ctx.prisma,
          userId: ctx.user.id,
          action: 'FINALIST_ENROLL',
          entityType: 'Project',
          entityId: enrollment.projectId,
          detailsJson: {
            projectId: enrollment.projectId,
            mode: enrollment.mode,
            roundId: input.roundId,
            programId: input.programId,
          },
        })
      }

      return { enrolled, emailed, adminConfirmed, skipped }
    }),

  /**
   * Reverse enrollment: removes a project from the LIVE_FINAL round and
   * deletes its FinalistConfirmation (cascade removes AttendingMember,
   * FlightDetail, VisaApplication, and MemberLunchPick rows).
   *
   * Mentor assignments (tied to the MENTORING round) are intentionally
   * left untouched. Safe to call even if the project was never enrolled
   * (deleteMany is a no-op when no rows match).
   */
  unenroll: adminProcedure
    .input(
      z.object({
        projectId: z.string(),
        roundId: z.string(), // the LIVE_FINAL round
      }),
    )
    .mutation(async ({ ctx, input }) => {
      // Guard: the project and round must belong to the same program, so a
      // mismatched (projectId, roundId) pair from different editions can't be
      // used to delete the wrong project's confirmation or round membership.
      const project = await ctx.prisma.project.findUniqueOrThrow({
        where: { id: input.projectId },
        select: { programId: true },
      })
      const round = await ctx.prisma.round.findUniqueOrThrow({
        where: { id: input.roundId },
        select: { competition: { select: { programId: true } } },
      })
      if (project.programId !== round.competition.programId) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Project and round belong to different programs',
        })
      }

      // Step 1: Capture the CONFIRMED confirmation (if any) BEFORE deleting,
      // so we can notify the team lead. We only notify when the team had
      // already confirmed (CONFIRMED status) — not for PENDING or absent rows.
      const confirmedConfirmation = await ctx.prisma.finalistConfirmation.findFirst({
        where: { projectId: input.projectId, status: 'CONFIRMED' },
        select: {
          project: {
            select: {
              title: true,
              teamMembers: {
                where: { role: 'LEAD' },
                take: 1,
                select: { userId: true },
              },
            },
          },
        },
      })

      // Step 2: Delete the FinalistConfirmation (cascade removes AttendingMember
      // / FlightDetail / VisaApplication / MemberLunchPick).
      // deleteMany is no-op-safe when no row exists.
      await ctx.prisma.finalistConfirmation.deleteMany({
        where: { projectId: input.projectId },
      })

      // Step 3: Delete the LIVE_FINAL ProjectRoundState.
      await ctx.prisma.projectRoundState.deleteMany({
        where: { projectId: input.projectId, roundId: input.roundId },
      })

      // Step 4: Audit log
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'FINALIST_UNENROLL',
        entityType: 'Project',
        entityId: input.projectId,
        detailsJson: {
          projectId: input.projectId,
          roundId: input.roundId,
        },
      })

      // Step 5: Withdrawal notification — only when a CONFIRMED row existed.
      // Best-effort, never throws.
      if (confirmedConfirmation) {
        try {
          const lead = confirmedConfirmation.project.teamMembers[0]
          if (lead) {
            const projectTitle = confirmedConfirmation.project.title
            await createNotification({
              userId: lead.userId,
              type: NotificationTypes.FINALIST_WITHDRAWN,
              title: 'Grand finale slot withdrawn',
              message: `Your team "${projectTitle}" is no longer a confirmed finalist.`,
              linkUrl: '/applicant',
              metadata: { projectTitle },
            })
          }
        } catch (err) {
          console.error('[finalist.unenroll] failed to send withdrawal notification to team:', err)
        }
      }

      return { ok: true }
    }),

  /** Manually remind finalist teams to upload their Grand Final documents. */
  sendDocumentReminders: adminProcedure
    .input(z.object({ programId: z.string(), projectIds: z.array(z.string()).optional() }))
    .mutation(async ({ ctx, input }) => {
      const result = await sendManualFinalDocReminders(ctx.prisma, {
        programId: input.programId,
        projectIds: input.projectIds,
        actorId: ctx.user.id,
      })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'FINALIST_DOCS_REMINDER_SENT',
        entityType: 'Program',
        entityId: input.programId,
        detailsJson: { sent: result.sent, projectIds: input.projectIds ?? 'all-missing' },
      })
      return result
    }),

  /** Read-only review of all finalists' grand-final documents (admins + finale jury). */
  /** Lightweight boolean — may the current user open the finalist documents review? Self-resolves the active program so the nav can gate the link without fetching the full payload. */
  canReviewDocuments: protectedProcedure.query(async ({ ctx }) => {
    const program = await ctx.prisma.program.findFirst({
      where: { status: 'ACTIVE' },
      orderBy: { year: 'desc' },
      select: { id: true },
    })
    if (!program) return false
    return userCanReviewFinals(ctx.prisma, ctx.user.id, ctx.user.role, program.id)
  }),

  listReviewDocuments: protectedProcedure
    .input(z.object({ programId: z.string() }))
    .query(async ({ ctx, input }) => {
      const allowed = await userCanReviewFinals(ctx.prisma, ctx.user.id, ctx.user.role, input.programId)
      if (!allowed) throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to the finalist documents review.' })
      return listFinalistDocumentsForReview(ctx.prisma, input.programId)
    }),

  /** Read whether finalists may upload revised grand-final documents (admin toggle). */
  getRevisedUploadSetting: adminProcedure
    .input(z.object({ roundId: z.string() }))
    .query(async ({ ctx, input }) => {
      const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true } })
      const cfg = (round?.configJson ?? {}) as { allowFinalistRevisedUploads?: boolean }
      return { enabled: !!cfg.allowFinalistRevisedUploads }
    }),

  /** Toggle whether finalists may upload revised grand-final documents (admin setting on the LIVE_FINAL round). */
  setRevisedUploadSetting: adminProcedure
    .input(z.object({ roundId: z.string(), enabled: z.boolean() }))
    .mutation(async ({ ctx, input }) => {
      const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true, roundType: true } })
      if (!round || round.roundType !== 'LIVE_FINAL') {
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a grand-final round' })
      }
      const cfg = (round.configJson ?? {}) as Record<string, unknown>
      await ctx.prisma.round.update({
        where: { id: input.roundId },
        data: { configJson: { ...cfg, allowFinalistRevisedUploads: input.enabled } },
      })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'FINALIST_REVISED_UPLOADS_TOGGLED',
        entityType: 'Round',
        entityId: input.roundId,
        detailsJson: { enabled: input.enabled },
      })
      return { ok: true, enabled: input.enabled }
    }),

  /** Options + current selection for the "documents shown to judges" picker. */
  getReviewDocSettings: adminProcedure
    .input(z.object({ programId: z.string(), roundId: z.string() }))
    .query(async ({ ctx, input }) => {
      const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true } })
      return {
        options: await listReviewVisibilityOptions(ctx.prisma, input.programId),
        selectedIds: reviewVisibleRequirementIds(round?.configJson ?? null),
      }
    }),

  /** Set which prior-round documents finale judges see. null = show all (clears curation). */
  setReviewVisibleRequirements: adminProcedure
    .input(z.object({ roundId: z.string(), requirementIds: z.array(z.string()).nullable() }))
    .mutation(async ({ ctx, input }) => {
      const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true, roundType: true } })
      if (!round || round.roundType !== 'LIVE_FINAL') {
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a grand-final round' })
      }
      const { reviewVisibleRequirementIds: _omit, ...rest } = (round.configJson ?? {}) as Record<string, unknown>
      const next = input.requirementIds === null ? rest : { ...rest, reviewVisibleRequirementIds: input.requirementIds }
      await ctx.prisma.round.update({ where: { id: input.roundId }, data: { configJson: next as object } })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'FINALIST_REVIEW_DOCS_CURATED',
        entityType: 'Round',
        entityId: input.roundId,
        detailsJson: { requirementIds: input.requirementIds },
      })
      return { ok: true }
    }),
})
