import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc'
import {
  MentorAssignmentMethod,
  MentorChangeRequestStatus,
  Prisma,
  type PrismaClient,
} from '@prisma/client'
import {
  sendMentorBulkAssignmentEmail,
  sendMentorChangeRequestEmail,
  sendMentorTeamAssignmentEmail,
  sendTeamMentorIntroductionEmail,
  getMentorBulkAssignmentTemplate,
  getTeamMentorIntroductionTemplate,
  getBaseUrl,
} from '@/lib/email'
import {
  getAIMentorSuggestions,
  getRoundRobinMentor,
  computeExpertiseOverlap,
} from '../services/mentor-matching'
import { getOpenAI } from '@/lib/openai'
import {
  createNotification,
  notifyProjectTeam,
  NotificationTypes,
} from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
import {
  activateWorkspace,
  sendMessage as workspaceSendMessage,
  getMessages as workspaceGetMessages,
  markRead as workspaceMarkRead,
  uploadFile as workspaceUploadFile,
  addFileComment as workspaceAddFileComment,
  promoteFile as workspacePromoteFile,
  getFiles as workspaceGetFilesService,
  deleteFile as workspaceDeleteFileService,
} from '../services/mentor-workspace'
import { triggerInProgressOnActivity } from '../services/round-engine'
import {
  generateMentorObjectKey,
  getPresignedUrl,
  BUCKET_NAME,
  deleteObject,
} from '@/lib/minio'
import {
  signMentorUploadToken,
  verifyMentorUploadToken,
} from '@/lib/mentor-upload-token'
import { getFinalDocumentStatusForProject } from '../services/final-documents'

/**
 * True if the project is enrolled in a MENTORING round that is still
 * ROUND_DRAFT. Used to defer mentor- and team-side emails until the round
 * opens, so admins can stage assignments without sending notifications.
 * If the project isn't in a MENTORING round at all, returns false
 * (i.e. send emails normally — there's no round-open event to wait for).
 */
async function shouldDeferEmailsForProject(
  prisma: PrismaClient,
  projectId: string,
): Promise<boolean> {
  const draftRoundEnrollment = await prisma.projectRoundState.findFirst({
    where: {
      projectId,
      round: { roundType: 'MENTORING', status: 'ROUND_DRAFT' },
    },
    select: { id: true },
  })
  return draftRoundEnrollment !== null
}

/**
 * Introduce the project team to ALL active mentors via email IF the project's
 * MENTORING round is currently ROUND_ACTIVE. Idempotent: only emails mentors
 * whose assignment row has `teamIntroducedAt: null`. If the round is not yet
 * active, this is a no-op — the activation step will fire the email instead.
 * Never throws.
 */
async function introduceTeamToMentorsIfRoundOpen(
  prisma: PrismaClient,
  projectId: string,
): Promise<void> {
  try {
    const project = await prisma.project.findUnique({
      where: { id: projectId },
      select: {
        id: true,
        title: true,
        projectRoundStates: {
          where: {
            round: { roundType: 'MENTORING', status: 'ROUND_ACTIVE' },
          },
          select: { id: true },
          take: 1,
        },
        mentorAssignments: {
          where: { droppedAt: null, teamIntroducedAt: null },
          select: {
            id: true,
            mentor: { select: { name: true, email: true } },
          },
        },
        teamMembers: {
          select: { user: { select: { name: true, email: true } } },
        },
        submittedByEmail: true,
        submittedBy: { select: { name: true } },
      },
    })
    if (!project) return
    if (project.projectRoundStates.length === 0) return // round not active yet
    const mentors = project.mentorAssignments
      .filter((a) => a.mentor?.email)
      .map((a) => ({ name: a.mentor.name, email: a.mentor.email }))
    if (mentors.length === 0) return

    const recipients = new Map<string, { name: string | null }>()
    for (const tm of project.teamMembers) {
      if (tm.user?.email) {
        recipients.set(tm.user.email, { name: tm.user.name })
      }
    }
    if (
      project.submittedByEmail &&
      !recipients.has(project.submittedByEmail)
    ) {
      recipients.set(project.submittedByEmail, {
        name: project.submittedBy?.name ?? null,
      })
    }
    for (const [email, { name }] of recipients) {
      await sendTeamMentorIntroductionEmail(
        email,
        name,
        project.title,
        project.id,
        mentors,
      )
    }
    await prisma.mentorAssignment.updateMany({
      where: { id: { in: project.mentorAssignments.map((a) => a.id) } },
      data: { teamIntroducedAt: new Date() },
    })
  } catch (e) {
    console.error('[introduceTeamToMentorsIfRoundOpen] failed (non-fatal):', e)
  }
}

/**
 * Throws TRPCError if the given user is neither the assigned mentor
 * nor a team member of the project linked to the assignment.
 * Returns the loaded MentorAssignment + Project on success.
 */
async function assertWorkspaceAccess(
  prisma: PrismaClient,
  userId: string,
  mentorAssignmentId: string,
) {
  const assignment = await prisma.mentorAssignment.findUnique({
    where: { id: mentorAssignmentId },
    include: { project: { select: { id: true, title: true } } },
  })
  if (!assignment) {
    throw new TRPCError({ code: 'NOT_FOUND', message: 'Mentor assignment not found' })
  }
  if (!assignment.workspaceEnabled) {
    throw new TRPCError({ code: 'FORBIDDEN', message: 'Workspace is not enabled' })
  }
  if (assignment.mentorId === userId) return assignment
  const teamMembership = await prisma.teamMember.findFirst({
    where: { projectId: assignment.projectId, userId },
    select: { id: true },
  })
  if (teamMembership) return assignment
  throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
}

/**
 * Project-scoped workspace access check (PR8 multi-mentor).
 *
 * Allowed when the user is either:
 *   1) currently assigned as a mentor on this project (droppedAt = null), OR
 *   2) a team member of the project.
 *
 * Also requires at least one active mentor assignment for the project with
 * workspaceEnabled = true — meaning the project actually has a live workspace.
 * Throws TRPCError on failure. Returns nothing on success.
 */
async function assertProjectWorkspaceAccess(
  prisma: PrismaClient,
  userId: string,
  projectId: string,
): Promise<void> {
  const liveMentorAssignment = await prisma.mentorAssignment.findFirst({
    where: { projectId, droppedAt: null, workspaceEnabled: true },
    select: { id: true },
  })
  if (!liveMentorAssignment) {
    throw new TRPCError({ code: 'FORBIDDEN', message: 'Workspace is not enabled' })
  }
  const mentorOnProject = await prisma.mentorAssignment.findFirst({
    where: { projectId, mentorId: userId, droppedAt: null },
    select: { id: true },
  })
  if (mentorOnProject) return
  const teamMembership = await prisma.teamMember.findFirst({
    where: { projectId, userId },
    select: { id: true },
  })
  if (teamMembership) return
  throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
}

export const mentorRouter = router({
  /** Grand-final document status for a project, for the mentor workspace panel. */
  getProjectFinalDocuments: protectedProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
      if (!isAdmin) {
        const [mentorAssignment, teamMembership] = await Promise.all([
          ctx.prisma.mentorAssignment.findFirst({ where: { mentorId: ctx.user.id, projectId: input.projectId, droppedAt: null }, select: { id: true } }),
          ctx.prisma.teamMember.findFirst({ where: { userId: ctx.user.id, projectId: input.projectId }, select: { id: true } }),
        ])
        if (!mentorAssignment && !teamMembership) throw new TRPCError({ code: 'FORBIDDEN', message: 'No access to this project' })
      }
      return getFinalDocumentStatusForProject(ctx.prisma, input.projectId)
    }),

  /**
   * Get AI-suggested mentor matches for a project
   */
  getSuggestions: adminProcedure
    .input(
      z.object({
        projectId: z.string(),
        limit: z.number().min(1).max(10).default(5),
      })
    )
    .query(async ({ ctx, input }) => {
      // Verify project exists
      const project = await ctx.prisma.project.findUniqueOrThrow({
        where: { id: input.projectId },
        include: {
          mentorAssignments: true,
        },
      })

      // With multi-mentor (PR8) the project can have several mentors. The
      // suggestions endpoint is informational — return whatever AI suggests
      // and let `mentor.assign` enforce per-pair uniqueness. We still surface
      // an existing primary mentor in the payload so UIs can label it.
      const primaryMentor = project.mentorAssignments[0] ?? null

      // Detect AI configuration so the UI can label "AI matching unavailable"
      // when we fall back to algorithmic ranking. An AI error mid-call still
      // reports source: 'ai' — accepted imprecision in exchange for a small diff.
      const openai = await getOpenAI()
      const source: 'ai' | 'fallback' = openai ? 'ai' : 'fallback'

      const suggestions = await getAIMentorSuggestions(
        ctx.prisma,
        input.projectId,
        input.limit
      )

      // Enrich with mentor details (batch query to avoid N+1)
      const mentorIds = suggestions.map((s) => s.mentorId)
      const mentors = await ctx.prisma.user.findMany({
        where: { id: { in: mentorIds } },
        select: {
          id: true,
          name: true,
          email: true,
          expertiseTags: true,
          mentorAssignments: {
            select: { id: true },
          },
        },
      })
      const mentorMap = new Map(mentors.map((m) => [m.id, m]))

      const enrichedSuggestions = suggestions.map((suggestion) => {
        const mentor = mentorMap.get(suggestion.mentorId)
        return {
          ...suggestion,
          mentor: mentor
            ? {
                id: mentor.id,
                name: mentor.name,
                email: mentor.email,
                expertiseTags: mentor.expertiseTags,
                assignmentCount: mentor.mentorAssignments.length,
              }
            : null,
        }
      })

      return {
        // TODO(PR8 Task 8): return the full mentor list. Legacy field kept
        // until the admin UI is updated.
        currentMentor: primaryMentor,
        suggestions: enrichedSuggestions.filter((s) => s.mentor !== null),
        source,
        message: null,
      }
    }),

  /**
   * List all MENTOR-role users with expertise overlap %, current load, capacity,
   * and country. Drives the manual-picker tab on /admin/projects/[id]/mentor.
   * Sorted by overlap desc, then by current load asc.
   */
  getCandidates: adminProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      const project = await ctx.prisma.project.findUniqueOrThrow({
        where: { id: input.projectId },
        select: {
          id: true,
          oceanIssue: true,
          competitionCategory: true,
          tags: true,
          description: true,
        },
      })

      const mentors = await ctx.prisma.user.findMany({
        where: {
          // MENTOR as primary role OR in the secondary roles[] array. Some legacy
          // users have role=MENTOR with an empty roles[], so checking roles only
          // would wrongly exclude them (mirrors userHasRole's fallback).
          OR: [{ role: 'MENTOR' }, { roles: { has: 'MENTOR' } }],
          status: { not: 'SUSPENDED' },
        },
        select: {
          id: true,
          name: true,
          email: true,
          country: true,
          expertiseTags: true,
          maxAssignments: true,
          mentorAssignments: { where: { droppedAt: null }, select: { id: true } },
        },
      })

      const candidates = mentors.map((m) => {
        const { score, matchedCount } = computeExpertiseOverlap(project, m.expertiseTags)
        return {
          id: m.id,
          name: m.name,
          email: m.email,
          country: m.country,
          expertiseTags: m.expertiseTags,
          currentAssignments: m.mentorAssignments.length,
          maxAssignments: m.maxAssignments,
          overlapScore: score,
          matchedKeywords: matchedCount,
        }
      })

      candidates.sort(
        (a, b) =>
          b.overlapScore - a.overlapScore || a.currentAssignments - b.currentAssignments,
      )
      return { candidates }
    }),

  /**
   * Manually assign a mentor to a project
   */
  assign: adminProcedure
    .input(
      z.object({
        projectId: z.string(),
        mentorId: z.string(),
        method: z.nativeEnum(MentorAssignmentMethod).default('MANUAL'),
        aiConfidenceScore: z.number().optional(),
        expertiseMatchScore: z.number().optional(),
        aiReasoning: z.string().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify project exists (multi-mentor: stacking is allowed; duplicate
      // (projectId, mentorId) pairs are rejected by the unique constraint
      // below).
      const project = await ctx.prisma.project.findUniqueOrThrow({
        where: { id: input.projectId },
      })

      // Gate: the project MUST be in a MENTORING round (any status, including
      // DRAFT, ACTIVE, or CLOSED). We do not allow mentor assignment for
      // projects that aren't part of a mentoring round — those should be
      // added to a mentoring round first.
      const inMentoringRound = await ctx.prisma.projectRoundState.findFirst({
        where: {
          projectId: input.projectId,
          round: { roundType: 'MENTORING' },
        },
        select: { id: true },
      })
      if (!inMentoringRound) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message:
            'This project is not in a mentoring round. Add it to a mentoring round first, then assign mentors.',
        })
      }

      // Verify mentor exists
      const mentor = await ctx.prisma.user.findUniqueOrThrow({
        where: { id: input.mentorId },
      })

      // Create assignment. P2002 on the composite (projectId, mentorId) unique
      // constraint means this exact mentor is already on this team — surface a
      // friendly error.
      let assignment
      try {
        assignment = await ctx.prisma.mentorAssignment.create({
          data: {
            projectId: input.projectId,
            mentorId: input.mentorId,
            method: input.method,
            assignedBy: ctx.user.id,
            aiConfidenceScore: input.aiConfidenceScore,
            expertiseMatchScore: input.expertiseMatchScore,
            aiReasoning: input.aiReasoning,
          },
          include: {
            mentor: {
              select: {
                id: true,
                name: true,
                email: true,
                expertiseTags: true,
              },
            },
            project: {
              select: {
                id: true,
                title: true,
              },
            },
          },
        })
      } catch (err) {
        if (
          err instanceof Prisma.PrismaClientKnownRequestError &&
          err.code === 'P2002'
        ) {
          throw new TRPCError({
            code: 'CONFLICT',
            message: 'This mentor is already assigned to that project.',
          })
        }
        throw err
      }

      // Audit outside transaction so failures don't roll back the assignment
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'MENTOR_ASSIGN',
        entityType: 'MentorAssignment',
        entityId: assignment.id,
        detailsJson: {
          projectId: input.projectId,
          projectTitle: assignment.project.title,
          mentorId: input.mentorId,
          mentorName: assignment.mentor.name,
          method: input.method,
          // PR8: per-team assignment (one row per mentor-project pair).
          assignmentScope: 'per-team',
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      // Get team lead info for mentor notification
      const teamLead = await ctx.prisma.teamMember.findFirst({
        where: { projectId: input.projectId, role: 'LEAD' },
        include: { user: { select: { name: true, email: true } } },
      })

      // Defer emails (mentor-side and team-side) while the project's MENTORING
      // round is still ROUND_DRAFT — `activateRound` coalesces and fires them
      // when the admin opens the round. In-app notifications still fire so the
      // staged assignment is visible immediately.
      const deferThisEmail = await shouldDeferEmailsForProject(
        ctx.prisma,
        input.projectId,
      )

      // Notify mentor of new mentee
      await createNotification({
        userId: input.mentorId,
        type: NotificationTypes.MENTEE_ASSIGNED,
        title: 'New Mentee Assigned',
        message: `You have been assigned to mentor "${assignment.project.title}".`,
        linkUrl: `/mentor/projects/${input.projectId}`,
        linkLabel: 'View Project',
        priority: 'high',
        metadata: {
          projectName: assignment.project.title,
          teamLeadName: teamLead?.user?.name || 'Team Lead',
          teamLeadEmail: teamLead?.user?.email,
        },
        skipEmail: deferThisEmail,
      })

      // Notify project team of mentor assignment
      await notifyProjectTeam(input.projectId, {
        type: NotificationTypes.MENTOR_ASSIGNED,
        title: 'Mentor Assigned',
        message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
        linkUrl: `/team/projects/${input.projectId}`,
        linkLabel: 'View Project',
        priority: 'high',
        metadata: {
          projectName: assignment.project.title,
          mentorName: assignment.mentor.name,
        },
        skipEmail: deferThisEmail,
      })

      if (
        !deferThisEmail &&
        assignment.notificationSentAt == null &&
        assignment.mentor.email
      ) {
        await sendMentorTeamAssignmentEmail(
          assignment.mentor.email,
          assignment.mentor.name,
          assignment.project.title,
          input.projectId,
        )
        try {
          await ctx.prisma.mentorAssignment.update({
            where: { id: assignment.id },
            data: { notificationSentAt: new Date() },
          })
        } catch (e) {
          console.error('[Mentor] failed to stamp notificationSentAt (non-fatal):', e)
        }
      }

      // Auto-transition: mark project IN_PROGRESS in any active MENTORING round
      try {
        const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
          where: {
            projectId: input.projectId,
            round: { roundType: 'MENTORING', status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] } },
            state: 'PENDING',
          },
          select: { roundId: true },
        })
        if (mentoringPrs) {
          await triggerInProgressOnActivity(input.projectId, mentoringPrs.roundId, ctx.user.id, ctx.prisma)
        }
      } catch (e) {
        console.error('[Mentor] triggerInProgressOnActivity failed (non-fatal):', e)
      }

      // If the project's MENTORING round is already open, introduce the team
      // to their mentor(s) by email now. Otherwise the activation hook fires it.
      await introduceTeamToMentorsIfRoundOpen(ctx.prisma, input.projectId)

      return assignment
    }),

  /**
   * Auto-assign a mentor using AI or round-robin
   */
  autoAssign: adminProcedure
    .input(
      z.object({
        projectId: z.string(),
        useAI: z.boolean().default(true),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify project exists and doesn't already have a mentor. Multi-mentor
      // stacking is reserved for explicit admin assignment via `mentor.assign`;
      // auto-assignment skips projects that already have at least one mentor
      // to avoid double-AI-assignments.
      const project = await ctx.prisma.project.findUniqueOrThrow({
        where: { id: input.projectId },
        include: { mentorAssignments: { select: { id: true } } },
      })

      if (project.mentorAssignments.length > 0) {
        throw new TRPCError({
          code: 'CONFLICT',
          message: 'Project already has a mentor assigned',
        })
      }

      let mentorId: string | null = null
      let method: MentorAssignmentMethod = 'ALGORITHM'
      let aiConfidenceScore: number | undefined
      let expertiseMatchScore: number | undefined
      let aiReasoning: string | undefined

      if (input.useAI) {
        // Try AI matching first
        const suggestions = await getAIMentorSuggestions(ctx.prisma, input.projectId, 1)

        if (suggestions.length > 0) {
          const best = suggestions[0]
          mentorId = best.mentorId
          method = 'AI_AUTO'
          aiConfidenceScore = best.confidenceScore
          expertiseMatchScore = best.expertiseMatchScore
          aiReasoning = best.reasoning
        }
      }

      // Fallback to round-robin
      if (!mentorId) {
        mentorId = await getRoundRobinMentor(ctx.prisma)
        method = 'ALGORITHM'
      }

      if (!mentorId) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'No available mentors found',
        })
      }

      // Create assignment
      const assignment = await ctx.prisma.mentorAssignment.create({
        data: {
          projectId: input.projectId,
          mentorId,
          method,
          assignedBy: ctx.user.id,
          aiConfidenceScore,
          expertiseMatchScore,
          aiReasoning,
        },
        include: {
          mentor: {
            select: {
              id: true,
              name: true,
              email: true,
              expertiseTags: true,
            },
          },
          project: {
            select: {
              id: true,
              title: true,
            },
          },
        },
      })

      // Create audit log
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'MENTOR_AUTO_ASSIGN',
        entityType: 'MentorAssignment',
        entityId: assignment.id,
        detailsJson: {
          projectId: input.projectId,
          projectTitle: assignment.project.title,
          mentorId,
          mentorName: assignment.mentor.name,
          method,
          aiConfidenceScore,
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      // Get team lead info for mentor notification
      const teamLead = await ctx.prisma.teamMember.findFirst({
        where: { projectId: input.projectId, role: 'LEAD' },
        include: { user: { select: { name: true, email: true } } },
      })

      // Defer email notifications if the project's MENTORING round is still
      // in draft — activateRound will fire coalesced emails at round-open.
      const deferThisEmail = await shouldDeferEmailsForProject(
        ctx.prisma,
        input.projectId,
      )

      // Notify mentor of new mentee
      await createNotification({
        userId: mentorId,
        type: NotificationTypes.MENTEE_ASSIGNED,
        title: 'New Mentee Assigned',
        message: `You have been assigned to mentor "${assignment.project.title}".`,
        linkUrl: `/mentor/projects/${input.projectId}`,
        linkLabel: 'View Project',
        priority: 'high',
        metadata: {
          projectName: assignment.project.title,
          teamLeadName: teamLead?.user?.name || 'Team Lead',
          teamLeadEmail: teamLead?.user?.email,
        },
        skipEmail: deferThisEmail,
      })

      // Notify project team of mentor assignment
      await notifyProjectTeam(input.projectId, {
        type: NotificationTypes.MENTOR_ASSIGNED,
        title: 'Mentor Assigned',
        message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
        linkUrl: `/team/projects/${input.projectId}`,
        linkLabel: 'View Project',
        priority: 'high',
        metadata: {
          projectName: assignment.project.title,
          mentorName: assignment.mentor.name,
        },
        skipEmail: deferThisEmail,
      })

      return assignment
    }),

  /**
   * Bulk-assign MANY mentors to MANY projects (cartesian product) in one
   * call. Skips (mentor, project) pairs where the mentor is already an
   * active mentor on that project. Each affected mentor receives ONE
   * coalesced email listing only their newly-assigned projects. Each team
   * whose project's MENTORING round is already open receives ONE intro
   * email listing all their active mentors (including any pre-existing).
   */
  bulkAssign: adminProcedure
    .input(
      z.object({
        mentorIds: z.array(z.string()).min(1),
        projectIds: z.array(z.string()).min(1),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const mentors = await ctx.prisma.user.findMany({
        where: { id: { in: input.mentorIds } },
        select: { id: true, name: true, email: true, role: true, roles: true },
      })
      // Accept MENTOR as primary role OR in roles[] (legacy users may have
      // role=MENTOR with an empty roles[] array).
      const validMentors = mentors.filter((m) => m.roles.includes('MENTOR') || m.role === 'MENTOR')
      if (validMentors.length === 0) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'None of the selected users have the MENTOR role',
        })
      }

      const projects = await ctx.prisma.project.findMany({
        where: {
          id: { in: input.projectIds },
          // Gate: only projects that are in some MENTORING round (any status)
          projectRoundStates: {
            some: { round: { roundType: 'MENTORING' } },
          },
        },
        select: {
          id: true,
          title: true,
          mentorAssignments: {
            where: {
              mentorId: { in: validMentors.map((m) => m.id) },
              droppedAt: null,
            },
            select: { mentorId: true },
          },
        },
      })

      if (projects.length === 0) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message:
            'None of the selected projects are in a mentoring round. Add them to a mentoring round first.',
        })
      }

      const ineligibleCount = input.projectIds.length - projects.length

      // Track per-mentor (for emails) and per-project (for team intros) state.
      const perMentor = new Map<
        string,
        {
          email: string | null
          name: string | null
          assignmentIds: string[]
          newProjects: { id: string; title: string }[]
          skippedProjects: { id: string; title: string }[]
        }
      >()
      for (const m of validMentors) {
        perMentor.set(m.id, {
          email: m.email ?? null,
          name: m.name ?? null,
          assignmentIds: [],
          newProjects: [],
          skippedProjects: [],
        })
      }
      const touchedProjectIds = new Set<string>()
      let totalAssigned = 0
      let totalSkipped = 0

      // Pre-compute which projects must defer outbound email because their
      // MENTORING round is still in draft. The in-app notification still
      // fires; only the parallel notification-system email is suppressed,
      // exactly like the coalesced mentor email path below. `activateRound`
      // sends one combined email per mentor + one team intro per project
      // when the admin opens the round.
      const draftProjectIds = new Set<string>()
      for (const project of projects) {
        if (await shouldDeferEmailsForProject(ctx.prisma, project.id)) {
          draftProjectIds.add(project.id)
        }
      }

      for (const project of projects) {
        const alreadyOn = new Set(project.mentorAssignments.map((a) => a.mentorId))
        const deferForThis = draftProjectIds.has(project.id)
        for (const mentor of validMentors) {
          const bucket = perMentor.get(mentor.id)!
          if (alreadyOn.has(mentor.id)) {
            bucket.skippedProjects.push({ id: project.id, title: project.title })
            totalSkipped++
            continue
          }
          const created = await ctx.prisma.mentorAssignment.create({
            data: {
              projectId: project.id,
              mentorId: mentor.id,
              method: 'MANUAL',
              assignedBy: ctx.user.id,
            },
          })
          bucket.assignmentIds.push(created.id)
          bucket.newProjects.push({ id: project.id, title: project.title })
          touchedProjectIds.add(project.id)
          totalAssigned++

          await createNotification({
            userId: mentor.id,
            type: NotificationTypes.MENTEE_ASSIGNED,
            title: 'New Mentee Assigned',
            message: `You have been assigned to mentor "${project.title}".`,
            linkUrl: `/mentor/projects/${project.id}`,
            linkLabel: 'View Project',
            priority: 'high',
            metadata: { projectName: project.title },
            skipEmail: deferForThis,
          })

          await notifyProjectTeam(project.id, {
            type: NotificationTypes.MENTOR_ASSIGNED,
            title: 'Mentor Assigned',
            message: `${mentor.name || 'A mentor'} has been assigned to support your project.`,
            linkUrl: `/team/projects/${project.id}`,
            linkLabel: 'View Project',
            priority: 'high',
            metadata: { projectName: project.title, mentorName: mentor.name },
            skipEmail: deferForThis,
          })
        }

        // Best-effort: mark project IN_PROGRESS in the active MENTORING round
        if (touchedProjectIds.has(project.id)) {
          try {
            const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
              where: {
                projectId: project.id,
                round: {
                  roundType: 'MENTORING',
                  status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] },
                },
                state: 'PENDING',
              },
              select: { roundId: true },
            })
            if (mentoringPrs) {
              await triggerInProgressOnActivity(
                project.id,
                mentoringPrs.roundId,
                ctx.user.id,
                ctx.prisma,
              )
            }
          } catch (e) {
            console.error(
              '[Mentor.bulkAssign] triggerInProgressOnActivity failed (non-fatal):',
              e,
            )
          }
        }
      }

      // `draftProjectIds` was computed before the assignment loop above.
      // One email per mentor, listing only their NEW projects whose mentoring
      // round is NOT in draft. If every new project is deferred, no email.
      for (const bucket of perMentor.values()) {
        if (!bucket.email) continue
        const sendableProjects = bucket.newProjects.filter(
          (p) => !draftProjectIds.has(p.id),
        )
        if (sendableProjects.length === 0) continue
        await sendMentorBulkAssignmentEmail(
          bucket.email,
          bucket.name,
          sendableProjects,
        )
        // Only stamp notificationSentAt for the assignments that correspond
        // to projects we actually emailed about. Draft-deferred ones stay
        // unstamped so activateRound picks them up.
        const sendableProjectIds = new Set(sendableProjects.map((p) => p.id))
        await ctx.prisma.mentorAssignment.updateMany({
          where: {
            id: { in: bucket.assignmentIds },
            projectId: { in: Array.from(sendableProjectIds) },
          },
          data: { notificationSentAt: new Date() },
        })
      }

      // Team-intro email per touched project (only fires if the round is
      // already ROUND_ACTIVE — the helper short-circuits otherwise, so draft
      // projects are naturally deferred to activateRound's intro pass).
      for (const projectId of touchedProjectIds) {
        await introduceTeamToMentorsIfRoundOpen(ctx.prisma, projectId)
      }

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'MENTOR_BULK_ASSIGN',
        entityType: 'BulkAssign',
        entityId: 'multi',
        detailsJson: {
          mentorIds: validMentors.map((m) => m.id),
          projectIds: input.projectIds,
          totalAssigned,
          totalSkipped,
          perMentor: Array.from(perMentor.entries()).map(([id, b]) => ({
            mentorId: id,
            assigned: b.newProjects.length,
            skipped: b.skippedProjects.length,
          })),
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return {
        totalAssigned,
        totalSkipped,
        ineligibleProjectCount: ineligibleCount,
        touchedProjectCount: touchedProjectIds.size,
        perMentor: Array.from(perMentor.entries()).map(([id, b]) => ({
          mentorId: id,
          mentorName: b.name,
          assigned: b.newProjects.length,
          skipped: b.skippedProjects.length,
        })),
        emailsSent: Array.from(perMentor.values()).filter(
          (b) => b.newProjects.length > 0 && b.email,
        ).length,
      }
    }),

  /**
   * Remove mentor assignment.
   *
   * Multi-mentor (PR8): callers should pass `assignmentId` to target a
   * specific co-mentor. Legacy callers passing only `projectId` get the
   * most-recent assignment removed (kept for backward compatibility).
   */
  unassign: adminProcedure
    .input(
      z
        .object({
          assignmentId: z.string().optional(),
          projectId: z.string().optional(),
        })
        .refine((v) => !!v.assignmentId || !!v.projectId, {
          message: 'Either assignmentId or projectId is required',
        }),
    )
    .mutation(async ({ ctx, input }) => {
      const assignment = input.assignmentId
        ? await ctx.prisma.mentorAssignment.findUnique({
            where: { id: input.assignmentId },
            include: {
              mentor: { select: { id: true, name: true } },
              project: { select: { id: true, title: true } },
            },
          })
        : await ctx.prisma.mentorAssignment.findFirst({
            where: { projectId: input.projectId! },
            orderBy: { assignedAt: 'desc' },
            include: {
              mentor: { select: { id: true, name: true } },
              project: { select: { id: true, title: true } },
            },
          })

      if (!assignment) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'No mentor assignment found',
        })
      }

      // Delete assignment
      await ctx.prisma.mentorAssignment.delete({
        where: { id: assignment.id },
      })

      // Audit outside transaction so failures don't roll back the unassignment
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'MENTOR_UNASSIGN',
        entityType: 'MentorAssignment',
        entityId: assignment.id,
        detailsJson: {
          projectId: assignment.project.id,
          projectTitle: assignment.project.title,
          mentorId: assignment.mentor.id,
          mentorName: assignment.mentor.name,
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { success: true }
    }),

  /**
   * Bulk auto-assign mentors to projects without one
   */
  bulkAutoAssign: adminProcedure
    .input(
      z.object({
        programId: z.string(),
        useAI: z.boolean().default(true),
        maxAssignments: z.number().min(1).max(100).default(50),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Get projects without mentors
      const projects = await ctx.prisma.project.findMany({
        where: {
          programId: input.programId,
          mentorAssignments: { none: {} },
          wantsMentorship: true,
        },
        select: { id: true },
        take: input.maxAssignments,
      })

      if (projects.length === 0) {
        return {
          assigned: 0,
          failed: 0,
          message: 'No projects need mentor assignment',
        }
      }

      let assigned = 0
      let failed = 0

      for (const project of projects) {
        try {
          let mentorId: string | null = null
          let method: MentorAssignmentMethod = 'ALGORITHM'
          let aiConfidenceScore: number | undefined
          let expertiseMatchScore: number | undefined
          let aiReasoning: string | undefined

          if (input.useAI) {
            const suggestions = await getAIMentorSuggestions(ctx.prisma, project.id, 1)
            if (suggestions.length > 0) {
              const best = suggestions[0]
              mentorId = best.mentorId
              method = 'AI_AUTO'
              aiConfidenceScore = best.confidenceScore
              expertiseMatchScore = best.expertiseMatchScore
              aiReasoning = best.reasoning
            }
          }

          if (!mentorId) {
            mentorId = await getRoundRobinMentor(ctx.prisma)
            method = 'ALGORITHM'
          }

          if (mentorId) {
            const assignment = await ctx.prisma.mentorAssignment.create({
              data: {
                projectId: project.id,
                mentorId,
                method,
                assignedBy: ctx.user.id,
                aiConfidenceScore,
                expertiseMatchScore,
                aiReasoning,
              },
              include: {
                mentor: { select: { name: true } },
                project: { select: { title: true } },
              },
            })

            // Get team lead info
            const teamLead = await ctx.prisma.teamMember.findFirst({
              where: { projectId: project.id, role: 'LEAD' },
              include: { user: { select: { name: true, email: true } } },
            })

            // Defer emails when the project's MENTORING round is still in draft.
            const deferThisEmail = await shouldDeferEmailsForProject(
              ctx.prisma,
              project.id,
            )

            // Notify mentor
            await createNotification({
              userId: mentorId,
              type: NotificationTypes.MENTEE_ASSIGNED,
              title: 'New Mentee Assigned',
              message: `You have been assigned to mentor "${assignment.project.title}".`,
              linkUrl: `/mentor/projects/${project.id}`,
              linkLabel: 'View Project',
              priority: 'high',
              metadata: {
                projectName: assignment.project.title,
                teamLeadName: teamLead?.user?.name || 'Team Lead',
                teamLeadEmail: teamLead?.user?.email,
              },
              skipEmail: deferThisEmail,
            })

            // Notify project team
            await notifyProjectTeam(project.id, {
              type: NotificationTypes.MENTOR_ASSIGNED,
              title: 'Mentor Assigned',
              message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
              linkUrl: `/team/projects/${project.id}`,
              linkLabel: 'View Project',
              priority: 'high',
              metadata: {
                projectName: assignment.project.title,
                mentorName: assignment.mentor.name,
              },
              skipEmail: deferThisEmail,
            })

            assigned++
          } else {
            failed++
          }
        } catch (err) {
          console.error('Failed to send mentor assignment notifications:', err)
          failed++
        }
      }

      // Create audit log
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'MENTOR_BULK_ASSIGN',
        entityType: 'Program',
        entityId: input.programId,
        detailsJson: {
          assigned,
          failed,
          useAI: input.useAI,
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return {
        assigned,
        failed,
        message: `Assigned ${assigned} mentor(s), ${failed} failed`,
      }
    }),

  /**
   * Round-scoped bulk auto-assign. Filters to projects in the round without a
   * mentor, further scoped by configJson.eligibility:
   *   - requested_only:  project.wantsMentorship === true
   *   - all_advancing:   every project in the round
   *   - admin_selected:  refuses (admin must pick manually)
   */
  autoAssignBulkForRound: adminProcedure
    .input(
      z.object({
        roundId: z.string(),
        useAI: z.boolean().default(true),
        maxAssignments: z.number().min(1).max(200).default(100),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const round = await ctx.prisma.round.findUniqueOrThrow({
        where: { id: input.roundId },
        select: { id: true, roundType: true, configJson: true, status: true },
      })
      if (round.roundType !== 'MENTORING') {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Round is not a MENTORING round',
        })
      }

      const config = (round.configJson ?? {}) as Record<string, unknown>
      const eligibility = (config.eligibility as string) ?? 'requested_only'
      if (eligibility === 'admin_selected') {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message:
            'Round eligibility is admin_selected — assign each project manually.',
        })
      }

      const projectStates = await ctx.prisma.projectRoundState.findMany({
        where: {
          roundId: input.roundId,
          project: {
            mentorAssignments: { none: { droppedAt: null } },
            // Only assign mentors to projects whose team has confirmed they will
            // attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED
            // confirmations and any project without a confirmation row at all.
            finalistConfirmation: { status: 'CONFIRMED' },
            ...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
          },
        },
        select: { project: { select: { id: true, title: true } } },
        take: input.maxAssignments,
      })

      if (projectStates.length === 0) {
        return {
          assigned: 0,
          skipped: 0,
          unassignable: 0,
          message: 'No projects need a mentor.',
        }
      }

      let assigned = 0
      let unassignable = 0

      // Defer outbound emails when the round is still in draft — same gate
      // used by mentor.assign/bulkAssign. In-app notifications still fire so
      // the staged assignment is visible to the mentor + team immediately.
      const deferEmailsForRound = round.status === 'ROUND_DRAFT'

      // Coalesce per-mentor so we send ONE email per mentor at the end of the
      // batch, even when the algorithm assigns the same mentor to several teams.
      const perMentor = new Map<
        string,
        {
          email: string | null
          name: string | null
          assignmentIds: string[]
          projects: { id: string; title: string }[]
        }
      >()

      for (const { project } of projectStates) {
        try {
          let mentorId: string | null = null
          let method: MentorAssignmentMethod = 'ALGORITHM'
          let aiConfidenceScore: number | undefined
          let expertiseMatchScore: number | undefined
          let aiReasoning: string | undefined

          if (input.useAI) {
            const suggestions = await getAIMentorSuggestions(ctx.prisma, project.id, 1)
            if (suggestions.length > 0) {
              const best = suggestions[0]
              mentorId = best.mentorId
              method = 'AI_AUTO'
              aiConfidenceScore = best.confidenceScore
              expertiseMatchScore = best.expertiseMatchScore
              aiReasoning = best.reasoning
            }
          }

          if (!mentorId) {
            mentorId = await getRoundRobinMentor(ctx.prisma)
            method = 'ALGORITHM'
          }

          if (!mentorId) {
            unassignable++
            continue
          }

          const assignment = await ctx.prisma.mentorAssignment.create({
            data: {
              projectId: project.id,
              mentorId,
              method,
              assignedBy: ctx.user.id,
              aiConfidenceScore,
              expertiseMatchScore,
              aiReasoning,
            },
            include: {
              mentor: { select: { id: true, name: true, email: true } },
              project: { select: { title: true } },
            },
          })

          const teamLead = await ctx.prisma.teamMember.findFirst({
            where: { projectId: project.id, role: 'LEAD' },
            include: { user: { select: { name: true, email: true } } },
          })

          await createNotification({
            userId: mentorId,
            type: NotificationTypes.MENTEE_ASSIGNED,
            title: 'New Mentee Assigned',
            message: `You have been assigned to mentor "${assignment.project.title}".`,
            linkUrl: `/mentor/projects/${project.id}`,
            linkLabel: 'View Project',
            priority: 'high',
            metadata: {
              projectName: assignment.project.title,
              teamLeadName: teamLead?.user?.name || 'Team Lead',
              teamLeadEmail: teamLead?.user?.email,
            },
            skipEmail: deferEmailsForRound,
          })

          await notifyProjectTeam(project.id, {
            type: NotificationTypes.MENTOR_ASSIGNED,
            title: 'Mentor Assigned',
            message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
            linkUrl: `/team/projects/${project.id}`,
            linkLabel: 'View Project',
            priority: 'high',
            metadata: {
              projectName: assignment.project.title,
              mentorName: assignment.mentor.name,
            },
            skipEmail: deferEmailsForRound,
          })

          // Accumulate for the coalesced email
          const bucket = perMentor.get(mentorId) ?? {
            email: assignment.mentor.email ?? null,
            name: assignment.mentor.name ?? null,
            assignmentIds: [],
            projects: [],
          }
          bucket.assignmentIds.push(assignment.id)
          bucket.projects.push({ id: project.id, title: assignment.project.title })
          perMentor.set(mentorId, bucket)

          assigned++
        } catch (err) {
          console.error(
            '[Mentor] autoAssignBulkForRound failure for project',
            project.id,
            err,
          )
          unassignable++
        }
      }

      // Defer all emails when the round is still ROUND_DRAFT — activateRound
      // will coalesce and send them when the admin opens the round. Stamp
      // notificationSentAt only for assignments we actually email about, so
      // activateRound's `notificationSentAt IS NULL` filter catches the rest.
      const roundStatus = await ctx.prisma.round.findUnique({
        where: { id: input.roundId },
        select: { status: true },
      })
      const isRoundLive = roundStatus?.status === 'ROUND_ACTIVE'

      if (isRoundLive) {
        for (const bucket of perMentor.values()) {
          if (!bucket.email || bucket.projects.length === 0) continue
          await sendMentorBulkAssignmentEmail(
            bucket.email,
            bucket.name,
            bucket.projects,
          )
          try {
            await ctx.prisma.mentorAssignment.updateMany({
              where: { id: { in: bucket.assignmentIds } },
              data: { notificationSentAt: new Date() },
            })
          } catch (e) {
            console.error(
              '[Mentor.autoAssignBulkForRound] failed to stamp notificationSentAt (non-fatal):',
              e,
            )
          }
        }

        const introducedProjects = new Set<string>()
        for (const bucket of perMentor.values()) {
          for (const p of bucket.projects) {
            if (introducedProjects.has(p.id)) continue
            introducedProjects.add(p.id)
            await introduceTeamToMentorsIfRoundOpen(ctx.prisma, p.id)
          }
        }
      }
      // If the round is still ROUND_DRAFT, no emails fire here — the assignments
      // remain unstamped and activateRound will batch-send when the round opens.

      const skipped = await ctx.prisma.projectRoundState.count({
        where: {
          roundId: input.roundId,
          project: {
            mentorAssignments: { some: {} },
            ...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
          },
        },
      })

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'MENTOR_BULK_ASSIGN',
        entityType: 'Round',
        entityId: input.roundId,
        detailsJson: {
          eligibility,
          assigned,
          unassignable,
          skipped,
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return {
        assigned,
        skipped: Math.max(0, skipped - assigned),
        unassignable,
        message: `Assigned ${assigned} mentor(s), ${Math.max(0, skipped - assigned)} already assigned, ${unassignable} unassignable.`,
      }
    }),

  /**
   * MENTORING-round stats card: totals + request window + workspace activity.
   * Single round-scoped query set; cheap enough to call uncached.
   */
  getRoundStats: adminProcedure
    .input(z.object({ roundId: z.string() }))
    .query(async ({ ctx, input }) => {
      const round = await ctx.prisma.round.findUniqueOrThrow({
        where: { id: input.roundId },
        select: {
          id: true,
          roundType: true,
          configJson: true,
          windowOpenAt: true,
        },
      })
      if (round.roundType !== 'MENTORING') {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Round is not a MENTORING round',
        })
      }

      const [
        totalProjects,
        requestedCount,
        assignedAndRequested,
        totalAssigned,
        messageCount,
        fileCount,
        milestoneCount,
        latestMessage,
        latestFile,
        latestMilestone,
      ] = await Promise.all([
        ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId } }),
        ctx.prisma.projectRoundState.count({
          where: { roundId: input.roundId, project: { wantsMentorship: true } },
        }),
        ctx.prisma.projectRoundState.count({
          where: {
            roundId: input.roundId,
            project: { wantsMentorship: true, mentorAssignments: { some: {} } },
          },
        }),
        ctx.prisma.projectRoundState.count({
          where: {
            roundId: input.roundId,
            project: { mentorAssignments: { some: {} } },
          },
        }),
        ctx.prisma.mentorMessage.count({
          where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } },
        }),
        ctx.prisma.mentorFile.count({
          where: {
            mentorAssignment: {
              project: { projectRoundStates: { some: { roundId: input.roundId } } },
            },
          },
        }),
        ctx.prisma.mentorMilestoneCompletion.count({
          where: {
            mentorAssignment: {
              project: { projectRoundStates: { some: { roundId: input.roundId } } },
            },
          },
        }),
        ctx.prisma.mentorMessage.findFirst({
          where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } },
          orderBy: { createdAt: 'desc' },
          select: { createdAt: true },
        }),
        ctx.prisma.mentorFile.findFirst({
          where: {
            mentorAssignment: {
              project: { projectRoundStates: { some: { roundId: input.roundId } } },
            },
          },
          orderBy: { createdAt: 'desc' },
          select: { createdAt: true },
        }),
        ctx.prisma.mentorMilestoneCompletion.findFirst({
          where: {
            mentorAssignment: {
              project: { projectRoundStates: { some: { roundId: input.roundId } } },
            },
          },
          orderBy: { completedAt: 'desc' },
          select: { completedAt: true },
        }),
      ])

      const config = (round.configJson ?? {}) as Record<string, unknown>
      const deadlineDays =
        typeof config.mentoringRequestDeadlineDays === 'number'
          ? config.mentoringRequestDeadlineDays
          : 14
      const deadline = round.windowOpenAt
        ? new Date(round.windowOpenAt.getTime() + deadlineDays * 86_400_000)
        : null

      const lastActivityAt =
        [latestMessage?.createdAt, latestFile?.createdAt, latestMilestone?.completedAt]
          .filter((d): d is Date => Boolean(d))
          .sort((a, b) => b.getTime() - a.getTime())[0] ?? null

      return {
        totalProjects,
        requestedCount,
        assignedCount: totalAssigned,
        awaitingAssignment: Math.max(0, requestedCount - assignedAndRequested),
        requestWindow: {
          deadline,
          deadlineDays,
        },
        workspaceActivity: {
          messageCount,
          fileCount,
          milestoneCount,
          lastActivityAt,
        },
      }
    }),

  /**
   * All MENTOR-role users with current/completed assignment counts, capacity,
   * country, expertise, and last activity. Drives the /admin/mentors list page
   * and the round-overview pool card.
   */
  getMentorPool: adminProcedure
    .input(z.object({ programId: z.string().optional() }))
    .query(async ({ ctx, input }) => {
      const mentors = await ctx.prisma.user.findMany({
        where: {
          // MENTOR as primary role OR in the secondary roles[] array (see
          // getCandidates: legacy users may have role=MENTOR with empty roles[]).
          OR: [{ role: 'MENTOR' }, { roles: { has: 'MENTOR' } }],
          status: { not: 'SUSPENDED' },
        },
        select: {
          id: true,
          name: true,
          email: true,
          country: true,
          expertiseTags: true,
          maxAssignments: true,
          mentorAssignments: {
            where: {
              droppedAt: null,
              ...(input.programId ? { project: { programId: input.programId } } : {}),
            },
            select: {
              completionStatus: true,
              project: { select: { id: true, title: true } },
              messages: {
                orderBy: { createdAt: 'desc' },
                take: 1,
                select: { createdAt: true },
              },
              files: {
                orderBy: { createdAt: 'desc' },
                take: 1,
                select: { createdAt: true },
              },
              milestoneCompletions: {
                orderBy: { completedAt: 'desc' },
                take: 1,
                select: { completedAt: true },
              },
            },
          },
        },
        orderBy: { name: 'asc' },
      })

      let totalCurrentAssignments = 0

      const enriched = mentors.map((m) => {
        let current = 0
        let completed = 0
        const activityDates: Date[] = []
        const activeTeams: { id: string; title: string }[] = []
        for (const a of m.mentorAssignments) {
          if (a.completionStatus === 'completed') {
            completed++
          } else {
            current++
            activeTeams.push(a.project)
          }
          if (a.messages[0]) activityDates.push(a.messages[0].createdAt)
          if (a.files[0]) activityDates.push(a.files[0].createdAt)
          if (a.milestoneCompletions[0])
            activityDates.push(a.milestoneCompletions[0].completedAt)
        }
        totalCurrentAssignments += current
        const lastActivityAt =
          activityDates.length > 0
            ? activityDates.sort((a, b) => b.getTime() - a.getTime())[0]
            : null
        const capacityRemaining =
          m.maxAssignments != null ? Math.max(0, m.maxAssignments - current) : null
        return {
          id: m.id,
          name: m.name,
          email: m.email,
          country: m.country,
          expertiseTags: m.expertiseTags,
          currentAssignments: current,
          completedAssignments: completed,
          maxAssignments: m.maxAssignments,
          capacityRemaining,
          lastActivityAt,
          activeTeams,
        }
      })

      return {
        mentors: enriched,
        poolSize: enriched.length,
        totalCurrentAssignments,
      }
    }),

  /**
   * Project-centric activity view: every project that wants mentorship,
   * with assignment status, latest activity timestamps, and a derived
   * status (unassigned / assigned / active / stalled).
   * Drives the "Mentees & Activity" tab on /admin/mentors.
   */
  getMenteeActivity: adminProcedure
    .input(z.object({ programId: z.string().optional() }))
    .query(async ({ ctx, input }) => {
      const projects = await ctx.prisma.project.findMany({
        where: {
          wantsMentorship: true,
          ...(input.programId ? { programId: input.programId } : {}),
        },
        select: {
          id: true,
          title: true,
          country: true,
          status: true,
          oceanIssue: true,
          competitionCategory: true,
          mentorAssignments: {
            // TODO(PR8 Task 8): surface all mentors in the activity view.
            // For now keep the legacy single-mentor activity row by picking the
            // latest-assigned, non-dropped assignment (or the most-recent overall).
            orderBy: { assignedAt: 'desc' },
            select: {
              id: true,
              method: true,
              assignedAt: true,
              completionStatus: true,
              droppedAt: true,
              mentor: {
                select: {
                  id: true,
                  name: true,
                  email: true,
                  maxAssignments: true,
                  _count: {
                    select: {
                      mentorAssignments: { where: { droppedAt: null } },
                    },
                  },
                },
              },
              messages: {
                orderBy: { createdAt: 'desc' },
                take: 1,
                select: { createdAt: true },
              },
              files: {
                orderBy: { createdAt: 'desc' },
                take: 1,
                select: { createdAt: true },
              },
              _count: { select: { messages: true, files: true } },
            },
          },
          teamMembers: {
            where: { role: 'LEAD' },
            take: 1,
            select: { user: { select: { name: true, email: true } } },
          },
        },
        orderBy: { title: 'asc' },
      })

      const ACTIVE_WINDOW_MS = 7 * 86_400_000
      const STALLED_WINDOW_MS = 14 * 86_400_000
      const now = Date.now()

      const totals = { unassigned: 0, assigned: 0, active: 0, stalled: 0 }

      const rows = projects.map((p) => {
        // Treat a dropped mentor assignment as if no mentor is assigned.
        // TODO(PR8 Task 8): surface all mentors. Legacy shape: pick the most
        // recent non-dropped assignment for the activity row.
        const firstActive = p.mentorAssignments.find((a) => !a.droppedAt) ?? null
        const ma = firstActive
        const lastMessageAt = ma?.messages[0]?.createdAt ?? null
        const lastFileAt = ma?.files[0]?.createdAt ?? null
        const lastActivityAt = [lastMessageAt, lastFileAt]
          .filter((d): d is Date => d != null)
          .sort((a, b) => b.getTime() - a.getTime())[0] ?? null

        let status: 'unassigned' | 'assigned' | 'active' | 'stalled'
        if (!ma) {
          status = 'unassigned'
        } else if (lastActivityAt && now - lastActivityAt.getTime() <= ACTIVE_WINDOW_MS) {
          status = 'active'
        } else {
          const referenceTime = lastActivityAt ?? ma.assignedAt
          const elapsed = now - referenceTime.getTime()
          status = elapsed > STALLED_WINDOW_MS ? 'stalled' : 'assigned'
        }
        totals[status]++

        const teamLead = p.teamMembers[0]?.user ?? null

        return {
          project: {
            id: p.id,
            title: p.title,
            country: p.country,
            status: p.status,
            oceanIssue: p.oceanIssue,
            competitionCategory: p.competitionCategory,
          },
          teamLead: teamLead ? { name: teamLead.name, email: teamLead.email } : null,
          mentor: ma?.mentor
            ? {
                id: ma.mentor.id,
                name: ma.mentor.name,
                email: ma.mentor.email,
                currentLoad: ma.mentor._count.mentorAssignments,
                maxAssignments: ma.mentor.maxAssignments,
              }
            : null,
          assignmentMethod: ma?.method ?? null,
          assignedAt: ma?.assignedAt ?? null,
          lastMessageAt,
          lastFileAt,
          lastActivityAt,
          messageCount: ma?._count.messages ?? 0,
          fileCount: ma?._count.files ?? 0,
          status,
        }
      })

      return { rows, totals }
    }),

  /**
   * Get mentor's assigned projects
   */
  getMyProjects: mentorProcedure.query(async ({ ctx }) => {
    const assignments = await ctx.prisma.mentorAssignment.findMany({
      where: { mentorId: ctx.user.id, droppedAt: null },
      include: {
        project: {
          include: {
            program: { select: { id: true, name: true, year: true } },
            teamMembers: {
              include: {
                user: { select: { id: true, name: true, email: true } },
              },
            },
          },
        },
      },
      orderBy: { assignedAt: 'desc' },
    })

    return assignments
  }),

  /**
   * List all active mentors assigned to a project (PR8 multi-mentor).
   *
   * Returns one row per active MentorAssignment (droppedAt = null) with the
   * mentor's id + name. Used by the mentor workspace page to display the
   * co-mentor team so each mentor knows who else they're working with.
   *
   * Authorization: caller must be an active mentor on the project (or an
   * admin via mentorProcedure). Non-assigned mentors get FORBIDDEN.
   */
  getProjectMentors: mentorProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)

      if (!isAdmin) {
        const ownAssignment = await ctx.prisma.mentorAssignment.findFirst({
          where: {
            projectId: input.projectId,
            mentorId: ctx.user.id,
            droppedAt: null,
          },
          select: { id: true },
        })
        if (!ownAssignment) {
          throw new TRPCError({
            code: 'FORBIDDEN',
            message: 'You are not assigned to mentor this project',
          })
        }
      }

      const assignments = await ctx.prisma.mentorAssignment.findMany({
        where: { projectId: input.projectId, droppedAt: null },
        select: {
          id: true,
          mentor: { select: { id: true, name: true } },
        },
        orderBy: { assignedAt: 'asc' },
      })

      return assignments
    }),

  /**
   * Get detailed project info for a mentor's assigned project
   */
  getProjectDetail: mentorProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      // Verify the mentor is assigned to this project
      const assignment = await ctx.prisma.mentorAssignment.findFirst({
        where: {
          projectId: input.projectId,
          mentorId: ctx.user.id,
        },
      })

      // Allow admins to access any project
      const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)

      if (!assignment && !isAdmin) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You are not assigned to mentor this project',
        })
      }

      const project = await ctx.prisma.project.findUniqueOrThrow({
        where: { id: input.projectId },
        include: {
          program: { select: { id: true, name: true, year: true } },
          teamMembers: {
            include: {
              user: {
                select: {
                  id: true,
                  name: true,
                  email: true,
                  phoneNumber: true,
                },
              },
            },
            orderBy: { role: 'asc' },
          },
          files: {
            orderBy: { createdAt: 'desc' },
          },
          mentorAssignments: {
            include: {
              mentor: {
                select: { id: true, name: true, email: true },
              },
            },
          },
        },
      })

      return {
        ...project,
        assignedAt: assignment?.assignedAt,
      }
    }),

  /**
   * Send a message to the project team (mentor side)
   */
  sendMessage: mentorProcedure
    .input(
      z.object({
        projectId: z.string(),
        message: z.string().min(1).max(5000),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify the mentor is assigned to this project
      const assignment = await ctx.prisma.mentorAssignment.findFirst({
        where: {
          projectId: input.projectId,
          mentorId: ctx.user.id,
        },
      })

      const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)

      if (!assignment && !isAdmin) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You are not assigned to mentor this project',
        })
      }

      const mentorMessage = await ctx.prisma.mentorMessage.create({
        data: {
          projectId: input.projectId,
          senderId: ctx.user.id,
          message: input.message,
        },
        include: {
          sender: {
            select: { id: true, name: true, email: true },
          },
        },
      })

      // Notify project team members
      await notifyProjectTeam(input.projectId, {
        type: 'MENTOR_MESSAGE',
        title: 'New Message from Mentor',
        message: `${ctx.user.name || 'Your mentor'} sent you a message`,
        linkUrl: `/applicant/mentor`,
        linkLabel: 'View Message',
        priority: 'normal',
        metadata: {
          projectId: input.projectId,
        },
      })

      return mentorMessage
    }),

  /**
   * Get messages for a project (mentor side)
   */
  getMessages: mentorProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      // Verify the mentor is assigned to this project
      const assignment = await ctx.prisma.mentorAssignment.findFirst({
        where: {
          projectId: input.projectId,
          mentorId: ctx.user.id,
        },
      })

      const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)

      if (!assignment && !isAdmin) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You are not assigned to mentor this project',
        })
      }

      const messages = await ctx.prisma.mentorMessage.findMany({
        where: { projectId: input.projectId },
        include: {
          sender: {
            select: { id: true, name: true, email: true, role: true },
          },
        },
        orderBy: { createdAt: 'asc' },
      })

      // Mark unread messages from the team as read
      await ctx.prisma.mentorMessage.updateMany({
        where: {
          projectId: input.projectId,
          senderId: { not: ctx.user.id },
          isRead: false,
        },
        data: { isRead: true },
      })

      return messages
    }),

  /**
   * Recent unread messages from team members across all of the mentor's
   * assignments. Drives the 'Recent Messages' card on /mentor.
   */
  getRecentMessages: mentorProcedure
    .input(z.object({ limit: z.number().min(1).max(20).default(5) }).optional())
    .query(async ({ ctx, input }) => {
      const limit = input?.limit ?? 5
      const unread = await ctx.prisma.mentorMessage.findMany({
        where: {
          senderId: { not: ctx.user.id },
          isRead: false,
          workspace: { mentorId: ctx.user.id },
        },
        include: {
          sender: { select: { id: true, name: true, email: true } },
          project: { select: { id: true, title: true } },
        },
        orderBy: { createdAt: 'desc' },
        take: limit,
      })
      return { unread }
    }),

  /**
   * List all mentor assignments (admin)
   */
  listAssignments: adminProcedure
    .input(
      z.object({
        programId: z.string().optional(),
        mentorId: z.string().optional(),
        page: z.number().min(1).default(1),
        perPage: z.number().min(1).max(100).default(20),
      })
    )
    .query(async ({ ctx, input }) => {
      const where = {
        ...(input.programId && { project: { programId: input.programId } }),
        ...(input.mentorId && { mentorId: input.mentorId }),
      }

      const [assignments, total] = await Promise.all([
        ctx.prisma.mentorAssignment.findMany({
          where,
          include: {
            project: {
              select: {
                id: true,
                title: true,
                teamName: true,
                oceanIssue: true,
                competitionCategory: true,
                status: true,
              },
            },
            mentor: {
              select: {
                id: true,
                name: true,
                email: true,
                expertiseTags: true,
              },
            },
          },
          orderBy: { assignedAt: 'desc' },
          skip: (input.page - 1) * input.perPage,
          take: input.perPage,
        }),
        ctx.prisma.mentorAssignment.count({ where }),
      ])

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

  // =========================================================================
  // Mentor Notes CRUD (F8)
  // =========================================================================

  /**
   * Create a mentor note for an assignment
   */
  createNote: mentorProcedure
    .input(
      z.object({
        mentorAssignmentId: z.string(),
        content: z.string().min(1).max(10000),
        isVisibleToAdmin: z.boolean().default(true),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify the user owns this assignment or is admin
      const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
        where: { id: input.mentorAssignmentId },
        select: { mentorId: true, projectId: true },
      })

      const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
      if (assignment.mentorId !== ctx.user.id && !isAdmin) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You are not assigned to this mentorship',
        })
      }

      const note = await ctx.prisma.mentorNote.create({
        data: {
          mentorAssignmentId: input.mentorAssignmentId,
          authorId: ctx.user.id,
          content: input.content,
          isVisibleToAdmin: input.isVisibleToAdmin,
        },
        include: {
          author: { select: { id: true, name: true, email: true } },
        },
      })

      try {
        await logAudit({
          prisma: ctx.prisma,
          userId: ctx.user.id,
          action: 'CREATE_MENTOR_NOTE',
          entityType: 'MentorNote',
          entityId: note.id,
          detailsJson: {
            mentorAssignmentId: input.mentorAssignmentId,
            projectId: assignment.projectId,
          },
          ipAddress: ctx.ip,
          userAgent: ctx.userAgent,
        })
      } catch (err) {
        console.error('[Mentor] Audit log failed:', err)
      }

      return note
    }),

  /**
   * Update a mentor note
   */
  updateNote: mentorProcedure
    .input(
      z.object({
        noteId: z.string(),
        content: z.string().min(1).max(10000),
        isVisibleToAdmin: z.boolean().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const note = await ctx.prisma.mentorNote.findUniqueOrThrow({
        where: { id: input.noteId },
        select: { authorId: true },
      })

      if (note.authorId !== ctx.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You can only edit your own notes',
        })
      }

      return ctx.prisma.mentorNote.update({
        where: { id: input.noteId },
        data: {
          content: input.content,
          ...(input.isVisibleToAdmin !== undefined && { isVisibleToAdmin: input.isVisibleToAdmin }),
        },
        include: {
          author: { select: { id: true, name: true, email: true } },
        },
      })
    }),

  /**
   * Delete a mentor note
   */
  deleteNote: mentorProcedure
    .input(z.object({ noteId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const note = await ctx.prisma.mentorNote.findUniqueOrThrow({
        where: { id: input.noteId },
        select: { authorId: true },
      })

      if (note.authorId !== ctx.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You can only delete your own notes',
        })
      }

      return ctx.prisma.mentorNote.delete({
        where: { id: input.noteId },
      })
    }),

  /**
   * Get notes for a mentor assignment
   */
  getNotes: mentorProcedure
    .input(z.object({ mentorAssignmentId: z.string() }))
    .query(async ({ ctx, input }) => {
      const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
        where: { id: input.mentorAssignmentId },
        select: { mentorId: true },
      })

      const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)

      if (assignment.mentorId !== ctx.user.id && !isAdmin) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You are not assigned to this mentorship',
        })
      }

      // Admins see all notes; mentors see only their own
      const where: Record<string, unknown> = { mentorAssignmentId: input.mentorAssignmentId }
      if (!isAdmin) {
        where.authorId = ctx.user.id
      }

      return ctx.prisma.mentorNote.findMany({
        where,
        include: {
          author: { select: { id: true, name: true, email: true } },
        },
        orderBy: { createdAt: 'desc' },
      })
    }),

  // =========================================================================
  // Milestone Operations (F8)
  // =========================================================================

  /**
   * Get milestones for a program with completion status
   */
  getMilestones: mentorProcedure
    .input(z.object({ programId: z.string() }))
    .query(async ({ ctx, input }) => {
      const milestones = await ctx.prisma.mentorMilestone.findMany({
        where: { programId: input.programId },
        include: {
          completions: {
            include: {
              mentorAssignment: { select: { id: true, projectId: true } },
            },
          },
        },
        orderBy: { sortOrder: 'asc' },
      })

      // Get current user's assignments for completion status context
      const myAssignments = await ctx.prisma.mentorAssignment.findMany({
        where: { mentorId: ctx.user.id },
        select: { id: true, projectId: true },
      })
      const myAssignmentIds = new Set(myAssignments.map((a) => a.id))

      return milestones.map((milestone: typeof milestones[number]) => ({
        ...milestone,
        myCompletions: milestone.completions.filter((c: { mentorAssignmentId: string }) =>
          myAssignmentIds.has(c.mentorAssignmentId)
        ),
      }))
    }),

  /**
   * Mark a milestone as completed for an assignment
   */
  completeMilestone: mentorProcedure
    .input(
      z.object({
        milestoneId: z.string(),
        mentorAssignmentId: z.string(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify the user owns this assignment
      const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
        where: { id: input.mentorAssignmentId },
        select: { mentorId: true, projectId: true },
      })

      const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
      if (assignment.mentorId !== ctx.user.id && !isAdmin) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You are not assigned to this mentorship',
        })
      }

      const completion = await ctx.prisma.mentorMilestoneCompletion.create({
        data: {
          milestoneId: input.milestoneId,
          mentorAssignmentId: input.mentorAssignmentId,
          completedById: ctx.user.id,
        },
      })

      // Check if all required milestones are now completed
      const milestone = await ctx.prisma.mentorMilestone.findUniqueOrThrow({
        where: { id: input.milestoneId },
        select: { programId: true },
      })

      const requiredMilestones = await ctx.prisma.mentorMilestone.findMany({
        where: { programId: milestone.programId, isRequired: true },
        select: { id: true },
      })

      const completedMilestones = await ctx.prisma.mentorMilestoneCompletion.findMany({
        where: {
          mentorAssignmentId: input.mentorAssignmentId,
          milestoneId: { in: requiredMilestones.map((m: { id: string }) => m.id) },
        },
        select: { milestoneId: true },
      })

      const allRequiredDone = requiredMilestones.length > 0 &&
        completedMilestones.length >= requiredMilestones.length

      if (allRequiredDone) {
        await ctx.prisma.mentorAssignment.update({
          where: { id: input.mentorAssignmentId },
          data: { completionStatus: 'completed' },
        })
      }

      try {
        await logAudit({
          prisma: ctx.prisma,
          userId: ctx.user.id,
          action: 'COMPLETE_MILESTONE',
          entityType: 'MentorMilestoneCompletion',
          entityId: completion.id,
          detailsJson: {
            milestoneId: input.milestoneId,
            mentorAssignmentId: input.mentorAssignmentId,
            allRequiredDone,
          },
          ipAddress: ctx.ip,
          userAgent: ctx.userAgent,
        })
      } catch (err) {
        console.error('[Mentor] Audit log failed:', err)
      }

      return { completion, allRequiredDone }
    }),

  /**
   * Uncomplete a milestone for an assignment
   */
  uncompleteMilestone: mentorProcedure
    .input(
      z.object({
        milestoneId: z.string(),
        mentorAssignmentId: z.string(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
        where: { id: input.mentorAssignmentId },
        select: { mentorId: true },
      })

      const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
      if (assignment.mentorId !== ctx.user.id && !isAdmin) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You are not assigned to this mentorship',
        })
      }

      await ctx.prisma.mentorMilestoneCompletion.delete({
        where: {
          milestoneId_mentorAssignmentId: {
            milestoneId: input.milestoneId,
            mentorAssignmentId: input.mentorAssignmentId,
          },
        },
      })

      // Revert completion status if it was completed
      await ctx.prisma.mentorAssignment.update({
        where: { id: input.mentorAssignmentId },
        data: { completionStatus: 'in_progress' },
      })

      return { success: true }
    }),

  // =========================================================================
  // Admin Milestone Management (F8)
  // =========================================================================

  /**
   * Create a milestone for a program
   */
  createMilestone: adminProcedure
    .input(
      z.object({
        programId: z.string(),
        name: z.string().min(1).max(255),
        description: z.string().max(2000).optional(),
        isRequired: z.boolean().default(false),
        deadlineOffsetDays: z.number().int().optional().nullable(),
        sortOrder: z.number().int().default(0),
      })
    )
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.mentorMilestone.create({
        data: input,
      })
    }),

  /**
   * Update a milestone
   */
  updateMilestone: adminProcedure
    .input(
      z.object({
        milestoneId: z.string(),
        name: z.string().min(1).max(255).optional(),
        description: z.string().max(2000).optional().nullable(),
        isRequired: z.boolean().optional(),
        deadlineOffsetDays: z.number().int().optional().nullable(),
        sortOrder: z.number().int().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const { milestoneId, ...data } = input
      return ctx.prisma.mentorMilestone.update({
        where: { id: milestoneId },
        data,
      })
    }),

  /**
   * Delete a milestone (cascades completions)
   */
  deleteMilestone: adminProcedure
    .input(z.object({ milestoneId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.mentorMilestone.delete({
        where: { id: input.milestoneId },
      })
    }),

  /**
   * Reorder milestones
   */
  reorderMilestones: adminProcedure
    .input(
      z.object({
        milestoneIds: z.array(z.string()),
      })
    )
    .mutation(async ({ ctx, input }) => {
      await ctx.prisma.$transaction(
        input.milestoneIds.map((id, index) =>
          ctx.prisma.mentorMilestone.update({
            where: { id },
            data: { sortOrder: index },
          })
        )
      )

      return { success: true }
    }),

  // =========================================================================
  // Activity Tracking (F8)
  // =========================================================================

  /**
   * Track a mentor's view of an assignment
   */
  trackView: mentorProcedure
    .input(z.object({ mentorAssignmentId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
        where: { id: input.mentorAssignmentId },
        select: { mentorId: true },
      })

      const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
      if (assignment.mentorId !== ctx.user.id && !isAdmin) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You are not assigned to this mentorship',
        })
      }

      return ctx.prisma.mentorAssignment.update({
        where: { id: input.mentorAssignmentId },
        data: { lastViewedAt: new Date() },
      })
    }),

  /**
   * Get activity stats for all mentors (admin)
   */
  getActivityStats: adminProcedure
    .input(
      z.object({
        programId: z.string().optional(),
      })
    )
    .query(async ({ ctx, input }) => {
      const where = input.programId
        ? { project: { programId: input.programId } }
        : {}

      const assignments = await ctx.prisma.mentorAssignment.findMany({
        where,
        include: {
          mentor: { select: { id: true, name: true, email: true } },
          project: { select: { id: true, title: true } },
          notes: { select: { id: true } },
          milestoneCompletions: { select: { milestoneId: true } },
        },
      })

      // Get message counts per mentor
      const mentorIds = [...new Set(assignments.map((a) => a.mentorId))]
      const messageCounts = await ctx.prisma.mentorMessage.groupBy({
        by: ['senderId'],
        where: { senderId: { in: mentorIds } },
        _count: true,
      })
      const messageCountMap = new Map(messageCounts.map((m) => [m.senderId, m._count]))

      // Build per-mentor stats
      const mentorStats = new Map<string, {
        mentor: { id: string; name: string | null; email: string }
        assignments: number
        lastViewedAt: Date | null
        notesCount: number
        milestonesCompleted: number
        messagesSent: number
        completionStatuses: string[]
      }>()

      for (const assignment of assignments) {
        const existing = mentorStats.get(assignment.mentorId)
        if (existing) {
          existing.assignments++
          existing.notesCount += assignment.notes.length
          existing.milestonesCompleted += assignment.milestoneCompletions.length
          existing.completionStatuses.push(assignment.completionStatus)
          if (assignment.lastViewedAt && (!existing.lastViewedAt || assignment.lastViewedAt > existing.lastViewedAt)) {
            existing.lastViewedAt = assignment.lastViewedAt
          }
        } else {
          mentorStats.set(assignment.mentorId, {
            mentor: assignment.mentor,
            assignments: 1,
            lastViewedAt: assignment.lastViewedAt,
            notesCount: assignment.notes.length,
            milestonesCompleted: assignment.milestoneCompletions.length,
            messagesSent: messageCountMap.get(assignment.mentorId) || 0,
            completionStatuses: [assignment.completionStatus],
          })
        }
      }

      return Array.from(mentorStats.values())
    }),

  // =========================================================================
  // Workspace Procedures (Phase 4)
  // =========================================================================

  /**
   * Activate a mentor workspace
   */
  activateWorkspace: adminProcedure
    .input(z.object({ mentorAssignmentId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const result = await activateWorkspace(input.mentorAssignmentId, ctx.user.id, ctx.prisma)
      if (!result.success) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: result.errors?.join('; ') ?? 'Failed to activate workspace',
        })
      }
      return result
    }),

  /**
   * Send a message in a mentor workspace
   */
  workspaceSendMessage: protectedProcedure
    .input(
      z.object({
        mentorAssignmentId: z.string(),
        message: z.string().min(1).max(5000),
        role: z.enum(['MENTOR_ROLE', 'APPLICANT_ROLE', 'ADMIN_ROLE']),
      })
    )
    .mutation(async ({ ctx, input }) => {
      await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
      return workspaceSendMessage(
        {
          workspaceId: input.mentorAssignmentId,
          senderId: ctx.user.id,
          message: input.message,
          role: input.role,
        },
        ctx.prisma,
      )
    }),

  /**
   * Get workspace messages
   */
  workspaceGetMessages: protectedProcedure
    .input(z.object({ mentorAssignmentId: z.string() }))
    .query(async ({ ctx, input }) => {
      await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
      return workspaceGetMessages(input.mentorAssignmentId, ctx.prisma)
    }),

  /**
   * Mark a workspace message as read.
   * Resolves the message to its workspace and verifies caller belongs to it.
   * Caller must also not be the sender (you only mark someone else's message
   * as read, not your own — keeps unread state honest).
   */
  workspaceMarkRead: protectedProcedure
    .input(z.object({ messageId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const message = await ctx.prisma.mentorMessage.findUnique({
        where: { id: input.messageId },
        select: { workspaceId: true, senderId: true },
      })
      if (!message || !message.workspaceId) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'Message not found' })
      }
      await assertWorkspaceAccess(ctx.prisma, ctx.user.id, message.workspaceId)
      if (message.senderId === ctx.user.id) {
        // Senders can't mark their own messages as read by others.
        return { success: true }
      }
      await workspaceMarkRead(input.messageId, ctx.prisma)
      return { success: true }
    }),

  /**
   * Issue a presigned upload URL + signed token for a mentor-workspace file.
   * The token binds the bucket, objectKey, and uploader so the client cannot
   * forge a path; workspaceUploadFile reads the token, never the
   * client-supplied path.
   */
  workspaceGetUploadUrl: protectedProcedure
    .input(
      z.object({
        mentorAssignmentId: z.string(),
        fileName: z.string().min(1).max(255),
        mimeType: z.string().min(1).max(200),
        size: z.number().int().min(0).max(500 * 1024 * 1024),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const assignment = await assertWorkspaceAccess(
        ctx.prisma, ctx.user.id, input.mentorAssignmentId,
      )
      const objectKey = generateMentorObjectKey(assignment.project.title, input.fileName)
      const uploadUrl = await getPresignedUrl(BUCKET_NAME, objectKey, 'PUT', 3600)
      const exp = Math.floor(Date.now() / 1000) + 3600
      const uploadToken = signMentorUploadToken({
        mentorAssignmentId: assignment.id,
        projectId: assignment.projectId,
        uploaderUserId: ctx.user.id,
        fileName: input.fileName,
        mimeType: input.mimeType,
        size: input.size,
        bucket: BUCKET_NAME,
        objectKey,
        exp,
      })
      return { uploadUrl, uploadToken, bucket: BUCKET_NAME, objectKey }
    }),

  /**
   * Record a workspace file upload. Requires a valid uploadToken issued by
   * workspaceGetUploadUrl — the token contains the server-built bucket,
   * objectKey, and uploader binding. The client cannot pass a path directly.
   */
  workspaceUploadFile: protectedProcedure
    .input(
      z.object({
        uploadToken: z.string(),
        description: z.string().max(2000).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      let payload
      try {
        payload = verifyMentorUploadToken(input.uploadToken)
      } catch (e) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: e instanceof Error ? e.message : 'Invalid upload token',
        })
      }
      if (payload.uploaderUserId !== ctx.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Upload token does not belong to the current user',
        })
      }
      await assertWorkspaceAccess(ctx.prisma, ctx.user.id, payload.mentorAssignmentId)
      return workspaceUploadFile(
        {
          workspaceId: payload.mentorAssignmentId,
          uploadedByUserId: ctx.user.id,
          fileName: payload.fileName,
          mimeType: payload.mimeType,
          size: payload.size,
          bucket: payload.bucket,
          objectKey: payload.objectKey,
          description: input.description,
        },
        ctx.prisma,
      )
    }),

  /**
   * List files in a project's mentor workspace. Authorized for any mentor
   * currently assigned to the project, or any team member of the project.
   *
   * Project-scoped (PR8): all co-mentors share one file list, and files
   * survive even when an originating assignment is later dropped.
   */
  workspaceGetFiles: protectedProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, input.projectId)
      return workspaceGetFilesService(input.projectId, ctx.prisma)
    }),

  /**
   * Issue a short-lived presigned GET URL to download a workspace file.
   */
  workspaceGetFileDownloadUrl: protectedProcedure
    .input(z.object({
      mentorFileId: z.string(),
      disposition: z.enum(['inline', 'attachment']).default('attachment'),
    }))
    .mutation(async ({ ctx, input }) => {
      const file = await ctx.prisma.mentorFile.findUnique({
        where: { id: input.mentorFileId },
        select: { bucket: true, objectKey: true, fileName: true, mimeType: true, projectId: true },
      })
      if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
      await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
      const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900,
        input.disposition === 'inline'
          ? { inline: true, contentType: file.mimeType }
          : { downloadFileName: file.fileName })
      return { url }
    }),

  /**
   * Delete a workspace file. Authorized for the uploader, any mentor
   * currently assigned to the file's project, or any team member of the
   * file's project. Final auth check lives in the service.
   */
  workspaceDeleteFile: protectedProcedure
    .input(z.object({ mentorFileId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const file = await ctx.prisma.mentorFile.findUnique({
        where: { id: input.mentorFileId },
        select: { projectId: true },
      })
      if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
      await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
      try {
        await workspaceDeleteFileService(
          { mentorFileId: input.mentorFileId, userId: ctx.user.id },
          ctx.prisma,
          deleteObject,
        )
      } catch (e) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: e instanceof Error ? e.message : 'Delete failed',
        })
      }
      return { success: true }
    }),

  /**
   * Add a comment to a workspace file
   */
  workspaceAddFileComment: protectedProcedure
    .input(
      z.object({
        mentorFileId: z.string(),
        content: z.string().min(1).max(5000),
        parentCommentId: z.string().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const file = await ctx.prisma.mentorFile.findUnique({
        where: { id: input.mentorFileId },
        select: { projectId: true },
      })
      if (!file) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
      }
      await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
      return workspaceAddFileComment(
        {
          mentorFileId: input.mentorFileId,
          authorId: ctx.user.id,
          content: input.content,
          parentCommentId: input.parentCommentId,
        },
        ctx.prisma,
      )
    }),

  /**
   * Promote a workspace file to official submission
   */
  workspacePromoteFile: adminProcedure
    .input(
      z.object({
        mentorFileId: z.string(),
        roundId: z.string(),
        slotKey: z.string(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const result = await workspacePromoteFile(
        {
          mentorFileId: input.mentorFileId,
          roundId: input.roundId,
          slotKey: input.slotKey,
          promotedById: ctx.user.id,
        },
        ctx.prisma,
      )
      if (!result.success) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: result.errors?.join('; ') ?? 'Failed to promote file',
        })
      }
      return result
    }),

  /**
   * Mentor self-drops an assignment with a required reason. Notifies all
   * program admins so they can re-assign. Audit-logged.
   */
  dropAssignment: mentorProcedure
    .input(
      z.object({
        assignmentId: z.string(),
        reason: z
          .string()
          .min(10, 'Reason must be at least 10 characters')
          .max(500),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
        where: { id: input.assignmentId },
        include: {
          project: { select: { id: true, title: true } },
        },
      })
      if (assignment.mentorId !== ctx.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'This is not your assignment',
        })
      }
      if (assignment.droppedAt) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Assignment is already dropped',
        })
      }
      if (assignment.completionStatus === 'completed') {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Assignment is already completed',
        })
      }

      const dropped = await ctx.prisma.mentorAssignment.update({
        where: { id: assignment.id },
        data: {
          droppedAt: new Date(),
          droppedReason: input.reason,
          droppedBy: 'mentor',
        },
      })

      // Notify program admins (best-effort, never block the drop)
      try {
        const admins = await ctx.prisma.user.findMany({
          where: {
            roles: { hasSome: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
            status: { not: 'SUSPENDED' },
          },
          select: { id: true },
        })
        const mentorName = ctx.user.name ?? ctx.user.email
        for (const admin of admins) {
          await createNotification({
            userId: admin.id,
            type: NotificationTypes.MENTOR_DROPPED,
            title: 'Mentor dropped a team',
            message: `${mentorName} dropped their mentee "${assignment.project.title}". Reason: ${input.reason}`,
            linkUrl: `/admin/projects/${assignment.project.id}/mentor`,
            priority: 'high',
          })
        }
      } catch (err) {
        console.error('[mentor.dropAssignment] notify admins failed:', err)
      }

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'MENTOR_DROP_ASSIGNMENT',
        entityType: 'MentorAssignment',
        entityId: assignment.id,
        detailsJson: {
          reason: input.reason,
          projectId: assignment.project.id,
        },
      })
      return dropped
    }),

  /**
   * Per-mentor activity detail used by the admin mentor side sheet. For each
   * (active or dropped) assignment, returns the project, key timestamps, and
   * counts of messages, files, and completed milestones so the admin can see
   * "what has this mentor been up to" at a glance.
   */
  getMentorDetail: adminProcedure
    .input(z.object({ mentorId: z.string() }))
    .query(async ({ ctx, input }) => {
      const mentor = await ctx.prisma.user.findUniqueOrThrow({
        where: { id: input.mentorId },
        select: {
          id: true,
          name: true,
          email: true,
          country: true,
          expertiseTags: true,
          maxAssignments: true,
          createdAt: true,
        },
      })

      const assignments = await ctx.prisma.mentorAssignment.findMany({
        where: { mentorId: input.mentorId },
        orderBy: { assignedAt: 'desc' },
        include: {
          project: {
            select: {
              id: true,
              title: true,
              competitionCategory: true,
              country: true,
            },
          },
          _count: {
            select: { messages: true, files: true, milestoneCompletions: true },
          },
          messages: {
            orderBy: { createdAt: 'desc' },
            take: 1,
            select: { createdAt: true },
          },
          files: {
            orderBy: { createdAt: 'desc' },
            take: 1,
            select: { createdAt: true },
          },
          milestoneCompletions: {
            orderBy: { completedAt: 'desc' },
            take: 1,
            select: { completedAt: true },
          },
        },
      })

      return {
        mentor,
        assignments: assignments.map((a) => ({
          id: a.id,
          assignedAt: a.assignedAt,
          method: a.method,
          completionStatus: a.completionStatus,
          droppedAt: a.droppedAt,
          droppedReason: a.droppedReason,
          droppedBy: a.droppedBy,
          workspaceEnabled: a.workspaceEnabled,
          project: a.project,
          messageCount: a._count.messages,
          fileCount: a._count.files,
          milestoneCompletionCount: a._count.milestoneCompletions,
          lastMessageAt: a.messages[0]?.createdAt ?? null,
          lastFileAt: a.files[0]?.createdAt ?? null,
          lastMilestoneAt: a.milestoneCompletions[0]?.completedAt ?? null,
        })),
      }
    }),

  // ===========================================================================
  // Mentor change requests (PR8)
  //
  // Applicants (team members) or admins can open a PENDING change request for
  // a project — optionally targeting a specific co-mentor assignment. Admins
  // are notified by email; mentors are intentionally NOT notified, even after
  // resolution (per design decision in the PR8 plan).
  // ===========================================================================

  /**
   * Open a new mentor change request. Allowed for:
   *   • SUPER_ADMIN / PROGRAM_ADMIN (any project), or
   *   • a team member of the target project.
   *
   * Rejects with CONFLICT if the same user already has an open (PENDING) request
   * for the same project. The raw `reason` is intentionally NOT included in
   * audit logs — only its length — for privacy. Email delivery to admins is
   * best-effort and never throws.
   */
  requestChange: protectedProcedure
    .input(
      z.object({
        projectId: z.string().min(1),
        targetAssignmentId: z.string().min(1).optional(),
        reason: z.string().min(10).max(2000),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)

      // Authorization: admin OR team member of the project
      if (!isAdmin) {
        const teamMembership = await ctx.prisma.teamMember.findFirst({
          where: { projectId: input.projectId, userId: ctx.user.id },
          select: { id: true },
        })
        if (!teamMembership) {
          throw new TRPCError({
            code: 'FORBIDDEN',
            message: 'You are not a member of this project',
          })
        }
      }

      // Load project (also confirms it exists) and validate optional target
      const project = await ctx.prisma.project.findUnique({
        where: { id: input.projectId },
        select: { id: true, title: true },
      })
      if (!project) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
      }

      if (input.targetAssignmentId) {
        const targetAssignment = await ctx.prisma.mentorAssignment.findUnique({
          where: { id: input.targetAssignmentId },
          select: { id: true, projectId: true },
        })
        if (!targetAssignment || targetAssignment.projectId !== input.projectId) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: 'Target assignment does not belong to this project',
          })
        }
      }

      // One open request per (user, project)
      const existingOpen = await ctx.prisma.mentorChangeRequest.findFirst({
        where: {
          projectId: input.projectId,
          requestedByUserId: ctx.user.id,
          status: MentorChangeRequestStatus.PENDING,
        },
        select: { id: true },
      })
      if (existingOpen) {
        throw new TRPCError({
          code: 'CONFLICT',
          message: 'You already have an open mentor change request for this project.',
        })
      }

      const created = await ctx.prisma.mentorChangeRequest.create({
        data: {
          projectId: input.projectId,
          targetAssignmentId: input.targetAssignmentId ?? null,
          requestedByUserId: ctx.user.id,
          reason: input.reason,
          status: MentorChangeRequestStatus.PENDING,
        },
        select: { id: true, status: true, createdAt: true },
      })

      // Notify admins (best-effort, never throw)
      try {
        const admins = await ctx.prisma.user.findMany({
          where: {
            OR: [
              { roles: { has: 'SUPER_ADMIN' } },
              { roles: { has: 'PROGRAM_ADMIN' } },
            ],
            status: 'ACTIVE',
          },
          select: { email: true },
        })
        const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
        const adminDashboardUrl = `${baseUrl.replace(/\/$/, '')}/admin/projects/${input.projectId}/mentor`
        await sendMentorChangeRequestEmail(
          admins.map((a) => a.email),
          project.title,
          ctx.user.name ?? null,
          input.reason,
          adminDashboardUrl,
        )
      } catch (err) {
        // Defense-in-depth: the helper already has its own try/catch
        console.error('[mentor.requestChange] notify admins failed:', err)
      }

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'MENTOR_CHANGE_REQUEST_CREATE',
        entityType: 'MentorChangeRequest',
        entityId: created.id,
        detailsJson: {
          projectId: input.projectId,
          targetAssignmentId: input.targetAssignmentId ?? null,
          reasonLength: input.reason.length,
        },
      })

      return created
    }),

  /**
   * Admin inbox — list MentorChangeRequest rows, optionally filtered by status
   * and/or project. PENDING rows are surfaced first; within each status group
   * rows are ordered by createdAt desc. No pagination (low-volume admin view).
   */
  listChangeRequests: adminProcedure
    .input(
      z
        .object({
          status: z.nativeEnum(MentorChangeRequestStatus).optional(),
          projectId: z.string().optional(),
        })
        .optional(),
    )
    .query(async ({ ctx, input }) => {
      const where: Prisma.MentorChangeRequestWhereInput = {}
      if (input?.status) where.status = input.status
      if (input?.projectId) where.projectId = input.projectId

      const rows = await ctx.prisma.mentorChangeRequest.findMany({
        where,
        include: {
          project: { select: { id: true, title: true } },
          targetAssignment: {
            select: {
              id: true,
              mentor: { select: { id: true, name: true, email: true } },
            },
          },
          requestedBy: { select: { id: true, name: true, email: true } },
          resolvedBy: { select: { id: true, name: true, email: true } },
        },
        // PENDING first, then RESOLVED/DISMISSED. Within each: newest first.
        orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
      })

      // Enum order is PENDING < RESOLVED < DISMISSED alphabetically — DISMISSED
      // is "D" so it sorts before PENDING. Re-sort in JS to guarantee PENDING
      // appears first regardless of enum string ordering.
      const statusRank: Record<MentorChangeRequestStatus, number> = {
        [MentorChangeRequestStatus.PENDING]: 0,
        [MentorChangeRequestStatus.RESOLVED]: 1,
        [MentorChangeRequestStatus.DISMISSED]: 2,
      }
      return rows.sort((a, b) => {
        const sa = statusRank[a.status] - statusRank[b.status]
        if (sa !== 0) return sa
        return b.createdAt.getTime() - a.createdAt.getTime()
      })
    }),

  /**
   * Admin resolves a PENDING request as RESOLVED or DISMISSED. Re-resolution
   * is rejected. No email or notification is sent to the requester or mentors
   * (per PR8 design decision — mentors are never informed of change requests).
   */
  resolveChangeRequest: adminProcedure
    .input(
      z.object({
        id: z.string().min(1),
        status: z.enum(['RESOLVED', 'DISMISSED']),
        resolutionNote: z.string().max(2000).optional(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const existing = await ctx.prisma.mentorChangeRequest.findUnique({
        where: { id: input.id },
        select: { id: true, status: true, projectId: true },
      })
      if (!existing) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'Request not found' })
      }
      if (existing.status !== MentorChangeRequestStatus.PENDING) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Request already resolved',
        })
      }

      const updated = await ctx.prisma.mentorChangeRequest.update({
        where: { id: existing.id },
        data: {
          status: input.status as MentorChangeRequestStatus,
          resolvedByUserId: ctx.user.id,
          resolvedAt: new Date(),
          resolutionNote: input.resolutionNote ?? null,
        },
      })

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'MENTOR_CHANGE_REQUEST_RESOLVE',
        entityType: 'MentorChangeRequest',
        entityId: existing.id,
        detailsJson: {
          status: input.status,
          projectId: existing.projectId,
        },
      })

      return updated
    }),

  previewMentorshipWelcome: adminProcedure
    .input(z.object({ roundId: z.string(), customNote: z.string().max(2000).optional() }))
    .query(async ({ ctx, input }) => {
      const { roundId, customNote } = input
      const baseUrl = getBaseUrl()

      const assignments = await ctx.prisma.mentorAssignment.findMany({
        where: { droppedAt: null, project: { projectRoundStates: { some: { roundId } } } },
        select: {
          mentorId: true,
          mentor: { select: { name: true, email: true } },
          project: {
            select: {
              id: true,
              title: true,
              teamMembers: { select: { user: { select: { name: true, email: true } } } },
            },
          },
        },
      })

      const mentorIds = new Set<string>()
      const teamEmails = new Set<string>()
      for (const a of assignments) {
        if (a.mentor?.email) mentorIds.add(a.mentorId)
        for (const tm of a.project.teamMembers) {
          if (tm.user?.email) teamEmails.add(tm.user.email)
        }
      }
      const recipientCount = mentorIds.size + teamEmails.size

      const firstMentor = assignments.find((a) => a.mentor?.email)
      const mentorTemplate = firstMentor
        ? getMentorBulkAssignmentTemplate(
            firstMentor.mentor!.name || '',
            assignments
              .filter((a) => a.mentorId === firstMentor.mentorId)
              .map((a) => ({
                title: a.project.title,
                url: `${baseUrl}/mentor/workspace/${a.project.id}`,
                teamMembers: a.project.teamMembers
                  .filter((tm) => tm.user?.email)
                  .map((tm) => ({ name: tm.user!.name, email: tm.user!.email })),
              })),
            `${baseUrl}/mentor`,
            customNote,
          )
        : getMentorBulkAssignmentTemplate(
            'Sample Mentor',
            [
              {
                title: 'Sample Project',
                url: `${baseUrl}/mentor`,
                teamMembers: [{ name: 'Sample Applicant', email: 'applicant@example.com' }],
              },
            ],
            `${baseUrl}/mentor`,
            customNote,
          )

      const firstProject = assignments.find((a) => a.mentor?.email)
      let teamTemplate
      if (firstProject) {
        const projMentors = assignments
          .filter((a) => a.project.id === firstProject.project.id && a.mentor?.email)
          .map((a) => ({ name: a.mentor!.name, email: a.mentor!.email }))
        const teammates = firstProject.project.teamMembers
          .filter((tm) => tm.user?.email)
          .map((tm) => ({ name: tm.user!.name, email: tm.user!.email }))
        teamTemplate = getTeamMentorIntroductionTemplate(
          teammates[0]?.name ?? null,
          firstProject.project.title,
          projMentors,
          `${baseUrl}/applicant/mentor`,
          teammates.slice(1),
          customNote,
        )
      } else {
        teamTemplate = getTeamMentorIntroductionTemplate(
          'Sample Applicant',
          'Sample Project',
          [{ name: 'Sample Mentor', email: 'mentor@example.com' }],
          `${baseUrl}/applicant/mentor`,
          [{ name: 'Sample Teammate', email: 'teammate@example.com' }],
          customNote,
        )
      }

      const isSample = !firstMentor
      const banner = (label: string) =>
        `<div style="max-width:560px;margin:24px auto 0;padding:10px 14px;background:#053d57;color:#fff;font-weight:600;text-align:center;border-radius:6px;font-family:-apple-system,sans-serif;">${label}</div>`
      const sampleNote = isSample
        ? `<div style="max-width:560px;margin:16px auto 0;padding:10px 14px;background:#fff7ed;color:#9a3412;text-align:center;border-radius:6px;font-family:-apple-system,sans-serif;">No assignments in this round yet — showing sample data.</div>`
        : ''

      const html = `${sampleNote}${banner('Mentor version')}${mentorTemplate.html}${banner('Team version')}${teamTemplate.html}`

      return { html, recipientCount }
    }),

  sendMentorshipWelcome: adminProcedure
    .input(z.object({ roundId: z.string(), customNote: z.string().max(2000).optional() }))
    .mutation(async ({ ctx, input }) => {
      const { roundId, customNote } = input

      const assignments = await ctx.prisma.mentorAssignment.findMany({
        where: { droppedAt: null, project: { projectRoundStates: { some: { roundId } } } },
        select: {
          id: true,
          mentorId: true,
          mentor: { select: { name: true, email: true } },
          project: {
            select: {
              id: true,
              title: true,
              teamMembers: { select: { user: { select: { name: true, email: true } } } },
              submittedByEmail: true,
              submittedBy: { select: { name: true } },
            },
          },
        },
      })

      let sent = 0
      let failed = 0

      // Mentor emails (coalesced per mentor).
      const perMentor = new Map<
        string,
        {
          email: string
          name: string | null
          projects: { id: string; title: string; teamMembers: { name: string | null; email: string }[] }[]
        }
      >()
      for (const a of assignments) {
        if (!a.mentor?.email) continue
        const bucket = perMentor.get(a.mentorId) ?? {
          email: a.mentor.email,
          name: a.mentor.name,
          projects: [],
        }
        bucket.projects.push({
          id: a.project.id,
          title: a.project.title,
          teamMembers: a.project.teamMembers
            .filter((tm) => tm.user?.email)
            .map((tm) => ({ name: tm.user!.name, email: tm.user!.email })),
        })
        perMentor.set(a.mentorId, bucket)
      }
      for (const bucket of perMentor.values()) {
        const ok = await sendMentorBulkAssignmentEmail(bucket.email, bucket.name, bucket.projects, customNote)
        if (ok) sent++
        else failed++
      }

      // Team emails (per project, to all members + original submitter).
      const byProject = new Map<string, typeof assignments>()
      for (const a of assignments) {
        const arr = byProject.get(a.project.id) ?? []
        arr.push(a)
        byProject.set(a.project.id, arr)
      }
      for (const projAssignments of byProject.values()) {
        const p = projAssignments[0].project
        const mentors = projAssignments
          .filter((a) => a.mentor?.email)
          .map((a) => ({ name: a.mentor!.name, email: a.mentor!.email }))
        if (mentors.length === 0) continue
        const allMembers = p.teamMembers
          .filter((tm) => tm.user?.email)
          .map((tm) => ({ name: tm.user!.name, email: tm.user!.email }))
        const recipients = new Map<string, { name: string | null }>()
        for (const m of allMembers) recipients.set(m.email, { name: m.name })
        if (p.submittedByEmail && !recipients.has(p.submittedByEmail)) {
          recipients.set(p.submittedByEmail, { name: p.submittedBy?.name ?? null })
        }
        for (const [email, { name }] of recipients) {
          const teammates = allMembers.filter((m) => m.email !== email)
          const ok = await sendTeamMentorIntroductionEmail(email, name, p.title, p.id, mentors, teammates, customNote)
          if (ok) sent++
          else failed++
        }
      }

      try {
        await logAudit({
          prisma: ctx.prisma,
          userId: ctx.user.id,
          action: 'MENTORSHIP_WELCOME_SENT',
          entityType: 'Round',
          entityId: roundId,
          detailsJson: { sent, failed, hasCustomNote: !!customNote },
          ipAddress: ctx.ip,
          userAgent: ctx.userAgent,
        })
      } catch (err) {
        console.error('[sendMentorshipWelcome] audit failed', err)
      }

      return { sent, failed }
    }),
})
