import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../../trpc'
import { createNotification, createBulkNotifications, notifyAdmins, NotificationTypes } from '../../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
import { reassignAfterCOI, reassignDroppedJurorAssignments } from '../../services/juror-reassignment'
import { MOVABLE_EVAL_STATUSES, getCandidateJurors } from './shared'

export const assignmentRedistributionRouter = router({
  reassignCOI: adminProcedure
    .input(z.object({ assignmentId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const result = await reassignAfterCOI({
        assignmentId: input.assignmentId,
        auditUserId: ctx.user.id,
        auditIp: ctx.ip,
        auditUserAgent: ctx.userAgent,
      })

      if (!result) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'No eligible juror found for reassignment. All jurors are either already assigned to this project, have a COI, or are at their assignment limit.',
        })
      }

      return result
    }),

  reassignDroppedJuror: adminProcedure
    .input(z.object({ roundId: z.string(), jurorId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      return reassignDroppedJurorAssignments({
        roundId: input.roundId,
        droppedJurorId: input.jurorId,
        auditUserId: ctx.user.id,
        auditIp: ctx.ip,
        auditUserAgent: ctx.userAgent,
      })
    }),

  /**
   * Redistribute all movable assignments from a juror to other jurors (without dropping them from the group).
   * Uses the same greedy algorithm as reassignDroppedJuror but keeps the juror in the jury group.
   * Prefers jurors who haven't finished all evaluations; as last resort uses completed jurors.
   */
  redistributeJurorAssignments: adminProcedure
    .input(z.object({ roundId: z.string(), jurorId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const round = await ctx.prisma.round.findUniqueOrThrow({
        where: { id: input.roundId },
        select: { id: true, name: true, configJson: true, juryGroupId: true, windowCloseAt: true },
      })

      const sourceJuror = await ctx.prisma.user.findUniqueOrThrow({
        where: { id: input.jurorId },
        select: { id: true, name: true, email: true },
      })

      const config = (round.configJson ?? {}) as Record<string, unknown>
      const fallbackCap =
        (config.maxLoadPerJuror as number) ??
        (config.maxAssignmentsPerJuror as number) ??
        20

      const assignmentsToMove = await ctx.prisma.assignment.findMany({
        where: {
          roundId: input.roundId,
          userId: input.jurorId,
          OR: [
            { evaluation: null },
            { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
          ],
        },
        select: {
          id: true, projectId: true, juryGroupId: true, isRequired: true,
          project: { select: { title: true } },
        },
        orderBy: { createdAt: 'asc' },
      })

      if (assignmentsToMove.length === 0) {
        return { movedCount: 0, failedCount: 0, failedProjects: [] as string[] }
      }

      // Build candidate pool
      const candidateJurors = await getCandidateJurors(
        ctx.prisma,
        input.roundId,
        round.juryGroupId,
        input.jurorId,
      )

      if (candidateJurors.length === 0) {
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active replacement jurors available' })
      }

      const candidateIds = candidateJurors.map((j) => j.id)

      const existingAssignments = await ctx.prisma.assignment.findMany({
        where: { roundId: input.roundId },
        select: { userId: true, projectId: true },
      })
      const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
      const currentLoads = new Map<string, number>()
      for (const a of existingAssignments) currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)

      const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
        where: { assignment: { roundId: input.roundId }, hasConflict: true, userId: { in: candidateIds } },
        select: { userId: true, projectId: true },
      })
      const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))

      // Completed eval counts for "prefer not-finished" logic
      const completedEvals = await ctx.prisma.evaluation.findMany({
        where: { assignment: { roundId: input.roundId, userId: { in: candidateIds } }, status: 'SUBMITTED' },
        select: { assignment: { select: { userId: true } } },
      })
      const completedCounts = new Map<string, number>()
      for (const e of completedEvals) completedCounts.set(e.assignment.userId, (completedCounts.get(e.assignment.userId) ?? 0) + 1)

      const caps = new Map<string, number>()
      for (const j of candidateJurors) caps.set(j.id, j.maxAssignments ?? fallbackCap)

      const plannedMoves: { assignmentId: string; projectId: string; projectTitle: string; newJurorId: string; juryGroupId: string | null; isRequired: boolean }[] = []
      const failedProjects: string[] = []

      for (const assignment of assignmentsToMove) {
        // First pass: prefer jurors who haven't completed all evals
        let eligible = candidateIds
          .filter((jid) => !alreadyAssigned.has(`${jid}:${assignment.projectId}`))
          .filter((jid) => !coiPairs.has(`${jid}:${assignment.projectId}`))
          .filter((jid) => (currentLoads.get(jid) ?? 0) < (caps.get(jid) ?? fallbackCap))

        // Sort: prefer not-all-completed, then lowest load
        eligible.sort((a, b) => {
          const loadA = currentLoads.get(a) ?? 0
          const loadB = currentLoads.get(b) ?? 0
          const compA = completedCounts.get(a) ?? 0
          const compB = completedCounts.get(b) ?? 0
          const doneA = loadA > 0 && compA === loadA ? 1 : 0
          const doneB = loadB > 0 && compB === loadB ? 1 : 0
          if (doneA !== doneB) return doneA - doneB
          return loadA - loadB
        })

        if (eligible.length === 0) {
          failedProjects.push(assignment.project.title)
          continue
        }

        const selectedId = eligible[0]
        plannedMoves.push({
          assignmentId: assignment.id, projectId: assignment.projectId,
          projectTitle: assignment.project.title, newJurorId: selectedId,
          juryGroupId: assignment.juryGroupId ?? round.juryGroupId, isRequired: assignment.isRequired,
        })
        alreadyAssigned.add(`${selectedId}:${assignment.projectId}`)
        currentLoads.set(selectedId, (currentLoads.get(selectedId) ?? 0) + 1)
      }

      // Execute in transaction
      const actualMoves: typeof plannedMoves = []
      if (plannedMoves.length > 0) {
        await ctx.prisma.$transaction(async (tx) => {
          for (const move of plannedMoves) {
            const deleted = await tx.assignment.deleteMany({
              where: {
                id: move.assignmentId, userId: input.jurorId,
                OR: [{ evaluation: null }, { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } }],
              },
            })
            if (deleted.count === 0) { failedProjects.push(move.projectTitle); continue }
            await tx.assignment.create({
              data: {
                roundId: input.roundId, projectId: move.projectId, userId: move.newJurorId,
                juryGroupId: move.juryGroupId ?? undefined, isRequired: move.isRequired,
                method: 'MANUAL', createdBy: ctx.user.id,
              },
            })
            actualMoves.push(move)
          }
        })
      }

      // Send MANUAL_REASSIGNED emails per destination juror
      if (actualMoves.length > 0) {
        const destProjectNames: Record<string, string[]> = {}
        for (const move of actualMoves) {
          if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = []
          destProjectNames[move.newJurorId].push(move.projectTitle)
        }

        const deadline = round.windowCloseAt
          ? new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Paris' }).format(round.windowCloseAt)
          : undefined

        for (const [jurorId, projectNames] of Object.entries(destProjectNames)) {
          const count = projectNames.length
          await createNotification({
            userId: jurorId,
            type: NotificationTypes.MANUAL_REASSIGNED,
            title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`,
            message: count === 1
              ? `The project "${projectNames[0]}" has been reassigned to you for evaluation in ${round.name}.`
              : `${count} projects have been reassigned to you for evaluation in ${round.name}: ${projectNames.join(', ')}.`,
            linkUrl: `/jury/competitions`,
            linkLabel: 'View Assignments',
            metadata: { roundId: round.id, roundName: round.name, projectNames, deadline, reason: 'admin_redistribute' },
          })
        }

        const sourceName = sourceJuror.name || sourceJuror.email
        const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
        const topReceivers = Object.entries(destProjectNames)
          .map(([jid, ps]) => { const j = candidateMeta.get(jid); return `${j?.name || j?.email || jid} (${ps.length})` })
          .join(', ')

        await notifyAdmins({
          type: NotificationTypes.EVALUATION_MILESTONE,
          title: 'Assignment Redistribution',
          message: `Redistributed ${actualMoves.length} project(s) from ${sourceName} to: ${topReceivers}.${failedProjects.length > 0 ? ` ${failedProjects.length} could not be reassigned.` : ''}`,
          linkUrl: `/admin/rounds/${round.id}`,
          linkLabel: 'View Round',
          metadata: { roundId: round.id, sourceJurorId: input.jurorId, movedCount: actualMoves.length, failedCount: failedProjects.length },
        })

        await logAudit({
          prisma: ctx.prisma, userId: ctx.user.id, action: 'ASSIGNMENT_REDISTRIBUTE',
          entityType: 'Round', entityId: round.id,
          detailsJson: { sourceJurorId: input.jurorId, sourceName, movedCount: actualMoves.length, failedCount: failedProjects.length },
          ipAddress: ctx.ip, userAgent: ctx.userAgent,
        })
      }

      return { movedCount: actualMoves.length, failedCount: failedProjects.length, failedProjects }
    }),

  /**
   * Get transfer candidates: which of the source juror's assignments can be moved,
   * and which other jurors are eligible to receive them.
   */
  getTransferCandidates: adminProcedure
    .input(z.object({
      roundId: z.string(),
      sourceJurorId: z.string(),
      assignmentIds: z.array(z.string()),
    }))
    .query(async ({ ctx, input }) => {
      const round = await ctx.prisma.round.findUniqueOrThrow({
        where: { id: input.roundId },
        select: { id: true, name: true, configJson: true, juryGroupId: true },
      })

      const config = (round.configJson ?? {}) as Record<string, unknown>
      const fallbackCap =
        (config.maxLoadPerJuror as number) ??
        (config.maxAssignmentsPerJuror as number) ??
        20

      // Fetch requested assignments — must belong to source juror
      const requestedAssignments = await ctx.prisma.assignment.findMany({
        where: {
          id: { in: input.assignmentIds },
          roundId: input.roundId,
          userId: input.sourceJurorId,
        },
        select: {
          id: true,
          projectId: true,
          project: { select: { title: true } },
          evaluation: { select: { status: true } },
        },
      })

      // Filter to movable only
      const assignments = requestedAssignments.map((a) => ({
        id: a.id,
        projectId: a.projectId,
        projectTitle: a.project.title,
        evalStatus: a.evaluation?.status ?? null,
        movable: !a.evaluation || MOVABLE_EVAL_STATUSES.includes(a.evaluation.status as typeof MOVABLE_EVAL_STATUSES[number]),
      }))

      const movableProjectIds = assignments
        .filter((a) => a.movable)
        .map((a) => a.projectId)

      // Build candidate juror pool — same pattern as reassignDroppedJurorAssignments
      const candidateJurors = await getCandidateJurors(
        ctx.prisma,
        input.roundId,
        round.juryGroupId,
        input.sourceJurorId,
      )

      const candidateIds = candidateJurors.map((j) => j.id)

      // Existing assignments, loads, COI pairs
      const existingAssignments = await ctx.prisma.assignment.findMany({
        where: { roundId: input.roundId },
        select: { userId: true, projectId: true },
      })

      const currentLoads = new Map<string, number>()
      for (const a of existingAssignments) {
        currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
      }

      const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))

      // Completed evaluations count per candidate
      const completedEvals = await ctx.prisma.evaluation.findMany({
        where: {
          assignment: { roundId: input.roundId, userId: { in: candidateIds } },
          status: 'SUBMITTED',
        },
        select: { assignment: { select: { userId: true } } },
      })
      const completedCounts = new Map<string, number>()
      for (const e of completedEvals) {
        const uid = e.assignment.userId
        completedCounts.set(uid, (completedCounts.get(uid) ?? 0) + 1)
      }

      const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
        where: {
          assignment: { roundId: input.roundId },
          hasConflict: true,
          userId: { in: candidateIds },
        },
        select: { userId: true, projectId: true },
      })
      const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))

      // Build candidate list with eligibility per project
      const candidates = candidateJurors.map((j) => {
        const load = currentLoads.get(j.id) ?? 0
        const cap = j.maxAssignments ?? fallbackCap
        const completed = completedCounts.get(j.id) ?? 0
        const allCompleted = load > 0 && completed === load

        const eligibleProjectIds = movableProjectIds.filter((pid) =>
          !alreadyAssigned.has(`${j.id}:${pid}`) &&
          !coiPairs.has(`${j.id}:${pid}`) &&
          load < cap
        )

        // Track which movable projects this candidate already has assigned
        const alreadyAssignedProjectIds = movableProjectIds.filter((pid) =>
          alreadyAssigned.has(`${j.id}:${pid}`)
        )

        return {
          userId: j.id,
          name: j.name || j.email,
          email: j.email,
          currentLoad: load,
          cap,
          allCompleted,
          eligibleProjectIds,
          alreadyAssignedProjectIds,
        }
      })

      // Sort: not-all-done first, then by lowest load
      candidates.sort((a, b) => {
        if (a.allCompleted !== b.allCompleted) return a.allCompleted ? 1 : -1
        return a.currentLoad - b.currentLoad
      })

      return { assignments, candidates }
    }),

  /**
   * Transfer specific assignments from one juror to destination jurors.
   */
  transferAssignments: adminProcedure
    .input(z.object({
      roundId: z.string(),
      sourceJurorId: z.string(),
      transfers: z.array(z.object({
        assignmentId: z.string(),
        destinationJurorId: z.string(),
      })),
      forceOverCap: z.boolean().default(false),
    }))
    .mutation(async ({ ctx, input }) => {
      const round = await ctx.prisma.round.findUniqueOrThrow({
        where: { id: input.roundId },
        select: { id: true, name: true, configJson: true, juryGroupId: true },
      })

      const config = (round.configJson ?? {}) as Record<string, unknown>
      const fallbackCap =
        (config.maxLoadPerJuror as number) ??
        (config.maxAssignmentsPerJuror as number) ??
        20

      // Verify all assignments belong to source juror and are movable
      const assignmentIds = input.transfers.map((t) => t.assignmentId)
      const sourceAssignments = await ctx.prisma.assignment.findMany({
        where: {
          id: { in: assignmentIds },
          roundId: input.roundId,
          userId: input.sourceJurorId,
          OR: [
            { evaluation: null },
            { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
          ],
        },
        select: {
          id: true,
          projectId: true,
          juryGroupId: true,
          isRequired: true,
          project: { select: { title: true } },
        },
      })

      const sourceMap = new Map(sourceAssignments.map((a) => [a.id, a]))

      // Build candidate pool data
      const destinationIds = [...new Set(input.transfers.map((t) => t.destinationJurorId))]
      const destinationUsers = await ctx.prisma.user.findMany({
        where: { id: { in: destinationIds } },
        select: { id: true, name: true, email: true, maxAssignments: true },
      })
      const destUserMap = new Map(destinationUsers.map((u) => [u.id, u]))

      const existingAssignments = await ctx.prisma.assignment.findMany({
        where: { roundId: input.roundId },
        select: { userId: true, projectId: true },
      })
      const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
      const currentLoads = new Map<string, number>()
      for (const a of existingAssignments) {
        currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
      }

      const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
        where: {
          assignment: { roundId: input.roundId },
          hasConflict: true,
          userId: { in: destinationIds },
        },
        select: { userId: true, projectId: true },
      })
      const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))

      // Validate each transfer
      type PlannedMove = {
        assignmentId: string
        projectId: string
        projectTitle: string
        destinationJurorId: string
        juryGroupId: string | null
        isRequired: boolean
      }
      const plannedMoves: PlannedMove[] = []
      const failed: { assignmentId: string; reason: string }[] = []

      for (const transfer of input.transfers) {
        const assignment = sourceMap.get(transfer.assignmentId)
        if (!assignment) {
          failed.push({ assignmentId: transfer.assignmentId, reason: 'Assignment not found or not movable' })
          continue
        }

        const destUser = destUserMap.get(transfer.destinationJurorId)
        if (!destUser) {
          failed.push({ assignmentId: transfer.assignmentId, reason: 'Destination juror not found' })
          continue
        }

        if (alreadyAssigned.has(`${transfer.destinationJurorId}:${assignment.projectId}`)) {
          failed.push({ assignmentId: transfer.assignmentId, reason: `${destUser.name || destUser.email} is already assigned to this project` })
          continue
        }

        if (coiPairs.has(`${transfer.destinationJurorId}:${assignment.projectId}`)) {
          failed.push({ assignmentId: transfer.assignmentId, reason: `${destUser.name || destUser.email} has a COI with this project` })
          continue
        }

        const destCap = destUser.maxAssignments ?? fallbackCap
        const destLoad = currentLoads.get(transfer.destinationJurorId) ?? 0
        if (destLoad >= destCap && !input.forceOverCap) {
          failed.push({ assignmentId: transfer.assignmentId, reason: `${destUser.name || destUser.email} is at cap (${destLoad}/${destCap})` })
          continue
        }

        plannedMoves.push({
          assignmentId: assignment.id,
          projectId: assignment.projectId,
          projectTitle: assignment.project.title,
          destinationJurorId: transfer.destinationJurorId,
          juryGroupId: assignment.juryGroupId ?? round.juryGroupId,
          isRequired: assignment.isRequired,
        })

        // Track updated load for subsequent transfers to same destination
        alreadyAssigned.add(`${transfer.destinationJurorId}:${assignment.projectId}`)
        currentLoads.set(transfer.destinationJurorId, destLoad + 1)
      }

      // Execute in transaction with TOCTOU guard
      const actualMoves: (PlannedMove & { newAssignmentId: string })[] = []

      if (plannedMoves.length > 0) {
        await ctx.prisma.$transaction(async (tx) => {
          for (const move of plannedMoves) {
            const deleted = await tx.assignment.deleteMany({
              where: {
                id: move.assignmentId,
                userId: input.sourceJurorId,
                OR: [
                  { evaluation: null },
                  { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
                ],
              },
            })

            if (deleted.count === 0) {
              failed.push({ assignmentId: move.assignmentId, reason: 'Assignment was modified concurrently' })
              continue
            }

            const created = await tx.assignment.create({
              data: {
                roundId: input.roundId,
                projectId: move.projectId,
                userId: move.destinationJurorId,
                juryGroupId: move.juryGroupId ?? undefined,
                isRequired: move.isRequired,
                method: 'MANUAL',
                createdBy: ctx.user.id,
              },
            })

            actualMoves.push({ ...move, newAssignmentId: created.id })
          }
        })
      }

      // Notify destination jurors with per-juror project names
      if (actualMoves.length > 0) {
        const destMoves: Record<string, string[]> = {}
        for (const move of actualMoves) {
          if (!destMoves[move.destinationJurorId]) destMoves[move.destinationJurorId] = []
          destMoves[move.destinationJurorId].push(move.projectTitle)
        }

        for (const [jurorId, projectNames] of Object.entries(destMoves)) {
          const count = projectNames.length
          await createNotification({
            userId: jurorId,
            type: NotificationTypes.MANUAL_REASSIGNED,
            title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`,
            message: count === 1
              ? `The project "${projectNames[0]}" has been reassigned to you for evaluation in ${round.name}.`
              : `${count} projects have been reassigned to you for evaluation in ${round.name}: ${projectNames.join(', ')}.`,
            linkUrl: `/jury/competitions`,
            linkLabel: 'View Assignments',
            metadata: { roundId: round.id, roundName: round.name, projectNames, reason: 'admin_transfer' },
          })
        }

        // Notify admins
        const sourceJuror = await ctx.prisma.user.findUnique({
          where: { id: input.sourceJurorId },
          select: { name: true, email: true },
        })
        const sourceName = sourceJuror?.name || sourceJuror?.email || 'Unknown'

        const topReceivers = Object.entries(destMoves)
          .map(([jurorId, projects]) => {
            const u = destUserMap.get(jurorId)
            return `${u?.name || u?.email || jurorId} (${projects.length})`
          })
          .join(', ')

        await notifyAdmins({
          type: NotificationTypes.EVALUATION_MILESTONE,
          title: 'Assignment Transfer',
          message: `Transferred ${actualMoves.length} project(s) from ${sourceName} to: ${topReceivers}.${failed.length > 0 ? ` ${failed.length} transfer(s) failed.` : ''}`,
          linkUrl: `/admin/rounds/${round.id}`,
          linkLabel: 'View Round',
          metadata: {
            roundId: round.id,
            sourceJurorId: input.sourceJurorId,
            movedCount: actualMoves.length,
            failedCount: failed.length,
          },
        })

        // Audit
        await logAudit({
          prisma: ctx.prisma,
          userId: ctx.user.id,
          action: 'ASSIGNMENT_TRANSFER',
          entityType: 'Round',
          entityId: round.id,
          detailsJson: {
            sourceJurorId: input.sourceJurorId,
            sourceJurorName: sourceName,
            movedCount: actualMoves.length,
            failedCount: failed.length,
            moves: actualMoves.map((m) => ({
              projectId: m.projectId,
              projectTitle: m.projectTitle,
              newJurorId: m.destinationJurorId,
              newJurorName: destUserMap.get(m.destinationJurorId)?.name || destUserMap.get(m.destinationJurorId)?.email || m.destinationJurorId,
            })),
          },
          ipAddress: ctx.ip,
          userAgent: ctx.userAgent,
        })
      }

      return {
        succeeded: actualMoves.map((m) => ({
          assignmentId: m.assignmentId,
          projectId: m.projectId,
          destinationJurorId: m.destinationJurorId,
        })),
        failed,
      }
    }),

  /**
   * Preview the impact of lowering a juror's cap below their current load.
   */
  getOverCapPreview: adminProcedure
    .input(z.object({
      roundId: z.string(),
      jurorId: z.string(),
      newCap: z.number().int().min(1),
    }))
    .query(async ({ ctx, input }) => {
      const total = await ctx.prisma.assignment.count({
        where: { roundId: input.roundId, userId: input.jurorId },
      })

      const immovableCount = await ctx.prisma.assignment.count({
        where: {
          roundId: input.roundId,
          userId: input.jurorId,
          evaluation: { status: { notIn: [...MOVABLE_EVAL_STATUSES] } },
        },
      })

      const movableCount = total - immovableCount
      const overCapCount = Math.max(0, total - input.newCap)

      return {
        total,
        overCapCount,
        movableOverCap: Math.min(overCapCount, movableCount),
        immovableOverCap: Math.max(0, overCapCount - movableCount),
      }
    }),

  /**
   * Redistribute over-cap assignments after lowering a juror's cap.
   * Moves the newest/least-progressed movable assignments to other eligible jurors.
   */
  redistributeOverCap: adminProcedure
    .input(z.object({
      roundId: z.string(),
      jurorId: z.string(),
      newCap: z.number().int().min(1),
    }))
    .mutation(async ({ ctx, input }) => {
      const round = await ctx.prisma.round.findUniqueOrThrow({
        where: { id: input.roundId },
        select: { id: true, name: true, configJson: true, juryGroupId: true },
      })

      const config = (round.configJson ?? {}) as Record<string, unknown>
      const fallbackCap =
        (config.maxLoadPerJuror as number) ??
        (config.maxAssignmentsPerJuror as number) ??
        20

      // Get juror's assignments sorted: null eval first, then DRAFT, newest first
      const jurorAssignments = await ctx.prisma.assignment.findMany({
        where: { roundId: input.roundId, userId: input.jurorId },
        select: {
          id: true,
          projectId: true,
          juryGroupId: true,
          isRequired: true,
          createdAt: true,
          project: { select: { title: true } },
          evaluation: { select: { status: true } },
        },
        orderBy: { createdAt: 'desc' },
      })

      const overCapCount = Math.max(0, jurorAssignments.length - input.newCap)
      if (overCapCount === 0) {
        return { redistributed: 0, failed: 0, failedProjects: [] as string[], moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[] }
      }

      // Separate movable and immovable, pick the newest movable ones for redistribution
      const movable = jurorAssignments.filter(
        (a) => !a.evaluation || MOVABLE_EVAL_STATUSES.includes(a.evaluation.status as typeof MOVABLE_EVAL_STATUSES[number])
      )

      // Sort movable: null eval first, then DRAFT, then by createdAt descending (newest first to remove)
      movable.sort((a, b) => {
        const statusOrder = (s: string | null) => s === null ? 0 : s === 'NOT_STARTED' ? 1 : s === 'DRAFT' ? 2 : 3
        const diff = statusOrder(a.evaluation?.status ?? null) - statusOrder(b.evaluation?.status ?? null)
        if (diff !== 0) return diff
        return b.createdAt.getTime() - a.createdAt.getTime()
      })

      const assignmentsToMove = movable.slice(0, overCapCount)

      if (assignmentsToMove.length === 0) {
        return { redistributed: 0, failed: 0, failedProjects: [] as string[], moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[] }
      }

      // Build candidate pool — same pattern as reassignDroppedJurorAssignments
      const candidateJurors = await getCandidateJurors(
        ctx.prisma,
        input.roundId,
        round.juryGroupId,
        input.jurorId,
      )

      if (candidateJurors.length === 0) {
        return {
          redistributed: 0,
          failed: assignmentsToMove.length,
          failedProjects: assignmentsToMove.map((a) => a.project.title),
          moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[],
        }
      }

      const candidateIds = candidateJurors.map((j) => j.id)

      const existingAssignments = await ctx.prisma.assignment.findMany({
        where: { roundId: input.roundId },
        select: { userId: true, projectId: true },
      })

      const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
      const currentLoads = new Map<string, number>()
      for (const a of existingAssignments) {
        currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
      }

      const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
        where: {
          assignment: { roundId: input.roundId },
          hasConflict: true,
          userId: { in: candidateIds },
        },
        select: { userId: true, projectId: true },
      })
      const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))

      const caps = new Map<string, number>()
      for (const juror of candidateJurors) {
        caps.set(juror.id, juror.maxAssignments ?? fallbackCap)
      }

      // Check which candidates have completed all their evaluations
      const completedEvals = await ctx.prisma.evaluation.findMany({
        where: {
          assignment: { roundId: input.roundId, userId: { in: candidateIds } },
          status: 'SUBMITTED',
        },
        select: { assignment: { select: { userId: true } } },
      })
      const completedCounts = new Map<string, number>()
      for (const e of completedEvals) {
        completedCounts.set(e.assignment.userId, (completedCounts.get(e.assignment.userId) ?? 0) + 1)
      }

      const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))

      type PlannedMove = {
        assignmentId: string
        projectId: string
        projectTitle: string
        newJurorId: string
        juryGroupId: string | null
        isRequired: boolean
      }
      const plannedMoves: PlannedMove[] = []
      const failedProjects: string[] = []

      for (const assignment of assignmentsToMove) {
        const eligible = candidateIds
          .filter((jurorId) => !alreadyAssigned.has(`${jurorId}:${assignment.projectId}`))
          .filter((jurorId) => !coiPairs.has(`${jurorId}:${assignment.projectId}`))
          .filter((jurorId) => (currentLoads.get(jurorId) ?? 0) < (caps.get(jurorId) ?? fallbackCap))
          .sort((a, b) => {
            // Prefer jurors who haven't completed all their work
            const aLoad = currentLoads.get(a) ?? 0
            const bLoad = currentLoads.get(b) ?? 0
            const aComplete = aLoad > 0 && (completedCounts.get(a) ?? 0) === aLoad
            const bComplete = bLoad > 0 && (completedCounts.get(b) ?? 0) === bLoad
            if (aComplete !== bComplete) return aComplete ? 1 : -1
            const loadDiff = aLoad - bLoad
            if (loadDiff !== 0) return loadDiff
            return a.localeCompare(b)
          })

        if (eligible.length === 0) {
          failedProjects.push(assignment.project.title)
          continue
        }

        const selectedJurorId = eligible[0]
        plannedMoves.push({
          assignmentId: assignment.id,
          projectId: assignment.projectId,
          projectTitle: assignment.project.title,
          newJurorId: selectedJurorId,
          juryGroupId: assignment.juryGroupId ?? round.juryGroupId,
          isRequired: assignment.isRequired,
        })

        alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`)
        currentLoads.set(selectedJurorId, (currentLoads.get(selectedJurorId) ?? 0) + 1)
      }

      // Execute in transaction with TOCTOU guard
      const actualMoves: PlannedMove[] = []

      if (plannedMoves.length > 0) {
        await ctx.prisma.$transaction(async (tx) => {
          for (const move of plannedMoves) {
            const deleted = await tx.assignment.deleteMany({
              where: {
                id: move.assignmentId,
                userId: input.jurorId,
                OR: [
                  { evaluation: null },
                  { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
                ],
              },
            })

            if (deleted.count === 0) {
              failedProjects.push(move.projectTitle)
              continue
            }

            await tx.assignment.create({
              data: {
                roundId: input.roundId,
                projectId: move.projectId,
                userId: move.newJurorId,
                juryGroupId: move.juryGroupId ?? undefined,
                isRequired: move.isRequired,
                method: 'MANUAL',
                createdBy: ctx.user.id,
              },
            })
            actualMoves.push(move)
          }
        })
      }

      // Notify destination jurors
      if (actualMoves.length > 0) {
        const destCounts: Record<string, number> = {}
        for (const move of actualMoves) {
          destCounts[move.newJurorId] = (destCounts[move.newJurorId] ?? 0) + 1
        }

        await createBulkNotifications({
          userIds: Object.keys(destCounts),
          type: NotificationTypes.BATCH_ASSIGNED,
          title: 'Additional Projects Assigned',
          message: `You have received additional project assignments due to a cap adjustment in ${round.name}.`,
          linkUrl: `/jury/competitions`,
          linkLabel: 'View Assignments',
          metadata: { roundId: round.id, reason: 'cap_redistribute' },
        })

        const juror = await ctx.prisma.user.findUnique({
          where: { id: input.jurorId },
          select: { name: true, email: true },
        })
        const jurorName = juror?.name || juror?.email || 'Unknown'

        const topReceivers = Object.entries(destCounts)
          .map(([jurorId, count]) => {
            const u = candidateMeta.get(jurorId)
            return `${u?.name || u?.email || jurorId} (${count})`
          })
          .join(', ')

        await notifyAdmins({
          type: NotificationTypes.EVALUATION_MILESTONE,
          title: 'Cap Redistribution',
          message: `Redistributed ${actualMoves.length} project(s) from ${jurorName} (cap lowered to ${input.newCap}) to: ${topReceivers}.${failedProjects.length > 0 ? ` ${failedProjects.length} project(s) could not be reassigned.` : ''}`,
          linkUrl: `/admin/rounds/${round.id}`,
          linkLabel: 'View Round',
          metadata: {
            roundId: round.id,
            jurorId: input.jurorId,
            newCap: input.newCap,
            movedCount: actualMoves.length,
            failedCount: failedProjects.length,
          },
        })

        await logAudit({
          prisma: ctx.prisma,
          userId: ctx.user.id,
          action: 'CAP_REDISTRIBUTE',
          entityType: 'Round',
          entityId: round.id,
          detailsJson: {
            jurorId: input.jurorId,
            jurorName,
            newCap: input.newCap,
            movedCount: actualMoves.length,
            failedCount: failedProjects.length,
            failedProjects,
            moves: actualMoves.map((m) => ({
              projectId: m.projectId,
              projectTitle: m.projectTitle,
              newJurorId: m.newJurorId,
              newJurorName: candidateMeta.get(m.newJurorId)?.name || candidateMeta.get(m.newJurorId)?.email || m.newJurorId,
            })),
          },
          ipAddress: ctx.ip,
          userAgent: ctx.userAgent,
        })
      }

      return {
        redistributed: actualMoves.length,
        failed: failedProjects.length,
        failedProjects,
        moves: actualMoves.map((m) => ({
          projectId: m.projectId,
          projectTitle: m.projectTitle,
          newJurorId: m.newJurorId,
          newJurorName: candidateMeta.get(m.newJurorId)?.name || candidateMeta.get(m.newJurorId)?.email || m.newJurorId,
        })),
      }
    }),

  /**
   * Get reshuffle history for a round — shows all dropout/COI reassignment events
   * with per-project detail of where each project was moved to.
   */
  getReassignmentHistory: adminProcedure
    .input(z.object({ roundId: z.string() }))
    .query(async ({ ctx, input }) => {
      // Get all reshuffle + COI audit entries for this round
      const auditEntries = await ctx.prisma.auditLog.findMany({
        where: {
          entityType: { in: ['Round', 'Assignment'] },
          action: { in: ['JUROR_DROPOUT_RESHUFFLE', 'COI_REASSIGNMENT', 'ASSIGNMENT_TRANSFER', 'CAP_REDISTRIBUTE'] },
          entityId: input.roundId,
        },
        orderBy: { timestamp: 'desc' },
        include: {
          user: { select: { id: true, name: true, email: true } },
        },
      })

      // Also get COI reassignment entries that reference this round in detailsJson
      const coiEntries = await ctx.prisma.auditLog.findMany({
        where: {
          action: 'COI_REASSIGNMENT',
          entityType: 'Assignment',
        },
        orderBy: { timestamp: 'desc' },
        include: {
          user: { select: { id: true, name: true, email: true } },
        },
      })

      // Filter COI entries to this round
      const coiForRound = coiEntries.filter((e) => {
        const details = e.detailsJson as Record<string, unknown> | null
        return details?.roundId === input.roundId
      })

      // For retroactive data: find all MANUAL assignments created in this round
      // that were created by an admin (not the juror themselves)
      const manualAssignments = await ctx.prisma.assignment.findMany({
        where: {
          roundId: input.roundId,
          method: 'MANUAL',
          createdBy: { not: null },
        },
        include: {
          user: { select: { id: true, name: true, email: true } },
          project: { select: { id: true, title: true } },
        },
        orderBy: { createdAt: 'desc' },
      })

      type ReshuffleEvent = {
        id: string
        type: 'DROPOUT' | 'COI' | 'TRANSFER' | 'CAP_REDISTRIBUTE'
        timestamp: Date
        performedBy: { name: string | null; email: string }
        droppedJuror: { id: string; name: string }
        movedCount: number
        failedCount: number
        failedProjects: string[]
        moves: { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]
      }

      const events: ReshuffleEvent[] = []

      for (const entry of auditEntries) {
        const details = entry.detailsJson as Record<string, unknown> | null
        if (!details) continue

        if (entry.action === 'JUROR_DROPOUT_RESHUFFLE') {
          // Check if this entry already has per-move detail (new format)
          const moves = (details.moves as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]) || []

          // If no moves in audit (old format), reconstruct from assignments
          let reconstructedMoves = moves
          if (moves.length === 0 && (details.movedCount as number) > 0) {
            // Find MANUAL assignments created around the same time (within 5 seconds)
            const eventTime = entry.timestamp.getTime()
            reconstructedMoves = manualAssignments
              .filter((a) => {
                const diff = Math.abs(a.createdAt.getTime() - eventTime)
                return diff < 5000 && a.createdBy === entry.userId
              })
              .map((a) => ({
                projectId: a.project.id,
                projectTitle: a.project.title,
                newJurorId: a.user.id,
                newJurorName: a.user.name || a.user.email,
              }))
          }

          events.push({
            id: entry.id,
            type: 'DROPOUT',
            timestamp: entry.timestamp,
            performedBy: {
              name: entry.user?.name ?? null,
              email: entry.user?.email ?? '',
            },
            droppedJuror: {
              id: details.droppedJurorId as string,
              name: (details.droppedJurorName as string) || 'Unknown',
            },
            movedCount: (details.movedCount as number) || 0,
            failedCount: (details.failedCount as number) || 0,
            failedProjects: (details.failedProjects as string[]) || [],
            moves: reconstructedMoves,
          })
        } else if (entry.action === 'ASSIGNMENT_TRANSFER') {
          const moves = (details.moves as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]) || []
          events.push({
            id: entry.id,
            type: 'TRANSFER',
            timestamp: entry.timestamp,
            performedBy: {
              name: entry.user?.name ?? null,
              email: entry.user?.email ?? '',
            },
            droppedJuror: {
              id: (details.sourceJurorId as string) || '',
              name: (details.sourceJurorName as string) || 'Unknown',
            },
            movedCount: (details.movedCount as number) || 0,
            failedCount: (details.failedCount as number) || 0,
            failedProjects: (details.failedProjects as string[]) || [],
            moves,
          })
        } else if (entry.action === 'CAP_REDISTRIBUTE') {
          const moves = (details.moves as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]) || []
          events.push({
            id: entry.id,
            type: 'CAP_REDISTRIBUTE',
            timestamp: entry.timestamp,
            performedBy: {
              name: entry.user?.name ?? null,
              email: entry.user?.email ?? '',
            },
            droppedJuror: {
              id: (details.jurorId as string) || '',
              name: (details.jurorName as string) || 'Unknown',
            },
            movedCount: (details.movedCount as number) || 0,
            failedCount: (details.failedCount as number) || 0,
            failedProjects: (details.failedProjects as string[]) || [],
            moves,
          })
        }
      }

      // Process COI entries
      for (const entry of coiForRound) {
        const details = entry.detailsJson as Record<string, unknown> | null
        if (!details) continue

        // Look up project title
        const project = details.projectId
          ? await ctx.prisma.project.findUnique({
              where: { id: details.projectId as string },
              select: { title: true },
            })
          : null

        // Look up new juror name
        const newJuror = details.newJurorId
          ? await ctx.prisma.user.findUnique({
              where: { id: details.newJurorId as string },
              select: { name: true, email: true },
            })
          : null

        // Look up old juror name
        const oldJuror = details.oldJurorId
          ? await ctx.prisma.user.findUnique({
              where: { id: details.oldJurorId as string },
              select: { name: true, email: true },
            })
          : null

        events.push({
          id: entry.id,
          type: 'COI',
          timestamp: entry.timestamp,
          performedBy: {
            name: entry.user?.name ?? null,
            email: entry.user?.email ?? '',
          },
          droppedJuror: {
            id: (details.oldJurorId as string) || '',
            name: oldJuror?.name || oldJuror?.email || 'Unknown',
          },
          movedCount: 1,
          failedCount: 0,
          failedProjects: [],
          moves: [{
            projectId: (details.projectId as string) || '',
            projectTitle: project?.title || 'Unknown',
            newJurorId: (details.newJurorId as string) || '',
            newJurorName: newJuror?.name || newJuror?.email || 'Unknown',
          }],
        })
      }

      // Sort all events by timestamp descending
      events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())

      return events
    }),
})
