import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import type { Prisma } from '@prisma/client'
import { UserRole } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
import { sendInvitationEmail, sendJuryInvitationEmail, sendMagicLinkEmail, sendMentorOnboardingEmail, sendPasswordResetEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password'
import { attachAvatarUrls, getUserAvatarUrl } from '@/server/utils/avatar-url'
import { logAudit } from '@/server/utils/audit'
import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'

export const userRouter = router({
  /**
   * Get current user profile
   */
  me: protectedProcedure.query(async ({ ctx }) => {
    const user = await ctx.prisma.user.findUnique({
      where: { id: ctx.user.id },
      select: {
        id: true,
        email: true,
        name: true,
        role: true,
        status: true,
        expertiseTags: true,
        metadataJson: true,
        phoneNumber: true,
        country: true,
        nationality: true,
        institution: true,
        bio: true,
        notificationPreference: true,
        profileImageKey: true,
        digestFrequency: true,
        availabilityJson: true,
        preferredWorkload: true,
        createdAt: true,
        lastLoginAt: true,
      },
    })

    if (!user) {
      throw new TRPCError({
        code: 'UNAUTHORIZED',
        message: 'User session is stale. Please log out and log back in.',
      })
    }

    return user
  }),

  /**
   * Validate an invitation token (public, no auth required)
   */
  validateInviteToken: publicProcedure
    .input(z.object({ token: z.string().min(1) }))
    .query(async ({ ctx, input }) => {
      const user = await ctx.prisma.user.findUnique({
        where: { inviteToken: input.token },
        select: { id: true, name: true, email: true, role: true, status: true, inviteTokenExpiresAt: true },
      })

      if (!user) {
        return { valid: false, error: 'INVALID_TOKEN' as const }
      }

      if (user.status !== 'INVITED') {
        return { valid: false, error: 'ALREADY_ACCEPTED' as const }
      }

      if (user.inviteTokenExpiresAt && user.inviteTokenExpiresAt < new Date()) {
        return { valid: false, error: 'EXPIRED_TOKEN' as const }
      }

      // Check if user belongs to a team (was invited as team member)
      const teamMembership = await ctx.prisma.teamMember.findFirst({
        where: { userId: user.id },
        select: {
          role: true,
          project: { select: { title: true, teamName: true } },
        },
      })

      return {
        valid: true,
        user: { name: user.name, email: user.email, role: user.role },
        team: teamMembership
          ? { projectTitle: teamMembership.project.title, teamName: teamMembership.project.teamName }
          : null,
      }
    }),

  /**
   * Update current user profile
   */
  updateProfile: protectedProcedure
    .input(
      z.object({
        name: z.string().min(1).max(255).optional(),
        bio: z.string().max(1000).optional(),
        phoneNumber: z.string().max(20).optional().nullable(),
        nationality: z.string().max(100).optional().nullable(),
        institution: z.string().max(255).optional().nullable(),
        country: z.string().max(100).optional(),
        notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
        expertiseTags: z.array(z.string()).max(15).optional(),
        digestFrequency: z.enum(['none', 'daily', 'weekly']).optional(),
        availabilityJson: z.array(z.object({ start: z.string(), end: z.string() })).optional(),
        preferredWorkload: z.number().int().min(1).max(100).optional().nullable(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Email is intentionally NOT in the input schema. Allowing self-service
      // email changes turns any short-lived session compromise into permanent
      // account takeover via password reset on the new address. Email changes
      // require an admin-driven flow (or a future verified-change procedure).
      const {
        bio,
        expertiseTags,
        availabilityJson,
        preferredWorkload,
        digestFrequency,
        ...directFields
      } = input

      // If bio is provided, merge it into metadataJson
      let metadataJson: Prisma.InputJsonValue | undefined
      if (bio !== undefined) {
        const currentUser = await ctx.prisma.user.findUniqueOrThrow({
          where: { id: ctx.user.id },
          select: { metadataJson: true },
        })
        const currentMeta = (currentUser.metadataJson as Record<string, string>) || {}
        metadataJson = { ...currentMeta, bio } as Prisma.InputJsonValue
      }

      return ctx.prisma.user.update({
        where: { id: ctx.user.id },
        data: {
          ...directFields,
          ...(metadataJson !== undefined && { metadataJson }),
          ...(expertiseTags !== undefined && { expertiseTags }),
          ...(digestFrequency !== undefined && { digestFrequency }),
          ...(availabilityJson !== undefined && { availabilityJson: availabilityJson as Prisma.InputJsonValue }),
          ...(preferredWorkload !== undefined && { preferredWorkload }),
        },
      })
    }),

  /**
   * Delete own account (requires password confirmation)
   */
  deleteAccount: protectedProcedure
    .input(
      z.object({
        password: z.string().min(1),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Get current user with password hash
      const user = await ctx.prisma.user.findUniqueOrThrow({
        where: { id: ctx.user.id },
        select: { id: true, email: true, passwordHash: true, role: true },
      })

      // Prevent super admins from self-deleting
      if (user.role === 'SUPER_ADMIN') {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Super admins cannot delete their own account',
        })
      }

      if (!user.passwordHash) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'No password set. Please set a password first.',
        })
      }

      // Verify password
      const { verifyPassword } = await import('@/lib/password')
      const isValid = await verifyPassword(input.password, user.passwordHash)
      if (!isValid) {
        throw new TRPCError({
          code: 'UNAUTHORIZED',
          message: 'Password is incorrect',
        })
      }

      // TODO: This delete will fail with a FK violation for any user with activity
      // (COI declarations, mentor assignments, messages, evaluations, etc.).
      // A proper purge flow with pre-deletion cleanup or soft-delete is needed
      // before hard-delete can work reliably for active users.
      await ctx.prisma.user.delete({
        where: { id: ctx.user.id },
      })

      // Audit outside transaction so failures don't roll back the deletion
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'DELETE_OWN_ACCOUNT',
        entityType: 'User',
        entityId: ctx.user.id,
        detailsJson: { email: user.email },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { success: true }
    }),

  /**
   * List all users (admin only)
   */
  list: adminProcedure
    .input(
      z.object({
        role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
        roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
        status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
        search: z.string().optional(),
        page: z.number().int().min(1).default(1),
        perPage: z.number().int().min(1).max(100).default(20),
        sortBy: z.enum(['name', 'email', 'role', 'status', 'lastLoginAt', 'createdAt']).optional(),
        sortDir: z.enum(['asc', 'desc']).optional(),
      })
    )
    .query(async ({ ctx, input }) => {
      const { role, roles, status, search, page, perPage, sortBy, sortDir } = input
      const skip = (page - 1) * perPage

      const where: Record<string, unknown> = {}
      const and: Record<string, unknown>[] = []

      // Match the role as EITHER the primary role (User.role) or a secondary
      // role (User.roles[]) so the type tabs include multi-role users, mirroring
      // the multi-role checks used elsewhere (userHasRole / hasRole middleware).
      if (roles && roles.length > 0) {
        and.push({ OR: [{ role: { in: roles } }, { roles: { hasSome: roles } }] })
      } else if (role) {
        and.push({ OR: [{ role }, { roles: { has: role } }] })
      }
      if (status) where.status = status
      if (search) {
        and.push({
          OR: [
            { email: { contains: search, mode: 'insensitive' } },
            { name: { contains: search, mode: 'insensitive' } },
          ],
        })
      }
      if (and.length > 0) where.AND = and

      const dir = sortDir ?? 'asc'
      const orderBy: Record<string, string> = sortBy
        ? { [sortBy]: dir }
        : { createdAt: 'desc' }

      const [users, total] = await Promise.all([
        ctx.prisma.user.findMany({
          where,
          skip,
          take: perPage,
          orderBy,
          select: {
            id: true,
            email: true,
            name: true,
            role: true,
            roles: true,
            status: true,
            expertiseTags: true,
            maxAssignments: true,
            availabilityJson: true,
            preferredWorkload: true,
            profileImageKey: true,
            profileImageProvider: true,
            createdAt: true,
            lastLoginAt: true,
            _count: {
              select: { assignments: true, mentorAssignments: true },
            },
          },
        }),
        ctx.prisma.user.count({ where }),
      ])

      const usersWithAvatars = await attachAvatarUrls(users)

      // For APPLICANT users, attach their project's current round info
      const applicantIds = users.filter((u) => u.role === 'APPLICANT').map((u) => u.id)
      const applicantRoundMap = new Map<string, { projectName: string; roundName: string; state: string } | null>()

      if (applicantIds.length > 0) {
        // Find each applicant's project, then the latest round state
        const projects = await ctx.prisma.project.findMany({
          where: {
            OR: [
              { submittedByUserId: { in: applicantIds } },
              { teamMembers: { some: { userId: { in: applicantIds } } } },
            ],
          },
          include: {
            teamMembers: { select: { userId: true } },
            projectRoundStates: {
              select: {
                state: true,
                round: { select: { name: true, sortOrder: true } },
              },
              orderBy: { round: { sortOrder: 'desc' } },
            },
          },
        })

        // Build a map of userId -> project's current round info
        for (const proj of projects) {
          const userIds = [
            proj.submittedByUserId,
            ...proj.teamMembers.map((tm) => tm.userId),
          ].filter((id): id is string => id !== null)

          // Find the latest active round state (non-terminal first, fallback to terminal)
          const latestActive = proj.projectRoundStates.find((rs) =>
            rs.state === 'IN_PROGRESS' || rs.state === 'PENDING'
          )
          const latestTerminal = proj.projectRoundStates.find((rs) =>
            rs.state === 'REJECTED' || rs.state === 'WITHDRAWN'
          )
          const latest = latestActive ?? proj.projectRoundStates[0]

          for (const uid of userIds) {
            if (!applicantIds.includes(uid)) continue
            if (latestTerminal && !latestActive) {
              applicantRoundMap.set(uid, {
                projectName: proj.title,
                roundName: latestTerminal.round.name,
                state: latestTerminal.state,
              })
            } else if (latest) {
              applicantRoundMap.set(uid, {
                projectName: proj.title,
                roundName: latest.round.name,
                state: latest.state,
              })
            } else {
              applicantRoundMap.set(uid, null)
            }
          }
        }
      }

      const enrichedUsers = usersWithAvatars.map((u) => ({
        ...u,
        applicantRoundInfo: applicantRoundMap.get(u.id) ?? null,
      }))

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

  /**
   * List all invitable user IDs for current filters (not paginated)
   */
  listInvitableIds: adminProcedure
    .input(
      z.object({
        role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
        roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
        search: z.string().optional(),
      })
    )
    .query(async ({ ctx, input }) => {
      const where: Record<string, unknown> = {
        status: { in: ['NONE', 'INVITED'] },
      }
      const and: Record<string, unknown>[] = []

      // Match primary OR secondary role (see user.list for rationale).
      if (input.roles && input.roles.length > 0) {
        and.push({ OR: [{ role: { in: input.roles } }, { roles: { hasSome: input.roles } }] })
      } else if (input.role) {
        and.push({ OR: [{ role: input.role }, { roles: { has: input.role } }] })
      }

      if (input.search) {
        and.push({
          OR: [
            { email: { contains: input.search, mode: 'insensitive' } },
            { name: { contains: input.search, mode: 'insensitive' } },
          ],
        })
      }
      if (and.length > 0) where.AND = and

      const users = await ctx.prisma.user.findMany({
        where,
        select: { id: true },
      })

      return {
        userIds: users.map((u) => u.id),
        total: users.length,
      }
    }),

  /**
   * Get a single user (admin only)
   */
  get: adminProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const user = await ctx.prisma.user.findUniqueOrThrow({
        where: { id: input.id },
        include: {
          _count: {
            select: { assignments: true, mentorAssignments: true },
          },
          teamMemberships: {
            include: {
              project: {
                select: { id: true, title: true, teamName: true, competitionCategory: true },
              },
            },
          },
          juryGroupMemberships: {
            include: {
              juryGroup: {
                select: { id: true, name: true },
              },
            },
          },
        },
      })
      const avatarUrl = await getUserAvatarUrl(user.profileImageKey, user.profileImageProvider)
      return { ...user, avatarUrl }
    }),

  /**
   * Resolve a batch of user IDs to names (admin only).
   * Returns a map of id → name for displaying in audit logs, etc.
   */
  resolveNames: adminProcedure
    .input(z.object({ ids: z.array(z.string()).max(50) }))
    .query(async ({ ctx, input }) => {
      if (input.ids.length === 0) return {}
      const users = await ctx.prisma.user.findMany({
        where: { id: { in: input.ids } },
        select: { id: true, name: true, email: true },
      })
      const map: Record<string, string> = {}
      for (const u of users) {
        map[u.id] = u.name || u.email
      }
      return map
    }),

  /**
   * Create/invite a new user (admin only)
   */
  create: adminProcedure
    .input(
      z.object({
        email: z.string().email(),
        name: z.string().optional(),
        role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
        expertiseTags: z.array(z.string()).optional(),
        maxAssignments: z.number().int().min(1).max(100).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Check if user already exists
      const existing = await ctx.prisma.user.findUnique({
        where: { email: input.email },
      })

      if (existing) {
        throw new TRPCError({
          code: 'CONFLICT',
          message: 'A user with this email already exists',
        })
      }

      // Prevent non-super-admins from creating super admins or program admins
      if (input.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only super admins can create super admins',
        })
      }
      if (input.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only super admins can create program admins',
        })
      }

      // Generate invite token upfront so the user can accept even if the
      // subsequent invitation email fails to send.  Re-sending from the
      // members table will just overwrite the token.
      const inviteToken = generateInviteToken()
      const expiryHours = await getInviteExpiryHours(ctx.prisma)

      const user = await ctx.prisma.user.create({
        data: {
          ...input,
          roles: [input.role],
          status: 'INVITED',
          inviteToken,
          inviteTokenExpiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000),
        },
      })

      // Audit outside transaction so failures don't roll back the user creation
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'CREATE',
        entityType: 'User',
        entityId: user.id,
        detailsJson: { email: input.email, role: input.role },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return user
    }),

  /**
   * Update a user (admin only)
   */
  update: adminProcedure
    .input(
      z.object({
        id: z.string(),
        email: z.string().email().optional(),
        name: z.string().optional().nullable(),
        role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE']).optional(),
        roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE'])).optional(),
        status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
        expertiseTags: z.array(z.string()).optional(),
        maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
        availabilityJson: z.array(z.object({ start: z.string(), end: z.string() })).optional(),
        preferredWorkload: z.number().int().min(1).max(100).optional().nullable(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const { id, ...data } = input
      const normalizedEmail = data.email?.toLowerCase().trim()

      // Prevent changing super admin role
      const targetUser = await ctx.prisma.user.findUniqueOrThrow({
        where: { id },
      })

      const callerIsSuperAdmin = ctx.user.role === 'SUPER_ADMIN'
      const targetHasSuperAdmin =
        targetUser.role === 'SUPER_ADMIN' || targetUser.roles.includes('SUPER_ADMIN')
      const targetHasProgramAdmin =
        targetUser.role === 'PROGRAM_ADMIN' || targetUser.roles.includes('PROGRAM_ADMIN')

      if (targetHasSuperAdmin && !callerIsSuperAdmin) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Cannot modify super admin',
        })
      }

      // Prevent non-super-admins from changing admin roles (singular OR array)
      if (
        (data.role || data.roles) &&
        targetHasProgramAdmin &&
        !callerIsSuperAdmin
      ) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only super admins can change admin roles',
        })
      }

      // Prevent non-super-admins from assigning super admin or admin role
      // — check both the singular `role` field AND the `roles[]` array.
      if (data.role === 'SUPER_ADMIN' && !callerIsSuperAdmin) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only super admins can assign super admin role',
        })
      }
      if (data.role === 'PROGRAM_ADMIN' && !callerIsSuperAdmin) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only super admins can assign admin role',
        })
      }
      if (data.roles?.includes('SUPER_ADMIN') && !callerIsSuperAdmin) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only super admins can assign super admin role',
        })
      }
      if (data.roles?.includes('PROGRAM_ADMIN') && !callerIsSuperAdmin) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only super admins can assign admin role',
        })
      }

      if (normalizedEmail !== undefined) {
        const existing = await ctx.prisma.user.findFirst({
          where: {
            email: normalizedEmail,
            NOT: { id },
          },
          select: { id: true },
        })

        if (existing) {
          throw new TRPCError({
            code: 'CONFLICT',
            message: 'Another user already uses this email address',
          })
        }
      }

      const updateData = {
        ...data,
        ...(normalizedEmail !== undefined && { email: normalizedEmail }),
      }

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

      // Audit outside transaction so failures don't roll back the update
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'UPDATE',
        entityType: 'User',
        entityId: id,
        detailsJson: updateData,
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      // Track role change specifically
      if (data.role && data.role !== targetUser.role) {
        await logAudit({
          prisma: ctx.prisma,
          userId: ctx.user.id,
          action: 'ROLE_CHANGED',
          entityType: 'User',
          entityId: id,
          detailsJson: { previousRole: targetUser.role, newRole: data.role },
          ipAddress: ctx.ip,
          userAgent: ctx.userAgent,
        })
      }

      return user
    }),

  /**
   * Delete a user (super admin only)
   */
  delete: superAdminProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      // Prevent self-deletion
      if (input.id === ctx.user.id) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Cannot delete yourself',
        })
      }

      // Fetch user data before deletion for the audit log
      const target = await ctx.prisma.user.findUniqueOrThrow({
        where: { id: input.id },
        select: { email: true },
      })

      // TODO: This delete will fail with a FK violation for any user with activity
      // (COI declarations, mentor assignments, messages, evaluations, etc.).
      // A proper purge flow with pre-deletion cleanup or soft-delete is needed
      // before hard-delete can work reliably for active users.
      const user = await ctx.prisma.user.delete({
        where: { id: input.id },
      })

      // Audit outside transaction so failures don't roll back the deletion
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'DELETE',
        entityType: 'User',
        entityId: input.id,
        detailsJson: { email: target.email },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return user
    }),

  /**
   * Bulk import users (admin only)
   * Optionally pre-assign projects to jury members during invitation
   */
  bulkCreate: adminProcedure
    .input(
      z.object({
        users: z.array(
          z.object({
            email: z.string().email(),
            name: z.string().optional(),
            role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
            expertiseTags: z.array(z.string()).optional(),
            // Optional pre-assignments for jury members
            assignments: z
              .array(
                z.object({
                  projectId: z.string(),
                  roundId: z.string(),
                })
              )
              .optional(),
            // Competition architecture: optional jury group memberships
            juryGroupIds: z.array(z.string()).optional(),
            juryGroupRole: z.enum(['CHAIR', 'MEMBER', 'OBSERVER']).default('MEMBER'),
            // Competition architecture: optional assignment intents
            assignmentIntents: z
              .array(
                z.object({
                  roundId: z.string(),
                  projectId: z.string(),
                })
              )
              .optional(),
          })
        ),
        sendInvitation: z.boolean().default(true),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Prevent non-super-admins from creating super admins or program admins
      const hasSuperAdminRole = input.users.some((u) => u.role === 'SUPER_ADMIN')
      if (hasSuperAdminRole && ctx.user.role !== 'SUPER_ADMIN') {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only super admins can create super admins',
        })
      }
      const hasAdminRole = input.users.some((u) => u.role === 'PROGRAM_ADMIN')
      if (hasAdminRole && ctx.user.role !== 'SUPER_ADMIN') {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only super admins can create program admins',
        })
      }

      // Deduplicate input by email (keep first occurrence)
      const seenEmails = new Set<string>()
      const uniqueUsers = input.users.filter((u) => {
        const email = u.email.toLowerCase()
        if (seenEmails.has(email)) return false
        seenEmails.add(email)
        return true
      })

      // Get existing emails from database
      const existingUsers = await ctx.prisma.user.findMany({
        where: { email: { in: uniqueUsers.map((u) => u.email.toLowerCase()) } },
        select: { email: true },
      })
      const existingEmails = new Set(existingUsers.map((u) => u.email.toLowerCase()))

      // Filter out existing users
      const newUsers = uniqueUsers.filter((u) => !existingEmails.has(u.email.toLowerCase()))

      const duplicatesInInput = input.users.length - uniqueUsers.length
      const skipped = existingEmails.size + duplicatesInInput

      if (newUsers.length === 0) {
        return { created: 0, skipped }
      }

      const emailToAssignments = new Map<string, Array<{ projectId: string; roundId: string }>>()
      const emailToJuryGroupIds = new Map<string, { ids: string[]; role: 'CHAIR' | 'MEMBER' | 'OBSERVER' }>()
      const emailToIntents = new Map<string, Array<{ roundId: string; projectId: string }>>()
      for (const u of newUsers) {
        if (u.assignments && u.assignments.length > 0) {
          emailToAssignments.set(u.email.toLowerCase(), u.assignments)
        }
        if (u.juryGroupIds && u.juryGroupIds.length > 0) {
          emailToJuryGroupIds.set(u.email.toLowerCase(), { ids: u.juryGroupIds, role: u.juryGroupRole })
        }
        if (u.assignmentIntents && u.assignmentIntents.length > 0) {
          emailToIntents.set(u.email.toLowerCase(), u.assignmentIntents)
        }
      }

      const created = await ctx.prisma.user.createMany({
        data: newUsers.map((u) => ({
          email: u.email.toLowerCase(),
          name: u.name,
          role: u.role,
          roles: [u.role],
          expertiseTags: u.expertiseTags,
          status: input.sendInvitation ? 'INVITED' : 'NONE',
        })),
      })

      // Audit log
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'BULK_CREATE',
        entityType: 'User',
        detailsJson: { count: created.count, skipped, duplicatesInInput },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      // Fetch newly created users for assignments and optional invitation emails
      const createdUsers = await ctx.prisma.user.findMany({
        where: { email: { in: newUsers.map((u) => u.email.toLowerCase()) } },
        select: { id: true, email: true, name: true, role: true },
      })

      // Create pre-assignments for users who have them (batched)
      const assignmentData: Array<{ userId: string; projectId: string; roundId: string; method: 'MANUAL'; createdBy: string }> = []
      for (const user of createdUsers) {
        const assignments = emailToAssignments.get(user.email.toLowerCase())
        if (assignments && assignments.length > 0) {
          for (const assignment of assignments) {
            assignmentData.push({
              userId: user.id,
              projectId: assignment.projectId,
              roundId: assignment.roundId,
              method: 'MANUAL',
              createdBy: ctx.user.id,
            })
          }
        }
      }
      let assignmentsCreated = 0
      if (assignmentData.length > 0) {
        const result = await ctx.prisma.assignment.createMany({
          data: assignmentData,
          skipDuplicates: true,
        })
        assignmentsCreated = result.count
      }

      // Audit log for assignments if any were created
      if (assignmentsCreated > 0) {
        await logAudit({
          prisma: ctx.prisma,
          userId: ctx.user.id,
          action: 'BULK_ASSIGN',
          entityType: 'Assignment',
          detailsJson: { count: assignmentsCreated, context: 'invitation_pre_assignment' },
          ipAddress: ctx.ip,
          userAgent: ctx.userAgent,
        })
      }

      // Create JuryGroupMember records for users with juryGroupIds (batched)
      const juryGroupMemberData: Array<{ juryGroupId: string; userId: string; role: 'CHAIR' | 'MEMBER' | 'OBSERVER' }> = []
      for (const user of createdUsers) {
        const groupInfo = emailToJuryGroupIds.get(user.email.toLowerCase())
        if (groupInfo) {
          for (const groupId of groupInfo.ids) {
            juryGroupMemberData.push({
              juryGroupId: groupId,
              userId: user.id,
              role: groupInfo.role,
            })
          }
        }
      }
      let juryGroupMembershipsCreated = 0
      if (juryGroupMemberData.length > 0) {
        const result = await ctx.prisma.juryGroupMember.createMany({
          data: juryGroupMemberData,
          skipDuplicates: true,
        })
        juryGroupMembershipsCreated = result.count
      }

      // Create AssignmentIntents for users who have them
      let assignmentIntentsCreated = 0
      const allIntentUsers = createdUsers.filter(
        (u) => emailToIntents.has(u.email.toLowerCase())
      )
      if (allIntentUsers.length > 0) {
        // Batch-fetch all relevant rounds to avoid N+1 lookups
        const allIntentRoundIds = new Set<string>()
        for (const u of allIntentUsers) {
          for (const intent of emailToIntents.get(u.email.toLowerCase())!) {
            allIntentRoundIds.add(intent.roundId)
          }
        }
        const rounds = await ctx.prisma.round.findMany({
          where: { id: { in: [...allIntentRoundIds] } },
          select: { id: true, juryGroupId: true },
        })
        const roundJuryGroupMap = new Map(rounds.map((r) => [r.id, r.juryGroupId]))

        // Batch-fetch all matching JuryGroupMembers
        const memberLookups = allIntentUsers.flatMap((u) => {
          const intents = emailToIntents.get(u.email.toLowerCase())!
          return intents
            .map((intent) => {
              const juryGroupId = roundJuryGroupMap.get(intent.roundId)
              return juryGroupId ? { juryGroupId, userId: u.id } : null
            })
            .filter((x): x is { juryGroupId: string; userId: string } => x !== null)
        })
        const members = memberLookups.length > 0
          ? await ctx.prisma.juryGroupMember.findMany({
              where: {
                OR: memberLookups.map((l) => ({
                  juryGroupId: l.juryGroupId,
                  userId: l.userId,
                })),
              },
              select: { id: true, juryGroupId: true, userId: true },
            })
          : []
        const memberMap = new Map(
          members.map((m) => [`${m.juryGroupId}:${m.userId}`, m.id])
        )

        // Batch-create all intents
        const intentData: Array<{
          juryGroupMemberId: string
          roundId: string
          projectId: string
          source: 'INVITE'
          status: 'INTENT_PENDING'
        }> = []
        for (const user of allIntentUsers) {
          const intents = emailToIntents.get(user.email.toLowerCase())!
          for (const intent of intents) {
            const juryGroupId = roundJuryGroupMap.get(intent.roundId)
            if (!juryGroupId) continue
            const memberId = memberMap.get(`${juryGroupId}:${user.id}`)
            if (!memberId) continue
            intentData.push({
              juryGroupMemberId: memberId,
              roundId: intent.roundId,
              projectId: intent.projectId,
              source: 'INVITE',
              status: 'INTENT_PENDING',
            })
          }
        }
        if (intentData.length > 0) {
          const result = await ctx.prisma.assignmentIntent.createMany({
            data: intentData,
            skipDuplicates: true,
          })
          assignmentIntentsCreated = result.count
        }
      }

      if (juryGroupMembershipsCreated > 0) {
        await logAudit({
          prisma: ctx.prisma,
          userId: ctx.user.id,
          action: 'BULK_CREATE',
          entityType: 'JuryGroupMember',
          detailsJson: { count: juryGroupMembershipsCreated, context: 'invitation_jury_group_binding' },
          ipAddress: ctx.ip,
          userAgent: ctx.userAgent,
        })
      }

      // Send invitation emails if requested
      let emailsSent = 0
      const emailErrors: string[] = []

      if (input.sendInvitation) {
        const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
        const expiryHours = await getInviteExpiryHours(ctx.prisma)
        const expiryMs = expiryHours * 60 * 60 * 1000

        for (const user of createdUsers) {
          try {
            const token = generateInviteToken()
            await ctx.prisma.user.update({
              where: { id: user.id },
              data: {
                inviteToken: token,
                inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
                status: 'INVITED',
              },
            })

            const inviteUrl = `${baseUrl}/accept-invite?token=${token}`

            // Use jury-specific template for jury members
            if (user.role === 'JURY_MEMBER') {
              await sendJuryInvitationEmail(user.email, user.name, inviteUrl, 'the evaluation round')
            } else {
              await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
            }

            await ctx.prisma.notificationLog.create({
              data: {
                userId: user.id,
                channel: 'EMAIL',
                provider: 'SMTP',
                type: 'JURY_INVITATION',
                status: 'SENT',
              },
            })
            emailsSent++
          } catch (e) {
            emailErrors.push(user.email)
            await ctx.prisma.notificationLog.create({
              data: {
                userId: user.id,
                channel: 'EMAIL',
                provider: 'SMTP',
                type: 'JURY_INVITATION',
                status: 'FAILED',
                errorMsg: e instanceof Error ? e.message : 'Unknown error',
              },
            })
          }
        }
      }

      return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated, juryGroupMembershipsCreated, assignmentIntentsCreated, invitationSent: input.sendInvitation }
    }),

  /**
   * Get jury members for assignment
   */
  getJuryMembers: adminProcedure
    .input(
      z.object({
        roundId: z.string().optional(),
        search: z.string().optional(),
      })
    )
    .query(async ({ ctx, input }) => {
      const where: Record<string, unknown> = {
        roles: { has: 'JURY_MEMBER' },
        status: 'ACTIVE',
      }

      if (input.search) {
        where.OR = [
          { email: { contains: input.search, mode: 'insensitive' } },
          { name: { contains: input.search, mode: 'insensitive' } },
        ]
      }

      const users = await ctx.prisma.user.findMany({
        where,
        select: {
          id: true,
          email: true,
          name: true,
          expertiseTags: true,
          maxAssignments: true,
          availabilityJson: true,
          preferredWorkload: true,
          profileImageKey: true,
          profileImageProvider: true,
          _count: {
            select: {
              assignments: input.roundId
                ? { where: { roundId: input.roundId } }
                : true,
            },
          },
        },
        orderBy: { name: 'asc' },
      })

      const mapped = users.map((u) => ({
        ...u,
        currentAssignments: u._count.assignments,
        availableSlots:
          u.maxAssignments !== null
            ? Math.max(0, u.maxAssignments - u._count.assignments)
            : null,
      }))

      return attachAvatarUrls(mapped)
    }),

  /**
   * Send invitation email to a user
   */
  sendInvitation: adminProcedure
    .input(z.object({
      userId: z.string(),
      juryGroupId: z.string().optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      const user = await ctx.prisma.user.findUniqueOrThrow({
        where: { id: input.userId },
      })

      if (user.status !== 'NONE' && user.status !== 'INVITED') {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'User has already accepted their invitation',
        })
      }

      // Bind to jury group if specified (upsert to be idempotent)
      if (input.juryGroupId) {
        await ctx.prisma.juryGroupMember.upsert({
          where: {
            juryGroupId_userId: {
              juryGroupId: input.juryGroupId,
              userId: user.id,
            },
          },
          create: {
            juryGroupId: input.juryGroupId,
            userId: user.id,
            role: 'MEMBER',
          },
          update: {}, // No-op if already exists
        })
      }

      // Generate invite token, set status to INVITED, and store on user
      const token = generateInviteToken()
      const expiryHours = await getInviteExpiryHours(ctx.prisma)
      await ctx.prisma.user.update({
        where: { id: user.id },
        data: {
          status: 'INVITED',
          inviteToken: token,
          inviteTokenExpiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000),
        },
      })

      const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
      const inviteUrl = `${baseUrl}/accept-invite?token=${token}`

      // Send invitation email — use jury-specific template for jury members
      if (user.role === 'JURY_MEMBER') {
        // Try to resolve a round name for the jury invitation email
        let roundName = 'the evaluation round'
        if (input.juryGroupId) {
          const juryGroup = await ctx.prisma.juryGroup.findUnique({
            where: { id: input.juryGroupId },
            select: { rounds: { select: { name: true }, take: 1, orderBy: { sortOrder: 'asc' } } },
          })
          if (juryGroup?.rounds[0]?.name) {
            roundName = juryGroup.rounds[0].name
          }
        }
        await sendJuryInvitationEmail(user.email, user.name, inviteUrl, roundName)
      } else {
        await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
      }

      // Log notification
      await ctx.prisma.notificationLog.create({
        data: {
          userId: user.id,
          channel: 'EMAIL',
          provider: 'SMTP',
          type: 'JURY_INVITATION',
          status: 'SENT',
        },
      })

      // Audit log
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'SEND_INVITATION',
        entityType: 'User',
        entityId: user.id,
        detailsJson: { email: user.email },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { success: true, email: user.email }
    }),

  /**
   * Generate an access link an admin can copy and hand to the user out-of-band
   * (Slack, WhatsApp, in person) when email isn't reaching them.
   *
   * One URL shape (`/accept-invite?token=…`) handles every case because the
   * credentials provider in auth.ts now branches on whether the user has a
   * password:
   *  - User has no password yet (INVITED / mustSetPassword) → flow walks
   *    them through accept → /set-password → onboarding.
   *  - User already has a password → magic-login: token consumed, JWT
   *    issued with their existing role, redirected to their dashboard.
   *    Their password is preserved.
   *
   * Each generation rotates the token, sets a 24h expiry, and is consumed
   * on first successful sign-in — leaked or screenshot links can't be
   * replayed. Audit-logged with the admin's id, the target user's id +
   * email, and whether it'll behave as setup or magic-login.
   */
  generateAccessLink: adminProcedure
    .input(z.object({ userId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const user = await ctx.prisma.user.findUniqueOrThrow({
        where: { id: input.userId },
        select: {
          id: true,
          email: true,
          name: true,
          status: true,
          passwordHash: true,
        },
      })

      if (user.status === 'SUSPENDED') {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Cannot generate an access link for a suspended user',
        })
      }

      const token = generateInviteToken()
      const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24h
      const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'

      const kind: 'setup' | 'magic_login' = user.passwordHash ? 'magic_login' : 'setup'

      await ctx.prisma.user.update({
        where: { id: user.id },
        data: {
          inviteToken: token,
          inviteTokenExpiresAt: expiresAt,
          // Bump NONE → INVITED so the accept-invite credentials provider works
          ...(user.status === 'NONE' ? { status: 'INVITED' as const } : {}),
        },
      })

      const url = `${baseUrl}/accept-invite?token=${token}`

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'GENERATE_ACCESS_LINK',
        entityType: 'User',
        entityId: user.id,
        detailsJson: {
          targetEmail: user.email,
          targetName: user.name,
          kind,
          expiresAt: expiresAt.toISOString(),
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { url, kind, expiresAt, email: user.email, name: user.name }
    }),

  /**
   * Send invitation emails to multiple users
   */
  bulkSendInvitations: adminProcedure
    .input(z.object({ userIds: z.array(z.string()) }))
    .mutation(async ({ ctx, input }) => {
      const users = await ctx.prisma.user.findMany({
        where: {
          id: { in: input.userIds },
          status: { in: ['NONE', 'INVITED'] },
        },
      })

      if (users.length === 0) {
        return { sent: 0, skipped: input.userIds.length }
      }

      const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
      const expiryHours = await getInviteExpiryHours(ctx.prisma)
      const expiryMs = expiryHours * 60 * 60 * 1000
      let sent = 0
      const errors: string[] = []

      for (const user of users) {
        try {
          // Generate invite token for each user and set status to INVITED
          const token = generateInviteToken()
          await ctx.prisma.user.update({
            where: { id: user.id },
            data: {
              status: 'INVITED',
              inviteToken: token,
              inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
            },
          })

          const inviteUrl = `${baseUrl}/accept-invite?token=${token}`

          // Use jury-specific template for jury members
          if (user.role === 'JURY_MEMBER') {
            await sendJuryInvitationEmail(user.email, user.name, inviteUrl, 'the evaluation round')
          } else {
            await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
          }

          await ctx.prisma.notificationLog.create({
            data: {
              userId: user.id,
              channel: 'EMAIL',
              provider: 'SMTP',
              type: 'JURY_INVITATION',
              status: 'SENT',
            },
          })

          sent++
        } catch (e) {
          errors.push(user.email)
          await ctx.prisma.notificationLog.create({
            data: {
              userId: user.id,
              channel: 'EMAIL',
              provider: 'SMTP',
              type: 'JURY_INVITATION',
              status: 'FAILED',
              errorMsg: e instanceof Error ? e.message : 'Unknown error',
            },
          })
        }
      }

      // Audit log
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'BULK_SEND_INVITATIONS',
        entityType: 'User',
        detailsJson: { sent, errors },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { sent, skipped: input.userIds.length - users.length, errors }
    }),

  /**
   * Complete onboarding for current user
   */
  completeOnboarding: protectedProcedure
    .input(
      z.object({
        name: z.string().min(1).max(255),
        phoneNumber: z.string().optional(),
        country: z.string().optional(),
        nationality: z.string().optional(),
        institution: z.string().optional(),
        bio: z.string().max(500).optional(),
        expertiseTags: z.array(z.string()).optional(),
        notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
        // Competition architecture: jury self-service preferences
        juryPreferences: z
          .array(
            z.object({
              juryGroupMemberId: z.string(),
              selfServiceCap: z.number().int().positive().optional(),
              selfServiceRatio: z.number().min(0).max(1).optional(),
            })
          )
          .optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Get existing user to preserve admin-set tags
      const existingUser = await ctx.prisma.user.findUniqueOrThrow({
        where: { id: ctx.user.id },
        select: { expertiseTags: true },
      })

      // Merge admin-set tags with user-selected tags (preserving order: admin first, then user)
      const adminTags = existingUser.expertiseTags || []
      const userTags = input.expertiseTags || []
      const mergedTags = [...new Set([...adminTags, ...userTags])]

      const user = await ctx.prisma.$transaction(async (tx) => {
        const updated = await tx.user.update({
          where: { id: ctx.user.id },
          data: {
            name: input.name,
            phoneNumber: input.phoneNumber,
            country: input.country,
            nationality: input.nationality,
            institution: input.institution,
            bio: input.bio,
            expertiseTags: mergedTags,
            notificationPreference: input.notificationPreference || 'EMAIL',
            onboardingCompletedAt: new Date(),
            status: 'ACTIVE', // Activate user after onboarding
          },
        })

        // Process jury self-service preferences
        if (input.juryPreferences && input.juryPreferences.length > 0) {
          for (const pref of input.juryPreferences) {
            // Security: verify this member belongs to the current user
            const member = await tx.juryGroupMember.findUnique({
              where: { id: pref.juryGroupMemberId },
            })
            if (!member || member.userId !== ctx.user.id) continue

            await tx.juryGroupMember.update({
              where: { id: pref.juryGroupMemberId },
              data: {
                selfServiceCap: pref.selfServiceCap != null ? Math.min(pref.selfServiceCap, 50) : undefined,
                selfServiceRatio: pref.selfServiceRatio,
              },
            })
          }
        }

        return updated
      })

      // Audit outside transaction so failures don't roll back the onboarding
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'COMPLETE_ONBOARDING',
        entityType: 'User',
        entityId: ctx.user.id,
        detailsJson: { name: input.name, juryPreferencesCount: input.juryPreferences?.length ?? 0 },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return user
    }),

  /**
   * Update jury preferences outside of onboarding (e.g., when a new round opens).
   */
  updateJuryPreferences: protectedProcedure
    .input(
      z.object({
        preferences: z.array(
          z.object({
            juryGroupMemberId: z.string(),
            selfServiceCap: z.number().int().min(1).max(50),
            selfServiceRatio: z.number().min(0).max(1),
          })
        ),
      })
    )
    .mutation(async ({ ctx, input }) => {
      for (const pref of input.preferences) {
        const member = await ctx.prisma.juryGroupMember.findUnique({
          where: { id: pref.juryGroupMemberId },
        })
        if (!member || member.userId !== ctx.user.id) continue

        await ctx.prisma.juryGroupMember.update({
          where: { id: pref.juryGroupMemberId },
          data: {
            selfServiceCap: pref.selfServiceCap,
            selfServiceRatio: pref.selfServiceRatio,
          },
        })
      }
      return { success: true }
    }),

  /**
   * Get onboarding context for the current user.
   * Returns jury group memberships for self-service preferences.
   */
  getOnboardingContext: protectedProcedure.query(async ({ ctx }) => {
    const memberships = await ctx.prisma.juryGroupMember.findMany({
      where: {
        userId: ctx.user.id,
        juryGroup: {
          rounds: {
            some: {
              roundType: {
                in: ['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING'],
              },
            },
          },
        },
      },
      include: {
        juryGroup: {
          select: {
            id: true,
            name: true,
            defaultMaxAssignments: true,
          },
        },
      },
    })

    return {
      hasSelfServiceOptions: memberships.length > 0,
      memberships: memberships.map((m) => ({
        juryGroupMemberId: m.id,
        juryGroupName: m.juryGroup.name,
        currentCap: m.maxAssignmentsOverride ?? m.juryGroup.defaultMaxAssignments,
        selfServiceCap: m.selfServiceCap,
        selfServiceRatio: m.selfServiceRatio,
        preferredStartupRatio: m.preferredStartupRatio,
      })),
    }
  }),

  /**
   * Check if current user needs onboarding
   */
  needsOnboarding: protectedProcedure.query(async ({ ctx }) => {
    const user = await ctx.prisma.user.findUniqueOrThrow({
      where: { id: ctx.user.id },
      select: { onboardingCompletedAt: true, role: true },
    })

    // Jury members, mentors, and admins need onboarding
    const rolesRequiringOnboarding = ['JURY_MEMBER', 'MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN']
    if (!rolesRequiringOnboarding.includes(user.role)) {
      return false
    }

    return user.onboardingCompletedAt === null
  }),

  /**
   * Check if current user needs to set a password
   */
  needsPasswordSetup: protectedProcedure.query(async ({ ctx }) => {
    const user = await ctx.prisma.user.findUniqueOrThrow({
      where: { id: ctx.user.id },
      select: { mustSetPassword: true, passwordHash: true },
    })

    return user.mustSetPassword || user.passwordHash === null
  }),

  /**
   * Set password for current user
   */
  setPassword: protectedProcedure
    .input(
      z.object({
        password: z.string().min(8),
        confirmPassword: z.string().min(8),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Validate passwords match
      if (input.password !== input.confirmPassword) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Passwords do not match',
        })
      }

      // Validate password requirements
      const validation = validatePassword(input.password)
      if (!validation.valid) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: validation.errors.join('. '),
        })
      }

      // Hash the password
      const passwordHash = await hashPassword(input.password)

      // Update user with new password
      const user = await ctx.prisma.user.update({
        where: { id: ctx.user.id },
        data: {
          passwordHash,
          passwordSetAt: new Date(),
          mustSetPassword: false,
        },
      })

      // Audit outside transaction so failures don't roll back the password set
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'PASSWORD_SET',
        entityType: 'User',
        entityId: ctx.user.id,
        detailsJson: { timestamp: new Date().toISOString() },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { success: true, email: user.email }
    }),

  /**
   * Change password for current user (requires current password)
   */
  changePassword: protectedProcedure
    .input(
      z.object({
        currentPassword: z.string().min(1),
        newPassword: z.string().min(8),
        confirmNewPassword: z.string().min(8),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Get current user with password hash
      const user = await ctx.prisma.user.findUniqueOrThrow({
        where: { id: ctx.user.id },
        select: { passwordHash: true },
      })

      if (!user.passwordHash) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'No password set. Please use magic link to sign in.',
        })
      }

      // Verify current password
      const { verifyPassword } = await import('@/lib/password')
      const isValid = await verifyPassword(input.currentPassword, user.passwordHash)
      if (!isValid) {
        throw new TRPCError({
          code: 'UNAUTHORIZED',
          message: 'Current password is incorrect',
        })
      }

      // Validate new passwords match
      if (input.newPassword !== input.confirmNewPassword) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'New passwords do not match',
        })
      }

      // Validate new password requirements
      const validation = validatePassword(input.newPassword)
      if (!validation.valid) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: validation.errors.join('. '),
        })
      }

      // Hash the new password
      const passwordHash = await hashPassword(input.newPassword)

      // Update user with new password
      await ctx.prisma.user.update({
        where: { id: ctx.user.id },
        data: {
          passwordHash,
          passwordSetAt: new Date(),
        },
      })

      // Audit outside transaction so failures don't roll back the password change
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'PASSWORD_CHANGED',
        entityType: 'User',
        entityId: ctx.user.id,
        detailsJson: { timestamp: new Date().toISOString() },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { success: true }
    }),

  /**
   * Request password reset (public - no auth required)
   * Sends a magic link and marks user for password reset
   */
  requestPasswordReset: publicProcedure
    .input(z.object({ email: z.string().email() }))
    .mutation(async ({ ctx, input }) => {
      const email = input.email.toLowerCase().trim()

      // Find user by email (case-insensitive — DB may store mixed-case emails)
      const user = await ctx.prisma.user.findFirst({
        where: { email: { equals: email, mode: 'insensitive' } },
        select: { id: true, email: true, name: true, status: true },
      })

      // Always return success to prevent email enumeration
      if (!user || user.status === 'SUSPENDED') {
        return { success: true }
      }

      // Generate reset token + expiry (30 minutes)
      const token = generateInviteToken()
      const expiryMinutes = 30
      const expiresAt = new Date(Date.now() + expiryMinutes * 60 * 1000)

      await ctx.prisma.user.update({
        where: { id: user.id },
        data: {
          passwordResetToken: token,
          passwordResetExpiresAt: expiresAt,
        },
      })

      // Send password reset email
      const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
      const resetUrl = `${baseUrl}/reset-password?token=${token}`

      try {
        await sendPasswordResetEmail(user.email, resetUrl, expiryMinutes)
      } catch (e) {
        console.error('[auth] Failed to send password reset email:', e)
        // Don't reveal failure to prevent enumeration
      }

      // Audit log
      await logAudit({
        prisma: ctx.prisma,
        userId: null,
        action: 'REQUEST_PASSWORD_RESET',
        entityType: 'User',
        entityId: user.id,
        detailsJson: { email, timestamp: new Date().toISOString() },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      }).catch(() => {})

      return { success: true }
    }),

  /**
   * Reset password using a token (public — from the reset-password page)
   */
  resetPassword: publicProcedure
    .input(z.object({
      token: z.string().min(1),
      password: z.string().min(8),
      confirmPassword: z.string().min(8),
    }))
    .mutation(async ({ ctx, input }) => {
      if (input.password !== input.confirmPassword) {
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'Passwords do not match' })
      }

      const validation = validatePassword(input.password)
      if (!validation.valid) {
        throw new TRPCError({ code: 'BAD_REQUEST', message: validation.errors.join('. ') })
      }

      // Find user by reset token
      const user = await ctx.prisma.user.findUnique({
        where: { passwordResetToken: input.token },
        select: { id: true, email: true, status: true, passwordResetExpiresAt: true },
      })

      if (!user) {
        await logAudit({
          prisma: ctx.prisma,
          userId: null,
          action: 'PASSWORD_RESET_LINK_INVALID',
          entityType: 'User',
          entityId: 'unknown',
          detailsJson: { reason: 'token_not_found', timestamp: new Date().toISOString() },
          ipAddress: ctx.ip,
          userAgent: ctx.userAgent,
        }).catch(() => {})
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid or expired reset link. Please request a new one.' })
      }

      if (user.status === 'SUSPENDED') {
        await logAudit({
          prisma: ctx.prisma,
          userId: user.id,
          action: 'PASSWORD_RESET_LINK_INVALID',
          entityType: 'User',
          entityId: user.id,
          detailsJson: { reason: 'account_suspended', email: user.email, timestamp: new Date().toISOString() },
          ipAddress: ctx.ip,
          userAgent: ctx.userAgent,
        }).catch(() => {})
        throw new TRPCError({ code: 'FORBIDDEN', message: 'This account has been suspended.' })
      }

      if (user.passwordResetExpiresAt && user.passwordResetExpiresAt < new Date()) {
        await logAudit({
          prisma: ctx.prisma,
          userId: user.id,
          action: 'PASSWORD_RESET_LINK_EXPIRED',
          entityType: 'User',
          entityId: user.id,
          detailsJson: { email: user.email, expiredAt: user.passwordResetExpiresAt.toISOString(), timestamp: new Date().toISOString() },
          ipAddress: ctx.ip,
          userAgent: ctx.userAgent,
        }).catch(() => {})
        // Clear expired token
        await ctx.prisma.user.update({
          where: { id: user.id },
          data: { passwordResetToken: null, passwordResetExpiresAt: null },
        })
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'This reset link has expired. Please request a new one.' })
      }

      // Audit: reset link clicked and valid
      await logAudit({
        prisma: ctx.prisma,
        userId: user.id,
        action: 'PASSWORD_RESET_LINK_CLICKED',
        entityType: 'User',
        entityId: user.id,
        detailsJson: { email: user.email, timestamp: new Date().toISOString() },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      }).catch(() => {})

      // Hash and save new password, clear reset token
      const passwordHash = await hashPassword(input.password)
      await ctx.prisma.user.update({
        where: { id: user.id },
        data: {
          passwordHash,
          passwordSetAt: new Date(),
          mustSetPassword: false,
          passwordResetToken: null,
          passwordResetExpiresAt: null,
        },
      })

      // Audit log
      await logAudit({
        prisma: ctx.prisma,
        userId: user.id,
        action: 'PASSWORD_RESET',
        entityType: 'User',
        entityId: user.id,
        detailsJson: { email: user.email },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      }).catch(() => {})

      return { success: true }
    }),

  /**
   * Get current user's digest settings along with global digest config
   */
  getDigestSettings: protectedProcedure.query(async ({ ctx }) => {
    const [user, digestEnabled, digestSections] = await Promise.all([
      ctx.prisma.user.findUniqueOrThrow({
        where: { id: ctx.user.id },
        select: { digestFrequency: true },
      }),
      ctx.prisma.systemSettings.findUnique({
        where: { key: 'digest_enabled' },
        select: { value: true },
      }),
      ctx.prisma.systemSettings.findUnique({
        where: { key: 'digest_sections' },
        select: { value: true },
      }),
    ])

    return {
      digestFrequency: user.digestFrequency,
      globalDigestEnabled: digestEnabled?.value === 'true',
      globalDigestSections: digestSections?.value ? JSON.parse(digestSections.value) : [],
    }
  }),

  /**
   * Update a user's roles array (admin only)
   * Also updates the primary role to the highest privilege role in the array.
   */
  updateRoles: adminProcedure
    .input(z.object({
      userId: z.string(),
      roles: z.array(z.nativeEnum(UserRole)).min(1),
    }))
    .mutation(async ({ ctx, input }) => {
      const callerIsSuperAdmin = ctx.user.role === 'SUPER_ADMIN'

      // Guard: only SUPER_ADMIN can grant SUPER_ADMIN or PROGRAM_ADMIN
      if (input.roles.includes('SUPER_ADMIN') && !callerIsSuperAdmin) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can grant super admin role' })
      }
      if (input.roles.includes('PROGRAM_ADMIN') && !callerIsSuperAdmin) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can grant admin role' })
      }

      // Guard: only SUPER_ADMIN can modify a user who currently holds SUPER_ADMIN
      // or PROGRAM_ADMIN — otherwise a PROGRAM_ADMIN could demote peers/super-admins.
      const target = await ctx.prisma.user.findUniqueOrThrow({
        where: { id: input.userId },
        select: { role: true, roles: true },
      })
      const targetHasAdmin =
        target.role === 'SUPER_ADMIN' ||
        target.role === 'PROGRAM_ADMIN' ||
        target.roles.includes('SUPER_ADMIN') ||
        target.roles.includes('PROGRAM_ADMIN')
      if (targetHasAdmin && !callerIsSuperAdmin) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can change admin roles' })
      }

      // Set primary role to highest privilege role
      const rolePriority: UserRole[] = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE']
      const primaryRole = rolePriority.find(r => input.roles.includes(r)) || input.roles[0]

      return ctx.prisma.user.update({
        where: { id: input.userId },
        data: { roles: input.roles, role: primaryRole },
      })
    }),

  /**
   * Bulk add/remove a single role across multiple users. Fires the mentor
   * onboarding email exactly once per user when MENTOR is freshly added,
   * idempotent via User.mentorOnboardingSentAt.
   */
  bulkUpdateRoles: adminProcedure
    .input(
      z.object({
        userIds: z.array(z.string()).min(1).max(200),
        addRole: z.nativeEnum(UserRole).optional(),
        removeRole: z.nativeEnum(UserRole).optional(),
      }).refine((d) => d.addRole || d.removeRole, {
        message: 'Provide addRole or removeRole',
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const callerIsSuperAdmin = ctx.user.role === 'SUPER_ADMIN'

      // Self-demote guards
      if (input.removeRole === 'SUPER_ADMIN' && input.userIds.includes(ctx.user.id)) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You cannot remove SUPER_ADMIN from self',
        })
      }
      if (input.removeRole === 'PROGRAM_ADMIN' && input.userIds.includes(ctx.user.id)) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You cannot remove PROGRAM_ADMIN from self',
        })
      }

      // Privilege guards: only SUPER_ADMIN may add/remove SUPER_ADMIN or PROGRAM_ADMIN.
      // Without the symmetric remove-side guard a PROGRAM_ADMIN could strip
      // SUPER_ADMIN from peers; without the add-side PROGRAM_ADMIN guard a
      // PROGRAM_ADMIN could grant peer-admin laterally.
      if (
        (input.addRole === 'SUPER_ADMIN' || input.removeRole === 'SUPER_ADMIN') &&
        !callerIsSuperAdmin
      ) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only super admins can change super admin role',
        })
      }
      if (
        (input.addRole === 'PROGRAM_ADMIN' || input.removeRole === 'PROGRAM_ADMIN') &&
        !callerIsSuperAdmin
      ) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only super admins can change admin role',
        })
      }

      const targets = await ctx.prisma.user.findMany({
        where: { id: { in: input.userIds } },
        select: { id: true, name: true, email: true, role: true, roles: true, mentorOnboardingSentAt: true },
      })

      // Block modifying any target who currently holds an admin tier role
      // unless the caller is SUPER_ADMIN. This prevents a PROGRAM_ADMIN from
      // using a non-admin add/remove (e.g. addRole: MENTOR) to mutate the
      // record of a SUPER_ADMIN target — even though Prisma would only touch
      // `roles[]`, the audit trail and downstream logic shouldn't allow
      // peer admins to mutate higher-tier accounts at all.
      if (!callerIsSuperAdmin) {
        const adminTarget = targets.find(
          (t) =>
            t.role === 'SUPER_ADMIN' ||
            t.role === 'PROGRAM_ADMIN' ||
            t.roles.includes('SUPER_ADMIN') ||
            t.roles.includes('PROGRAM_ADMIN'),
        )
        if (adminTarget) {
          throw new TRPCError({
            code: 'FORBIDDEN',
            message: 'Only super admins can modify admin accounts',
          })
        }
      }

      let updated = 0
      let alreadyHadRole = 0
      const newlyMentor: typeof targets = []

      const rolePriority: UserRole[] = [
        'SUPER_ADMIN',
        'PROGRAM_ADMIN',
        'JURY_MEMBER',
        'MENTOR',
        'OBSERVER',
        'APPLICANT',
        'AUDIENCE',
      ]

      for (const u of targets) {
        const current = new Set(u.roles)
        const next = new Set(current)

        if (input.addRole) {
          if (current.has(input.addRole)) {
            alreadyHadRole++
            continue
          }
          next.add(input.addRole)
        }
        if (input.removeRole) {
          if (!current.has(input.removeRole)) {
            alreadyHadRole++
            continue
          }
          next.delete(input.removeRole)
        }
        if (next.size === 0) next.add('APPLICANT' as UserRole) // safety: never empty

        const nextArr = Array.from(next)
        const primary = rolePriority.find((r) => nextArr.includes(r)) ?? nextArr[0]

        const isFreshMentor =
          input.addRole === 'MENTOR' && !current.has('MENTOR') && !u.mentorOnboardingSentAt

        await ctx.prisma.user.update({
          where: { id: u.id },
          data: {
            roles: nextArr,
            role: primary,
            ...(isFreshMentor ? { mentorOnboardingSentAt: new Date() } : {}),
          },
        })

        await logAudit({
          prisma: ctx.prisma,
          userId: ctx.user.id,
          action: input.addRole ? 'USER_ROLE_ADD' : 'USER_ROLE_REMOVE',
          entityType: 'User',
          entityId: u.id,
          detailsJson: {
            addRole: input.addRole,
            removeRole: input.removeRole,
            before: u.roles,
            after: nextArr,
          },
          ipAddress: ctx.ip,
          userAgent: ctx.userAgent,
        })

        if (isFreshMentor) newlyMentor.push(u)
        updated++
      }

      // Send onboarding emails outside the per-user mutation loop. Errors
      // caught and logged — we don't roll back the role grant if email
      // delivery fails.
      for (const u of newlyMentor) {
        try {
          await sendMentorOnboardingEmail(u.email, u.name)
        } catch (err) {
          console.error('[bulkUpdateRoles] mentor-onboarding email failed for', u.email, err)
        }
      }

      return { updated, alreadyHadRole, total: targets.length }
    }),

  /**
   * List applicant users with project info for admin bulk-invite page.
   */
  getApplicants: adminProcedure
    .input(
      z.object({
        search: z.string().optional(),
        roundId: z.string().optional(),
        status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
        page: z.number().int().positive().default(1),
        perPage: z.number().int().positive().max(100).default(20),
      })
    )
    .query(async ({ ctx, input }) => {
      const where: Prisma.UserWhereInput = {
        role: 'APPLICANT',
        ...(input.status && { status: input.status }),
        ...(input.search && {
          OR: [
            { name: { contains: input.search, mode: 'insensitive' as const } },
            { email: { contains: input.search, mode: 'insensitive' as const } },
            { teamMemberships: { some: { project: { title: { contains: input.search, mode: 'insensitive' as const } } } } },
          ],
        }),
        ...(input.roundId && {
          teamMemberships: { some: { project: { projectRoundStates: { some: { roundId: input.roundId } } } } },
        }),
      }

      const [users, total] = await Promise.all([
        ctx.prisma.user.findMany({
          where,
          select: {
            id: true,
            email: true,
            name: true,
            status: true,
            nationality: true,
            institution: true,
            lastLoginAt: true,
            onboardingCompletedAt: true,
            teamMemberships: {
              take: 1,
              select: {
                role: true,
                project: { select: { id: true, title: true } },
              },
            },
            submittedProjects: {
              take: 1,
              select: { id: true, title: true },
            },
          },
          orderBy: { name: 'asc' },
          skip: (input.page - 1) * input.perPage,
          take: input.perPage,
        }),
        ctx.prisma.user.count({ where }),
      ])

      return {
        users: users.map((u) => {
          const project = u.submittedProjects[0] || u.teamMemberships[0]?.project || null
          return {
            id: u.id,
            email: u.email,
            name: u.name,
            status: u.status,
            nationality: u.nationality,
            institution: u.institution,
            lastLoginAt: u.lastLoginAt,
            onboardingCompleted: !!u.onboardingCompletedAt,
            projectName: project?.title ?? null,
            projectId: project?.id ?? null,
          }
        }),
        total,
        totalPages: Math.ceil(total / input.perPage),
        perPage: input.perPage,
      }
    }),

  /**
   * Bulk invite applicant users — generates tokens, sets INVITED, sends emails.
   */
  bulkInviteApplicants: adminProcedure
    .input(z.object({ userIds: z.array(z.string()).min(1).max(500) }))
    .mutation(async ({ ctx, input }) => {
      const users = await ctx.prisma.user.findMany({
        where: {
          id: { in: input.userIds },
          role: 'APPLICANT',
          status: { in: ['NONE', 'INVITED'] },
        },
        select: { id: true, email: true, name: true, status: true },
      })

      const expiryMs = await getInviteExpiryMs(ctx.prisma)
      let sent = 0
      let skipped = 0
      const failed: string[] = []
      const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'

      for (const user of users) {
        try {
          const token = generateInviteToken()
          await ctx.prisma.user.update({
            where: { id: user.id },
            data: {
              status: 'INVITED',
              inviteToken: token,
              inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
            },
          })

          const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
          await sendInvitationEmail(user.email, user.name || 'Applicant', inviteUrl, 'APPLICANT')
          sent++
        } catch (error) {
          failed.push(user.email)
        }
      }

      skipped = input.userIds.length - users.length

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'BULK_INVITE_APPLICANTS',
        entityType: 'User',
        entityId: 'bulk',
        detailsJson: { sent, skipped, failed: failed.length, total: input.userIds.length },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { sent, skipped, failed }
    }),

  /**
   * Start impersonating a user (super admin only)
   */
  startImpersonation: superAdminProcedure
    .input(z.object({ targetUserId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      // Block nested impersonation
      if ((ctx.session as unknown as { user?: { impersonating?: unknown } })?.user?.impersonating) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Cannot start nested impersonation. End current impersonation first.',
        })
      }

      const target = await ctx.prisma.user.findUnique({
        where: { id: input.targetUserId },
        select: { id: true, email: true, name: true, role: true, roles: true, status: true },
      })

      if (!target) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' })
      }

      if (target.status === 'SUSPENDED') {
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot impersonate a suspended user' })
      }

      if (target.role === 'SUPER_ADMIN') {
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot impersonate another super admin' })
      }

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'IMPERSONATION_START',
        entityType: 'User',
        entityId: target.id,
        detailsJson: {
          adminId: ctx.user.id,
          adminEmail: ctx.user.email,
          targetId: target.id,
          targetEmail: target.email,
          targetRole: target.role,
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { targetUserId: target.id, targetRole: target.role }
    }),

  /**
   * End impersonation and return to admin
   */
  endImpersonation: protectedProcedure
    .mutation(async ({ ctx }) => {
      const session = ctx.session as unknown as { user?: { impersonating?: { originalId: string; originalEmail: string } } }
      const impersonating = session?.user?.impersonating

      if (!impersonating) {
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not currently impersonating' })
      }

      await logAudit({
        prisma: ctx.prisma,
        userId: impersonating.originalId,
        action: 'IMPERSONATION_END',
        entityType: 'User',
        entityId: ctx.user.id,
        detailsJson: {
          adminId: impersonating.originalId,
          adminEmail: impersonating.originalEmail,
          targetId: ctx.user.id,
          targetEmail: ctx.user.email,
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { ended: true }
    }),

  /**
   * Context-aware default dashboard. Returns the highest-priority role for
   * which the user has actionable work right now, or the highest-priority
   * role they hold (static fallback) if nothing is actionable.
   *
   * Priority order: SUPER_ADMIN > PROGRAM_ADMIN > JURY_MEMBER >
   * MENTOR > APPLICANT > OBSERVER > AUDIENCE.
   *
   * Used by src/app/page.tsx to route users at login.
   */
  getDefaultDashboard: protectedProcedure.query(async ({ ctx }) => {
    const user = await ctx.prisma.user.findUniqueOrThrow({
      where: { id: ctx.user.id },
      select: { id: true, roles: true, role: true },
    })

    const userRoles = new Set<UserRole>([...(user.roles ?? []), user.role])

    type Entry = { role: UserRole; path: string; predicate: () => Promise<boolean> | boolean }
    const PRIORITY: Entry[] = [
      { role: 'SUPER_ADMIN', path: '/admin', predicate: () => true },
      { role: 'PROGRAM_ADMIN', path: '/admin', predicate: () => true },
      {
        role: 'JURY_MEMBER',
        path: '/jury',
        predicate: async () => {
          const cnt = await ctx.prisma.assignment.count({
            where: {
              userId: user.id,
              isCompleted: false,
              round: { status: 'ROUND_ACTIVE' },
            },
          })
          return cnt > 0
        },
      },
      {
        role: 'MENTOR',
        path: '/mentor',
        predicate: async () => {
          const cnt = await ctx.prisma.mentorAssignment.count({
            where: {
              mentorId: user.id,
              workspaceEnabled: true,
              project: {
                projectRoundStates: {
                  some: { round: { status: 'ROUND_ACTIVE' } },
                },
              },
            },
          })
          return cnt > 0
        },
      },
      {
        role: 'APPLICANT',
        path: '/applicant',
        predicate: async () => {
          const cnt = await ctx.prisma.teamMember.count({
            where: {
              userId: user.id,
              project: {
                projectRoundStates: {
                  some: {
                    round: { status: 'ROUND_ACTIVE' },
                    state: { in: ['PENDING', 'IN_PROGRESS'] },
                  },
                },
              },
            },
          })
          return cnt > 0
        },
      },
      { role: 'OBSERVER', path: '/observer', predicate: () => false },
      { role: 'AUDIENCE', path: '/applicant', predicate: () => false },
    ]

    // Walk priority. Return first role the user holds whose predicate is true.
    for (const entry of PRIORITY) {
      if (!userRoles.has(entry.role)) continue
      const has = await entry.predicate()
      if (has) {
        return { role: entry.role, path: entry.path, reason: 'has-active-work' as const }
      }
    }

    // Static fallback: highest-priority role they hold.
    for (const entry of PRIORITY) {
      if (userRoles.has(entry.role)) {
        return { role: entry.role, path: entry.path, reason: 'static-fallback' as const }
      }
    }

    return { role: 'APPLICANT' as UserRole, path: '/applicant', reason: 'static-fallback' as const }
  }),
})
