import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl, generateObjectKey, BUCKET_NAME, objectKeyBelongsToProject } from '@/lib/minio'
import { generateLogoKey, createStorageProvider, type StorageProviderType } from '@/lib/storage'
import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload'
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit'
import { createNotification, notifyProjectMentors, NotificationTypes } from '../services/in-app-notification'
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
import { getFinalDocumentStatusForProject, finalistUploadsEnabled } from '../services/final-documents'
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
import type { PrismaClient, Prisma, RoundType } from '@prisma/client'

// All uploads use the single configured bucket (MINIO_BUCKET / mopc-files).
// Files are organized by path prefix: {ProjectName}/{RoundName}/... for submissions,
// avatars/{userId}/... for profile images, logos/{projectId}/... for project logos.
const TEAM_INVITE_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000 // 30 days

function generateInviteToken(): string {
  return crypto.randomBytes(32).toString('hex')
}

/** Check if a project has been rejected in any round (based on ProjectRoundState, not Project.status) */
async function isProjectRejected(prisma: PrismaClient, projectId: string): Promise<boolean> {
  const rejected = await prisma.projectRoundState.findFirst({
    where: { projectId, state: 'REJECTED' },
    select: { id: true },
  })
  return !!rejected
}

export const applicantRouter = router({
  /**
   * Get submission info for an applicant (by round slug)
   */
  getSubmissionBySlug: publicProcedure
    .input(z.object({ slug: z.string() }))
    .query(async ({ ctx, input }) => {
      const round = await ctx.prisma.round.findFirst({
        where: { slug: input.slug },
        include: {
          competition: {
            include: {
              program: { select: { id: true, name: true, year: true, description: true } },
            },
          },
        },
      })

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

      const now = new Date()
      const isOpen = round.status === 'ROUND_ACTIVE'

      return {
        stage: {
          id: round.id,
          name: round.name,
          slug: round.slug,
          windowCloseAt: null,
          isOpen,
        },
        program: round.competition.program,
      }
    }),

  /**
   * Get the current user's submission for a round (as submitter or team member)
   */
  getMySubmission: protectedProcedure
    .input(z.object({ roundId: z.string().optional(), programId: z.string().optional() }))
    .query(async ({ ctx, input }) => {
      // Only applicants can use this
      if (ctx.user.role !== 'APPLICANT') {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only applicants can access submissions',
        })
      }

      const where: Record<string, unknown> = {
        OR: [
          { submittedByUserId: ctx.user.id },
          {
            teamMembers: {
              some: { userId: ctx.user.id },
            },
          },
        ],
      }

      if (input.roundId) {
        where.roundAssignments = { some: { roundId: input.roundId } }
      }
      if (input.programId) {
        where.programId = input.programId
      }

      const project = await ctx.prisma.project.findFirst({
        where,
        include: {
          files: true,
          program: { select: { id: true, name: true, year: true } },
          teamMembers: {
            include: {
              user: {
                select: { id: true, name: true, email: true },
              },
            },
          },
        },
      })

      if (project) {
        const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
        return {
          ...project,
          userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
          isTeamLead: project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD',
        }
      }

      return null
    }),

  /**
   * Create or update a submission (draft or submitted)
   */
  saveSubmission: protectedProcedure
    .input(
      z.object({
        programId: z.string().optional(),
        projectId: z.string().optional(),
        title: z.string().min(1).max(500),
        teamName: z.string().optional(),
        description: z.string().optional(),
        tags: z.array(z.string()).optional(),
        metadataJson: z.record(z.unknown()).optional(),
        submit: z.boolean().default(false),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Only applicants can use this
      if (ctx.user.role !== 'APPLICANT') {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only applicants can submit projects',
        })
      }

      const now = new Date()

      const { projectId, submit, programId, metadataJson, ...data } = input

      if (projectId) {
        // Update existing
        const existing = await ctx.prisma.project.findFirst({
          where: {
            id: projectId,
            submittedByUserId: ctx.user.id,
          },
        })

        if (!existing) {
          throw new TRPCError({
            code: 'NOT_FOUND',
            message: 'Project not found or you do not have access',
          })
        }

        // Can't update if already submitted
        if (existing.submittedAt && !submit) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: 'Cannot modify a submitted project',
          })
        }

        const project = await ctx.prisma.project.update({
          where: { id: projectId },
          data: {
            ...data,
            metadataJson: metadataJson as unknown ?? undefined,
            submittedAt: submit && !existing.submittedAt ? now : existing.submittedAt,
          },
        })

        // Update Project status if submitting
        if (submit) {
          await ctx.prisma.project.update({
            where: { id: projectId },
            data: { status: 'SUBMITTED' },
          })
        }

        return project
      } else {
        if (!programId) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: 'programId is required when creating a new submission',
          })
        }

        // Create new project
        const project = await ctx.prisma.project.create({
          data: {
            programId,
            ...data,
            metadataJson: metadataJson as unknown ?? undefined,
            submittedByUserId: ctx.user.id,
            submittedByEmail: ctx.user.email,
            submissionSource: 'MANUAL',
            submittedAt: submit ? now : null,
            status: 'SUBMITTED',
          },
        })

        // Audit log
        await logAudit({
          prisma: ctx.prisma,
          userId: ctx.user.id,
          action: 'CREATE',
          entityType: 'Project',
          entityId: project.id,
          detailsJson: { title: input.title, source: 'applicant_portal' },
          ipAddress: ctx.ip,
          userAgent: ctx.userAgent,
        })

        return project
      }
    }),

  /**
   * Get upload URL for a submission file
   */
  getUploadUrl: protectedProcedure
    .input(
      z.object({
        projectId: z.string(),
        fileName: z.string(),
        mimeType: z.string(),
        fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
        roundId: z.string().optional(),
        requirementId: z.string().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Applicants or team members can upload
      if (ctx.user.role !== 'APPLICANT') {
        // Check if user is a team member of the project
        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: 'Only applicants or team members can upload files',
          })
        }
      }

      // Verify project access (owner or team member)
      const project = await ctx.prisma.project.findFirst({
        where: {
          id: input.projectId,
          OR: [
            { submittedByUserId: ctx.user.id },
            { teamMembers: { some: { userId: ctx.user.id } } },
          ],
        },
      })

      if (!project) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Project not found or you do not have access',
        })
      }

      // Block rejected projects from uploading
      if (await isProjectRejected(ctx.prisma, input.projectId)) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. Uploads are no longer permitted.' })
      }

      // If uploading against a requirement, validate mime type and size
      if (input.requirementId) {
        const requirement = await ctx.prisma.fileRequirement.findUnique({
          where: { id: input.requirementId },
        })
        if (!requirement) {
          throw new TRPCError({ code: 'NOT_FOUND', message: 'File requirement not found' })
        }
        // Validate mime type
        if (requirement.acceptedMimeTypes.length > 0) {
          const accepted = requirement.acceptedMimeTypes.some((pattern) => {
            if (pattern.endsWith('/*')) {
              return input.mimeType.startsWith(pattern.replace('/*', '/'))
            }
            return input.mimeType === pattern
          })
          if (!accepted) {
            throw new TRPCError({
              code: 'BAD_REQUEST',
              message: `File type ${input.mimeType} is not accepted. Accepted types: ${requirement.acceptedMimeTypes.join(', ')}`,
            })
          }
        }
      }

      let isLate = false

      // Can't upload if already submitted — but only for initial application edits.
      // Round-specific uploads (business plan, video for later rounds) are allowed
      // as long as the round is active.
      if (project.submittedAt && !input.roundId) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Cannot modify a submitted project',
        })
      }

      // Fetch round info and verify uploads are open. Normally a round must be
      // ROUND_ACTIVE; the grand-final documents are collected during the lead-up
      // while the LIVE_FINAL round is still DRAFT (it only "opens" at the event),
      // so allow uploads for a not-yet-closed LIVE_FINAL round too.
      let roundName: string | undefined
      if (input.roundId) {
        const round = await ctx.prisma.round.findUnique({
          where: { id: input.roundId },
          select: { name: true, status: true, roundType: true, finalizedAt: true, configJson: true },
        })
        if (round) {
          const uploadable =
            round.roundType === 'LIVE_FINAL'
              ? !round.finalizedAt &&
                (round.status === 'ROUND_DRAFT' || round.status === 'ROUND_ACTIVE') &&
                finalistUploadsEnabled(round.configJson)
              : round.status === 'ROUND_ACTIVE'
          if (!uploadable) {
            throw new TRPCError({
              code: 'BAD_REQUEST',
              message: 'This round is closed. Documents can no longer be uploaded.',
            })
          }
        }
        roundName = round?.name
      }

      const objectKey = generateObjectKey(project.title, input.fileName, roundName)

      const url = await getPresignedUrl(BUCKET_NAME, objectKey, 'PUT', 3600)

      return {
        url,
        bucket: BUCKET_NAME,
        objectKey,
        isLate,
        roundId: input.roundId || null,
      }
    }),

  /**
   * Save file metadata after upload
   */
  saveFileMetadata: protectedProcedure
    .input(
      z.object({
        projectId: z.string(),
        fileName: z.string(),
        mimeType: z.string(),
        size: z.number().int(),
        fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
        bucket: z.string(),
        objectKey: z.string(),
        roundId: z.string().optional(),
        isLate: z.boolean().optional(),
        requirementId: z.string().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Applicants or team members can save files
      if (ctx.user.role !== 'APPLICANT') {
        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: 'Only applicants or team members can save files',
          })
        }
      }

      // Verify project access
      const project = await ctx.prisma.project.findFirst({
        where: {
          id: input.projectId,
          OR: [
            { submittedByUserId: ctx.user.id },
            { teamMembers: { some: { userId: ctx.user.id } } },
          ],
        },
      })

      if (!project) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Project not found or you do not have access',
        })
      }

      // Verify the client-supplied objectKey actually belongs to this project's
      // sanitized prefix. Without this, a user could pass another team's
      // bucket+objectKey here, attach it to their own project row, and then
      // download the foreign object via file.getDownloadUrl (which authorizes
      // by bucket+objectKey -> projectId).
      if (input.bucket !== BUCKET_NAME) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Invalid file location',
        })
      }
      if (!objectKeyBelongsToProject(input.objectKey, project.title)) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Invalid file location for this project',
        })
      }

      // Block rejected projects
      if (await isProjectRejected(ctx.prisma, input.projectId)) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. File changes are no longer permitted.' })
      }

      const { projectId, roundId, isLate, requirementId, ...fileData } = input

      // Delete existing file: by requirementId if provided, otherwise by fileType
      if (requirementId) {
        await ctx.prisma.projectFile.deleteMany({
          where: {
            projectId,
            requirementId,
          },
        })
      } else {
        await ctx.prisma.projectFile.deleteMany({
          where: {
            projectId,
            fileType: input.fileType,
          },
        })
      }

      // Create new file record
      const file = await ctx.prisma.projectFile.create({
        data: {
          projectId,
          ...fileData,
          roundId: roundId || null,
          isLate: isLate || false,
          requirementId: requirementId || null,
        },
      })

      // Auto-transition: mark as IN_PROGRESS on file activity, then check completion
      if (roundId) {
        await triggerInProgressOnActivity(projectId, roundId, ctx.user.id, ctx.prisma)
        if (requirementId) {
          await checkRequirementsAndTransition(
            projectId,
            roundId,
            ctx.user.id,
            ctx.prisma,
          )
        }
      }

      // Audit log
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'APPLICANT_UPLOAD_FILE',
        entityType: 'ProjectFile',
        entityId: file.id,
        detailsJson: {
          projectId,
          fileName: input.fileName,
          fileType: input.fileType,
          roundId: roundId || null,
          isLate: isLate || false,
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      // Auto-analyze document (fire-and-forget, delayed for presigned upload)
      import('../services/document-analyzer').then(({ analyzeFileDelayed }) =>
        analyzeFileDelayed(file.id).catch((err) =>
          console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
      ).catch(() => {})

      // Notify the team's mentor(s) when a Grand-Final document is uploaded.
      if (input.roundId) {
        try {
          const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { roundType: true } })
          if (round?.roundType === 'LIVE_FINAL') {
            await notifyProjectMentors(input.projectId, {
              type: NotificationTypes.GRAND_FINAL_DOCS_SUBMITTED,
              title: 'Final document uploaded',
              message: `A team uploaded a Grand Final document: ${input.fileName}`,
              linkUrl: `/mentor/workspace/${input.projectId}`,
              metadata: { projectId: input.projectId, fileName: input.fileName },
            })
          }
        } catch (e) {
          console.error('[final-docs] mentor notify failed', e)
        }
      }

      return file
    }),

  /**
   * Delete a file from submission
   */
  deleteFile: protectedProcedure
    .input(z.object({ fileId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const file = await ctx.prisma.projectFile.findUniqueOrThrow({
        where: { id: input.fileId },
        include: { project: { include: { teamMembers: { select: { userId: true } } } } },
      })

      // Verify ownership or team membership
      const isOwner = file.project.submittedByUserId === ctx.user.id
      const isTeamMember = file.project.teamMembers.some((tm) => tm.userId === ctx.user.id)

      if (!isOwner && !isTeamMember) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You do not have access to this file',
        })
      }

      // Block rejected projects
      if (await isProjectRejected(ctx.prisma, file.project.id)) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. File changes are no longer permitted.' })
      }

      // Can't delete initial application files after submission
      if (file.project.submittedAt && !file.roundId) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Cannot modify a submitted project',
        })
      }

      // Round-specific files can only be modified while the round is open. As with
      // upload, a not-yet-closed LIVE_FINAL round (DRAFT, pre-event) counts as open.
      if (file.roundId) {
        const round = await ctx.prisma.round.findUnique({
          where: { id: file.roundId },
          select: { status: true, roundType: true, finalizedAt: true, configJson: true },
        })
        if (round) {
          const modifiable =
            round.roundType === 'LIVE_FINAL'
              ? !round.finalizedAt &&
                (round.status === 'ROUND_DRAFT' || round.status === 'ROUND_ACTIVE') &&
                finalistUploadsEnabled(round.configJson)
              : round.status === 'ROUND_ACTIVE'
          if (!modifiable) {
            throw new TRPCError({
              code: 'BAD_REQUEST',
              message: 'This round is closed. Documents can no longer be modified.',
            })
          }
        }
      }

      await ctx.prisma.projectFile.delete({
        where: { id: input.fileId },
      })

      // Audit log
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'APPLICANT_DELETE_FILE',
        entityType: 'ProjectFile',
        entityId: input.fileId,
        detailsJson: {
          projectId: file.project.id,
          fileName: file.fileName,
          fileType: file.fileType,
          roundId: file.roundId,
        },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { success: true }
    }),

  /**
   * Get status timeline from ProjectStatusHistory
   */
  getStatusTimeline: protectedProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      // Verify user has access to this project
      const project = await ctx.prisma.project.findFirst({
        where: {
          id: input.projectId,
          OR: [
            { submittedByUserId: ctx.user.id },
            {
              teamMembers: {
                some: { userId: ctx.user.id },
              },
            },
          ],
        },
        select: { id: true },
      })

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

      const history = await ctx.prisma.projectStatusHistory.findMany({
        where: { projectId: input.projectId },
        orderBy: { changedAt: 'asc' },
        select: {
          status: true,
          changedAt: true,
          changedBy: true,
        },
      })

      return history
    }),

  /**
   * Get submission status timeline
   */
  getSubmissionStatus: protectedProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      const project = await ctx.prisma.project.findFirst({
        where: {
          id: input.projectId,
          OR: [
            { submittedByUserId: ctx.user.id },
            {
              teamMembers: {
                some: { userId: ctx.user.id },
              },
            },
          ],
        },
        include: {
          program: { select: { id: true, name: true, year: true } },
          files: true,
          teamMembers: {
            include: {
              user: {
                select: { id: true, name: true, email: true },
              },
            },
          },
          wonAwards: {
            select: { id: true, name: true },
          },
        },
      })

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

      // Get the project status
      const currentStatus = project.status ?? 'SUBMITTED'

      // Fetch actual status history
      const statusHistory = await ctx.prisma.projectStatusHistory.findMany({
        where: { projectId: input.projectId },
        orderBy: { changedAt: 'asc' },
        select: { status: true, changedAt: true },
      })

      // Build a map of status -> earliest changedAt
      const statusDateMap = new Map<string, Date>()
      for (const entry of statusHistory) {
        if (!statusDateMap.has(entry.status)) {
          statusDateMap.set(entry.status, entry.changedAt)
        }
      }

      const isRejected = currentStatus === 'REJECTED'
      const hasWonAward = project.wonAwards.length > 0

      // Build timeline - handle REJECTED as terminal state
      const timeline = [
        {
          status: 'CREATED',
          label: 'Application Started',
          date: project.createdAt,
          completed: true,
          isTerminal: false,
        },
        {
          status: 'SUBMITTED',
          label: 'Application Submitted',
          date: project.submittedAt || statusDateMap.get('SUBMITTED') || null,
          completed: !!project.submittedAt || statusDateMap.has('SUBMITTED'),
          isTerminal: false,
        },
        {
          status: 'UNDER_REVIEW',
          label: 'Under Review',
          date: statusDateMap.get('ELIGIBLE') || statusDateMap.get('ASSIGNED') ||
            (currentStatus !== 'SUBMITTED' && project.submittedAt ? project.submittedAt : null),
          completed: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(currentStatus),
          isTerminal: false,
        },
      ]

      if (isRejected) {
        // For rejected projects, show REJECTED as the terminal red step
        timeline.push({
          status: 'REJECTED',
          label: 'Not Selected',
          date: statusDateMap.get('REJECTED') || null,
          completed: true,
          isTerminal: true,
        })
      } else {
        // Normal progression
        timeline.push(
          {
            status: 'SEMIFINALIST',
            label: 'Semi-finalist',
            date: statusDateMap.get('SEMIFINALIST') || null,
            completed: ['SEMIFINALIST', 'FINALIST'].includes(currentStatus) || hasWonAward,
            isTerminal: false,
          },
          {
            status: 'FINALIST',
            label: 'Finalist',
            date: statusDateMap.get('FINALIST') || null,
            completed: currentStatus === 'FINALIST' || hasWonAward,
            isTerminal: false,
          },
        )

        if (hasWonAward) {
          timeline.push({
            status: 'WINNER',
            label: `Winner${project.wonAwards.length > 0 ? ` - ${project.wonAwards[0].name}` : ''}`,
            date: null,
            completed: true,
            isTerminal: false,
          })
        }
      }

      return {
        project,
        timeline,
        currentStatus,
      }
    }),

  /**
   * List all submissions for current user (including as team member)
   */
  listMySubmissions: protectedProcedure.query(async ({ ctx }) => {
    if (ctx.user.role !== 'APPLICANT') {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: 'Only applicants can access submissions',
      })
    }

    // Find projects where user is either the submitter OR a team member
    const projects = await ctx.prisma.project.findMany({
      where: {
        OR: [
          { submittedByUserId: ctx.user.id },
          {
            teamMembers: {
              some: { userId: ctx.user.id },
            },
          },
        ],
      },
      include: {
        program: { select: { id: true, name: true, year: true } },
        files: true,
        teamMembers: {
          include: {
            user: {
              select: { id: true, name: true, email: true },
            },
          },
        },
      },
      orderBy: { createdAt: 'desc' },
    })

    // Add user's role in each project
    return projects.map((project) => {
      const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
      return {
        ...project,
        userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
        isTeamLead: project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD',
      }
    })
  }),

  /**
   * Get team members for a project
   */
  getTeamMembers: protectedProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      // Verify user has access to this project
      const project = await ctx.prisma.project.findFirst({
        where: {
          id: input.projectId,
          OR: [
            { submittedByUserId: ctx.user.id },
            {
              teamMembers: {
                some: { userId: ctx.user.id },
              },
            },
          ],
        },
        include: {
          teamMembers: {
            include: {
              user: {
                select: {
                  id: true,
                  name: true,
                  email: true,
                  status: true,
                  lastLoginAt: true,
                  profileImageKey: true,
                  profileImageProvider: true,
                },
              },
            },
            orderBy: { joinedAt: 'asc' },
          },
          submittedBy: {
            select: { id: true, name: true, email: true },
          },
        },
      })

      if (!project) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Project not found or you do not have access',
        })
      }

      // Generate presigned avatar URLs for team members with profile images
      const avatarUrls: Record<string, string> = {}
      for (const member of project.teamMembers) {
        if (member.user.profileImageKey) {
          const providerType = (member.user.profileImageProvider as StorageProviderType) || 's3'
          const provider = createStorageProvider(providerType)
          avatarUrls[member.userId] = await provider.getDownloadUrl(member.user.profileImageKey)
        }
      }

      return {
        teamMembers: project.teamMembers,
        submittedBy: project.submittedBy,
        avatarUrls,
      }
    }),

  /**
   * Invite a new team member
   */
  inviteTeamMember: protectedProcedure
    .input(
      z.object({
        projectId: z.string(),
        email: z.string().email(),
        name: z.string().min(1),
        role: z.enum(['MEMBER', 'ADVISOR']),
        title: z.string().optional(),
        nationality: z.string().optional(),
        country: z.string().optional(),
        institution: z.string().optional(),
        sendInvite: z.boolean().default(true),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const normalizedEmail = input.email.trim().toLowerCase()

      // Verify user is team lead
      const project = await ctx.prisma.project.findFirst({
        where: {
          id: input.projectId,
          OR: [
            { submittedByUserId: ctx.user.id },
            {
              teamMembers: {
                some: {
                  userId: ctx.user.id,
                  role: 'LEAD',
                },
              },
            },
          ],
        },
      })

      if (!project) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only team leads can invite new members',
        })
      }

      // Block rejected projects
      if (await isProjectRejected(ctx.prisma, input.projectId)) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. Team changes are no longer permitted.' })
      }

      // Check if already a team member
      const existingMember = await ctx.prisma.teamMember.findFirst({
        where: {
          projectId: input.projectId,
          user: { email: normalizedEmail },
        },
      })

      if (existingMember) {
        throw new TRPCError({
          code: 'CONFLICT',
          message: 'This person is already a team member',
        })
      }

      // Find or create user
      let user = await ctx.prisma.user.findUnique({
        where: { email: normalizedEmail },
      })

      if (!user) {
        user = await ctx.prisma.user.create({
          data: {
            email: normalizedEmail,
            name: input.name,
            role: 'APPLICANT',
            roles: ['APPLICANT'],
            status: 'NONE',
            nationality: input.nationality,
            country: input.country,
            institution: input.institution,
          },
        })
      } else {
        // Update existing user with new profile fields if provided
        const profileUpdates: Record<string, string> = {}
        if (input.nationality && !user.nationality) profileUpdates.nationality = input.nationality
        if (input.country && !user.country) profileUpdates.country = input.country
        if (input.institution && !user.institution) profileUpdates.institution = input.institution
        if (Object.keys(profileUpdates).length > 0) {
          user = await ctx.prisma.user.update({
            where: { id: user.id },
            data: profileUpdates,
          })
        }
      }

      if (user.status === 'SUSPENDED') {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'This user account is suspended and cannot be invited',
        })
      }

      const teamLeadName = ctx.user.name?.trim() || 'A team lead'
      const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
      const requiresAccountSetup = user.status !== 'ACTIVE'

      // If sendInvite is false, skip email entirely and leave user as NONE
      if (!input.sendInvite) {
        // No email, no status change — just create team membership below
      } else try {
        if (requiresAccountSetup) {
          const token = generateInviteToken()
          await ctx.prisma.user.update({
            where: { id: user.id },
            data: {
              status: 'INVITED',
              inviteToken: token,
              inviteTokenExpiresAt: new Date(Date.now() + TEAM_INVITE_TOKEN_EXPIRY_MS),
            },
          })

          const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
          await sendTeamMemberInviteEmail(
            user.email,
            user.name || input.name,
            project.title,
            teamLeadName,
            inviteUrl
          )
        } else {
          await sendStyledNotificationEmail(
            user.email,
            user.name || input.name,
            'TEAM_INVITATION',
            {
              title: 'You were added to a project team',
              message: `${teamLeadName} added you to the project "${project.title}".`,
              linkUrl: `${baseUrl}/applicant/team`,
              linkLabel: 'Open Team',
              metadata: {
                projectId: project.id,
                projectName: project.title,
              },
            },
            `You've been added to "${project.title}"`
          )
        }
      } catch (error) {
        try {
          await ctx.prisma.notificationLog.create({
            data: {
              userId: user.id,
              channel: 'EMAIL',
              provider: 'SMTP',
              type: 'TEAM_INVITATION',
              status: 'FAILED',
              errorMsg: error instanceof Error ? error.message : 'Unknown error',
            },
          })
        } catch (err) {
          console.error('Failed to log failed team invitation notification:', err)
          // Never fail on notification logging
        }

        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'Failed to send invitation email. Please try again.',
        })
      }

      // Create team membership
      const teamMember = await ctx.prisma.teamMember.create({
        data: {
          projectId: input.projectId,
          userId: user.id,
          role: input.role,
          title: input.title,
        },
        include: {
          user: {
            select: { id: true, name: true, email: true, status: true },
          },
        },
      })

      try {
        await ctx.prisma.notificationLog.create({
          data: {
            userId: user.id,
            channel: 'EMAIL',
            provider: 'SMTP',
            type: 'TEAM_INVITATION',
            status: 'SENT',
          },
        })
      } catch (err) {
        console.error('Failed to log sent team invitation notification:', err)
        // Never fail on notification logging
      }

      try {
        await createNotification({
          userId: user.id,
          type: 'TEAM_INVITATION',
          title: 'Team Invitation',
          message: `${teamLeadName} added you to "${project.title}"`,
          linkUrl: '/applicant/team',
          linkLabel: 'View Team',
          priority: 'normal',
          metadata: {
            projectId: project.id,
            projectName: project.title,
          },
        })
      } catch (err) {
        console.error('Failed to create in-app team invitation notification:', err)
        // Never fail invitation flow on in-app notification issues
      }

      return {
        teamMember,
        inviteEmailSent: true,
        requiresAccountSetup,
      }
    }),

  /**
   * Remove a team member
   */
  removeTeamMember: protectedProcedure
    .input(
      z.object({
        projectId: z.string(),
        userId: z.string(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify user is team lead
      const project = await ctx.prisma.project.findFirst({
        where: {
          id: input.projectId,
          OR: [
            { submittedByUserId: ctx.user.id },
            {
              teamMembers: {
                some: {
                  userId: ctx.user.id,
                  role: 'LEAD',
                },
              },
            },
          ],
        },
      })

      if (!project) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only team leads can remove members',
        })
      }

      // Block rejected projects
      if (await isProjectRejected(ctx.prisma, input.projectId)) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. Team changes are no longer permitted.' })
      }

      // Can't remove the original submitter
      if (project.submittedByUserId === input.userId) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Cannot remove the original applicant from the team',
        })
      }

      await ctx.prisma.teamMember.deleteMany({
        where: {
          projectId: input.projectId,
          userId: input.userId,
        },
      })

      return { success: true }
    }),

  /**
   * Send a message to the assigned mentor
   */
  sendMentorMessage: protectedProcedure
    .input(
      z.object({
        projectId: z.string(),
        message: z.string().min(1).max(5000),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify user is part of this project team
      const project = await ctx.prisma.project.findFirst({
        where: {
          id: input.projectId,
          OR: [
            { submittedByUserId: ctx.user.id },
            {
              teamMembers: {
                some: { userId: ctx.user.id },
              },
            },
          ],
        },
        include: {
          mentorAssignments: { select: { mentorId: true } },
        },
      })

      if (!project) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Project not found or you do not have access',
        })
      }

      // TODO(PR8 Task 7): notify ALL assigned mentors. For now we notify the
      // first one for legacy parity.
      const primaryMentorAssignment = project.mentorAssignments[0] ?? null
      if (!primaryMentorAssignment) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'No mentor assigned to 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 the (primary) mentor
      await createNotification({
        userId: primaryMentorAssignment.mentorId,
        type: 'MENTOR_MESSAGE',
        title: 'New Message',
        message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`,
        linkUrl: `/mentor/projects/${input.projectId}`,
        linkLabel: 'View Message',
        priority: 'normal',
        metadata: {
          projectId: input.projectId,
          projectName: project.title,
        },
      })

      return mentorMessage
    }),

  /**
   * Get mentor messages for a project (applicant side)
   */
  getMentorMessages: protectedProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      // Verify user is part of this project team
      const project = await ctx.prisma.project.findFirst({
        where: {
          id: input.projectId,
          OR: [
            { submittedByUserId: ctx.user.id },
            {
              teamMembers: {
                some: { userId: ctx.user.id },
              },
            },
          ],
        },
        select: { id: true },
      })

      if (!project) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Project not found or you do not have access',
        })
      }

      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 mentor as read
      await ctx.prisma.mentorMessage.updateMany({
        where: {
          projectId: input.projectId,
          senderId: { not: ctx.user.id },
          isRead: false,
        },
        data: { isRead: true },
      })

      return messages
    }),

  /**
   * Get the applicant's dashboard data: their project (latest edition),
   * team members, open rounds for document submission, and status timeline.
   */
  getMyDashboard: protectedProcedure.query(async ({ ctx }) => {
    if (ctx.user.role !== 'APPLICANT') {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: 'Only applicants can access this',
      })
    }

    // Find the applicant's project (most recent, from active edition if possible)
    const project = await ctx.prisma.project.findFirst({
      where: {
        OR: [
          { submittedByUserId: ctx.user.id },
          { teamMembers: { some: { userId: ctx.user.id } } },
        ],
      },
      include: {
        program: { select: { id: true, name: true, year: true, status: true } },
        files: {
          orderBy: { createdAt: 'desc' },
        },
        teamMembers: {
          include: {
            user: {
              select: { id: true, name: true, email: true, status: true },
            },
          },
          orderBy: { joinedAt: 'asc' },
        },
        submittedBy: {
          select: { id: true, name: true, email: true },
        },
        mentorAssignments: {
          include: {
            mentor: {
              select: { id: true, name: true, email: true, expertiseTags: true },
            },
          },
          orderBy: { assignedAt: 'asc' },
        },
        wonAwards: {
          select: { id: true, name: true },
        },
      },
      orderBy: { createdAt: 'desc' },
    })

    if (!project) {
      return { project: null, openRounds: [], timeline: [], currentStatus: null }
    }

    const currentStatus = project.status ?? 'SUBMITTED'

    // Fetch status history
    const statusHistory = await ctx.prisma.projectStatusHistory.findMany({
      where: { projectId: project.id },
      orderBy: { changedAt: 'asc' },
      select: { status: true, changedAt: true },
    })

    const statusDateMap = new Map<string, Date>()
    for (const entry of statusHistory) {
      if (!statusDateMap.has(entry.status)) {
        statusDateMap.set(entry.status, entry.changedAt)
      }
    }

    const isRejected = await isProjectRejected(ctx.prisma, project.id)
    const hasWonAward = project.wonAwards.length > 0

    // Build timeline
    const timeline = [
      {
        status: 'CREATED',
        label: 'Application Started',
        date: project.createdAt,
        completed: true,
        isTerminal: false,
      },
      {
        status: 'SUBMITTED',
        label: 'Application Submitted',
        date: project.submittedAt || statusDateMap.get('SUBMITTED') || null,
        completed: !!project.submittedAt || statusDateMap.has('SUBMITTED'),
        isTerminal: false,
      },
      {
        status: 'UNDER_REVIEW',
        label: 'Under Review',
        date: statusDateMap.get('ELIGIBLE') || statusDateMap.get('ASSIGNED') ||
          (currentStatus !== 'SUBMITTED' && project.submittedAt ? project.submittedAt : null),
        completed: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(currentStatus),
        isTerminal: false,
      },
    ]

    if (isRejected) {
      timeline.push({
        status: 'REJECTED',
        label: 'Not Selected',
        date: statusDateMap.get('REJECTED') || null,
        completed: true,
        isTerminal: true,
      })
    } else {
      timeline.push(
        {
          status: 'SEMIFINALIST',
          label: 'Semi-finalist',
          date: statusDateMap.get('SEMIFINALIST') || null,
          completed: ['SEMIFINALIST', 'FINALIST'].includes(currentStatus) || hasWonAward,
          isTerminal: false,
        },
        {
          status: 'FINALIST',
          label: 'Finalist',
          date: statusDateMap.get('FINALIST') || null,
          completed: currentStatus === 'FINALIST' || hasWonAward,
          isTerminal: false,
        },
      )

      if (hasWonAward) {
        timeline.push({
          status: 'WINNER',
          label: `Winner${project.wonAwards.length > 0 ? ` - ${project.wonAwards[0].name}` : ''}`,
          date: null,
          completed: true,
          isTerminal: false,
        })
      }
    }

    const programId = project.programId
    let openRounds: Array<{ id: string; name: string; slug: string | null; roundType: string; windowCloseAt: Date | null }> = []
    if (programId) {
      const allActiveRounds = await ctx.prisma.round.findMany({
        where: {
          competition: { programId },
          // Active rounds, plus the not-yet-opened (DRAFT) LIVE_FINAL round so
          // enrolled finalists can upload their grand-final documents during the
          // lead-up while the round itself stays "closed" until the event. The
          // per-project membership filter below restricts this to teams in it.
          OR: [
            { status: 'ROUND_ACTIVE' },
            { roundType: 'LIVE_FINAL', status: 'ROUND_DRAFT', finalizedAt: null },
          ],
        },
        orderBy: { sortOrder: 'asc' },
        select: {
          id: true,
          name: true,
          slug: true,
          roundType: true,
          windowCloseAt: true,
          configJson: true,
          specialAwardId: true,
          specialAward: { select: { name: true } },
        },
      })

      // Filter rounds based on award track: only show rounds the project is actually in
      const projectRoundIds = new Set(
        (await ctx.prisma.projectRoundState.findMany({
          where: { projectId: project.id },
          select: { roundId: true },
        })).map((prs) => prs.roundId)
      )
      const isInAwardTrack = allActiveRounds.some(
        (r) => r.specialAwardId && projectRoundIds.has(r.id)
      )

      openRounds = allActiveRounds
        .filter((r) => {
          // LIVE_FINAL (grand-final documents) only shows to enrolled finalists,
          // and only when the admin has enabled revised uploads.
          if (r.roundType === 'LIVE_FINAL' && (!projectRoundIds.has(r.id) || !finalistUploadsEnabled(r.configJson))) return false
          // Award round project isn't in → hide
          if (r.specialAwardId && !projectRoundIds.has(r.id)) return false
          // Main round when project is in award track and has no state in this round → hide
          if (!r.specialAwardId && isInAwardTrack && !projectRoundIds.has(r.id)) return false
          return true
        })
        .map((r) => ({
          id: r.id,
          name: r.specialAward ? `${r.specialAward.name}: ${r.name}` : r.name,
          slug: r.slug,
          roundType: r.roundType,
          windowCloseAt: r.windowCloseAt,
        }))
    }

    // Determine user's role in the project
    const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
    const isTeamLead = project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD'

    // Check if project has passed intake
    const passedIntake = await ctx.prisma.projectRoundState.findFirst({
      where: { projectId: project.id, state: 'PASSED', round: { roundType: 'INTAKE' } },
      select: { id: true },
    })

    // Check if there is an active intake round (applicants can edit project details during intake)
    const activeIntakeRound = await ctx.prisma.round.findFirst({
      where: {
        competition: { programId: project.programId },
        roundType: 'INTAKE',
        status: 'ROUND_ACTIVE',
      },
      select: { id: true },
    })

    // Generate presigned logo URL if the project has a logo
    let logoUrl: string | null = null
    if (project.logoKey) {
      const providerType = (project.logoProvider as StorageProviderType) || 's3'
      const provider = createStorageProvider(providerType)
      logoUrl = await provider.getDownloadUrl(project.logoKey)
    }

    // Does this user have an open mentor-change request for this project?
    // (Used by the applicant mentor page to disable the "Request a change" button.)
    const myPendingChangeRequest = await ctx.prisma.mentorChangeRequest.findFirst({
      where: {
        projectId: project.id,
        requestedByUserId: ctx.user.id,
        status: 'PENDING',
      },
      select: { id: true },
    })

    return {
      project: {
        ...project,
        isTeamLead,
        userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
      },
      openRounds: isRejected ? [] : openRounds,
      timeline,
      currentStatus,
      isRejected,
      hasPassedIntake: !!passedIntake,
      isIntakeOpen: !!activeIntakeRound,
      logoUrl,
      hasPendingMentorChangeRequest: !!myPendingChangeRequest,
    }
  }),

  /** Grand-final document status for the caller's project (banner + mentor panel). */
  getFinalDocumentStatus: protectedProcedure.query(async ({ ctx }) => {
    const project = await ctx.prisma.project.findFirst({
      where: {
        OR: [
          { submittedByUserId: ctx.user.id },
          { teamMembers: { some: { userId: ctx.user.id } } },
        ],
      },
      orderBy: { createdAt: 'desc' },
      select: { id: true },
    })
    if (!project) return null
    return getFinalDocumentStatusForProject(ctx.prisma, project.id)
  }),

  /**
   * Lightweight flags for conditional nav rendering.
   */
  getNavFlags: protectedProcedure.query(async ({ ctx }) => {
    if (ctx.user.role !== 'APPLICANT') {
      throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
    }

    const project = await ctx.prisma.project.findFirst({
      where: {
        OR: [
          { submittedByUserId: ctx.user.id },
          { teamMembers: { some: { userId: ctx.user.id } } },
        ],
      },
      select: {
        id: true,
        programId: true,
        mentorAssignments: { select: { id: true }, take: 1 },
      },
    })

    if (!project) {
      return { hasMentor: false, hasEvaluationRounds: false }
    }

    // Check if mentor is assigned (any active assignment counts)
    const hasMentor = project.mentorAssignments.length > 0

    // Check if feedback is available — first check admin settings, then fall back to per-round config
    let hasEvaluationRounds = false
    if (project.programId) {
      // Check admin settings first
      const adminFlags = await ctx.prisma.systemSettings.findMany({
        where: { key: { in: [
          'applicant_show_evaluation_feedback',
          'applicant_show_livefinal_feedback',
          'applicant_show_deliberation_feedback',
          'applicant_hide_feedback_from_rejected',
        ] } },
      })
      const adminMap = new Map(adminFlags.map((s) => [s.key, s.value]))
      const adminSettingsExist = adminFlags.length > 0

      const navProjectRejected = await isProjectRejected(ctx.prisma, project.id)

      if (adminSettingsExist) {
        // Use admin settings
        if (adminMap.get('applicant_hide_feedback_from_rejected') === 'true' && navProjectRejected) {
          hasEvaluationRounds = false
        } else {
          const anyEnabled = adminMap.get('applicant_show_evaluation_feedback') === 'true'
            || adminMap.get('applicant_show_livefinal_feedback') === 'true'
            || adminMap.get('applicant_show_deliberation_feedback') === 'true'
          if (anyEnabled) {
            const enabledTypes: RoundType[] = []
            if (adminMap.get('applicant_show_evaluation_feedback') === 'true') enabledTypes.push('EVALUATION')
            if (adminMap.get('applicant_show_livefinal_feedback') === 'true') enabledTypes.push('LIVE_FINAL')
            if (adminMap.get('applicant_show_deliberation_feedback') === 'true') enabledTypes.push('DELIBERATION')

            const projectRoundIds = (await ctx.prisma.projectRoundState.findMany({
              where: { projectId: project.id },
              select: { roundId: true },
            })).map((prs) => prs.roundId)

            hasEvaluationRounds = projectRoundIds.length > 0 && await ctx.prisma.round.count({
              where: {
                competition: { programId: project.programId },
                roundType: { in: enabledTypes },
                status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
                id: { in: projectRoundIds },
              },
            }) > 0
          }
        }
      } else {
        // Fall back to old per-round config
        const projectRoundIds = new Set(
          (await ctx.prisma.projectRoundState.findMany({
            where: { projectId: project.id },
            select: { roundId: true },
          })).map((prs) => prs.roundId)
        )
        const closedEvalRounds = projectRoundIds.size > 0
          ? await ctx.prisma.round.findMany({
              where: {
                competition: { programId: project.programId },
                roundType: 'EVALUATION',
                status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
                id: { in: [...projectRoundIds] },
              },
              select: { configJson: true },
            })
          : []
        hasEvaluationRounds = closedEvalRounds.some((r) => {
          const parsed = EvaluationConfigSchema.safeParse(r.configJson)
          if (!parsed.success || !parsed.data.applicantVisibility.enabled) return false
          if (parsed.data.applicantVisibility.hideFromRejected && navProjectRejected) return false
          return true
        })
      }
    }

    return { hasMentor, hasEvaluationRounds }
  }),

  /**
   * Filtered competition timeline showing only EVALUATION + Grand Finale.
   * Hides FILTERING/INTAKE/SUBMISSION/MENTORING from applicants.
   */
  getMyCompetitionTimeline: protectedProcedure.query(async ({ ctx }) => {
    if (ctx.user.role !== 'APPLICANT') {
      throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
    }

    const project = await ctx.prisma.project.findFirst({
      where: {
        OR: [
          { submittedByUserId: ctx.user.id },
          { teamMembers: { some: { userId: ctx.user.id } } },
        ],
      },
      select: { id: true, programId: true },
    })

    if (!project?.programId) {
      return { competitionName: null, entries: [] }
    }

    // Find competition via programId (fixes the programId/competitionId bug)
    const competition = await ctx.prisma.competition.findFirst({
      where: { programId: project.programId },
      select: { id: true, name: true },
    })

    if (!competition) {
      return { competitionName: null, entries: [] }
    }

    // Get all rounds ordered by sortOrder (including award rounds in same competition)
    const rounds = await ctx.prisma.round.findMany({
      where: { competitionId: competition.id },
      orderBy: { sortOrder: 'asc' },
      select: {
        id: true,
        name: true,
        roundType: true,
        status: true,
        windowOpenAt: true,
        windowCloseAt: true,
        specialAwardId: true,
        specialAward: { select: { name: true } },
      },
    })

    // Get all ProjectRoundState for this project
    const projectStates = await ctx.prisma.projectRoundState.findMany({
      where: { projectId: project.id },
      select: { roundId: true, state: true },
    })
    const stateMap = new Map(projectStates.map((ps) => [ps.roundId, ps.state]))

    type TimelineEntry = {
      id: string
      label: string
      roundType: string
      status: string
      windowOpenAt: Date | null
      windowCloseAt: Date | null
      projectState: string | null
      isSynthesizedRejection: boolean
    }

    const entries: TimelineEntry[] = []

    // Build lookup for filtering rounds
    const filteringRounds = rounds.filter((r) => r.roundType === 'FILTERING')
    const liveFinalRounds = rounds.filter((r) => r.roundType === 'LIVE_FINAL')
    const deliberationRounds = rounds.filter((r) => r.roundType === 'DELIBERATION')

    // Check if this project is in any SEPARATE_POOL award track
    const projectAwardRoundIds = new Set(
      rounds.filter((r) => r.specialAwardId && stateMap.has(r.id)).map((r) => r.id)
    )
    const projectAwardIds = new Set(
      rounds.filter((r) => r.specialAwardId && stateMap.has(r.id)).map((r) => r.specialAwardId!)
    )
    const isInAwardTrack = projectAwardRoundIds.size > 0

    // Process visible rounds: hide FILTERING, LIVE_FINAL, DELIBERATION always.
    // Also hide MENTORING unless the project is actually participating in it.
    // For award rounds: only show ones the project is in. For main rounds after
    // the split point: hide if project isn't in them and is in an award track.
    const visibleRounds = rounds.filter(
      (r) => {
        if (r.roundType === 'FILTERING' || r.roundType === 'LIVE_FINAL' || r.roundType === 'DELIBERATION') return false
        if (r.roundType === 'MENTORING' && !stateMap.has(r.id)) return false
        // Award round that project is NOT in → hide
        if (r.specialAwardId && !stateMap.has(r.id)) return false
        // Award round for a different award → hide
        if (r.specialAwardId && !projectAwardIds.has(r.specialAwardId)) return false
        // Main competition round where project has no state AND project is in award track → hide
        if (!r.specialAwardId && isInAwardTrack && !stateMap.has(r.id)) return false
        return true
      }
    )

    for (const round of visibleRounds) {
      const actualState = stateMap.get(round.id) ?? null

      // Check if a FILTERING round before this round rejected the project
      let projectState = actualState
      let isSynthesizedRejection = false

      const roundSortOrder = rounds.findIndex((r) => r.id === round.id)
      const precedingFilterRounds = filteringRounds.filter((fr) => {
        const frIdx = rounds.findIndex((r) => r.id === fr.id)
        return frIdx < roundSortOrder
      })

      for (const fr of precedingFilterRounds) {
        const filterState = stateMap.get(fr.id)
        if (filterState === 'REJECTED') {
          projectState = 'REJECTED'
          isSynthesizedRejection = true
          break
        }
        if ((filterState === 'IN_PROGRESS' || filterState === 'PENDING') && !actualState) {
          projectState = 'IN_PROGRESS'
          isSynthesizedRejection = true
        }
      }

      entries.push({
        id: round.id,
        label: round.specialAward ? `${round.specialAward.name}: ${round.name}` : round.name,
        roundType: round.roundType,
        status: round.status,
        windowOpenAt: round.windowOpenAt,
        windowCloseAt: round.windowCloseAt,
        projectState,
        isSynthesizedRejection,
      })
    }

    // Grand Finale: combine LIVE_FINAL + DELIBERATION
    if (liveFinalRounds.length > 0 || deliberationRounds.length > 0) {
      const grandFinaleRounds = [...liveFinalRounds, ...deliberationRounds]

      // Project state: prefer LIVE_FINAL state, then DELIBERATION
      let gfState: string | null = null
      for (const lfr of liveFinalRounds) {
        const s = stateMap.get(lfr.id)
        if (s) { gfState = s; break }
      }
      if (!gfState) {
        for (const dr of deliberationRounds) {
          const s = stateMap.get(dr.id)
          if (s) { gfState = s; break }
        }
      }

      // Status: most advanced status among grouped rounds
      const statusPriority: Record<string, number> = {
        ROUND_ARCHIVED: 3,
        ROUND_CLOSED: 2,
        ROUND_ACTIVE: 1,
        ROUND_DRAFT: 0,
      }
      let gfStatus = 'ROUND_DRAFT'
      for (const r of grandFinaleRounds) {
        if ((statusPriority[r.status] ?? 0) > (statusPriority[gfStatus] ?? 0)) {
          gfStatus = r.status
        }
      }

      // Use earliest window open and latest window close
      const openDates = grandFinaleRounds.map((r) => r.windowOpenAt).filter(Boolean) as Date[]
      const closeDates = grandFinaleRounds.map((r) => r.windowCloseAt).filter(Boolean) as Date[]

      // Check if a prior filtering rejection should propagate
      let isSynthesizedRejection = false
      const gfSortOrder = Math.min(
        ...grandFinaleRounds.map((r) => rounds.findIndex((rr) => rr.id === r.id))
      )
      for (const fr of filteringRounds) {
        const frIdx = rounds.findIndex((r) => r.id === fr.id)
        if (frIdx < gfSortOrder && stateMap.get(fr.id) === 'REJECTED') {
          gfState = 'REJECTED'
          isSynthesizedRejection = true
          break
        }
      }

      entries.push({
        id: 'grand-finale',
        label: 'Grand Finale',
        roundType: 'GRAND_FINALE',
        status: gfStatus,
        windowOpenAt: openDates.length > 0 ? new Date(Math.min(...openDates.map((d) => d.getTime()))) : null,
        windowCloseAt: closeDates.length > 0 ? new Date(Math.max(...closeDates.map((d) => d.getTime()))) : null,
        projectState: gfState,
        isSynthesizedRejection,
      })
    }

    // Handle projects manually created at a non-intake round:
    // If a project has state in a later round but not earlier, mark prior rounds as PASSED.
    // Find the earliest visible entry (EVALUATION or GRAND_FINALE) that has a real state.
    const firstEntryWithState = entries.findIndex(
      (e) => e.projectState !== null && !e.isSynthesizedRejection
    )
    if (firstEntryWithState > 0) {
      // All entries before the first real state should show as PASSED (if the round is closed/archived)
      for (let i = 0; i < firstEntryWithState; i++) {
        const entry = entries[i]
        if (!entry.projectState) {
          const roundClosed = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
          if (roundClosed) {
            entry.projectState = 'PASSED'
            entry.isSynthesizedRejection = false // not a rejection, it's a synthesized pass
          }
        }
      }
    }

    // If the project was rejected in filtering and there are entries after,
    // null-out states for entries after the rejection point
    let foundRejection = false
    for (const entry of entries) {
      if (foundRejection) {
        entry.projectState = null
      }
      if (entry.projectState === 'REJECTED' && entry.isSynthesizedRejection) {
        foundRejection = true
      }
    }

    return { competitionName: competition.name, entries }
  }),

  /**
   * Get anonymous jury evaluations visible to the applicant.
   * Reads visibility config from admin SystemSettings (not per-round configJson).
   * Supports EVALUATION, LIVE_FINAL, and DELIBERATION round types.
   * NEVER leaks juror identity.
   */
  getMyEvaluations: protectedProcedure.query(async ({ ctx }) => {
    if (ctx.user.role !== 'APPLICANT') {
      throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
    }

    // Load admin visibility settings
    const visKeys = [
      'applicant_show_evaluation_feedback', 'applicant_show_evaluation_scores',
      'applicant_show_evaluation_criteria', 'applicant_show_evaluation_text',
      'applicant_show_livefinal_feedback', 'applicant_show_livefinal_scores',
      'applicant_show_deliberation_feedback',
      'applicant_hide_feedback_from_rejected',
    ]
    const settingsRows = await ctx.prisma.systemSettings.findMany({
      where: { key: { in: visKeys } },
    })
    const sMap = new Map(settingsRows.map((s) => [s.key, s.value]))
    const adminSettingsExist = settingsRows.length > 0
    const flag = (k: string) => sMap.get(k) === 'true'

    const project = await ctx.prisma.project.findFirst({
      where: {
        OR: [
          { submittedByUserId: ctx.user.id },
          { teamMembers: { some: { userId: ctx.user.id } } },
        ],
      },
      select: { id: true, programId: true },
    })

    if (!project?.programId) return []

    const projectRoundIds = new Set(
      (await ctx.prisma.projectRoundState.findMany({
        where: { projectId: project.id },
        select: { roundId: true },
      })).map((prs) => prs.roundId)
    )
    if (projectRoundIds.size === 0) return []

    type ResultItem = {
      roundId: string
      roundName: string
      roundType: string
      evaluationCount: number
      evaluations: Array<{
        id: string
        submittedAt: Date | null
        globalScore: number | null
        criterionScores: Prisma.JsonValue | null
        feedbackText: string | null
        criteria: Prisma.JsonValue | null
      }>
    }
    const results: ResultItem[] = []

    // --- Backwards compatibility: if no admin settings exist yet, fall back to
    // the old per-round applicantVisibility config for EVALUATION rounds ---
    if (!adminSettingsExist) {
      const evalRounds = await ctx.prisma.round.findMany({
        where: {
          competition: { programId: project.programId },
          roundType: 'EVALUATION',
          status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
          id: { in: [...projectRoundIds] },
        },
        select: { id: true, name: true, configJson: true },
        orderBy: { sortOrder: 'asc' },
      })

      const projectIsRejected = await isProjectRejected(ctx.prisma, project.id)

      for (let i = 0; i < evalRounds.length; i++) {
        const round = evalRounds[i]
        const parsed = EvaluationConfigSchema.safeParse(round.configJson)
        if (!parsed.success || !parsed.data.applicantVisibility.enabled) continue
        if (parsed.data.applicantVisibility.hideFromRejected && projectIsRejected) continue
        const vis = parsed.data.applicantVisibility

        const evaluations = await ctx.prisma.evaluation.findMany({
          where: {
            assignment: { projectId: project.id, roundId: round.id },
            status: { in: ['SUBMITTED', 'LOCKED'] },
          },
          select: {
            id: true, submittedAt: true,
            globalScore: vis.showGlobalScore,
            criterionScoresJson: vis.showCriterionScores,
            feedbackText: vis.showFeedbackText,
            form: vis.showCriterionScores ? { select: { criteriaJson: true } } : false,
          },
          orderBy: { submittedAt: 'asc' },
        })
        if (evaluations.length === 0) continue

        results.push({
          roundId: round.id,
          roundName: round.name,
          roundType: 'EVALUATION',
          evaluationCount: evaluations.length,
          evaluations: evaluations.map((ev) => ({
            id: ev.id,
            submittedAt: ev.submittedAt,
            globalScore: vis.showGlobalScore ? (ev as { globalScore?: number | null }).globalScore ?? null : null,
            criterionScores: vis.showCriterionScores ? (ev as { criterionScoresJson?: Prisma.JsonValue }).criterionScoresJson ?? null : null,
            feedbackText: vis.showFeedbackText ? (ev as { feedbackText?: string | null }).feedbackText ?? null : null,
            criteria: vis.showCriterionScores ? ((ev as { form?: { criteriaJson: Prisma.JsonValue } | null }).form?.criteriaJson ?? null) : null,
          })),
        })
      }
      return results
    }

    // --- New admin settings flow ---
    const evalEnabled = flag('applicant_show_evaluation_feedback')
    const evalShowScores = flag('applicant_show_evaluation_scores')
    const evalShowCriteria = flag('applicant_show_evaluation_criteria')
    const evalShowText = flag('applicant_show_evaluation_text')
    const liveFinalEnabled = flag('applicant_show_livefinal_feedback')
    const liveFinalShowScores = flag('applicant_show_livefinal_scores')
    const deliberationEnabled = flag('applicant_show_deliberation_feedback')
    const hideFromRejected = flag('applicant_hide_feedback_from_rejected')

    if (!evalEnabled && !liveFinalEnabled && !deliberationEnabled) return []

    const projectIsRejected = hideFromRejected ? await isProjectRejected(ctx.prisma, project.id) : false
    if (projectIsRejected) return []

    // Build round type filter
    const enabledTypes: RoundType[] = []
    if (evalEnabled) enabledTypes.push('EVALUATION')
    if (liveFinalEnabled) enabledTypes.push('LIVE_FINAL')
    if (deliberationEnabled) enabledTypes.push('DELIBERATION')

    const rounds = await ctx.prisma.round.findMany({
      where: {
        competition: { programId: project.programId },
        roundType: { in: enabledTypes },
        status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
        id: { in: [...projectRoundIds] },
      },
      select: { id: true, name: true, roundType: true },
      orderBy: { sortOrder: 'asc' },
    })

    for (const round of rounds) {
      if (round.roundType === 'EVALUATION') {
        const evaluations = await ctx.prisma.evaluation.findMany({
          where: {
            assignment: { projectId: project.id, roundId: round.id },
            status: { in: ['SUBMITTED', 'LOCKED'] },
          },
          select: {
            id: true,
            submittedAt: true,
            globalScore: evalShowScores,
            criterionScoresJson: evalShowCriteria,
            feedbackText: evalShowText,
            form: evalShowCriteria ? { select: { criteriaJson: true } } : false,
          },
          orderBy: { submittedAt: 'asc' },
        })
        if (evaluations.length === 0) continue

        results.push({
          roundId: round.id,
          roundName: round.name,
          roundType: 'EVALUATION',
          evaluationCount: evaluations.length,
          evaluations: evaluations.map((ev) => ({
            id: ev.id,
            submittedAt: ev.submittedAt,
            globalScore: evalShowScores ? (ev as { globalScore?: number | null }).globalScore ?? null : null,
            criterionScores: evalShowCriteria ? (ev as { criterionScoresJson?: Prisma.JsonValue }).criterionScoresJson ?? null : null,
            feedbackText: evalShowText ? (ev as { feedbackText?: string | null }).feedbackText ?? null : null,
            criteria: evalShowCriteria ? ((ev as { form?: { criteriaJson: Prisma.JsonValue } | null }).form?.criteriaJson ?? null) : null,
          })),
        })
      } else if (round.roundType === 'LIVE_FINAL') {
        // LiveVote scores — anonymized
        // Only show jury votes, not audience votes
        const votes = await ctx.prisma.liveVote.findMany({
          where: {
            projectId: project.id,
            session: { roundId: round.id },
            isAudienceVote: false,
          },
          select: {
            id: true,
            score: true,
            votedAt: true,
          },
          orderBy: { votedAt: 'asc' },
        })
        if (votes.length === 0) continue

        results.push({
          roundId: round.id,
          roundName: round.name,
          roundType: 'LIVE_FINAL',
          evaluationCount: votes.length,
          evaluations: votes.map((v) => ({
            id: v.id,
            submittedAt: v.votedAt,
            globalScore: liveFinalShowScores ? v.score : null,
            criterionScores: null,
            feedbackText: null,
            criteria: null,
          })),
        })
      } else if (round.roundType === 'DELIBERATION') {
        // DeliberationVote — per-juror votes for this project
        const votes = await ctx.prisma.deliberationVote.findMany({
          where: {
            session: { roundId: round.id },
            projectId: project.id,
            runoffRound: 0,
          },
          select: {
            id: true,
            createdAt: true,
            rank: true,
            isWinnerPick: true,
          },
          orderBy: { createdAt: 'asc' },
        })
        if (votes.length === 0) continue

        results.push({
          roundId: round.id,
          roundName: round.name,
          roundType: 'DELIBERATION',
          evaluationCount: votes.length,
          evaluations: votes.map((v) => ({
            id: v.id,
            submittedAt: v.createdAt,
            globalScore: v.rank,
            criterionScores: null,
            feedbackText: v.isWinnerPick ? 'Selected as winner' : (v.rank ? `Ranked #${v.rank}` : null),
            criteria: null,
          })),
        })
      }
    }

    return results
  }),

  /**
   * Upcoming deadlines for dashboard card.
   */
  getUpcomingDeadlines: protectedProcedure.query(async ({ ctx }) => {
    if (ctx.user.role !== 'APPLICANT') {
      throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
    }

    const project = await ctx.prisma.project.findFirst({
      where: {
        OR: [
          { submittedByUserId: ctx.user.id },
          { teamMembers: { some: { userId: ctx.user.id } } },
        ],
      },
      select: { id: true, programId: true },
    })

    if (!project?.programId) return []

    const now = new Date()
    const rounds = await ctx.prisma.round.findMany({
      where: {
        competition: { programId: project.programId },
        status: 'ROUND_ACTIVE',
        windowCloseAt: { gt: now },
      },
      select: {
        id: true,
        name: true,
        windowCloseAt: true,
        specialAwardId: true,
        specialAward: { select: { name: true } },
      },
      orderBy: { windowCloseAt: 'asc' },
    })

    // Filter by award track membership
    const projectStates = await ctx.prisma.projectRoundState.findMany({
      where: { projectId: project.id },
      select: { roundId: true, round: { select: { specialAwardId: true } } },
    })
    const projectRoundIds = new Set(projectStates.map((prs) => prs.roundId))
    const isInAwardTrack = projectStates.some((prs) => prs.round.specialAwardId)

    return rounds
      .filter((r) => {
        if (r.specialAwardId && !projectRoundIds.has(r.id)) return false
        if (!r.specialAwardId && isInAwardTrack && !projectRoundIds.has(r.id)) return false
        return true
      })
      .map((r) => ({
        roundName: r.specialAward ? `${r.specialAward.name}: ${r.name}` : r.name,
        windowCloseAt: r.windowCloseAt!,
      }))
  }),

  /**
   * Document completeness progress for dashboard card.
   */
  getDocumentCompleteness: protectedProcedure.query(async ({ ctx }) => {
    if (ctx.user.role !== 'APPLICANT') {
      throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
    }

    const project = await ctx.prisma.project.findFirst({
      where: {
        OR: [
          { submittedByUserId: ctx.user.id },
          { teamMembers: { some: { userId: ctx.user.id } } },
        ],
      },
      select: { id: true, programId: true },
    })

    if (!project?.programId) return []

    // Find active rounds with file requirements
    const allRounds = await ctx.prisma.round.findMany({
      where: {
        competition: { programId: project.programId },
        status: 'ROUND_ACTIVE',
        fileRequirements: { some: {} },
      },
      select: {
        id: true,
        name: true,
        specialAwardId: true,
        specialAward: { select: { name: true } },
        fileRequirements: {
          select: { id: true },
        },
      },
      orderBy: { sortOrder: 'asc' },
    })

    // Filter by award track membership
    const projectRoundIds = new Set(
      (await ctx.prisma.projectRoundState.findMany({
        where: { projectId: project.id },
        select: { roundId: true },
      })).map((prs) => prs.roundId)
    )
    const isInAwardTrack = allRounds.some(
      (r) => r.specialAwardId && projectRoundIds.has(r.id)
    )
    const rounds = allRounds.filter((r) => {
      if (r.specialAwardId && !projectRoundIds.has(r.id)) return false
      if (!r.specialAwardId && isInAwardTrack && !projectRoundIds.has(r.id)) return false
      return true
    })

    const results: Array<{ roundId: string; roundName: string; required: number; uploaded: number }> = []

    for (const round of rounds) {
      const requirementIds = round.fileRequirements.map((fr) => fr.id)
      if (requirementIds.length === 0) continue

      const uploaded = await ctx.prisma.projectFile.count({
        where: {
          projectId: project.id,
          requirementId: { in: requirementIds },
        },
      })

      results.push({
        roundId: round.id,
        roundName: round.specialAward ? `${round.specialAward.name}: ${round.name}` : round.name,
        required: requirementIds.length,
        uploaded,
      })
    }

    return results
  }),

  /**
   * Get onboarding context for applicant wizard — project info, institution, logo status.
   */
  getOnboardingContext: protectedProcedure.query(async ({ ctx }) => {
    if (ctx.user.role !== 'APPLICANT') {
      return null
    }

    const project = await ctx.prisma.project.findFirst({
      where: {
        OR: [
          { submittedByUserId: ctx.user.id },
          { teamMembers: { some: { userId: ctx.user.id } } },
        ],
      },
      select: {
        id: true,
        title: true,
        institution: true,
        logoKey: true,
      },
    })

    if (!project) return null

    return {
      projectId: project.id,
      projectTitle: project.title,
      institution: project.institution,
      hasLogo: !!project.logoKey,
    }
  }),

  /**
   * Get a pre-signed URL for uploading a project logo (applicant access).
   */
  getProjectLogoUploadUrl: protectedProcedure
    .input(
      z.object({
        projectId: z.string(),
        fileName: z.string(),
        contentType: z.string(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify team membership
      const isMember = await ctx.prisma.project.findFirst({
        where: {
          id: input.projectId,
          OR: [
            { submittedByUserId: ctx.user.id },
            { teamMembers: { some: { userId: ctx.user.id } } },
          ],
        },
        select: { id: true },
      })

      if (!isMember) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
      }

      // Block rejected projects
      if (await isProjectRejected(ctx.prisma, input.projectId)) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. Logo changes are no longer permitted.' })
      }

      return getImageUploadUrl(
        input.projectId,
        input.fileName,
        input.contentType,
        generateLogoKey
      )
    }),

  /**
   * Confirm project logo upload (applicant access).
   */
  confirmProjectLogo: protectedProcedure
    .input(
      z.object({
        projectId: z.string(),
        key: z.string(),
        providerType: z.enum(['s3', 'local']),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Verify team membership
      const isMember = await ctx.prisma.project.findFirst({
        where: {
          id: input.projectId,
          OR: [
            { submittedByUserId: ctx.user.id },
            { teamMembers: { some: { userId: ctx.user.id } } },
          ],
        },
        select: { id: true },
      })

      if (!isMember) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
      }

      const logoConfig: ImageUploadConfig<{ logoKey: string | null; logoProvider: string | null }> = {
        label: 'logo',
        generateKey: generateLogoKey,
        findCurrent: (prisma, entityId) =>
          prisma.project.findUnique({
            where: { id: entityId },
            select: { logoKey: true, logoProvider: true },
          }),
        getImageKey: (record) => record.logoKey,
        getProviderType: (record) =>
          (record.logoProvider as StorageProviderType) || 's3',
        setImage: (prisma, entityId, key, providerType) =>
          prisma.project.update({
            where: { id: entityId },
            data: { logoKey: key, logoProvider: providerType },
          }),
        clearImage: (prisma, entityId) =>
          prisma.project.update({
            where: { id: entityId },
            data: { logoKey: null, logoProvider: null },
          }),
        auditEntityType: 'Project',
        auditFieldName: 'logoKey',
      }

      await confirmImageUpload(ctx.prisma, logoConfig, input.projectId, input.key, input.providerType, {
        userId: ctx.user.id,
        ip: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { success: true }
    }),

  /**
   * Delete project logo (applicant access).
   */
  deleteProjectLogo: protectedProcedure
    .input(z.object({ projectId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const isMember = await ctx.prisma.project.findFirst({
        where: {
          id: input.projectId,
          OR: [
            { submittedByUserId: ctx.user.id },
            { teamMembers: { some: { userId: ctx.user.id } } },
          ],
        },
        select: { id: true },
      })

      if (!isMember) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
      }

      const logoConfig: ImageUploadConfig<{ logoKey: string | null; logoProvider: string | null }> = {
        label: 'logo',
        generateKey: generateLogoKey,
        findCurrent: (prisma, entityId) =>
          prisma.project.findUnique({
            where: { id: entityId },
            select: { logoKey: true, logoProvider: true },
          }),
        getImageKey: (record) => record.logoKey,
        getProviderType: (record) =>
          (record.logoProvider as StorageProviderType) || 's3',
        setImage: (prisma, entityId, key, providerType) =>
          prisma.project.update({
            where: { id: entityId },
            data: { logoKey: key, logoProvider: providerType },
          }),
        clearImage: (prisma, entityId) =>
          prisma.project.update({
            where: { id: entityId },
            data: { logoKey: null, logoProvider: null },
          }),
        auditEntityType: 'Project',
        auditFieldName: 'logoKey',
      }

      return deleteImage(ctx.prisma, logoConfig, input.projectId, {
        userId: ctx.user.id,
        ip: ctx.ip,
        userAgent: ctx.userAgent,
      })
    }),

  /**
   * Get project logo URL (applicant access).
   */
  getProjectLogoUrl: protectedProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      const logoConfig = {
        findCurrent: (prisma: typeof ctx.prisma, entityId: string) =>
          prisma.project.findUnique({
            where: { id: entityId },
            select: { logoKey: true, logoProvider: true },
          }),
        getImageKey: (record: { logoKey: string | null }) => record.logoKey,
        getProviderType: (record: { logoProvider: string | null }) =>
          (record.logoProvider as StorageProviderType) || 's3' as StorageProviderType,
      }

      return getImageUrl(ctx.prisma, logoConfig, input.projectId)
    }),

  /**
   * Withdraw from competition. Only team lead can withdraw.
   * Finds the current active (non-terminal) ProjectRoundState and transitions to WITHDRAWN.
   */
  /**
   * Get mentoring request status for a project in a MENTORING round
   */
  getMentoringRequestStatus: protectedProcedure
    .input(z.object({ projectId: z.string(), roundId: z.string() }))
    .query(async ({ ctx, input }) => {
      if (ctx.user.role !== 'APPLICANT') {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
      }

      const round = await ctx.prisma.round.findUnique({
        where: { id: input.roundId },
        select: { id: true, roundType: true, status: true, configJson: true, windowOpenAt: true },
      })

      if (!round || round.roundType !== 'MENTORING') {
        return { available: false, requested: false, requestedAt: null, deadline: null, canStillRequest: false }
      }

      const config = MentoringConfigSchema.safeParse(round.configJson)
      const deadlineDays = config.success ? config.data.mentoringRequestDeadlineDays : 14

      const deadline = round.windowOpenAt
        ? new Date(new Date(round.windowOpenAt).getTime() + deadlineDays * 24 * 60 * 60 * 1000)
        : null

      const canStillRequest = round.status === 'ROUND_ACTIVE' && (!deadline || new Date() < deadline)

      const prs = await ctx.prisma.projectRoundState.findUnique({
        where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } },
        select: { metadataJson: true },
      })

      const metadata = (prs?.metadataJson as Record<string, unknown>) ?? {}
      const requested = !!metadata.mentoringRequested
      const requestedAt = metadata.mentoringRequestedAt ? new Date(metadata.mentoringRequestedAt as string) : null

      return { available: true, requested, requestedAt, deadline, canStillRequest }
    }),

  /**
   * Request or cancel mentoring for the current MENTORING round
   */
  requestMentoring: protectedProcedure
    .input(z.object({ projectId: z.string(), roundId: z.string(), requesting: z.boolean() }))
    .mutation(async ({ ctx, input }) => {
      if (ctx.user.role !== 'APPLICANT') {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can request mentoring' })
      }

      // Verify caller is team lead
      const project = await ctx.prisma.project.findUnique({
        where: { id: input.projectId },
        select: { id: true, submittedByUserId: true, title: true },
      })
      if (!project) throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
      if (project.submittedByUserId !== ctx.user.id) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the team lead can request mentoring' })
      }

      // Verify round is MENTORING and ACTIVE
      const round = await ctx.prisma.round.findUnique({
        where: { id: input.roundId },
        select: { id: true, roundType: true, status: true, configJson: true, windowOpenAt: true },
      })
      if (!round || round.roundType !== 'MENTORING') {
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a mentoring round' })
      }
      if (round.status !== 'ROUND_ACTIVE') {
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'Mentoring round is not active' })
      }

      // Check deadline
      const config = MentoringConfigSchema.safeParse(round.configJson)
      const deadlineDays = config.success ? config.data.mentoringRequestDeadlineDays : 14
      if (round.windowOpenAt) {
        const deadline = new Date(new Date(round.windowOpenAt).getTime() + deadlineDays * 24 * 60 * 60 * 1000)
        if (new Date() > deadline) {
          throw new TRPCError({ code: 'BAD_REQUEST', message: 'Mentoring request window has closed' })
        }
      }

      // Find PRS
      const prs = await ctx.prisma.projectRoundState.findUnique({
        where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } },
      })
      if (!prs) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'Project is not assigned to this round' })
      }

      const existingMeta = (prs.metadataJson as Record<string, unknown>) ?? {}

      // Update metadataJson with mentoring request info
      await ctx.prisma.projectRoundState.update({
        where: { id: prs.id },
        data: {
          metadataJson: {
            ...existingMeta,
            mentoringRequested: input.requesting,
            mentoringRequestedAt: input.requesting ? new Date().toISOString() : null,
          },
        },
      })

      // If requesting mentoring and currently PASSED (pass-through), transition to IN_PROGRESS
      if (input.requesting && prs.state === 'PASSED') {
        await transitionProject(
          input.projectId, input.roundId,
          'IN_PROGRESS' as Parameters<typeof transitionProject>[2],
          ctx.user.id, ctx.prisma,
        )
      }

      await logAudit({
        prisma: ctx.prisma,
        action: input.requesting ? 'MENTORING_REQUESTED' : 'MENTORING_CANCELLED',
        entityType: 'Project',
        entityId: input.projectId,
        userId: ctx.user.id,
        detailsJson: { roundId: input.roundId, projectTitle: project.title },
      })

      return { success: true, requesting: input.requesting }
    }),

  updateDescription: protectedProcedure
    .input(z.object({
      projectId: z.string(),
      description: z.string().max(10000),
    }))
    .mutation(async ({ ctx, input }) => {
      if (ctx.user.role !== 'APPLICANT') {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can update descriptions' })
      }

      // Check admin setting
      const setting = await ctx.prisma.systemSettings.findUnique({
        where: { key: 'applicant_allow_description_edit' },
      })
      if (setting?.value !== 'true') {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Description editing is currently disabled' })
      }

      // Verify membership
      const member = await ctx.prisma.teamMember.findFirst({
        where: { projectId: input.projectId, userId: ctx.user.id },
      })
      if (!member) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
      }

      await ctx.prisma.project.update({
        where: { id: input.projectId },
        data: { description: input.description },
      })

      return { success: true }
    }),

  withdrawFromCompetition: protectedProcedure
    .input(z.object({ projectId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      if (ctx.user.role !== 'APPLICANT') {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can withdraw' })
      }

      // Verify caller is team lead (submittedByUserId)
      const project = await ctx.prisma.project.findUnique({
        where: { id: input.projectId },
        select: { id: true, submittedByUserId: true, title: true },
      })

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

      if (project.submittedByUserId !== ctx.user.id) {
        throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the team lead can withdraw from the competition' })
      }

      // Find the active (non-terminal) ProjectRoundState
      const activePrs = await ctx.prisma.projectRoundState.findFirst({
        where: {
          projectId: input.projectId,
          round: { status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] } },
        },
        include: { round: { select: { id: true, name: true } } },
        orderBy: { round: { sortOrder: 'desc' } },
      })

      if (!activePrs || isTerminalState(activePrs.state as Parameters<typeof isTerminalState>[0])) {
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active round participation to withdraw from' })
      }

      const result = await transitionProject(
        input.projectId,
        activePrs.roundId,
        'WITHDRAWN' as Parameters<typeof transitionProject>[2],
        ctx.user.id,
        ctx.prisma,
      )

      if (!result.success) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: result.errors?.join('; ') ?? 'Failed to withdraw',
        })
      }

      // Audit log
      await logAudit({
        prisma: ctx.prisma,
        action: 'WITHDRAWAL',
        entityType: 'Project',
        entityId: input.projectId,
        userId: ctx.user.id,
        detailsJson: { roundId: activePrs.roundId, roundName: activePrs.round.name, projectTitle: project.title },
      })

      return { success: true, roundId: activePrs.roundId, roundName: activePrs.round.name }
    }),

  /**
   * Last N messages + unread count for the applicant's project mentor workspace.
   * Drives the 'Conversation with [Mentor]' card on /applicant.
   */
  getMentorConversationPreview: protectedProcedure
    .input(z.object({ projectId: z.string(), limit: z.number().min(1).max(10).default(3) }))
    .query(async ({ ctx, input }) => {
      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: 'Not a team member of this project',
        })
      }

      // TODO(PR8 Task 7): when multiple mentors are assigned, surface them all
      // in the applicant message thread. For now we display the most recently
      // assigned (non-dropped) mentor as the "primary".
      const assignment = await ctx.prisma.mentorAssignment.findFirst({
        where: { projectId: input.projectId },
        orderBy: { assignedAt: 'desc' },
        include: { mentor: { select: { id: true, name: true, email: true } } },
      })

      const [messages, unreadCount] = await Promise.all([
        ctx.prisma.mentorMessage.findMany({
          where: { projectId: input.projectId },
          include: { sender: { select: { id: true, name: true, email: true } } },
          orderBy: { createdAt: 'desc' },
          take: input.limit,
        }),
        ctx.prisma.mentorMessage.count({
          where: {
            projectId: input.projectId,
            senderId: { not: ctx.user.id },
            isRead: false,
          },
        }),
      ])

      return {
        mentor: assignment?.mentor ?? null,
        messages: messages.reverse(),
        unreadCount,
      }
    }),

  /**
   * Returns the caller's project's finalist confirmation (if any) plus the
   * data needed by the team-lead's "Edit attendees" dialog: the team roster,
   * the current AttendingMember rows, the program cap, and the editable
   * cutoff derived from the LIVE_FINAL round window.
   *
   * Returns null when the caller is not on a team with a confirmation.
   */
  getMyFinalistConfirmation: protectedProcedure.query(async ({ ctx }) => {
    const project = await ctx.prisma.project.findFirst({
      where: {
        OR: [
          { submittedByUserId: ctx.user.id },
          { teamMembers: { some: { userId: ctx.user.id } } },
        ],
        finalistConfirmation: { isNot: null },
      },
      include: {
        program: { select: { id: true, defaultAttendeeCap: true } },
        teamMembers: {
          include: {
            user: { select: { id: true, name: true, email: true } },
          },
          orderBy: { joinedAt: 'asc' },
        },
        finalistConfirmation: {
          include: {
            attendingMembers: {
              select: { id: true, userId: true, needsVisa: true },
            },
          },
        },
      },
      orderBy: { createdAt: 'desc' },
    })

    if (!project || !project.finalistConfirmation) return null

    const callerMember = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
    const isLead = callerMember?.role === 'LEAD'

    let cutoffAt: Date | null = null
    let editableNow = true
    const round = await ctx.prisma.round.findFirst({
      where: { competition: { programId: project.program.id }, roundType: 'LIVE_FINAL' },
      orderBy: { sortOrder: 'desc' },
      select: { windowOpenAt: true, configJson: true },
    })
    if (round?.windowOpenAt) {
      const cfg = (round.configJson ?? {}) as { attendeeEditCutoffHours?: number }
      const cutoffHours = cfg.attendeeEditCutoffHours ?? 48
      cutoffAt = new Date(round.windowOpenAt.getTime() - cutoffHours * 3_600_000)
      editableNow = Date.now() <= cutoffAt.getTime()
    }

    return {
      project: {
        id: project.id,
        title: project.title,
        programId: project.program.id,
        teamMembers: project.teamMembers.map((tm) => ({
          userId: tm.userId,
          role: tm.role,
          user: tm.user,
        })),
        program: { defaultAttendeeCap: project.program.defaultAttendeeCap },
      },
      confirmation: {
        id: project.finalistConfirmation.id,
        status: project.finalistConfirmation.status,
        attendingMembers: project.finalistConfirmation.attendingMembers,
      },
      isLead,
      cutoffAt,
      editableNow,
    }
  }),

  /**
   * Returns the caller's visa application rows (one per AttendingMember
   * the caller owns) when their program has visaStatusVisibleToMembers=true.
   * Returns null when the toggle is off so the UI can hide the section
   * entirely. Returns an empty array when the toggle is on but the caller
   * has no needsVisa attendees yet.
   */
  getMyVisaApplications: protectedProcedure.query(async ({ ctx }) => {
    const attendees = await ctx.prisma.attendingMember.findMany({
      where: { userId: ctx.user.id },
      include: {
        confirmation: {
          select: {
            id: true,
            project: {
              select: {
                id: true,
                programId: true,
                program: { select: { visaStatusVisibleToMembers: true } },
              },
            },
          },
        },
        visaApplication: true,
      },
    })

    const visibleAttendees = attendees.filter(
      (a) => a.confirmation.project.program.visaStatusVisibleToMembers,
    )
    if (attendees.length > 0 && visibleAttendees.length === 0) {
      return null
    }

    return visibleAttendees
      .filter((a) => a.visaApplication !== null)
      .map((a) => ({
        id: a.visaApplication!.id,
        attendingMemberId: a.id,
        userId: a.userId,
        status: a.visaApplication!.status,
        nationality: a.visaApplication!.nationality,
        invitationSentAt: a.visaApplication!.invitationSentAt,
        appointmentAt: a.visaApplication!.appointmentAt,
        decisionAt: a.visaApplication!.decisionAt,
        notes: a.visaApplication!.notes,
        projectId: a.confirmation.project.id,
      }))
  }),

  /**
   * Returns logistics info for the caller's confirmed finalist project:
   * hotel details, the caller's own flight detail, and visa visibility + status.
   * Returns null when the caller has no confirmed finalist project.
   */
  getMyLogistics: protectedProcedure.query(async ({ ctx }) => {
    const project = await ctx.prisma.project.findFirst({
      where: {
        OR: [
          { submittedByUserId: ctx.user.id },
          { teamMembers: { some: { userId: ctx.user.id } } },
        ],
        finalistConfirmation: { isNot: null },
      },
      include: {
        program: {
          select: { id: true, visaStatusVisibleToMembers: true },
        },
        finalistConfirmation: true,
      },
      orderBy: { createdAt: 'desc' },
    })

    if (!project || !project.finalistConfirmation) return null
    if (project.finalistConfirmation.status !== 'CONFIRMED') return null

    const programId = project.program.id
    const confirmationId = project.finalistConfirmation.id
    const visaVisible = project.program.visaStatusVisibleToMembers

    // Caller's own AttendingMember + hotel stay + flight + visa
    const attendee = await ctx.prisma.attendingMember.findFirst({
      where: { confirmationId, userId: ctx.user.id },
      include: {
        hotelStay: { include: { hotel: true } },
        flightDetail: true,
        visaApplication: visaVisible,
      },
    })

    const stay = attendee?.hotelStay ?? null
    const hotel = stay?.hotel
      ? {
          name: stay.hotel.name,
          address: stay.hotel.address,
          link: stay.hotel.link,
          notes: stay.hotel.notes,
        }
      : null
    const room = stay
      ? {
          roomNumber: stay.roomNumber,
          checkInAt: stay.checkInAt,
          checkOutAt: stay.checkOutAt,
        }
      : null

    const fd = attendee?.flightDetail ?? null
    const myFlight = fd
      ? {
          arrivalAt: fd.arrivalAt,
          arrivalFlightNumber: fd.arrivalFlightNumber,
          arrivalAirport: fd.arrivalAirport,
          departureAt: fd.departureAt,
          departureFlightNumber: fd.departureFlightNumber,
          departureAirport: fd.departureAirport,
          status: fd.status,
        }
      : null

    const va = visaVisible ? (attendee?.visaApplication ?? null) : null
    const myVisa = va ? { status: va.status, nationality: va.nationality } : null

    return {
      projectTitle: project.title,
      confirmationStatus: project.finalistConfirmation.status,
      hotel,
      room,
      myFlight,
      visaVisible,
      myVisa,
    }
  }),

  /**
   * Allows the authenticated user to self-declare their passport nationality
   * on their own VisaApplication when visaStatusVisibleToMembers is true.
   */
  updateMyVisaNationality: protectedProcedure
    .input(z.object({ nationality: z.string().max(100) }))
    .mutation(async ({ ctx, input }) => {
      // Find the caller's AttendingMember whose program has visaStatusVisibleToMembers=true
      // and which has a visaApplication.
      const attendee = await ctx.prisma.attendingMember.findFirst({
        where: {
          userId: ctx.user.id,
          confirmation: {
            project: {
              program: { visaStatusVisibleToMembers: true },
            },
          },
          visaApplication: { isNot: null },
        },
        include: { visaApplication: true },
      })

      if (!attendee || !attendee.visaApplication) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'No visa application to update',
        })
      }

      const updated = await ctx.prisma.visaApplication.update({
        where: { id: attendee.visaApplication.id },
        data: { nationality: input.nationality },
      })

      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'VISA_NATIONALITY_SELF_SET',
        entityType: 'VisaApplication',
        entityId: updated.id,
        detailsJson: { nationality: input.nationality },
        ipAddress: ctx.ip,
        userAgent: ctx.userAgent,
      })

      return { ok: true }
    }),
})
