import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url'
import { logAudit } from '../utils/audit'
import { processEligibilityJob } from '../services/award-eligibility-job'
import { resolveAwardWinner } from '../services/award-winner-resolver'
import {
  getAwardSelectionNotificationTemplate,
  sendJuryInvitationEmail,
  sendAwardJurorNotificationEmail,
} from '@/lib/email'
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
import { attachProjectLogoUrls } from '../utils/project-logo-url'
import { sendBatchNotifications } from '../services/notification-sender'
import type { NotificationItem } from '../services/notification-sender'
import type { PrismaClient } from '@prisma/client'

/**
 * Verify the current session user exists in the database.
 * Guards against stale JWT sessions (e.g., after database reseed).
 */
async function ensureUserExists(db: PrismaClient, userId: string): Promise<string> {
  const user = await db.user.findUnique({
    where: { id: userId },
    select: { id: true },
  })
  if (!user) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
      message: 'Your session refers to a user that no longer exists. Please log out and log back in.',
    })
  }
  return user.id
}

/**
 * Send the "you've been assigned to vote on this award" email to a set of
 * jurors. Used by addJuror / bulkInviteJurors (auto-send on assignment) and
 * by the explicit notifyJurors admin reminder. Errors per recipient are
 * caught so a single SMTP failure doesn't break the bulk operation.
 */
async function sendAwardJurorEmails(
  db: PrismaClient,
  awardId: string,
  userIds: string[],
  options: { customMessage?: string; isReminder?: boolean } = {},
): Promise<{ sent: number; failed: number }> {
  if (userIds.length === 0) return { sent: 0, failed: 0 }

  const award = await db.specialAward.findUniqueOrThrow({
    where: { id: awardId },
    select: { id: true, name: true, votingEndAt: true },
  })
  const eligibleCount = await db.awardEligibility.count({
    where: { awardId, eligible: true },
  })
  const users = await db.user.findMany({
    where: { id: { in: userIds } },
    select: { id: true, email: true, name: true },
  })

  const baseUrl = process.env.NEXTAUTH_URL || ''
  const url = `${baseUrl}/jury/awards/${awardId}`

  let sent = 0
  let failed = 0
  for (const u of users) {
    try {
      await sendAwardJurorNotificationEmail({
        email: u.email,
        name: u.name,
        awardName: award.name,
        url,
        eligibleCount,
        votingEndAt: award.votingEndAt,
        customMessage: options.customMessage,
        isReminder: options.isReminder,
      })
      sent++
    } catch {
      // Email failure shouldn't break the calling operation.
      failed++
    }
  }
  return { sent, failed }
}

export const specialAwardRouter = router({
  // ─── Admin Queries ──────────────────────────────────────────────────────

  /**
   * List awards for a program
   */
  list: protectedProcedure
    .input(
      z.object({
        programId: z.string().optional(),
      })
    )
    .query(async ({ ctx, input }) => {
      return ctx.prisma.specialAward.findMany({
        where: input.programId ? { programId: input.programId } : {},
        orderBy: { sortOrder: 'asc' },
        include: {
          _count: {
            select: {
              eligibilities: { where: { eligible: true } },
              jurors: true,
              votes: true,
            },
          },
          winnerProject: {
            select: { id: true, title: true, teamName: true },
          },
        },
      })
    }),

  /**
   * Get award detail with stats
   */
  get: protectedProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const award = await ctx.prisma.specialAward.findUniqueOrThrow({
        where: { id: input.id },
        include: {
          _count: {
            select: {
              eligibilities: { where: { eligible: true } },
              jurors: true,
              votes: true,
            },
          },
          winnerProject: {
            select: { id: true, title: true, teamName: true },
          },
          program: {
            select: { id: true, name: true, year: true },
          },
          competition: {
            select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } },
          },
          evaluationRound: {
            select: { id: true, name: true, roundType: true },
          },
          awardJuryGroup: {
            select: { id: true, name: true },
          },
        },
      })

      // Auto-resolve competition if missing (legacy awards created without competitionId)
      let { competition } = award
      if (!competition && award.programId) {
        const comp = await ctx.prisma.competition.findFirst({
          where: { programId: award.programId },
          orderBy: { createdAt: 'desc' },
          select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' as const } } },
        })
        if (comp) {
          competition = comp
        }
      }

      // Count eligible projects and total assessed
      const [eligibleCount, totalAssessed] = await Promise.all([
        ctx.prisma.awardEligibility.count({
          where: { awardId: input.id, eligible: true },
        }),
        ctx.prisma.awardEligibility.count({
          where: { awardId: input.id },
        }),
      ])

      return { ...award, competition, eligibleCount, totalAssessed }
    }),

  // ─── Admin Mutations ────────────────────────────────────────────────────

  /**
   * Create award
   */
  create: adminProcedure
    .input(
      z.object({
        programId: z.string(),
        name: z.string().min(1),
        description: z.string().optional(),
        criteriaText: z.string().optional(),
        useAiEligibility: z.boolean().optional(),
        scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']),
        maxRankedPicks: z.number().int().min(1).max(20).optional(),
        competitionId: z.string().optional(),
        evaluationRoundId: z.string().optional(),
        juryGroupId: z.string().optional(),
        eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Auto-resolve competitionId from program if not provided
      let competitionId = input.competitionId
      if (!competitionId) {
        const comp = await ctx.prisma.competition.findFirst({
          where: { programId: input.programId },
          orderBy: { createdAt: 'desc' },
          select: { id: true },
        })
        competitionId = comp?.id ?? undefined
      }

      const maxOrder = await ctx.prisma.specialAward.aggregate({
        where: { programId: input.programId },
        _max: { sortOrder: true },
      })

      const award = await ctx.prisma.specialAward.create({
        data: {
          programId: input.programId,
          name: input.name,
          description: input.description,
          criteriaText: input.criteriaText,
          useAiEligibility: input.useAiEligibility ?? true,
          scoringMode: input.scoringMode,
          maxRankedPicks: input.maxRankedPicks,
          competitionId,
          evaluationRoundId: input.evaluationRoundId,
          juryGroupId: input.juryGroupId,
          eligibilityMode: input.eligibilityMode,
          sortOrder: (maxOrder._max.sortOrder || 0) + 1,
        },
      })

      // Audit outside transaction so failures don't roll back the create
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'CREATE',
        entityType: 'SpecialAward',
        entityId: award.id,
        detailsJson: { name: input.name, scoringMode: input.scoringMode },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return award
    }),

  /**
   * Update award config
   */
  update: adminProcedure
    .input(
      z.object({
        id: z.string(),
        name: z.string().min(1).optional(),
        description: z.string().optional(),
        criteriaText: z.string().optional(),
        useAiEligibility: z.boolean().optional(),
        scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(),
        maxRankedPicks: z.number().int().min(1).max(20).optional(),
        votingStartAt: z.date().optional(),
        votingEndAt: z.date().optional(),
        competitionId: z.string().nullable().optional(),
        evaluationRoundId: z.string().nullable().optional(),
        juryGroupId: z.string().nullable().optional(),
        eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
        decisionMode: z.enum(['JURY_VOTE', 'ADMIN_DECISION']).nullable().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const { id, ...rest } = input

      // Auto-resolve competitionId if missing on existing award
      if (rest.competitionId === undefined) {
        const existing = await ctx.prisma.specialAward.findUnique({
          where: { id },
          select: { competitionId: true, programId: true },
        })
        if (existing && !existing.competitionId) {
          const comp = await ctx.prisma.competition.findFirst({
            where: { programId: existing.programId },
            orderBy: { createdAt: 'desc' },
            select: { id: true },
          })
          if (comp) rest.competitionId = comp.id
        }
      }

      const award = await ctx.prisma.specialAward.update({
        where: { id },
        data: rest,
      })

      await logAudit({
        userId: ctx.user.id,
        action: 'UPDATE',
        entityType: 'SpecialAward',
        entityId: id,
      })

      return award
    }),

  /**
   * Delete award
   */
  delete: adminProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      await ctx.prisma.specialAward.delete({ where: { id: input.id } })

      // Audit outside transaction so failures don't break the delete
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'DELETE',
        entityType: 'SpecialAward',
        entityId: input.id,
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })
    }),

  /**
   * Update award status
   */
  updateStatus: adminProcedure
    .input(
      z.object({
        id: z.string(),
        status: z.enum([
          'DRAFT',
          'NOMINATIONS_OPEN',
          'VOTING_OPEN',
          'CLOSED',
          'ARCHIVED',
        ]),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const current = await ctx.prisma.specialAward.findUniqueOrThrow({
        where: { id: input.id },
        select: { status: true, votingStartAt: true, votingEndAt: true },
      })

      const now = new Date()

      // When opening voting, auto-set votingStartAt to now if it's in the future or not set
      let votingStartAtUpdated = false
      const updateData: Parameters<typeof ctx.prisma.specialAward.update>[0]['data'] = {
        status: input.status,
      }

      if (input.status === 'VOTING_OPEN' && current.status !== 'VOTING_OPEN') {
        // If no voting start date, or if it's in the future, set it to 1 minute ago
        // to ensure voting is immediately open (avoids race condition with page render)
        if (!current.votingStartAt || current.votingStartAt > now) {
          const oneMinuteAgo = new Date(now.getTime() - 60 * 1000)
          updateData.votingStartAt = oneMinuteAgo
          votingStartAtUpdated = true
        }
      }

      const award = await ctx.prisma.specialAward.update({
        where: { id: input.id },
        data: updateData,
      })

      // Audit outside transaction so failures don't break the status update
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'UPDATE_STATUS',
        entityType: 'SpecialAward',
        entityId: input.id,
        detailsJson: {
          previousStatus: current.status,
          newStatus: input.status,
          ...(votingStartAtUpdated && {
            votingStartAtUpdated: true,
            previousVotingStartAt: current.votingStartAt,
            newVotingStartAt: now,
          }),
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return award
    }),

  // ─── Eligibility ────────────────────────────────────────────────────────

  /**
   * Run auto-tag + AI eligibility
   */
  runEligibility: adminProcedure
    .input(z.object({
      awardId: z.string(),
      includeSubmitted: z.boolean().optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      // Set job status to PENDING immediately
      await ctx.prisma.specialAward.update({
        where: { id: input.awardId },
        data: {
          eligibilityJobStatus: 'PENDING',
          eligibilityJobTotal: null,
          eligibilityJobDone: null,
          eligibilityJobError: null,
          eligibilityJobStarted: null,
        },
      })

      await logAudit({
        userId: ctx.user.id,
        action: 'UPDATE',
        entityType: 'SpecialAward',
        entityId: input.awardId,
        detailsJson: { action: 'RUN_ELIGIBILITY_STARTED' },
      })

      // Fire and forget - process in background
      processEligibilityJob(
        input.awardId,
        input.includeSubmitted ?? false,
        ctx.user.id
      ).catch((err) => {
        console.error('[SpecialAward] processEligibilityJob failed:', err)
      })

      return { started: true }
    }),

  /**
   * Get eligibility job status for polling
   */
  getEligibilityJobStatus: protectedProcedure
    .input(z.object({ awardId: z.string() }))
    .query(async ({ ctx, input }) => {
      const award = await ctx.prisma.specialAward.findUniqueOrThrow({
        where: { id: input.awardId },
        select: {
          eligibilityJobStatus: true,
          eligibilityJobTotal: true,
          eligibilityJobDone: true,
          eligibilityJobError: true,
          eligibilityJobStarted: true,
        },
      })
      return award
    }),

  /**
   * List eligible projects
   */
  listEligible: protectedProcedure
    .input(
      z.object({
        awardId: z.string(),
        eligibleOnly: z.boolean().default(false),
        page: z.number().int().min(1).default(1),
        perPage: z.number().int().min(1).max(100).default(50),
      })
    )
    .query(async ({ ctx, input }) => {
      const { awardId, eligibleOnly, page, perPage } = input
      const skip = (page - 1) * perPage

      const where: Record<string, unknown> = { awardId }
      if (eligibleOnly) where.eligible = true

      const [eligibilities, total] = await Promise.all([
        ctx.prisma.awardEligibility.findMany({
          where,
          skip,
          take: perPage,
          include: {
            project: {
              select: {
                id: true,
                title: true,
                teamName: true,
                competitionCategory: true,
                country: true,
                tags: true,
              },
            },
          },
          orderBy: { project: { title: 'asc' } },
        }),
        ctx.prisma.awardEligibility.count({ where }),
      ])

      return { eligibilities, total, page, perPage, totalPages: Math.ceil(total / perPage) }
    }),

  /**
   * Manual eligibility override
   */
  setEligibility: adminProcedure
    .input(
      z.object({
        awardId: z.string(),
        projectId: z.string(),
        eligible: z.boolean(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const verifiedUserId = await ensureUserExists(ctx.prisma, ctx.user.id)

      await ctx.prisma.awardEligibility.upsert({
        where: {
          awardId_projectId: {
            awardId: input.awardId,
            projectId: input.projectId,
          },
        },
        create: {
          award: { connect: { id: input.awardId } },
          project: { connect: { id: input.projectId } },
          eligible: input.eligible,
          method: 'MANUAL',
          overriddenByUser: { connect: { id: verifiedUserId } },
          overriddenAt: new Date(),
        },
        update: {
          eligible: input.eligible,
          overriddenByUser: { connect: { id: verifiedUserId } },
          overriddenAt: new Date(),
        },
      })
    }),

  // ─── Jurors ─────────────────────────────────────────────────────────────

  /**
   * List jurors for an award
   */
  listJurors: protectedProcedure
    .input(z.object({ awardId: z.string() }))
    .query(async ({ ctx, input }) => {
      const jurors = await ctx.prisma.awardJuror.findMany({
        where: { awardId: input.awardId },
        include: {
          user: {
            select: {
              id: true,
              name: true,
              email: true,
              role: true,
              profileImageKey: true,
              profileImageProvider: true,
            },
          },
        },
      })
      return Promise.all(
        jurors.map(async (j) => ({
          ...j,
          user: {
            ...j.user,
            avatarUrl: await getUserAvatarUrl(j.user.profileImageKey, j.user.profileImageProvider),
          },
        }))
      )
    }),

  /**
   * Add juror
   */
  addJuror: adminProcedure
    .input(
      z.object({
        awardId: z.string(),
        userId: z.string(),
        sendEmail: z.boolean().default(true),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const created = await ctx.prisma.awardJuror.create({
        data: {
          awardId: input.awardId,
          userId: input.userId,
        },
      })

      if (input.sendEmail) {
        await sendAwardJurorEmails(ctx.prisma, input.awardId, [input.userId])
      }

      return created
    }),

  /**
   * Remove juror
   */
  removeJuror: adminProcedure
    .input(
      z.object({
        awardId: z.string(),
        userId: z.string(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      await ctx.prisma.awardJuror.delete({
        where: {
          awardId_userId: {
            awardId: input.awardId,
            userId: input.userId,
          },
        },
      })
    }),

  /**
   * Bulk add jurors
   */
  bulkAddJurors: adminProcedure
    .input(
      z.object({
        awardId: z.string(),
        userIds: z.array(z.string()),
        sendEmail: z.boolean().default(true),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const existingRows = await ctx.prisma.awardJuror.findMany({
        where: { awardId: input.awardId, userId: { in: input.userIds } },
        select: { userId: true },
      })
      const existing = new Set(existingRows.map((r) => r.userId))
      const newlyAddedIds = input.userIds.filter((id) => !existing.has(id))

      const data = newlyAddedIds.map((userId) => ({
        awardId: input.awardId,
        userId,
      }))

      if (data.length > 0) {
        await ctx.prisma.awardJuror.createMany({
          data,
          skipDuplicates: true,
        })
      }

      let emailStats = { sent: 0, failed: 0 }
      if (input.sendEmail && newlyAddedIds.length > 0) {
        emailStats = await sendAwardJurorEmails(ctx.prisma, input.awardId, newlyAddedIds)
      }

      return { added: newlyAddedIds.length, ...emailStats }
    }),

  /**
   * Bulk invite new users as award jurors. Creates JURY_MEMBER accounts,
   * attaches them as AwardJuror, and (if the award has an assigned jury
   * group) also adds them as JuryGroupMember so they appear on the
   * round-page jury panel alongside existing members.
   */
  bulkInviteJurors: adminProcedure
    .input(
      z.object({
        awardId: z.string(),
        invitees: z.array(
          z.object({
            name: z.string().optional(),
            email: z.string().email(),
          })
        ).min(1).max(50),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const award = await ctx.prisma.specialAward.findUniqueOrThrow({
        where: { id: input.awardId },
        select: { id: true, name: true, juryGroupId: true },
      })

      const results: Array<{ email: string; status: 'created' | 'existing' | 'error'; error?: string }> = []

      for (const invitee of input.invitees) {
        try {
          let user = await ctx.prisma.user.findUnique({
            where: { email: invitee.email },
            select: { id: true, status: true, role: true },
          })

          if (!user) {
            const inviteToken = generateInviteToken()
            const expiryMs = await getInviteExpiryMs(ctx.prisma)

            user = await ctx.prisma.user.create({
              data: {
                email: invitee.email,
                name: invitee.name || null,
                role: 'JURY_MEMBER',
                roles: ['JURY_MEMBER'],
                status: 'INVITED',
                inviteToken,
                inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
              },
              select: { id: true, status: true, role: true },
            })

            const inviteUrl = `${process.env.NEXTAUTH_URL}/accept-invite?token=${inviteToken}`
            try {
              await sendJuryInvitationEmail(
                invitee.email,
                invitee.name || null,
                inviteUrl,
                award.name
              )
            } catch {
              // Email failure shouldn't block the invite
            }

            results.push({ email: invitee.email, status: 'created' })
          } else {
            results.push({ email: invitee.email, status: 'existing' })
          }

          const priorAttachment = await ctx.prisma.awardJuror.findUnique({
            where: {
              awardId_userId: { awardId: input.awardId, userId: user.id },
            },
            select: { id: true },
          })

          await ctx.prisma.awardJuror.upsert({
            where: {
              awardId_userId: { awardId: input.awardId, userId: user.id },
            },
            update: {},
            create: { awardId: input.awardId, userId: user.id },
          })

          if (award.juryGroupId) {
            await ctx.prisma.juryGroupMember.upsert({
              where: {
                juryGroupId_userId: {
                  juryGroupId: award.juryGroupId,
                  userId: user.id,
                },
              },
              update: {},
              create: {
                juryGroupId: award.juryGroupId,
                userId: user.id,
                role: 'MEMBER',
              },
            })
          }

          // For existing-user invitees the new-account invite email above
          // never fired (no `created` branch). Send the juror-assignment
          // notification so they know they were added — but only if this
          // call actually attached them (skip duplicate "Bulk Invite" clicks
          // to avoid spam).
          const lastResult = results[results.length - 1]
          if (lastResult?.status === 'existing' && !priorAttachment) {
            await sendAwardJurorEmails(ctx.prisma, input.awardId, [user.id])
          }
        } catch (err) {
          results.push({
            email: invitee.email,
            status: 'error',
            error: err instanceof Error ? err.message : 'Unknown error',
          })
        }
      }

      await logAudit({
        userId: ctx.user.id,
        action: 'CREATE',
        entityType: 'AwardJuror',
        entityId: input.awardId,
        detailsJson: {
          action: 'BULK_INVITE',
          awardName: award.name,
          juryGroupId: award.juryGroupId,
          count: input.invitees.length,
          results,
        },
      })

      return {
        created: results.filter((r) => r.status === 'created').length,
        existing: results.filter((r) => r.status === 'existing').length,
        errors: results.filter((r) => r.status === 'error').length,
        results,
      }
    }),

  /**
   * Send a reminder email to currently-assigned jurors. Pass `userIds` to
   * target a subset, omit to email every juror on the award. The email links
   * the juror straight to the voting page.
   */
  notifyJurors: adminProcedure
    .input(
      z.object({
        awardId: z.string(),
        userIds: z.array(z.string()).optional(),
        customMessage: z.string().max(1000).optional(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const jurors = await ctx.prisma.awardJuror.findMany({
        where: {
          awardId: input.awardId,
          ...(input.userIds && input.userIds.length > 0
            ? { userId: { in: input.userIds } }
            : {}),
        },
        select: { userId: true },
      })

      const targetIds = jurors.map((j) => j.userId)
      const stats = await sendAwardJurorEmails(ctx.prisma, input.awardId, targetIds, {
        customMessage: input.customMessage,
        isReminder: true,
      })

      await logAudit({
        userId: ctx.user.id,
        action: 'NOTIFY',
        entityType: 'AwardJuror',
        entityId: input.awardId,
        detailsJson: {
          action: 'JUROR_REMINDER',
          targetUserIds: targetIds,
          ...stats,
        },
      })

      return { targeted: targetIds.length, ...stats }
    }),

  // ─── Jury Queries ───────────────────────────────────────────────────────

  /**
   * Get awards where current user is a juror
   */
  getMyAwards: protectedProcedure.query(async ({ ctx }) => {
    const jurorships = await ctx.prisma.awardJuror.findMany({
      where: { userId: ctx.user.id },
      include: {
        award: {
          include: {
            _count: {
              select: { eligibilities: { where: { eligible: true } } },
            },
          },
        },
      },
    })

    return jurorships.map((j) => j.award)
  }),

  /**
   * Get award detail for voting (jury view). Includes upstream evaluation
   * scores per project (when the award is anchored to an evaluation round)
   * and — for the chair — the votes cast by other jurors so they can
   * tie-break and finalize the award.
   */
  getMyAwardDetail: protectedProcedure
    .input(z.object({ awardId: z.string() }))
    .query(async ({ ctx, input }) => {
      const juror = await ctx.prisma.awardJuror.findUnique({
        where: {
          awardId_userId: {
            awardId: input.awardId,
            userId: ctx.user.id,
          },
        },
      })

      if (!juror) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You are not a juror for this award',
        })
      }

      const [award, eligibleProjects, myVotes, allJurors] = await Promise.all([
        ctx.prisma.specialAward.findUniqueOrThrow({
          where: { id: input.awardId },
        }),
        ctx.prisma.awardEligibility.findMany({
          where: { awardId: input.awardId, eligible: true },
          include: {
            project: {
              select: {
                id: true,
                title: true,
                teamName: true,
                description: true,
                competitionCategory: true,
                country: true,
                tags: true,
                logoKey: true,
                logoProvider: true,
                files: {
                  where: { replacedById: null },
                  select: {
                    id: true,
                    fileName: true,
                    fileType: true,
                    mimeType: true,
                    size: true,
                    bucket: true,
                    objectKey: true,
                    version: true,
                    isLate: true,
                    pageCount: true,
                    detectedLang: true,
                    langConfidence: true,
                    analyzedAt: true,
                    createdAt: true,
                  },
                  orderBy: { createdAt: 'desc' },
                },
                teamMembers: {
                  select: {
                    id: true,
                    role: true,
                    user: { select: { name: true, email: true } },
                  },
                },
              },
            },
          },
        }),
        ctx.prisma.awardVote.findMany({
          where: { awardId: input.awardId, userId: ctx.user.id },
        }),
        ctx.prisma.awardJuror.findMany({
          where: { awardId: input.awardId },
          select: { userId: true, isChair: true, user: { select: { name: true } } },
        }),
      ])

      const projectIds = eligibleProjects.map((e) => e.project.id)
      const projectScores: Record<string, { avg: number; count: number }> = {}

      if (award.evaluationRoundId) {
        const evaluations = await ctx.prisma.evaluation.findMany({
          where: {
            status: 'SUBMITTED',
            assignment: {
              roundId: award.evaluationRoundId,
              projectId: { in: projectIds },
            },
          },
          select: {
            globalScore: true,
            assignment: { select: { projectId: true } },
          },
        })

        const scoreMap = new Map<string, number[]>()
        for (const ev of evaluations) {
          if (ev.globalScore !== null) {
            const pid = ev.assignment.projectId
            if (!scoreMap.has(pid)) scoreMap.set(pid, [])
            scoreMap.get(pid)!.push(ev.globalScore)
          }
        }
        for (const [pid, scores] of scoreMap) {
          projectScores[pid] = {
            avg: scores.reduce((a, b) => a + b, 0) / scores.length,
            count: scores.length,
          }
        }
      }

      const isSolo = allJurors.length === 1
      const isChair = juror.isChair || isSolo
      let otherVotes: Array<{ userId: string; userName: string | null; projectId: string; justification: string | null }> = []
      if (isChair && !isSolo) {
        const votes = await ctx.prisma.awardVote.findMany({
          where: { awardId: input.awardId, userId: { not: ctx.user.id } },
          select: {
            userId: true, projectId: true, justification: true,
            user: { select: { name: true } },
          },
        })
        otherVotes = votes.map((v) => ({
          userId: v.userId,
          userName: v.user.name,
          projectId: v.projectId,
          justification: v.justification,
        }))
      }

      const projectsWithScores = eligibleProjects.map((e) => ({
        ...e.project,
        evaluationScore: projectScores[e.project.id] ?? null,
      }))
      const projectsWithLogos = await attachProjectLogoUrls(projectsWithScores)

      return {
        award,
        projects: projectsWithLogos,
        myVotes,
        isChair,
        otherVotes,
        totalJurors: allJurors.length,
        jurors: allJurors.map((j) => ({ userId: j.userId, name: j.user.name, isChair: j.isChair })),
      }
    }),

  // ─── Voting ─────────────────────────────────────────────────────────────

  /**
   * Submit vote (PICK_WINNER or RANKED). For PICK_WINNER, jurors may attach
   * an optional justification — visible to the chair when they review votes.
   */
  submitVote: protectedProcedure
    .input(
      z.object({
        awardId: z.string(),
        votes: z.array(
          z.object({
            projectId: z.string(),
            rank: z.number().int().min(1).optional(),
            justification: z.string().max(2000).optional(),
          })
        ),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const juror = await ctx.prisma.awardJuror.findUnique({
        where: {
          awardId_userId: {
            awardId: input.awardId,
            userId: ctx.user.id,
          },
        },
      })

      if (!juror) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You are not a juror for this award',
        })
      }

      const award = await ctx.prisma.specialAward.findUniqueOrThrow({
        where: { id: input.awardId },
      })

      if (award.status !== 'VOTING_OPEN') {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Voting is not currently open for this award',
        })
      }

      await ctx.prisma.$transaction([
        ctx.prisma.awardVote.deleteMany({
          where: { awardId: input.awardId, userId: ctx.user.id },
        }),
        ...input.votes.map((vote) =>
          ctx.prisma.awardVote.create({
            data: {
              awardId: input.awardId,
              userId: ctx.user.id,
              projectId: vote.projectId,
              rank: vote.rank,
              justification: vote.justification || null,
            },
          })
        ),
      ])

      await logAudit({
        userId: ctx.user.id,
        action: 'CREATE',
        entityType: 'AwardVote',
        entityId: input.awardId,
        detailsJson: {
          awardId: input.awardId,
          voteCount: input.votes.length,
          scoringMode: award.scoringMode,
        },
      })

      return { submitted: input.votes.length }
    }),

  // ─── Results ────────────────────────────────────────────────────────────

  /**
   * Get aggregated vote results
   */
  getVoteResults: adminProcedure
    .input(z.object({ awardId: z.string() }))
    .query(async ({ ctx, input }) => {
      const [award, votes, jurorCount] = await Promise.all([
        ctx.prisma.specialAward.findUniqueOrThrow({
          where: { id: input.awardId },
        }),
        ctx.prisma.awardVote.findMany({
          where: { awardId: input.awardId },
          include: {
            project: {
              select: { id: true, title: true, teamName: true },
            },
            user: {
              select: { id: true, name: true, email: true },
            },
          },
        }),
        ctx.prisma.awardJuror.count({
          where: { awardId: input.awardId },
        }),
      ])

      const votedJurorCount = new Set(votes.map((v) => v.userId)).size

      // Tally by scoring mode
      const projectTallies = new Map<
        string,
        { project: { id: string; title: string; teamName: string | null }; votes: number; points: number }
      >()

      for (const vote of votes) {
        const existing = projectTallies.get(vote.projectId) || {
          project: vote.project,
          votes: 0,
          points: 0,
        }
        existing.votes += 1
        if (award.scoringMode === 'RANKED' && vote.rank) {
          existing.points += (award.maxRankedPicks || 5) - vote.rank + 1
        } else {
          existing.points += 1
        }
        projectTallies.set(vote.projectId, existing)
      }

      const ranked = Array.from(projectTallies.values()).sort(
        (a, b) => b.points - a.points
      )

      return {
        scoringMode: award.scoringMode,
        jurorCount,
        votedJurorCount,
        results: ranked,
        winnerId: award.winnerProjectId,
        winnerOverridden: award.winnerOverridden,
      }
    }),

  /**
   * Set/override winner
   */
  setWinner: adminProcedure
    .input(
      z.object({
        awardId: z.string(),
        projectId: z.string(),
        overridden: z.boolean().default(false),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const previous = await ctx.prisma.specialAward.findUniqueOrThrow({
        where: { id: input.awardId },
        select: { winnerProjectId: true },
      })

      const award = await ctx.prisma.specialAward.update({
        where: { id: input.awardId },
        data: {
          winnerProjectId: input.projectId,
          winnerOverridden: input.overridden,
          winnerOverriddenBy: input.overridden ? ctx.user.id : null,
        },
      })

      // Audit outside transaction so failures don't break the winner update
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'UPDATE',
        entityType: 'SpecialAward',
        entityId: input.awardId,
        detailsJson: {
          action: 'SET_AWARD_WINNER',
          previousWinner: previous.winnerProjectId,
          newWinner: input.projectId,
          overridden: input.overridden,
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return award
    }),

  /**
   * Chair confirms the winner — resolves tiebreaks, sets winner, closes the award
   */
  confirmWinner: protectedProcedure
    .input(z.object({ awardId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const allJurors = await ctx.prisma.awardJuror.findMany({
        where: { awardId: input.awardId },
        select: { userId: true, isChair: true },
      })
      const myJuror = allJurors.find((j) => j.userId === ctx.user.id)
      if (!myJuror) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Not assigned to this award' })
      }

      const isSolo = allJurors.length === 1
      if (!myJuror.isChair && !isSolo) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the chair can confirm the winner' })
      }

      const award = await ctx.prisma.specialAward.findUniqueOrThrow({
        where: { id: input.awardId },
      })
      if (award.status !== 'VOTING_OPEN') {
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'Award must be in VOTING_OPEN status' })
      }

      const chairVote = await ctx.prisma.awardVote.findFirst({
        where: { awardId: input.awardId, userId: ctx.user.id },
      })
      if (!chairVote) {
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'You must vote before confirming' })
      }

      const allVotes = await ctx.prisma.awardVote.findMany({
        where: { awardId: input.awardId },
        select: { projectId: true, userId: true },
      })

      const winnerId = resolveAwardWinner(allVotes, ctx.user.id)

      await ctx.prisma.specialAward.update({
        where: { id: input.awardId },
        data: {
          winnerProjectId: winnerId,
          status: 'CLOSED',
          winnerOverridden: false,
          winnerOverriddenBy: null,
        },
      })

      await logAudit({
        userId: ctx.user.id,
        action: 'UPDATE',
        entityType: 'SpecialAward',
        entityId: input.awardId,
        detailsJson: {
          action: 'CONFIRM_WINNER',
          winnerId,
          totalVotes: allVotes.length,
          confirmedBy: ctx.user.id,
        },
      })

      return { winnerId, closed: true }
    }),

  /**
   * Admin: set/unset chair status for an award juror (only one chair per award)
   */
  setChair: adminProcedure
    .input(z.object({
      awardId: z.string(),
      userId: z.string(),
      isChair: z.boolean(),
    }))
    .mutation(async ({ ctx, input }) => {
      if (input.isChair) {
        await ctx.prisma.awardJuror.updateMany({
          where: { awardId: input.awardId, isChair: true },
          data: { isChair: false },
        })
      }

      await ctx.prisma.awardJuror.update({
        where: { awardId_userId: { awardId: input.awardId, userId: input.userId } },
        data: { isChair: input.isChair },
      })

      await logAudit({
        userId: ctx.user.id,
        action: 'UPDATE',
        entityType: 'AwardJuror',
        entityId: `${input.awardId}:${input.userId}`,
        detailsJson: { action: 'SET_CHAIR', isChair: input.isChair },
      })

      return { success: true }
    }),

  // ─── Round-Scoped Eligibility & Shortlists ──────────────────────────────

  /**
   * List awards for a competition (from a filtering round context)
   */
  listForRound: protectedProcedure
    .input(z.object({ roundId: z.string() }))
    .query(async ({ ctx, input }) => {
      // Get competition from round
      const round = await ctx.prisma.round.findUniqueOrThrow({
        where: { id: input.roundId },
        select: { competitionId: true },
      })

      const awards = await ctx.prisma.specialAward.findMany({
        where: { competitionId: round.competitionId },
        orderBy: { sortOrder: 'asc' },
        include: {
          _count: {
            select: {
              eligibilities: { where: { eligible: true } },
            },
          },
        },
      })

      // Get shortlisted counts
      const shortlistedCounts = await ctx.prisma.awardEligibility.groupBy({
        by: ['awardId'],
        where: {
          awardId: { in: awards.map((a) => a.id) },
          shortlisted: true,
        },
        _count: true,
      })
      const shortlistMap = new Map(shortlistedCounts.map((s) => [s.awardId, s._count]))

      return awards.map((a) => ({
        ...a,
        shortlistedCount: shortlistMap.get(a.id) ?? 0,
      }))
    }),

  /**
   * Run eligibility scoped to a filtering round's PASSED projects
   */
  runEligibilityForRound: adminProcedure
    .input(z.object({
      awardId: z.string(),
      roundId: z.string(),
    }))
    .mutation(async ({ ctx, input }) => {
      // Set job status to PENDING
      await ctx.prisma.specialAward.update({
        where: { id: input.awardId },
        data: {
          eligibilityJobStatus: 'PENDING',
          eligibilityJobTotal: null,
          eligibilityJobDone: null,
          eligibilityJobError: null,
          eligibilityJobStarted: null,
        },
      })

      await logAudit({
        userId: ctx.user.id,
        action: 'UPDATE',
        entityType: 'SpecialAward',
        entityId: input.awardId,
        detailsJson: { action: 'RUN_ELIGIBILITY_FOR_ROUND', roundId: input.roundId },
      })

      // Fire and forget - process in background with round scoping
      processEligibilityJob(
        input.awardId,
        true, // include submitted
        ctx.user.id,
        input.roundId
      ).catch((err) => {
        console.error('[SpecialAward] processEligibilityJob (round) failed:', err)
      })

      return { started: true }
    }),

  /**
   * Get ranked shortlist for an award
   */
  listShortlist: protectedProcedure
    .input(z.object({
      awardId: z.string(),
      page: z.number().int().min(1).default(1),
      perPage: z.number().int().min(1).max(100).default(50),
    }))
    .query(async ({ ctx, input }) => {
      const { awardId, page, perPage } = input
      const skip = (page - 1) * perPage

      const [eligibilities, total, award] = await Promise.all([
        ctx.prisma.awardEligibility.findMany({
          where: { awardId, eligible: true },
          skip,
          take: perPage,
          include: {
            project: {
              select: {
                id: true,
                title: true,
                teamName: true,
                competitionCategory: true,
                country: true,
                tags: true,
                description: true,
              },
            },
          },
          orderBy: { qualityScore: 'desc' },
        }),
        ctx.prisma.awardEligibility.count({ where: { awardId, eligible: true } }),
        ctx.prisma.specialAward.findUniqueOrThrow({
          where: { id: awardId },
          select: { shortlistSize: true, eligibilityMode: true, name: true },
        }),
      ])

      return {
        eligibilities,
        total,
        page,
        perPage,
        totalPages: Math.ceil(total / perPage),
        shortlistSize: award.shortlistSize,
        eligibilityMode: award.eligibilityMode,
        awardName: award.name,
      }
    }),

  /**
   * Toggle shortlisted status for a project-award pair
   */
  toggleShortlisted: adminProcedure
    .input(z.object({
      awardId: z.string(),
      projectId: z.string(),
    }))
    .mutation(async ({ ctx, input }) => {
      const current = await ctx.prisma.awardEligibility.findUniqueOrThrow({
        where: {
          awardId_projectId: {
            awardId: input.awardId,
            projectId: input.projectId,
          },
        },
        select: { shortlisted: true },
      })

      const updated = await ctx.prisma.awardEligibility.update({
        where: {
          awardId_projectId: {
            awardId: input.awardId,
            projectId: input.projectId,
          },
        },
        data: { shortlisted: !current.shortlisted },
      })

      return { shortlisted: updated.shortlisted }
    }),

  /**
   * Bulk toggle shortlisted status for multiple projects
   */
  bulkToggleShortlisted: adminProcedure
    .input(z.object({
      awardId: z.string(),
      projectIds: z.array(z.string()),
      shortlisted: z.boolean(),
    }))
    .mutation(async ({ ctx, input }) => {
      const result = await ctx.prisma.awardEligibility.updateMany({
        where: {
          awardId: input.awardId,
          projectId: { in: input.projectIds },
          eligible: true,
        },
        data: { shortlisted: input.shortlisted },
      })

      return { updated: result.count, shortlisted: input.shortlisted }
    }),

  /**
   * Confirm shortlist — for SEPARATE_POOL awards, creates ProjectRoundState entries
   */
  confirmShortlist: adminProcedure
    .input(z.object({ awardId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const award = await ctx.prisma.specialAward.findUniqueOrThrow({
        where: { id: input.awardId },
        select: { eligibilityMode: true, name: true, competitionId: true },
      })

      // Get shortlisted projects
      const shortlisted = await ctx.prisma.awardEligibility.findMany({
        where: { awardId: input.awardId, shortlisted: true, eligible: true },
        select: { projectId: true },
      })

      if (shortlisted.length === 0) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'No shortlisted projects to confirm',
        })
      }

      const verifiedUserId = await ensureUserExists(ctx.prisma, ctx.user.id)

      // Mark all as confirmed
      await ctx.prisma.awardEligibility.updateMany({
        where: { awardId: input.awardId, shortlisted: true, eligible: true },
        data: {
          confirmedAt: new Date(),
          confirmedBy: verifiedUserId,
        },
      })

      // For SEPARATE_POOL: create ProjectRoundState entries in award rounds (if any exist)
      let routedCount = 0
      if (award.eligibilityMode === 'SEPARATE_POOL') {
        const awardRounds = await ctx.prisma.round.findMany({
          where: { specialAwardId: input.awardId },
          select: { id: true },
          orderBy: { sortOrder: 'asc' },
        })

        if (awardRounds.length > 0) {
          const firstRound = awardRounds[0]
          const projectIds = shortlisted.map((s) => s.projectId)

          // Create ProjectRoundState entries for confirmed projects in the first award round
          await ctx.prisma.projectRoundState.createMany({
            data: projectIds.map((projectId) => ({
              projectId,
              roundId: firstRound.id,
              state: 'PENDING' as const,
            })),
            skipDuplicates: true,
          })
          routedCount = projectIds.length
        }
      }

      await logAudit({
        userId: ctx.user.id,
        action: 'UPDATE',
        entityType: 'SpecialAward',
        entityId: input.awardId,
        detailsJson: {
          action: 'CONFIRM_SHORTLIST',
          confirmedCount: shortlisted.length,
          eligibilityMode: award.eligibilityMode,
          routedCount,
        },
      })

      return {
        confirmedCount: shortlisted.length,
        routedCount,
        eligibilityMode: award.eligibilityMode,
      }
    }),

  // ─── Award Rounds ────────────────────────────────────────────────────────

  /**
   * List rounds belonging to an award
   */
  listRounds: protectedProcedure
    .input(z.object({ awardId: z.string() }))
    .query(async ({ ctx, input }) => {
      return ctx.prisma.round.findMany({
        where: { specialAwardId: input.awardId },
        orderBy: { sortOrder: 'asc' },
        include: {
          juryGroup: { select: { id: true, name: true } },
          _count: {
            select: {
              projectRoundStates: true,
              assignments: true,
            },
          },
        },
      })
    }),

  /**
   * Create a round linked to an award
   */
  createRound: adminProcedure
    .input(z.object({
      awardId: z.string(),
      name: z.string().min(1),
      roundType: z.enum(['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'DELIBERATION']).default('EVALUATION'),
    }))
    .mutation(async ({ ctx, input }) => {
      const award = await ctx.prisma.specialAward.findUniqueOrThrow({
        where: { id: input.awardId },
        select: { competitionId: true, name: true },
      })

      if (!award.competitionId) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Award must be linked to a competition before creating rounds',
        })
      }

      // Get max sort order for this award's rounds
      const maxOrder = await ctx.prisma.round.aggregate({
        where: { specialAwardId: input.awardId },
        _max: { sortOrder: true },
      })

      // Also need max sortOrder across the competition to avoid unique constraint conflicts
      const maxCompOrder = await ctx.prisma.round.aggregate({
        where: { competitionId: award.competitionId },
        _max: { sortOrder: true },
      })

      const sortOrder = Math.max(
        (maxOrder._max.sortOrder ?? 0) + 1,
        (maxCompOrder._max.sortOrder ?? 0) + 1
      )

      const slug = `${award.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${input.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`

      const round = await ctx.prisma.round.create({
        data: {
          competitionId: award.competitionId,
          specialAwardId: input.awardId,
          name: input.name,
          slug,
          roundType: input.roundType,
          sortOrder,
        },
      })

      await logAudit({
        userId: ctx.user.id,
        action: 'CREATE',
        entityType: 'Round',
        entityId: round.id,
        detailsJson: { awardId: input.awardId, awardName: award.name, roundType: input.roundType },
      })

      return round
    }),

  // ─── Pool Notifications ─────────────────────────────────────────────────

  /**
   * Get account stats for eligible projects (how many need invite vs have account)
   */
  getNotificationStats: adminProcedure
    .input(z.object({ awardId: z.string() }))
    .query(async ({ ctx, input }) => {
      const eligibilities = await ctx.prisma.awardEligibility.findMany({
        where: { awardId: input.awardId, eligible: true },
        select: {
          project: {
            select: {
              submittedBy: { select: { id: true, passwordHash: true } },
              teamMembers: {
                select: { user: { select: { id: true, passwordHash: true } } },
              },
            },
          },
        },
      })

      const seen = new Set<string>()
      let needsInvite = 0
      let hasAccount = 0

      for (const e of eligibilities) {
        const submitter = e.project.submittedBy
        if (submitter && !seen.has(submitter.id)) {
          seen.add(submitter.id)
          if (submitter.passwordHash) hasAccount++
          else needsInvite++
        }
        for (const tm of e.project.teamMembers) {
          if (!seen.has(tm.user.id)) {
            seen.add(tm.user.id)
            if (tm.user.passwordHash) hasAccount++
            else needsInvite++
          }
        }
      }

      return { needsInvite, hasAccount, totalProjects: eligibilities.length }
    }),

  previewAwardSelectionEmail: adminProcedure
    .input(z.object({
      awardId: z.string(),
      customMessage: z.string().optional(),
    }))
    .query(async ({ ctx, input }) => {
      const award = await ctx.prisma.specialAward.findUniqueOrThrow({
        where: { id: input.awardId },
        select: { name: true },
      })

      const eligibleCount = await ctx.prisma.awardEligibility.count({
        where: { awardId: input.awardId, eligible: true },
      })

      const template = getAwardSelectionNotificationTemplate(
        'Team Member',
        'Your Project',
        award.name,
        input.customMessage || undefined,
      )

      return { html: template.html, subject: template.subject, recipientCount: eligibleCount }
    }),

  /**
   * Notify eligible projects that they've been selected for an award.
   * Generates invite tokens for passwordless users.
   */
  notifyEligibleProjects: adminProcedure
    .input(z.object({
      awardId: z.string(),
      customMessage: z.string().optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      const award = await ctx.prisma.specialAward.findUniqueOrThrow({
        where: { id: input.awardId },
        select: { id: true, name: true, description: true, status: true },
      })

      // Get eligible projects that haven't been notified yet
      // Exclude projects that have been rejected at any stage
      const eligibilities = await ctx.prisma.awardEligibility.findMany({
        where: {
          awardId: input.awardId,
          eligible: true,
          notifiedAt: null,
          project: {
            projectRoundStates: {
              none: { state: 'REJECTED' },
            },
          },
        },
        select: {
          id: true,
          projectId: true,
          project: {
            select: {
              id: true,
              title: true,
              submittedBy: {
                select: { id: true, email: true, name: true, passwordHash: true },
              },
              teamMembers: {
                select: {
                  user: {
                    select: { id: true, email: true, name: true, passwordHash: true },
                  },
                },
              },
            },
          },
        },
      })

      if (eligibilities.length === 0) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'No eligible projects to notify',
        })
      }

      // Pre-generate invite tokens for passwordless users
      const expiryMs = await getInviteExpiryMs(ctx.prisma)
      const expiresAt = new Date(Date.now() + expiryMs)
      const tokenMap = new Map<string, string>() // userId -> token

      const allUsers: Array<{ id: string; passwordHash: string | null }> = []
      for (const e of eligibilities) {
        if (e.project.submittedBy) allUsers.push(e.project.submittedBy)
        for (const tm of e.project.teamMembers) allUsers.push(tm.user)
      }

      const passwordlessUsers = allUsers.filter((u) => !u.passwordHash)
      const uniquePasswordless = [...new Map(passwordlessUsers.map((u) => [u.id, u])).values()]

      for (const user of uniquePasswordless) {
        const token = generateInviteToken()
        tokenMap.set(user.id, token)
        await ctx.prisma.user.update({
          where: { id: user.id },
          data: { inviteToken: token, inviteTokenExpiresAt: expiresAt, status: 'INVITED' },
        })
      }

      // Build notification items — track which eligibility each email belongs to
      const items: NotificationItem[] = []
      const eligibilityEmailMap = new Map<string, Set<string>>() // eligibilityId → Set<email>

      for (const e of eligibilities) {
        const recipients: Array<{ id: string; email: string; name: string | null }> = []
        if (e.project.submittedBy) recipients.push(e.project.submittedBy)
        for (const tm of e.project.teamMembers) {
          if (!recipients.some((r) => r.id === tm.user.id)) {
            recipients.push(tm.user)
          }
        }

        const emails = new Set<string>()
        for (const recipient of recipients) {
          const token = tokenMap.get(recipient.id)
          const accountUrl = token ? `/accept-invite?token=${token}` : undefined
          emails.add(recipient.email)

          items.push({
            email: recipient.email,
            name: recipient.name || '',
            type: 'AWARD_SELECTION_NOTIFICATION',
            context: {
              title: `Under consideration for ${award.name}`,
              message: input.customMessage || '',
              metadata: {
                projectName: e.project.title,
                awardName: award.name,
                customMessage: input.customMessage,
                accountUrl,
              },
            },
            projectId: e.projectId,
            userId: recipient.id,
          })
        }
        eligibilityEmailMap.set(e.id, emails)
      }

      const result = await sendBatchNotifications(items)

      // Determine which eligibilities had zero failures
      const failedEmails = new Set(result.errors.map((e) => e.email))
      const successfulEligibilityIds: string[] = []
      for (const [eligId, emails] of eligibilityEmailMap) {
        const hasFailure = [...emails].some((email) => failedEmails.has(email))
        if (!hasFailure) successfulEligibilityIds.push(eligId)
      }

      if (successfulEligibilityIds.length > 0) {
        await ctx.prisma.awardEligibility.updateMany({
          where: { id: { in: successfulEligibilityIds } },
          data: { notifiedAt: new Date() },
        })
      }

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'UPDATE',
        entityType: 'SpecialAward',
        entityId: input.awardId,
        detailsJson: {
          action: 'NOTIFY_ELIGIBLE_PROJECTS',
          eligibleCount: eligibilities.length,
          emailsSent: result.sent,
          emailsFailed: result.failed,
          failedRecipients: result.errors.length > 0 ? result.errors.map((e) => e.email) : undefined,
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { notified: successfulEligibilityIds.length, emailsSent: result.sent, emailsFailed: result.failed }
    }),

  /**
   * Delete an award round (only if DRAFT)
   */
  deleteRound: adminProcedure
    .input(z.object({ roundId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const round = await ctx.prisma.round.findUniqueOrThrow({
        where: { id: input.roundId },
        select: { status: true, specialAwardId: true },
      })

      if (!round.specialAwardId) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'This round is not an award round',
        })
      }

      if (round.status !== 'ROUND_DRAFT') {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Only draft rounds can be deleted',
        })
      }

      await ctx.prisma.round.delete({ where: { id: input.roundId } })

      await logAudit({
        userId: ctx.user.id,
        action: 'DELETE',
        entityType: 'Round',
        entityId: input.roundId,
        detailsJson: { awardId: round.specialAwardId },
      })
    }),

  /**
   * Reorder award rounds via drag-and-drop.
   * Uses a two-phase transaction: first set all to negative temps (avoid unique constraint),
   * then set to final values.
   */
  reorderAwardRounds: adminProcedure
    .input(z.object({
      awardId: z.string(),
      roundIds: z.array(z.string()).min(1),
    }))
    .mutation(async ({ ctx, input }) => {
      const existingRounds = await ctx.prisma.round.findMany({
        where: { specialAwardId: input.awardId },
        select: { id: true, competitionId: true, sortOrder: true },
        orderBy: { sortOrder: 'asc' },
      })

      if (existingRounds.length !== input.roundIds.length) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Round list does not match existing award rounds',
        })
      }

      const existingIds = new Set(existingRounds.map((r) => r.id))
      for (const id of input.roundIds) {
        if (!existingIds.has(id)) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: `Round ${id} does not belong to this award`,
          })
        }
      }

      // Collect the existing sortOrder values (in ascending order) and reassign them
      // to the new ordering. This keeps the same sortOrder slots, just remapped.
      const sortSlots = existingRounds.map((r) => r.sortOrder).sort((a, b) => a - b)
      const competitionId = existingRounds[0].competitionId

      await ctx.prisma.$transaction(async (tx) => {
        // Phase 1: set all to negative temps to avoid unique constraint
        for (let i = 0; i < existingRounds.length; i++) {
          await tx.round.update({
            where: { id: existingRounds[i].id },
            data: { sortOrder: -(i + 1000) },
          })
        }

        // Phase 2: assign final sort orders based on new ordering
        for (let i = 0; i < input.roundIds.length; i++) {
          await tx.round.update({
            where: { id: input.roundIds[i] },
            data: { sortOrder: sortSlots[i] },
          })
        }
      })

      await logAudit({
        userId: ctx.user.id,
        action: 'UPDATE',
        entityType: 'SpecialAward',
        entityId: input.awardId,
        detailsJson: { action: 'REORDER_ROUNDS', newOrder: input.roundIds },
      })
    }),

  /**
   * Assign (or reassign) eligible projects to the first award round.
   * Re-runnable: moves existing ProjectRoundState entries from other award rounds
   * to the first, and creates new PENDING entries for unassigned projects.
   */
  assignToFirstRound: adminProcedure
    .input(z.object({ awardId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const award = await ctx.prisma.specialAward.findUniqueOrThrow({
        where: { id: input.awardId },
        select: { eligibilityMode: true, name: true },
      })

      if (award.eligibilityMode !== 'SEPARATE_POOL') {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Assign to first round is only available for Separate Pool awards',
        })
      }

      const awardRounds = await ctx.prisma.round.findMany({
        where: { specialAwardId: input.awardId },
        select: { id: true },
        orderBy: { sortOrder: 'asc' },
      })

      if (awardRounds.length === 0) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Create at least one round before assigning projects',
        })
      }

      const firstRound = awardRounds[0]
      const otherRoundIds = awardRounds.slice(1).map((r) => r.id)

      // Get all eligible projects (confirmed or not — any eligible project)
      const eligible = await ctx.prisma.awardEligibility.findMany({
        where: { awardId: input.awardId, eligible: true },
        select: { projectId: true },
      })

      if (eligible.length === 0) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'No eligible projects to assign',
        })
      }

      const projectIds = eligible.map((e) => e.projectId)

      // Move existing entries from other award rounds to the first round
      let movedCount = 0
      if (otherRoundIds.length > 0) {
        const moved = await ctx.prisma.projectRoundState.updateMany({
          where: {
            roundId: { in: otherRoundIds },
            projectId: { in: projectIds },
          },
          data: { roundId: firstRound.id, state: 'PENDING' },
        })
        movedCount = moved.count
      }

      // Create PENDING entries for projects not yet in the first round
      const existing = await ctx.prisma.projectRoundState.findMany({
        where: { roundId: firstRound.id, projectId: { in: projectIds } },
        select: { projectId: true },
      })
      const existingSet = new Set(existing.map((e) => e.projectId))
      const newProjectIds = projectIds.filter((id) => !existingSet.has(id))

      let createdCount = 0
      if (newProjectIds.length > 0) {
        await ctx.prisma.projectRoundState.createMany({
          data: newProjectIds.map((projectId) => ({
            projectId,
            roundId: firstRound.id,
            state: 'PENDING' as const,
          })),
          skipDuplicates: true,
        })
        createdCount = newProjectIds.length
      }

      await logAudit({
        userId: ctx.user.id,
        action: 'UPDATE',
        entityType: 'SpecialAward',
        entityId: input.awardId,
        detailsJson: {
          action: 'ASSIGN_TO_FIRST_ROUND',
          firstRoundId: firstRound.id,
          movedCount,
          createdCount,
          totalEligible: projectIds.length,
        },
      })

      return { movedCount, createdCount, totalAssigned: existingSet.size + createdCount }
    }),
})
