import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, userHasRole } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url'
import { attachProjectLogoUrls } from '../utils/project-logo-url'
import {
  notifyProjectTeam,
  NotificationTypes,
} from '../services/in-app-notification'
import { normalizeCountryToCode } from '@/lib/countries'
import { logAudit } from '../utils/audit'
import { sendInvitationEmail, getBaseUrl } from '@/lib/email'
import { generateInviteToken, getInviteExpiryMs } from '../utils/invite'
import { sendBatchNotifications } from '../services/notification-sender'
import type { NotificationItem } from '../services/notification-sender'

const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const STATUSES_WITH_TEAM_NOTIFICATIONS = ['SEMIFINALIST', 'FINALIST', 'REJECTED'] as const

// Valid project status transitions
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
  SUBMITTED: ['ELIGIBLE', 'REJECTED'],           // New submissions get screened
  ELIGIBLE: ['ASSIGNED', 'REJECTED'],             // Eligible projects get assigned to jurors
  ASSIGNED: ['SEMIFINALIST', 'FINALIST', 'REJECTED'], // After evaluation
  SEMIFINALIST: ['FINALIST', 'REJECTED'],         // Semi-finalists advance or get cut
  FINALIST: ['REJECTED'],                          // Finalists can only be rejected (rare)
  REJECTED: ['SUBMITTED'],                         // Rejected can be re-submitted (admin override)
}

export const projectRouter = router({
  /**
   * List projects with filtering and pagination
   * Admin sees all, jury sees only assigned projects
   */
  list: protectedProcedure
    .input(
      z.object({
        programId: z.string().optional(),
        roundId: z.string().optional(),
        status: z
          .enum([
            'SUBMITTED',
            'ELIGIBLE',
            'ASSIGNED',
            'SEMIFINALIST',
            'FINALIST',
            'REJECTED',
          ])
          .optional(),
        statuses: z.array(
          z.enum([
            'SUBMITTED',
            'ELIGIBLE',
            'ASSIGNED',
            'SEMIFINALIST',
            'FINALIST',
            'REJECTED',
          ])
        ).optional(),
        excludeInRoundId: z.string().optional(), // Exclude projects already in this round
        unassignedOnly: z.boolean().optional(), // Projects not in any round
        search: z.string().optional(),
        tags: z.array(z.string()).optional(),
        competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
        oceanIssue: z.enum([
          'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
          'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
          'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
          'OCEAN_ACIDIFICATION', 'OTHER',
        ]).optional(),
        country: z.string().optional(),
        wantsMentorship: z.boolean().optional(),
        hasFiles: z.boolean().optional(),
        hasAssignments: z.boolean().optional(),
        roundStates: z.array(z.enum([
          'PENDING', 'IN_PROGRESS', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN',
        ])).optional(),
        page: z.number().int().min(1).default(1),
        perPage: z.number().int().min(1).max(200).default(20),
        sortBy: z.enum(['title', 'category', 'program', 'assignments', 'status', 'createdAt']).optional(),
        sortDir: z.enum(['asc', 'desc']).optional(),
      })
    )
    .query(async ({ ctx, input }) => {
      const {
        programId, roundId, excludeInRoundId, status, statuses, unassignedOnly, search, tags,
        competitionCategory, oceanIssue, country,
        wantsMentorship, hasFiles, hasAssignments, roundStates,
        page, perPage, sortBy, sortDir,
      } = input
      const skip = (page - 1) * perPage

      const dir = sortDir ?? 'desc'
      const orderBy: Prisma.ProjectOrderByWithRelationInput = (() => {
        switch (sortBy) {
          case 'title':       return { title: dir }
          case 'category':    return { competitionCategory: dir }
          case 'program':     return { program: { name: dir } }
          case 'assignments': return { assignments: { _count: dir } }
          case 'status':      return { status: dir }
          case 'createdAt':
          default:            return { createdAt: dir }
        }
      })()

      // Build where clause
      const where: Record<string, unknown> = {}

      // Filter by program
      if (programId) where.programId = programId

      // Filter by round (via ProjectRoundState)
      if (roundId) {
        where.projectRoundStates = { some: { roundId } }
      }

      // Exclude projects already in a specific round
      if (excludeInRoundId) {
        where.projectRoundStates = { none: { roundId: excludeInRoundId } }
      }

      // Filter by unassigned (not in any round)
      if (unassignedOnly) {
        where.projectRoundStates = { none: {} }
      }

      // Status filter
      if (statuses?.length || status) {
        const statusValues = statuses?.length ? statuses : status ? [status] : []
        if (statusValues.length > 0) {
          where.status = { in: statusValues }
        }
      }

      if (tags && tags.length > 0) {
        where.tags = { hasSome: tags }
      }
      if (competitionCategory) where.competitionCategory = competitionCategory
      if (oceanIssue) where.oceanIssue = oceanIssue
      if (country) where.country = country
      if (wantsMentorship !== undefined) where.wantsMentorship = wantsMentorship
      if (hasFiles === true) where.files = { some: {} }
      if (hasFiles === false) where.files = { none: {} }
      if (hasAssignments === true) where.assignments = { some: {} }
      if (hasAssignments === false) where.assignments = { none: {} }

      // Filter by latest round state (matches the statusCounts logic)
      if (roundStates?.length) {
        const stateFilter: Record<string, unknown> = {}
        if (programId) stateFilter.project = { programId }
        const allStates = await ctx.prisma.projectRoundState.findMany({
          where: stateFilter,
          select: { projectId: true, state: true, round: { select: { sortOrder: true } } },
          orderBy: { round: { sortOrder: 'desc' } },
        })
        const latestByProject = new Map<string, string>()
        for (const s of allStates) {
          if (!latestByProject.has(s.projectId)) {
            latestByProject.set(s.projectId, s.state)
          }
        }
        const matchingIds = [...latestByProject.entries()]
          .filter(([, state]) => roundStates.includes(state as typeof roundStates[number]))
          .map(([id]) => id)
        where.id = { in: matchingIds }
      }

      if (search) {
        where.OR = [
          { title: { contains: search, mode: 'insensitive' } },
          { teamName: { contains: search, mode: 'insensitive' } },
          { description: { contains: search, mode: 'insensitive' } },
          { teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } },
          { teamMembers: { some: { user: { email: { contains: search, mode: 'insensitive' } } } } },
        ]
      }

      // Per-role visibility filters. Admin / Observer see all (these roles
      // are designed for cross-program oversight). Other roles are scoped to
      // projects they have a relationship with.
      const isAdmin = userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')
      const isObserverLevel = userHasRole(ctx.user, 'OBSERVER')
      if (!isAdmin && !isObserverLevel) {
        const orClauses: Array<Record<string, unknown>> = []
        if (userHasRole(ctx.user, 'JURY_MEMBER')) {
          orClauses.push({ assignments: { some: { userId: ctx.user.id } } })
        }
        if (userHasRole(ctx.user, 'MENTOR')) {
          orClauses.push({ mentorAssignments: { some: { mentorId: ctx.user.id } } })
        }
        if (userHasRole(ctx.user, 'APPLICANT')) {
          orClauses.push({ teamMembers: { some: { userId: ctx.user.id } } })
          orClauses.push({ submittedByUserId: ctx.user.id })
        }
        if (orClauses.length === 0) {
          // No relationship-based access (e.g. AUDIENCE) — return nothing.
          where.id = { in: [] }
        } else if (orClauses.length === 1) {
          Object.assign(where, orClauses[0])
        } else {
          // Multiple roles — combine with the existing search OR if any.
          // Compose with existing `where.OR` (from `search`) by AND-ing.
          const existingOr = where.OR as Array<Record<string, unknown>> | undefined
          if (existingOr) {
            where.AND = [{ OR: existingOr }, { OR: orClauses }]
            delete where.OR
          } else {
            where.OR = orClauses
          }
        }
      }

      const [projects, total, roundStateCounts] = await Promise.all([
        ctx.prisma.project.findMany({
          where,
          skip,
          take: perPage,
          orderBy,
          include: {
            program: { select: { id: true, name: true, year: true } },
            _count: { select: { assignments: true, files: true } },
            projectRoundStates: {
              select: {
                state: true,
                round: { select: { name: true, sortOrder: true } },
              },
              orderBy: { round: { sortOrder: 'desc' } },
            },
          },
        }),
        ctx.prisma.project.count({ where }),
        // Count projects by their LATEST round state (highest sortOrder round).
        // This avoids inflated counts where a project that passed round 1
        // but was rejected in round 2 shows up in both PASSED and REJECTED.
        (async () => {
          const stateFilter: Record<string, unknown> = {}
          if (where.programId) stateFilter.project = { programId: where.programId as string }

          const allStates = await ctx.prisma.projectRoundState.findMany({
            where: stateFilter,
            select: { projectId: true, state: true, round: { select: { sortOrder: true } } },
            orderBy: { round: { sortOrder: 'desc' } },
          })

          // Pick the latest round state per project
          const latestByProject = new Map<string, string>()
          for (const s of allStates) {
            if (!latestByProject.has(s.projectId)) {
              latestByProject.set(s.projectId, s.state)
            }
          }

          // Aggregate counts
          const countMap = new Map<string, number>()
          for (const state of latestByProject.values()) {
            countMap.set(state, (countMap.get(state) ?? 0) + 1)
          }
          return countMap
        })(),
      ])

      // Build round-state counts from the latest-state map
      const statusCounts: Record<string, number> = {}
      for (const [state, count] of roundStateCounts) {
        statusCounts[state] = count
      }

      const projectsWithLogos = await attachProjectLogoUrls(projects)

      return {
        projects: projectsWithLogos,
        total,
        page,
        perPage,
        totalPages: Math.ceil(total / perPage),
        statusCounts,
      }
    }),

  /**
   * List all project IDs matching filters (no pagination).
   * Used for "select all across pages" in bulk operations.
   */
  listAllIds: adminProcedure
    .input(
      z.object({
        programId: z.string().optional(),
        roundId: z.string().optional(),
        excludeInRoundId: z.string().optional(),
        unassignedOnly: z.boolean().optional(),
        search: z.string().optional(),
        statuses: z.array(
          z.enum([
            'SUBMITTED',
            'ELIGIBLE',
            'ASSIGNED',
            'SEMIFINALIST',
            'FINALIST',
            'REJECTED',
          ])
        ).optional(),
        tags: z.array(z.string()).optional(),
        competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
        oceanIssue: z.enum([
          'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
          'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
          'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
          'OCEAN_ACIDIFICATION', 'OTHER',
        ]).optional(),
        country: z.string().optional(),
        wantsMentorship: z.boolean().optional(),
        hasFiles: z.boolean().optional(),
        hasAssignments: z.boolean().optional(),
      })
    )
    .query(async ({ ctx, input }) => {
      const {
        programId, roundId, excludeInRoundId, unassignedOnly,
        search, statuses, tags,
        competitionCategory, oceanIssue, country,
        wantsMentorship, hasFiles, hasAssignments,
      } = input

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

      if (programId) where.programId = programId
      if (roundId) {
        where.projectRoundStates = { some: { roundId } }
      }
      if (excludeInRoundId) {
        where.projectRoundStates = { none: { roundId: excludeInRoundId } }
      }
      if (unassignedOnly) {
        where.projectRoundStates = { none: {} }
      }
      if (statuses?.length) where.status = { in: statuses }
      if (tags && tags.length > 0) where.tags = { hasSome: tags }
      if (competitionCategory) where.competitionCategory = competitionCategory
      if (oceanIssue) where.oceanIssue = oceanIssue
      if (country) where.country = country
      if (wantsMentorship !== undefined) where.wantsMentorship = wantsMentorship
      if (hasFiles === true) where.files = { some: {} }
      if (hasFiles === false) where.files = { none: {} }
      if (hasAssignments === true) where.assignments = { some: {} }
      if (hasAssignments === false) where.assignments = { none: {} }
      if (search) {
        where.OR = [
          { title: { contains: search, mode: 'insensitive' } },
          { teamName: { contains: search, mode: 'insensitive' } },
          { description: { contains: search, mode: 'insensitive' } },
          { teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } },
          { teamMembers: { some: { user: { email: { contains: search, mode: 'insensitive' } } } } },
        ]
      }

      const projects = await ctx.prisma.project.findMany({
        where,
        select: { id: true },
        orderBy: { createdAt: 'desc' },
      })

      return { ids: projects.map((p) => p.id) }
    }),

  /**
   * Preview project-team recipients before bulk status update notifications.
   * Used by admin UI confirmation dialog to verify notification audience.
   */
  previewStatusNotificationRecipients: adminProcedure
    .input(
      z.object({
        ids: z.array(z.string()).min(1).max(10000),
        status: z.enum([
          'SUBMITTED',
          'ELIGIBLE',
          'ASSIGNED',
          'SEMIFINALIST',
          'FINALIST',
          'REJECTED',
        ]),
      })
    )
    .query(async ({ ctx, input }) => {
      const statusTriggersNotification = STATUSES_WITH_TEAM_NOTIFICATIONS.includes(
        input.status as (typeof STATUSES_WITH_TEAM_NOTIFICATIONS)[number]
      )

      if (!statusTriggersNotification) {
        return {
          status: input.status,
          statusTriggersNotification,
          totalProjects: 0,
          projectsWithRecipients: 0,
          totalRecipients: 0,
          projects: [] as Array<{
            id: string
            title: string
            recipientCount: number
            recipientsPreview: string[]
            hasMoreRecipients: boolean
          }>,
        }
      }

      const projects = await ctx.prisma.project.findMany({
        where: { id: { in: input.ids } },
        select: {
          id: true,
          title: true,
          teamMembers: {
            select: {
              userId: true,
              user: {
                select: {
                  email: true,
                },
              },
            },
          },
        },
        orderBy: { title: 'asc' },
      })

      const MAX_PREVIEW_RECIPIENTS_PER_PROJECT = 8

      const mappedProjects = projects.map((project) => {
        const uniqueEmails = Array.from(
          new Set(
            project.teamMembers
              .map((member) => member.user?.email?.toLowerCase().trim() ?? '')
              .filter((email) => email.length > 0)
          )
        )

        return {
          id: project.id,
          title: project.title,
          recipientCount: uniqueEmails.length,
          recipientsPreview: uniqueEmails.slice(0, MAX_PREVIEW_RECIPIENTS_PER_PROJECT),
          hasMoreRecipients: uniqueEmails.length > MAX_PREVIEW_RECIPIENTS_PER_PROJECT,
        }
      })

      const projectsWithRecipients = mappedProjects.filter((p) => p.recipientCount > 0).length
      const totalRecipients = mappedProjects.reduce((sum, project) => sum + project.recipientCount, 0)

      return {
        status: input.status,
        statusTriggersNotification,
        totalProjects: mappedProjects.length,
        projectsWithRecipients,
        totalRecipients,
        projects: mappedProjects,
      }
    }),

  /**
   * Get filter options for the project list (distinct values)
   */
  getFilterOptions: protectedProcedure
    .query(async ({ ctx }) => {
      const [countries, categories, issues] = await Promise.all([
        ctx.prisma.project.findMany({
          where: { country: { not: null } },
          select: { country: true },
          distinct: ['country'],
          orderBy: { country: 'asc' },
        }),
        ctx.prisma.project.groupBy({
          by: ['competitionCategory'],
          where: { competitionCategory: { not: null } },
          _count: true,
        }),
        ctx.prisma.project.groupBy({
          by: ['oceanIssue'],
          where: { oceanIssue: { not: null } },
          _count: true,
        }),
      ])

      return {
        countries: countries.map((c) => c.country).filter(Boolean) as string[],
        categories: categories.map((c) => ({
          value: c.competitionCategory!,
          count: c._count,
        })),
        issues: issues.map((i) => ({
          value: i.oceanIssue!,
          count: i._count,
        })),
      }
    }),

  /**
   * Get a single project with details
   */
  get: protectedProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const project = await ctx.prisma.project.findUniqueOrThrow({
        where: { id: input.id },
        include: {
          files: true,
          teamMembers: {
            include: {
              user: {
                select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true },
              },
            },
            orderBy: { joinedAt: 'asc' },
          },
          mentorAssignments: {
            include: {
              mentor: {
                select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
              },
            },
          },
        },
      })

      // Fetch project tags separately (table may not exist if migrations are pending)
      let projectTags: { id: string; projectId: string; tagId: string; confidence: number; tag: { id: string; name: string; category: string | null; color: string | null } }[] = []
      try {
        projectTags = await ctx.prisma.projectTag.findMany({
          where: { projectId: input.id },
          include: { tag: { select: { id: true, name: true, category: true, color: true } } },
          orderBy: { confidence: 'desc' },
        })
      } catch (err) {
        console.error('Failed to fetch project tags:', err)
        // ProjectTag table may not exist yet
      }

      // Per-role access check. Admin / Observer can read any project.
      // Jury / Mentor / Applicant must have a relationship to it.
      const isAdmin = userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')
      const isObserverLevel = userHasRole(ctx.user, 'OBSERVER')
      if (!isAdmin && !isObserverLevel) {
        const checks: Array<Promise<unknown>> = []
        if (userHasRole(ctx.user, 'JURY_MEMBER')) {
          checks.push(
            ctx.prisma.assignment.findFirst({
              where: { projectId: input.id, userId: ctx.user.id },
              select: { id: true },
            }),
          )
        }
        if (userHasRole(ctx.user, 'MENTOR')) {
          checks.push(
            ctx.prisma.mentorAssignment.findFirst({
              where: { projectId: input.id, mentorId: ctx.user.id },
              select: { id: true },
            }),
          )
        }
        if (userHasRole(ctx.user, 'APPLICANT')) {
          checks.push(
            ctx.prisma.teamMember.findFirst({
              where: { projectId: input.id, userId: ctx.user.id },
              select: { id: true },
            }),
          )
        }
        const results = await Promise.all(checks)
        const hasAccess = results.some((r) => r !== null && r !== undefined)
        if (!hasAccess) {
          throw new TRPCError({
            code: 'FORBIDDEN',
            message: 'You do not have access to this project',
          })
        }
      }

      // Attach avatar URLs to team members and mentor
      const teamMembersWithAvatars = await Promise.all(
        project.teamMembers.map(async (member) => ({
          ...member,
          user: {
            ...member.user,
            avatarUrl: await getUserAvatarUrl(member.user.profileImageKey, member.user.profileImageProvider),
          },
        }))
      )

      // TODO(PR8 Task 8): surface all mentors. For now we keep the legacy
      // single-mentor shape and just pick the first non-dropped assignment
      // so the admin UI keeps rendering without changes.
      const primaryAssignment = project.mentorAssignments[0] ?? null
      const mentorWithAvatar = primaryAssignment
        ? {
            ...primaryAssignment,
            mentor: {
              ...primaryAssignment.mentor,
              avatarUrl: await getUserAvatarUrl(
                primaryAssignment.mentor.profileImageKey,
                primaryAssignment.mentor.profileImageProvider
              ),
            },
          }
        : null

      return {
        ...project,
        projectTags,
        teamMembers: teamMembersWithAvatars,
        mentorAssignment: mentorWithAvatar,
      }
    }),

  /**
   * Create a single project (admin only)
   * Projects belong to a program.
   */
  create: adminProcedure
    .input(
      z.object({
        programId: z.string(),
        roundId: z.string().optional(),
        title: z.string().min(1).max(500),
        teamName: z.string().optional(),
        description: z.string().optional(),
        tags: z.array(z.string()).optional(),
        country: z.string().optional(),
        competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
        oceanIssue: z.enum([
          'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
          'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
          'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
          'OCEAN_ACIDIFICATION', 'OTHER',
        ]).optional(),
        institution: z.string().optional(),
        contactPhone: z.string().optional(),
        contactEmail: z.string().email('Invalid email address').optional(),
        contactName: z.string().optional(),
        city: z.string().optional(),
        metadataJson: z.record(z.unknown()).optional(),
        teamMembers: z.array(z.object({
          name: z.string().min(1),
          email: z.string().email(),
          role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']),
          title: z.string().optional(),
          phone: z.string().optional(),
          sendInvite: z.boolean().default(false),
        })).max(10).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const {
        metadataJson,
        contactPhone, contactEmail, contactName, city,
        teamMembers: teamMembersInput,
        ...rest
      } = input

      const resolvedProgramId = input.programId

      // Build metadata from contact fields + any additional metadata
      const fullMetadata: Record<string, unknown> = { ...metadataJson }
      if (contactPhone) fullMetadata.contactPhone = contactPhone
      if (contactEmail) fullMetadata.contactEmail = contactEmail
      if (contactName) fullMetadata.contactName = contactName
      if (city) fullMetadata.city = city

      // Normalize country to ISO code if provided
      const normalizedCountry = input.country
        ? normalizeCountryToCode(input.country)
        : undefined

      const { project, membersToInvite } = await ctx.prisma.$transaction(async (tx) => {
        const created = await tx.project.create({
          data: {
            programId: resolvedProgramId,
            title: input.title,
            teamName: input.teamName,
            description: input.description,
            tags: input.tags || [],
            country: normalizedCountry,
            competitionCategory: input.competitionCategory,
            oceanIssue: input.oceanIssue,
            institution: input.institution,
            metadataJson: Object.keys(fullMetadata).length > 0
              ? (fullMetadata as Prisma.InputJsonValue)
              : undefined,
            status: 'SUBMITTED',
          },
        })

        if (input.roundId) {
          await tx.projectRoundState.create({
            data: {
              projectId: created.id,
              roundId: input.roundId,
              state: 'PENDING',
            },
          })
        }

        // Create team members if provided
        const inviteList: { userId: string; email: string; name: string }[] = []
        if (teamMembersInput && teamMembersInput.length > 0) {
          for (const member of teamMembersInput) {
            // Find or create user
            let user = await tx.user.findUnique({
              where: { email: member.email.toLowerCase() },
              select: { id: true, status: true },
            })

            if (!user) {
              user = await tx.user.create({
                data: {
                  email: member.email.toLowerCase(),
                  name: member.name,
                  role: 'APPLICANT',
                  roles: ['APPLICANT'],
                  status: 'NONE',
                  phoneNumber: member.phone || null,
                },
                select: { id: true, status: true },
              })
            }

            // Create TeamMember link (skip if already linked)
            await tx.teamMember.upsert({
              where: {
                projectId_userId: {
                  projectId: created.id,
                  userId: user.id,
                },
              },
              create: {
                projectId: created.id,
                userId: user.id,
                role: member.role,
                title: member.title || null,
              },
              update: {
                role: member.role,
                title: member.title || null,
              },
            })

            if (member.sendInvite) {
              inviteList.push({ userId: user.id, email: member.email.toLowerCase(), name: member.name })
            }
          }
        }

        return { project: created, membersToInvite: inviteList }
      })

      // Audit outside transaction so failures don't roll back the project creation
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'CREATE',
        entityType: 'Project',
        entityId: project.id,
        detailsJson: {
          title: input.title,
          programId: resolvedProgramId,
          teamMembersCount: teamMembersInput?.length || 0,
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      // Send invite emails outside the transaction (never fail project creation)
      if (membersToInvite.length > 0) {
        const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
        for (const member of membersToInvite) {
          try {
            const token = crypto.randomBytes(32).toString('hex')
            await ctx.prisma.user.update({
              where: { id: member.userId },
              data: {
                status: 'INVITED',
                inviteToken: token,
                inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
              },
            })

            const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
            await sendInvitationEmail(member.email, member.name, inviteUrl, 'APPLICANT')

            // Log notification
            try {
              await ctx.prisma.notificationLog.create({
                data: {
                  userId: member.userId,
                  channel: 'EMAIL',
                  type: 'JURY_INVITATION',
                  status: 'SENT',
                },
              })
            } catch (err) {
              console.error('Failed to log invitation notification for project team member:', err)
              // Never fail on notification logging
            }
          } catch (err) {
            // Email sending failure should not break project creation
            console.error(`Failed to send invite to ${member.email}:`, err)
          }
        }
      }

      return project
    }),

  /**
   * Update a project (admin only)
   */
  update: adminProcedure
    .input(
      z.object({
        id: z.string(),
        title: z.string().min(1).max(500).optional(),
        teamName: z.string().optional().nullable(),
        description: z.string().optional().nullable(),
        country: z.string().optional().nullable(), // ISO-2 code or country name (will be normalized)
        competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional().nullable(),
        oceanIssue: z.enum([
          'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
          'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
          'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
          'OCEAN_ACIDIFICATION', 'OTHER',
        ]).optional().nullable(),
        institution: z.string().optional().nullable(),
        geographicZone: z.string().optional().nullable(),
        wantsMentorship: z.boolean().optional(),
        foundedAt: z.string().datetime().optional().nullable(),
        status: z
          .enum([
            'SUBMITTED',
            'ELIGIBLE',
            'ASSIGNED',
            'SEMIFINALIST',
            'FINALIST',
            'REJECTED',
          ])
          .optional(),
        tags: z.array(z.string()).optional(),
        metadataJson: z.record(z.unknown()).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const { id, metadataJson, status, country, foundedAt, ...data } = input

      // Normalize country to ISO-2 code if provided
      const normalizedCountry = country !== undefined
        ? (country === null ? null : normalizeCountryToCode(country))
        : undefined

      // Validate status transition if status is actually changing
      if (status) {
        const currentProject = await ctx.prisma.project.findUniqueOrThrow({
          where: { id },
          select: { status: true },
        })
        if (status !== currentProject.status) {
          const allowedTransitions = VALID_PROJECT_TRANSITIONS[currentProject.status] || []
          if (!allowedTransitions.includes(status)) {
            throw new TRPCError({
              code: 'BAD_REQUEST',
              message: `Invalid status transition: cannot change from ${currentProject.status} to ${status}. Allowed: ${allowedTransitions.join(', ') || 'none'}`,
            })
          }
        }
      }

      const project = await ctx.prisma.$transaction(async (tx) => {
        const updated = await tx.project.update({
          where: { id },
          data: {
            ...data,
            ...(status && { status }),
            ...(normalizedCountry !== undefined && { country: normalizedCountry }),
            ...(foundedAt !== undefined && { foundedAt: foundedAt ? new Date(foundedAt) : null }),
            metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
          },
        })

        // Record status change in history
        if (status) {
          await tx.projectStatusHistory.create({
            data: {
              projectId: id,
              status,
              changedBy: ctx.user.id,
            },
          })
        }

        return updated
      })

      // Send notifications if status changed
      if (status) {
        const notificationConfig: Record<
          string,
          { type: string; title: string; message: string }
        > = {
          SEMIFINALIST: {
            type: NotificationTypes.ADVANCED_SEMIFINAL,
            title: "Congratulations! You're a Semi-Finalist",
            message: `Your project "${project.title}" has advanced to the semi-finals!`,
          },
          FINALIST: {
            type: NotificationTypes.ADVANCED_FINAL,
            title: "Amazing News! You're a Finalist",
            message: `Your project "${project.title}" has been selected as a finalist!`,
          },
          REJECTED: {
            type: NotificationTypes.NOT_SELECTED,
            title: 'Application Status Update',
            message: `We regret to inform you that "${project.title}" was not selected for the next round.`,
          },
        }

        const config = notificationConfig[status]
        if (config) {
          await notifyProjectTeam(id, {
            type: config.type,
            title: config.title,
            message: config.message,
            linkUrl: `/team/projects/${id}`,
            linkLabel: 'View Project',
            priority: status === 'REJECTED' ? 'normal' : 'high',
            metadata: {
              projectName: project.title,
            },
          })
        }
      }

      // Audit log
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'UPDATE',
        entityType: 'Project',
        entityId: id,
        detailsJson: { ...data, status, metadataJson } as Record<string, unknown>,
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return project
    }),

  /**
   * Delete a project (admin only)
   */
  delete: adminProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const target = await ctx.prisma.project.findUniqueOrThrow({
        where: { id: input.id },
        select: { id: true, title: true, status: true },
      })

      const protectedStatuses = ['FINALIST', 'SEMIFINALIST']
      if (protectedStatuses.includes(target.status)) {
        throw new TRPCError({
          code: 'PRECONDITION_FAILED',
          message: `Cannot delete a project with status ${target.status}. Change status first.`,
        })
      }

      const project = await ctx.prisma.project.delete({
        where: { id: input.id },
      })

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

      return project
    }),

  /**
   * Bulk delete projects (admin only)
   */
  bulkDelete: adminProcedure
    .input(
      z.object({
        ids: z.array(z.string()).min(1).max(200),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const projects = await ctx.prisma.project.findMany({
        where: { id: { in: input.ids } },
        select: { id: true, title: true, status: true },
      })

      if (projects.length === 0) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'No projects found to delete',
        })
      }

      const protectedProjects = projects.filter((p) =>
        ['FINALIST', 'SEMIFINALIST'].includes(p.status)
      )
      if (protectedProjects.length > 0) {
        throw new TRPCError({
          code: 'PRECONDITION_FAILED',
          message: `Cannot delete ${protectedProjects.length} project(s) with FINALIST/SEMIFINALIST status. Remove them from the selection first.`,
        })
      }

      const result = await ctx.prisma.project.deleteMany({
        where: { id: { in: projects.map((p) => p.id) } },
      })

      // Audit outside transaction so failures don't roll back the bulk delete
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'BULK_DELETE',
        entityType: 'Project',
        detailsJson: {
          count: projects.length,
          titles: projects.map((p) => p.title),
          ids: projects.map((p) => p.id),
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { deleted: result.count }
    }),

  /**
   * Import projects from CSV data (admin only)
   * Projects belong to a program.
   */
  importCSV: adminProcedure
    .input(
      z.object({
        programId: z.string(),
        projects: z.array(
          z.object({
            title: z.string().min(1),
            teamName: z.string().optional(),
            description: z.string().optional(),
            tags: z.array(z.string()).optional(),
            metadataJson: z.record(z.unknown()).optional(),
          })
        ),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify program exists
      await ctx.prisma.program.findUniqueOrThrow({
        where: { id: input.programId },
      })

      // Create projects in a transaction
      const result = await ctx.prisma.$transaction(async (tx) => {
        const projectData = input.projects.map((p) => {
          const { metadataJson, ...rest } = p
          return {
            ...rest,
            programId: input.programId,
            status: 'SUBMITTED' as const,
            metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
          }
        })

        const created = await tx.project.createManyAndReturn({
          data: projectData,
          select: { id: true },
        })

        return { imported: created.length }
      })

      // Audit log
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'IMPORT',
        entityType: 'Project',
        detailsJson: { programId: input.programId, count: result.imported },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return result
    }),

  /**
   * Get all unique tags used in projects
   */
  getTags: protectedProcedure
    .input(z.object({
      programId: z.string().optional(),
    }))
    .query(async ({ ctx, input }) => {
      const where: Record<string, unknown> = {}
      if (input.programId) where.programId = input.programId

      const projects = await ctx.prisma.project.findMany({
        where: Object.keys(where).length > 0 ? where : undefined,
        select: { tags: true },
      })

      const allTags = projects.flatMap((p) => p.tags)
      const uniqueTags = [...new Set(allTags)].sort()

      return uniqueTags
    }),

  /**
   * Update project status in bulk (admin only)
   */
  bulkUpdateStatus: adminProcedure
    .input(
      z.object({
        ids: z.array(z.string()),
        status: z.enum([
          'SUBMITTED',
          'ELIGIBLE',
          'ASSIGNED',
          'SEMIFINALIST',
          'FINALIST',
          'REJECTED',
        ]),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Fetch matching projects BEFORE update so notifications match actually-updated records
      const projects = await ctx.prisma.project.findMany({
        where: {
          id: { in: input.ids },
        },
        select: { id: true, title: true },
      })

      const matchingIds = projects.map((p) => p.id)

      // Validate status transitions for all projects
      const projectsWithStatus = await ctx.prisma.project.findMany({
        where: { id: { in: matchingIds } },
        select: { id: true, title: true, status: true },
      })
      const invalidTransitions: string[] = []
      for (const p of projectsWithStatus) {
        const allowed = VALID_PROJECT_TRANSITIONS[p.status] || []
        if (!allowed.includes(input.status)) {
          invalidTransitions.push(`"${p.title}" (${p.status} → ${input.status})`)
        }
      }
      if (invalidTransitions.length > 0) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: `Invalid transitions for ${invalidTransitions.length} project(s): ${invalidTransitions.slice(0, 3).join('; ')}${invalidTransitions.length > 3 ? ` and ${invalidTransitions.length - 3} more` : ''}`,
        })
      }

      const updated = await ctx.prisma.$transaction(async (tx) => {
        const result = await tx.project.updateMany({
          where: { id: { in: matchingIds } },
          data: { status: input.status },
        })

        if (matchingIds.length > 0) {
          await tx.projectStatusHistory.createMany({
            data: matchingIds.map((projectId) => ({
              projectId,
              status: input.status,
              changedBy: ctx.user.id,
            })),
          })
        }

        return result
      })

      // Audit outside transaction so failures don't roll back the bulk update
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'BULK_UPDATE_STATUS',
        entityType: 'Project',
        detailsJson: { ids: matchingIds, status: input.status, count: updated.count },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      // Notify project teams based on status
      if (projects.length > 0) {
        const notificationConfig: Record<
          string,
          { type: string; titleFn: (name: string) => string; messageFn: (name: string) => string }
        > = {
          SEMIFINALIST: {
            type: NotificationTypes.ADVANCED_SEMIFINAL,
            titleFn: () => "Congratulations! You're a Semi-Finalist",
            messageFn: (name) => `Your project "${name}" has advanced to the semi-finals!`,
          },
          FINALIST: {
            type: NotificationTypes.ADVANCED_FINAL,
            titleFn: () => "Amazing News! You're a Finalist",
            messageFn: (name) => `Your project "${name}" has been selected as a finalist!`,
          },
          REJECTED: {
            type: NotificationTypes.NOT_SELECTED,
            titleFn: () => 'Application Status Update',
            messageFn: (name) =>
              `We regret to inform you that "${name}" was not selected for the next round.`,
          },
        }

        const config = notificationConfig[input.status]
        if (config) {
          for (const project of projects) {
            await notifyProjectTeam(project.id, {
              type: config.type,
              title: config.titleFn(project.title),
              message: config.messageFn(project.title),
              linkUrl: `/team/projects/${project.id}`,
              linkLabel: 'View Project',
              priority: input.status === 'REJECTED' ? 'normal' : 'high',
              metadata: {
                projectName: project.title,
              },
            })
          }
        }
      }

      return { updated: updated.count }
    }),

  /**
   * List projects in a program's pool (not assigned to any stage)
   */
  listPool: adminProcedure
    .input(
      z.object({
        programId: z.string(),
        search: z.string().optional(),
        page: z.number().int().min(1).default(1),
        perPage: z.number().int().min(1).max(100).default(50),
      })
    )
    .query(async ({ ctx, input }) => {
      const { programId, search, page, perPage } = input
      const skip = (page - 1) * perPage

      const where: Record<string, unknown> = {
        programId,
        projectRoundStates: { none: {} }, // Projects not assigned to any round
      }

      if (search) {
        where.OR = [
          { title: { contains: search, mode: 'insensitive' } },
          { teamName: { contains: search, mode: 'insensitive' } },
          { teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } },
          { teamMembers: { some: { user: { email: { contains: search, mode: 'insensitive' } } } } },
        ]
      }

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

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

  /**
   * Get full project detail with assignments and evaluation stats in one call.
   * Reduces client-side waterfall by combining project.get + assignment.listByProject + evaluation.getProjectStats.
   */
  getFullDetail: adminProcedure
    .input(z.object({ id: z.string(), roundId: z.string().optional() }))
    .query(async ({ ctx, input }) => {
      const [projectRaw, projectTags, assignments, submittedEvaluations] = await Promise.all([
        ctx.prisma.project.findUniqueOrThrow({
          where: { id: input.id },
          include: {
            files: true,
            teamMembers: {
              include: {
                user: {
                  select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true, nationality: true, country: true, institution: true },
                },
              },
              orderBy: { joinedAt: 'asc' },
            },
            mentorAssignments: {
              include: {
                mentor: {
                  select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
                },
              },
            },
            projectRoundStates: {
              select: {
                state: true,
                round: { select: { name: true, sortOrder: true } },
              },
              orderBy: { round: { sortOrder: 'desc' } },
            },
          },
        }),
        ctx.prisma.projectTag.findMany({
          where: { projectId: input.id },
          include: { tag: { select: { id: true, name: true, category: true, color: true } } },
          orderBy: { confidence: 'desc' },
        }).catch(() => [] as { id: string; projectId: string; tagId: string; confidence: number; tag: { id: string; name: string; category: string | null; color: string | null } }[]),
        ctx.prisma.assignment.findMany({
          where: { projectId: input.id },
          include: {
            user: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true } },
            round: { select: { id: true, name: true } },
            evaluation: { select: { id: true, status: true, submittedAt: true, globalScore: true, binaryDecision: true, criterionScoresJson: true, feedbackText: true } },
          },
          orderBy: { createdAt: 'desc' },
        }),
        ctx.prisma.evaluation.findMany({
          where: {
            status: 'SUBMITTED',
            assignment: {
              projectId: input.id,
              ...(input.roundId ? { roundId: input.roundId } : {}),
            },
          },
        }),
      ])

      // Resolve the boolean "advance to next round" criterion id for each round
      // so we can fall back to criterionScoresJson when binaryDecision is null.
      // Mirrors ranking.ts logic: prefer type='advance', else legacy boolean
      // criterion labeled "move to the next stage".
      const roundIdsInUse = Array.from(new Set(assignments.map((a) => a.round.id)))
      const advanceCriterionByRound = new Map<string, string | null>()
      if (roundIdsInUse.length > 0) {
        const forms = await ctx.prisma.evaluationForm.findMany({
          where: { roundId: { in: roundIdsInUse }, isActive: true },
          select: { roundId: true, criteriaJson: true },
        })
        for (const form of forms) {
          if (advanceCriterionByRound.has(form.roundId)) continue
          const criteria = (form.criteriaJson as Array<{ id: string; type?: string; label?: string }> | null) ?? []
          const found =
            criteria.find((c) => c.type === 'advance') ??
            criteria.find((c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'))
          advanceCriterionByRound.set(form.roundId, found?.id ?? null)
        }
      }

      // Compute evaluation stats
      let stats = null
      if (submittedEvaluations.length > 0) {
        const globalScores = submittedEvaluations
          .map((e) => e.globalScore)
          .filter((s): s is number => s !== null)

        // Count recommendations: check binaryDecision first, fall back to boolean criteria
        const yesVotes = submittedEvaluations.filter((e) => {
          if (e.binaryDecision != null) return e.binaryDecision === true
          const scores = e.criterionScoresJson as Record<string, unknown> | null
          if (!scores) return false
          const boolValues = Object.values(scores).filter((v) => typeof v === 'boolean')
          return boolValues.length > 0 && boolValues.every((v) => v === true)
        }).length

        const hasRecommendationData = submittedEvaluations.some((e) => {
          if (e.binaryDecision != null) return true
          const scores = e.criterionScoresJson as Record<string, unknown> | null
          if (!scores) return false
          return Object.values(scores).some((v) => typeof v === 'boolean')
        })

        stats = {
          totalEvaluations: submittedEvaluations.length,
          averageGlobalScore: globalScores.length > 0
            ? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
            : null,
          minScore: globalScores.length > 0 ? Math.min(...globalScores) : null,
          maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null,
          yesVotes,
          noVotes: submittedEvaluations.length - yesVotes,
          yesPercentage: hasRecommendationData
            ? (yesVotes / submittedEvaluations.length) * 100
            : null,
        }
      }

      // Attach avatar URLs in parallel
      const [teamMembersWithAvatars, assignmentsWithAvatars, mentorWithAvatar] = await Promise.all([
        Promise.all(
          projectRaw.teamMembers.map(async (member) => ({
            ...member,
            user: {
              ...member.user,
              avatarUrl: await getUserAvatarUrl(member.user.profileImageKey, member.user.profileImageProvider),
            },
          }))
        ),
        Promise.all(
          assignments.map(async (a) => {
            // Resolve decision: column first, criterion fallback second.
            let decision: boolean | null = a.evaluation?.binaryDecision ?? null
            if (decision == null && a.evaluation) {
              const advCritId = advanceCriterionByRound.get(a.round.id) ?? null
              if (advCritId) {
                const scores = a.evaluation.criterionScoresJson as Record<string, unknown> | null
                if (scores) {
                  const val = scores[advCritId]
                  if (typeof val === 'boolean') decision = val
                  else if (val === 'true') decision = true
                  else if (val === 'false') decision = false
                }
              }
            }
            return {
              ...a,
              user: {
                ...a.user,
                avatarUrl: await getUserAvatarUrl(a.user.profileImageKey, a.user.profileImageProvider),
              },
              evaluation: a.evaluation ? { ...a.evaluation, decision } : null,
            }
          })
        ),
        // TODO(PR8 Task 8): surface all mentors. Legacy shape — pick the first.
        (async () => {
          const primaryMa = projectRaw.mentorAssignments[0] ?? null
          if (!primaryMa) return null
          return {
            ...primaryMa,
            mentor: {
              ...primaryMa.mentor,
              avatarUrl: await getUserAvatarUrl(
                primaryMa.mentor.profileImageKey,
                primaryMa.mentor.profileImageProvider
              ),
            },
          }
        })(),
      ])

      return {
        project: {
          ...projectRaw,
          projectTags,
          teamMembers: teamMembersWithAvatars,
          mentorAssignment: mentorWithAvatar,
        },
        assignments: assignmentsWithAvatars,
        stats,
      }
    }),

  /**
   * Create a new project and assign it directly to a round.
   * Used for late-arriving projects that need to enter a specific round immediately.
   */
  createAndAssignToRound: adminProcedure
    .input(
      z.object({
        title: z.string().min(1).max(500),
        teamName: z.string().optional(),
        description: z.string().optional(),
        country: z.string().optional(),
        competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
        roundId: z.string(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const { roundId, country, ...projectFields } = input

      // Get the round to find competitionId, then competition to find programId
      const round = await ctx.prisma.round.findUniqueOrThrow({
        where: { id: roundId },
        select: {
          id: true,
          name: true,
          competition: {
            select: {
              id: true,
              programId: true,
            },
          },
        },
      })

      // Normalize country to ISO code if provided
      const normalizedCountry = country
        ? normalizeCountryToCode(country)
        : undefined

      const project = await ctx.prisma.$transaction(async (tx) => {
        // 1. Create the project
        const created = await tx.project.create({
          data: {
            programId: round.competition.programId,
            title: projectFields.title,
            teamName: projectFields.teamName,
            description: projectFields.description,
            country: normalizedCountry,
            competitionCategory: projectFields.competitionCategory,
            status: 'ASSIGNED',
          },
        })

        // 2. Create ProjectRoundState entry
        await tx.projectRoundState.create({
          data: {
            projectId: created.id,
            roundId,
            state: 'PENDING',
          },
        })

        // 3. Create ProjectStatusHistory entry
        await tx.projectStatusHistory.create({
          data: {
            projectId: created.id,
            status: 'ASSIGNED',
            changedBy: ctx.user.id,
          },
        })

        return created
      })

      // Audit outside transaction
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'CREATE_AND_ASSIGN',
        entityType: 'Project',
        entityId: project.id,
        detailsJson: {
          title: input.title,
          roundId,
          roundName: round.name,
          programId: round.competition.programId,
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return project
    }),

  /**
   * Add a team member to a project (admin only).
   * Finds or creates user, then creates TeamMember record.
   * Optionally sends invite email if user has no password set.
   */
  addTeamMember: adminProcedure
    .input(
      z.object({
        projectId: z.string(),
        email: z.string().email(),
        name: z.string().min(1),
        role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']),
        title: z.string().optional(),
        sendInvite: z.boolean().default(false),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const { projectId, email, name, role, title, sendInvite } = input

      // Verify project exists
      await ctx.prisma.project.findUniqueOrThrow({
        where: { id: projectId },
        select: { id: true },
      })

      // Find or create user
      let user = await ctx.prisma.user.findUnique({
        where: { email: email.toLowerCase() },
        select: { id: true, name: true, email: true, passwordHash: true, status: true },
      })

      if (!user) {
        user = await ctx.prisma.user.create({
          data: {
            email: email.toLowerCase(),
            name,
            role: 'APPLICANT',
            roles: ['APPLICANT'],
            status: 'INVITED',
          },
          select: { id: true, name: true, email: true, passwordHash: true, status: true },
        })
      }

      // Create TeamMember record
      let teamMember
      try {
        teamMember = await ctx.prisma.teamMember.create({
          data: {
            projectId,
            userId: user.id,
            role,
            title: title || null,
          },
          include: {
            user: {
              select: { id: true, name: true, email: true },
            },
          },
        })
      } catch (err) {
        if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
          throw new TRPCError({
            code: 'CONFLICT',
            message: 'This user is already a team member of this project',
          })
        }
        throw err
      }

      // Send invite email if requested and user has no password
      if (sendInvite && !user.passwordHash) {
        try {
          const token = generateInviteToken()
          const expiryMs = await getInviteExpiryMs(ctx.prisma)
          await ctx.prisma.user.update({
            where: { id: user.id },
            data: {
              status: 'INVITED',
              inviteToken: token,
              inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
            },
          })

          const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
          const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
          await sendInvitationEmail(email.toLowerCase(), name, inviteUrl, 'APPLICANT')
        } catch (err) {
          // Email sending failure should not block member creation
          console.error(`Failed to send invite to ${email}:`, err)
        }
      }

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'ADD_TEAM_MEMBER',
        entityType: 'Project',
        entityId: projectId,
        detailsJson: { memberId: user.id, email, role },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return teamMember
    }),

  /**
   * Remove a team member from a project (admin only).
   * Prevents removing the last LEAD.
   */
  removeTeamMember: adminProcedure
    .input(
      z.object({
        projectId: z.string(),
        userId: z.string(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const { projectId, userId } = input

      // Check if this is the last LEAD
      const targetMember = await ctx.prisma.teamMember.findUniqueOrThrow({
        where: { projectId_userId: { projectId, userId } },
        select: { id: true, role: true },
      })

      if (targetMember.role === 'LEAD') {
        const leadCount = await ctx.prisma.teamMember.count({
          where: { projectId, role: 'LEAD' },
        })
        if (leadCount <= 1) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: 'Cannot remove the last team lead',
          })
        }
      }

      await ctx.prisma.teamMember.delete({
        where: { projectId_userId: { projectId, userId } },
      })

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'REMOVE_TEAM_MEMBER',
        entityType: 'Project',
        entityId: projectId,
        detailsJson: { removedUserId: userId },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { success: true }
    }),

  /**
   * Update a team member's role (admin only).
   * Prevents removing the last LEAD.
   */
  updateTeamMemberRole: adminProcedure
    .input(
      z.object({
        projectId: z.string(),
        userId: z.string(),
        role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const { projectId, userId, role } = input

      const member = await ctx.prisma.teamMember.findUniqueOrThrow({
        where: { projectId_userId: { projectId, userId } },
        select: { role: true },
      })

      // Prevent removing the last LEAD
      if (member.role === 'LEAD' && role !== 'LEAD') {
        const leadCount = await ctx.prisma.teamMember.count({
          where: { projectId, role: 'LEAD' },
        })
        if (leadCount <= 1) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: 'Cannot change the role of the last team lead',
          })
        }
      }

      await ctx.prisma.teamMember.update({
        where: { projectId_userId: { projectId, userId } },
        data: { role },
      })

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'UPDATE_TEAM_MEMBER_ROLE',
        entityType: 'Project',
        entityId: projectId,
        detailsJson: { targetUserId: userId, oldRole: member.role, newRole: role },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { success: true }
    }),

  // =========================================================================
  // BULK NOTIFICATION ENDPOINTS
  // =========================================================================

  /**
   * Get summary of projects eligible for bulk notifications.
   * Returns counts for passed (by round), rejected, and award pool projects,
   * plus how many have already been notified.
   */
  getBulkNotificationSummary: adminProcedure
    .query(async ({ ctx }) => {
      // 1. Passed projects grouped by round
      const passedStates = await ctx.prisma.projectRoundState.findMany({
        where: { state: 'PASSED' },
        select: {
          projectId: true,
          roundId: true,
          round: { select: { name: true, sortOrder: true, competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } } } } },
        },
      })

      // Group by round and compute next round name
      const passedByRound = new Map<string, { roundId: string; roundName: string; nextRoundName: string; projectIds: Set<string> }>()
      for (const ps of passedStates) {
        if (!passedByRound.has(ps.roundId)) {
          const rounds = ps.round.competition.rounds
          const idx = rounds.findIndex((r) => r.id === ps.roundId)
          const nextRound = rounds[idx + 1]
          passedByRound.set(ps.roundId, {
            roundId: ps.roundId,
            roundName: ps.round.name,
            nextRoundName: nextRound?.name ?? 'Next Round',
            projectIds: new Set(),
          })
        }
        passedByRound.get(ps.roundId)!.projectIds.add(ps.projectId)
      }

      const passed = [...passedByRound.values()].map((g) => ({
        roundId: g.roundId,
        roundName: g.roundName,
        nextRoundName: g.nextRoundName,
        projectCount: g.projectIds.size,
      }))

      // 2. Rejected projects (REJECTED in ProjectRoundState + FILTERED_OUT in FilteringResult)
      const [rejectedPRS, filteredOut] = await Promise.all([
        ctx.prisma.projectRoundState.findMany({
          where: { state: 'REJECTED' },
          select: { projectId: true },
        }),
        ctx.prisma.filteringResult.findMany({
          where: {
            OR: [
              { finalOutcome: 'FILTERED_OUT' },
              { outcome: 'FILTERED_OUT', finalOutcome: null },
            ],
          },
          select: { projectId: true },
        }),
      ])
      const rejectedProjectIds = new Set([
        ...rejectedPRS.map((r) => r.projectId),
        ...filteredOut.map((r) => r.projectId),
      ])

      // 3. Award pools
      const awards = await ctx.prisma.specialAward.findMany({
        select: {
          id: true,
          name: true,
          _count: { select: { eligibilities: { where: { eligible: true } } } },
        },
      })
      const awardPools = awards.map((a) => ({
        awardId: a.id,
        awardName: a.name,
        eligibleCount: a._count.eligibilities,
      }))

      // 4. Already-sent counts from NotificationLog
      const [advancementSent, rejectionSent] = await Promise.all([
        ctx.prisma.notificationLog.count({
          where: { type: 'ADVANCEMENT_NOTIFICATION', status: 'SENT' },
        }),
        ctx.prisma.notificationLog.count({
          where: { type: 'REJECTION_NOTIFICATION', status: 'SENT' },
        }),
      ])

      return {
        passed,
        rejected: { count: rejectedProjectIds.size },
        awardPools,
        alreadyNotified: { advancement: advancementSent, rejection: rejectionSent },
      }
    }),

  /**
   * Send bulk advancement notifications to all PASSED projects.
   * Groups by round, determines next round, sends via batch sender.
   * Skips projects that have already been notified (unless skipAlreadySent=false).
   */
  previewAdvancementEmail: adminProcedure
    .input(
      z.object({
        roundId: z.string(),
        customMessage: z.string().optional(),
        fullCustomBody: z.boolean().default(false),
      })
    )
    .query(async ({ ctx, input }) => {
      const round = await ctx.prisma.round.findUnique({
        where: { id: input.roundId },
        select: {
          name: true,
          competitionId: true,
        },
      })
      if (!round) throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })

      const rounds = await ctx.prisma.round.findMany({
        where: { competitionId: round.competitionId },
        orderBy: { sortOrder: 'asc' },
        select: { id: true, name: true },
      })
      const idx = rounds.findIndex((r) => r.id === input.roundId)
      const nextRound = rounds[idx + 1]

      const { getAdvancementNotificationTemplate } = await import('@/lib/email')
      const template = getAdvancementNotificationTemplate(
        'Team Lead Name',
        'Example Project Title',
        round.name,
        nextRound?.name ?? 'Next Round',
        input.customMessage || undefined,
        undefined,
        input.fullCustomBody,
      )
      return { subject: template.subject, html: template.html }
    }),

  sendBulkPassedNotifications: adminProcedure
    .input(
      z.object({
        customMessage: z.string().optional(),
        fullCustomBody: z.boolean().default(false),
        skipAlreadySent: z.boolean().default(true),
        roundIds: z.array(z.string()).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const { customMessage, fullCustomBody, skipAlreadySent, roundIds } = input

      // Find all PASSED project round states (optionally filtered by round)
      const passedStates = await ctx.prisma.projectRoundState.findMany({
        where: {
          state: 'PASSED',
          ...(roundIds && roundIds.length > 0 ? { roundId: { in: roundIds } } : {}),
        },
        select: {
          projectId: true,
          roundId: true,
          round: {
            select: {
              name: true,
              sortOrder: true,
              competition: {
                select: {
                  rounds: {
                    select: { id: true, name: true, sortOrder: true },
                    orderBy: { sortOrder: 'asc' },
                  },
                },
              },
            },
          },
        },
      })

      // Get already-sent project IDs if needed
      const alreadySentProjectIds = new Set<string>()
      if (skipAlreadySent) {
        const sentLogs = await ctx.prisma.notificationLog.findMany({
          where: { type: 'ADVANCEMENT_NOTIFICATION', status: 'SENT', projectId: { not: null } },
          select: { projectId: true },
          distinct: ['projectId'],
        })
        for (const log of sentLogs) {
          if (log.projectId) alreadySentProjectIds.add(log.projectId)
        }
      }

      // Group by round for next-round resolution
      const roundMap = new Map<string, { roundName: string; nextRoundName: string }>()
      const projectIds = new Set<string>()
      for (const ps of passedStates) {
        if (skipAlreadySent && alreadySentProjectIds.has(ps.projectId)) continue
        projectIds.add(ps.projectId)
        if (!roundMap.has(ps.roundId)) {
          const rounds = ps.round.competition.rounds
          const idx = rounds.findIndex((r) => r.id === ps.roundId)
          const nextRound = rounds[idx + 1]
          roundMap.set(ps.roundId, {
            roundName: ps.round.name,
            nextRoundName: nextRound?.name ?? 'Next Round',
          })
        }
      }

      if (projectIds.size === 0) {
        return { sent: 0, failed: 0, skipped: alreadySentProjectIds.size }
      }

      // Fetch projects with team members
      const projects = await ctx.prisma.project.findMany({
        where: { id: { in: [...projectIds] } },
        select: {
          id: true,
          title: true,
          submittedByEmail: true,
          teamMembers: {
            select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
          },
          projectRoundStates: {
            where: { state: 'PASSED' },
            select: { roundId: true },
            take: 1,
          },
        },
      })

      // For passwordless users: generate invite tokens
      const baseUrl = getBaseUrl()
      const passwordlessUserIds: string[] = []
      for (const project of projects) {
        for (const tm of project.teamMembers) {
          if (!tm.user.passwordHash) {
            passwordlessUserIds.push(tm.user.id)
          }
        }
      }

      const tokenMap = new Map<string, string>()
      if (passwordlessUserIds.length > 0) {
        const expiryMs = await getInviteExpiryMs(ctx.prisma)
        for (const userId of [...new Set(passwordlessUserIds)]) {
          const token = generateInviteToken()
          await ctx.prisma.user.update({
            where: { id: userId },
            data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), status: 'INVITED' },
          })
          tokenMap.set(userId, token)
        }
      }

      // Build notification items
      const items: NotificationItem[] = []
      for (const project of projects) {
        const roundId = project.projectRoundStates[0]?.roundId
        const roundInfo = roundId ? roundMap.get(roundId) : undefined

        const recipients = new Map<string, { name: string | null; userId: string }>()
        for (const tm of project.teamMembers) {
          if (tm.user.email) {
            recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
          }
        }
        if (recipients.size === 0 && project.submittedByEmail) {
          recipients.set(project.submittedByEmail, { name: null, userId: '' })
        }

        for (const [email, { name, userId }] of recipients) {
          const inviteToken = tokenMap.get(userId)
          const accountUrl = inviteToken ? `${baseUrl}/accept-invite?token=${inviteToken}` : undefined

          items.push({
            email,
            name: name || '',
            type: 'ADVANCEMENT_NOTIFICATION',
            context: {
              title: 'Your project has advanced!',
              message: '',
              linkUrl: '/applicant',
              metadata: {
                projectName: project.title,
                fromRoundName: roundInfo?.roundName ?? 'this round',
                toRoundName: roundInfo?.nextRoundName ?? 'Next Round',
                customMessage: customMessage || undefined,
                fullCustomBody,
                accountUrl,
              },
            },
            projectId: project.id,
            userId: userId || undefined,
            roundId: roundId || undefined,
          })
        }
      }

      const result = await sendBatchNotifications(items)

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'SEND_BULK_PASSED_NOTIFICATIONS',
        entityType: 'Project',
        entityId: 'bulk',
        detailsJson: {
          sent: result.sent,
          failed: result.failed,
          projectCount: projectIds.size,
          skipped: alreadySentProjectIds.size,
          batchId: result.batchId,
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { sent: result.sent, failed: result.failed, skipped: alreadySentProjectIds.size }
    }),

  /**
   * Send bulk rejection notifications to all REJECTED and FILTERED_OUT projects.
   * Deduplicates by project, uses highest-sortOrder rejection round as context.
   */
  sendBulkRejectionNotifications: adminProcedure
    .input(
      z.object({
        customMessage: z.string().optional(),
        fullCustomBody: z.boolean().default(false),
        includeInviteLink: z.boolean().default(false),
        skipAlreadySent: z.boolean().default(true),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const { customMessage, fullCustomBody, includeInviteLink, skipAlreadySent } = input

      // Find REJECTED from ProjectRoundState
      const rejectedPRS = await ctx.prisma.projectRoundState.findMany({
        where: { state: 'REJECTED' },
        select: {
          projectId: true,
          roundId: true,
          round: { select: { name: true, sortOrder: true } },
        },
      })

      // Find FILTERED_OUT from FilteringResult
      const filteredOut = await ctx.prisma.filteringResult.findMany({
        where: {
          OR: [
            { finalOutcome: 'FILTERED_OUT' },
            { outcome: 'FILTERED_OUT', finalOutcome: null },
          ],
        },
        select: {
          projectId: true,
          roundId: true,
          round: { select: { name: true, sortOrder: true } },
        },
      })

      // Deduplicate by project, keep highest-sortOrder rejection round
      const projectRejectionMap = new Map<string, { roundId: string; roundName: string; sortOrder: number }>()
      for (const r of [...rejectedPRS, ...filteredOut]) {
        const existing = projectRejectionMap.get(r.projectId)
        if (!existing || r.round.sortOrder > existing.sortOrder) {
          projectRejectionMap.set(r.projectId, {
            roundId: r.roundId,
            roundName: r.round.name,
            sortOrder: r.round.sortOrder,
          })
        }
      }

      // Skip already-sent
      const alreadySentProjectIds = new Set<string>()
      if (skipAlreadySent) {
        const sentLogs = await ctx.prisma.notificationLog.findMany({
          where: { type: 'REJECTION_NOTIFICATION', status: 'SENT', projectId: { not: null } },
          select: { projectId: true },
          distinct: ['projectId'],
        })
        for (const log of sentLogs) {
          if (log.projectId) alreadySentProjectIds.add(log.projectId)
        }
      }

      const targetProjectIds = [...projectRejectionMap.keys()].filter(
        (pid) => !skipAlreadySent || !alreadySentProjectIds.has(pid)
      )

      if (targetProjectIds.length === 0) {
        return { sent: 0, failed: 0, skipped: alreadySentProjectIds.size }
      }

      // Fetch projects with team members
      const projects = await ctx.prisma.project.findMany({
        where: { id: { in: targetProjectIds } },
        select: {
          id: true,
          title: true,
          submittedByEmail: true,
          teamMembers: {
            select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
          },
        },
      })

      // Generate invite tokens for passwordless users if needed
      const baseUrl = getBaseUrl()
      const tokenMap = new Map<string, string>()
      if (includeInviteLink) {
        const passwordlessUserIds = new Set<string>()
        for (const project of projects) {
          for (const tm of project.teamMembers) {
            if (!tm.user.passwordHash) passwordlessUserIds.add(tm.user.id)
          }
        }
        if (passwordlessUserIds.size > 0) {
          const expiryMs = await getInviteExpiryMs(ctx.prisma)
          for (const userId of passwordlessUserIds) {
            const token = generateInviteToken()
            await ctx.prisma.user.update({
              where: { id: userId },
              data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), status: 'INVITED' },
            })
            tokenMap.set(userId, token)
          }
        }
      }

      // Build notification items
      const items: NotificationItem[] = []
      for (const project of projects) {
        const rejection = projectRejectionMap.get(project.id)

        const recipients = new Map<string, { name: string | null; userId: string }>()
        for (const tm of project.teamMembers) {
          if (tm.user.email) {
            recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
          }
        }
        if (recipients.size === 0 && project.submittedByEmail) {
          recipients.set(project.submittedByEmail, { name: null, userId: '' })
        }

        for (const [email, { name, userId }] of recipients) {
          const inviteToken = tokenMap.get(userId)
          const accountUrl = inviteToken ? `${baseUrl}/accept-invite?token=${inviteToken}` : undefined

          items.push({
            email,
            name: name || '',
            type: 'REJECTION_NOTIFICATION',
            context: {
              title: 'Project Status Update',
              message: '',
              linkUrl: includeInviteLink ? accountUrl : undefined,
              metadata: {
                projectName: project.title,
                roundName: rejection?.roundName ?? 'this round',
                customMessage: customMessage || undefined,
                fullCustomBody,
              },
            },
            projectId: project.id,
            userId: userId || undefined,
            roundId: rejection?.roundId,
          })
        }
      }

      const result = await sendBatchNotifications(items)

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'SEND_BULK_REJECTION_NOTIFICATIONS',
        entityType: 'Project',
        entityId: 'bulk',
        detailsJson: {
          sent: result.sent,
          failed: result.failed,
          projectCount: targetProjectIds.length,
          skipped: alreadySentProjectIds.size,
          batchId: result.batchId,
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { sent: result.sent, failed: result.failed, skipped: alreadySentProjectIds.size }
    }),

  /**
   * Send bulk award pool notifications for a specific award.
   * Uses the existing award notification pattern via batch sender.
   */
  sendBulkAwardNotifications: adminProcedure
    .input(
      z.object({
        awardId: z.string(),
        customMessage: z.string().optional(),
        skipAlreadySent: z.boolean().default(true),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const { awardId, customMessage, skipAlreadySent } = input

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

      // Get eligible projects for this award
      const eligibilities = await ctx.prisma.awardEligibility.findMany({
        where: {
          awardId,
          eligible: true,
          ...(skipAlreadySent ? { notifiedAt: null } : {}),
        },
        select: {
          id: true,
          projectId: true,
          project: {
            select: {
              id: true,
              title: true,
              submittedByEmail: true,
              teamMembers: {
                select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
              },
            },
          },
        },
      })

      if (eligibilities.length === 0) {
        return { sent: 0, failed: 0, skipped: 0 }
      }

      // Generate invite tokens for passwordless users
      const baseUrl = getBaseUrl()
      const tokenMap = new Map<string, string>()
      const passwordlessUserIds = new Set<string>()
      for (const elig of eligibilities) {
        for (const tm of elig.project.teamMembers) {
          if (!tm.user.passwordHash) passwordlessUserIds.add(tm.user.id)
        }
      }
      if (passwordlessUserIds.size > 0) {
        const expiryMs = await getInviteExpiryMs(ctx.prisma)
        for (const userId of passwordlessUserIds) {
          const token = generateInviteToken()
          await ctx.prisma.user.update({
            where: { id: userId },
            data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), status: 'INVITED' },
          })
          tokenMap.set(userId, token)
        }
      }

      // Build items with eligibility tracking
      const eligibilityEmailMap = new Map<string, Set<string>>() // eligId -> emails
      const items: NotificationItem[] = []
      for (const elig of eligibilities) {
        const project = elig.project
        const emailsForElig = new Set<string>()

        const recipients = new Map<string, { name: string | null; userId: string }>()
        for (const tm of project.teamMembers) {
          if (tm.user.email) {
            recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
          }
        }
        if (recipients.size === 0 && project.submittedByEmail) {
          recipients.set(project.submittedByEmail, { name: null, userId: '' })
        }

        for (const [email, { name, userId }] of recipients) {
          emailsForElig.add(email)
          const inviteToken = tokenMap.get(userId)
          const accountUrl = inviteToken ? `${baseUrl}/accept-invite?token=${inviteToken}` : undefined

          items.push({
            email,
            name: name || '',
            type: 'AWARD_SELECTION_NOTIFICATION',
            context: {
              title: `Your project is being considered for ${award.name}`,
              message: '',
              linkUrl: '/applicant',
              metadata: {
                projectName: project.title,
                awardName: award.name,
                customMessage: customMessage || undefined,
                accountUrl,
              },
            },
            projectId: project.id,
            userId: userId || undefined,
          })
        }

        eligibilityEmailMap.set(elig.id, emailsForElig)
      }

      const result = await sendBatchNotifications(items)

      // Stamp notifiedAt only for eligibilities where all emails succeeded
      const failedEmails = new Set(result.errors.map((e) => e.email))
      for (const [eligId, emails] of eligibilityEmailMap) {
        const anyFailed = [...emails].some((e) => failedEmails.has(e))
        if (!anyFailed) {
          await ctx.prisma.awardEligibility.update({
            where: { id: eligId },
            data: { notifiedAt: new Date() },
          })
        }
      }

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'SEND_BULK_AWARD_NOTIFICATIONS',
        entityType: 'SpecialAward',
        entityId: awardId,
        detailsJson: {
          awardName: award.name,
          sent: result.sent,
          failed: result.failed,
          eligibilityCount: eligibilities.length,
          batchId: result.batchId,
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { sent: result.sent, failed: result.failed, skipped: 0 }
    }),
})
