/**
 * Round Engine Service
 *
 * State machine for round lifecycle transitions, operating on Round +
 * ProjectRoundState. Parallels stage-engine.ts but for the Competition/Round
 * architecture.
 *
 * Key invariants:
 * - Round transitions follow: ROUND_DRAFT → ROUND_ACTIVE → ROUND_CLOSED → ROUND_ARCHIVED
 * - Project transitions within an active round only
 * - All mutations are transactional with dual audit trail
 */

import type { PrismaClient, ProjectRoundStateValue, Prisma } from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
import { safeValidateRoundConfig } from '@/types/competition-configs'
import { expireIntentsForRound } from './assignment-intent'
import { processRoundClose } from './round-finalization'
import {
  sendMentorBulkAssignmentEmail,
  sendTeamMentorIntroductionEmail,
} from '@/lib/email'

// ─── Types ──────────────────────────────────────────────────────────────────

export type RoundTransitionResult = {
  success: boolean
  round?: { id: string; status: string }
  errors?: string[]
}

export type ProjectRoundTransitionResult = {
  success: boolean
  projectRoundState?: {
    id: string
    projectId: string
    roundId: string
    state: ProjectRoundStateValue
  }
  errors?: string[]
}

export type BatchProjectTransitionResult = {
  succeeded: string[]
  failed: Array<{ projectId: string; errors: string[] }>
  total: number
}

// ─── Constants ──────────────────────────────────────────────────────────────

const BATCH_SIZE = 50

// ─── Valid Transition Maps ──────────────────────────────────────────────────

const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
  ROUND_DRAFT: ['ROUND_ACTIVE'],
  ROUND_ACTIVE: ['ROUND_CLOSED'],
  ROUND_CLOSED: ['ROUND_ACTIVE', 'ROUND_ARCHIVED'],
  ROUND_ARCHIVED: [],
}

const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
  PENDING: ['IN_PROGRESS', 'REJECTED', 'WITHDRAWN'],
  IN_PROGRESS: ['COMPLETED', 'REJECTED', 'WITHDRAWN'],
  COMPLETED: ['PASSED', 'REJECTED'],
  PASSED: ['IN_PROGRESS', 'WITHDRAWN'],
  REJECTED: ['PENDING'], // re-include
  WITHDRAWN: ['PENDING'], // re-include
}

// ─── Round-Level Transitions ────────────────────────────────────────────────

/**
 * Activate a round: ROUND_DRAFT → ROUND_ACTIVE
 * Guards: configJson is valid, competition is not ARCHIVED
 * Side effects: expire pending intents from previous round (if any)
 */
export async function activateRound(
  roundId: string,
  actorId: string,
  prisma: PrismaClient,
): Promise<RoundTransitionResult> {
  try {
    const round = await prisma.round.findUnique({
      where: { id: roundId },
      include: { competition: true },
    })

    if (!round) {
      return { success: false, errors: [`Round ${roundId} not found`] }
    }

    // Check valid transition
    if (round.status !== 'ROUND_DRAFT') {
      return {
        success: false,
        errors: [`Cannot activate round: current status is ${round.status}, expected ROUND_DRAFT`],
      }
    }

    // Guard: competition must not be ARCHIVED
    if (round.competition.status === 'ARCHIVED') {
      return {
        success: false,
        errors: ['Cannot activate round: competition is ARCHIVED'],
      }
    }

    // Guard: configJson must be valid
    if (round.configJson) {
      const validation = safeValidateRoundConfig(
        round.roundType,
        round.configJson as Record<string, unknown>,
      )
      if (!validation.success) {
        return {
          success: false,
          errors: [`Invalid round config: ${validation.error.message}`],
        }
      }
    }

    // If activating before the scheduled start, snap windowOpenAt to now
    const now = new Date()
    const windowData: Record<string, Date> = {}
    if (round.windowOpenAt && new Date(round.windowOpenAt) > now) {
      windowData.windowOpenAt = now
    }
    // If no windowOpenAt was set at all, also set it to now
    if (!round.windowOpenAt) {
      windowData.windowOpenAt = now
    }

    const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
      const result = await tx.round.update({
        where: { id: roundId },
        data: { status: 'ROUND_ACTIVE', ...windowData },
      })

      await tx.decisionAuditLog.create({
        data: {
          eventType: 'round.activated',
          entityType: 'Round',
          entityId: roundId,
          actorId,
          detailsJson: {
            roundName: round.name,
            roundType: round.roundType,
            competitionId: round.competitionId,
            previousStatus: 'ROUND_DRAFT',
          },
          snapshotJson: {
            timestamp: new Date().toISOString(),
            emittedBy: 'round-engine',
          },
        },
      })

      return result
    })

    // Audit log outside transaction to avoid FK violations poisoning the tx
    await logAudit({
      userId: actorId,
      action: 'ROUND_ACTIVATE',
      entityType: 'Round',
      entityId: roundId,
      detailsJson: { name: round.name, roundType: round.roundType },
    })

    // Retroactive check: auto-PASS any projects that already have all required docs uploaded
    // Non-fatal — runs after activation so it never blocks the transition
    try {
      const projectStates = await prisma.projectRoundState.findMany({
        where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
        select: { projectId: true },
      })
      if (projectStates.length > 0) {
        const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
        const result = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
        if (result.transitionedCount > 0) {
          console.log(`[RoundEngine] On activation: auto-completed ${result.transitionedCount} projects with complete documents`)
        }
      }
    } catch (retroError) {
      console.error('[RoundEngine] Retroactive document check failed (non-fatal):', retroError)
    }

    // Mentoring pass-through: for MENTORING rounds with passThroughIfNoRequest,
    // auto-set all PENDING projects to PASSED (they pass through unless they request mentoring)
    if (round.roundType === 'MENTORING') {
      try {
        const mentoringConfig = safeValidateRoundConfig('MENTORING', round.configJson as Record<string, unknown>)
        if (mentoringConfig.success && mentoringConfig.data.passThroughIfNoRequest) {
          const pendingProjects = await prisma.projectRoundState.findMany({
            where: { roundId, state: 'PENDING' },
            select: { id: true, projectId: true, metadataJson: true },
          })
          let passedCount = 0
          for (const prs of pendingProjects) {
            const meta = (prs.metadataJson as Record<string, unknown>) ?? {}
            // Only pass-through projects that haven't requested mentoring
            if (!meta.mentoringRequested) {
              await prisma.projectRoundState.update({
                where: { id: prs.id },
                data: { state: 'PASSED' },
              })
              passedCount++
            }
          }
          if (passedCount > 0) {
            console.log(`[RoundEngine] Mentoring pass-through: set ${passedCount} projects to PASSED`)
          }
        }
      } catch (mentoringError) {
        console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError)
      }

      // Mentor-side coalesced emails on round open. Picks up every assignment
      // for projects in this round whose notificationSentAt is null (i.e.
      // assignments made while the round was still in draft), groups by
      // mentor, and sends a single combined email per mentor listing all
      // their projects in this round.
      try {
        const pendingAssignments = await prisma.mentorAssignment.findMany({
          where: {
            droppedAt: null,
            notificationSentAt: null,
            project: { projectRoundStates: { some: { roundId } } },
          },
          select: {
            id: true,
            mentorId: true,
            mentor: { select: { name: true, email: true } },
            project: {
              select: {
                id: true,
                title: true,
                teamMembers: { select: { user: { select: { name: true, email: true } } } },
              },
            },
          },
        })
        const perMentor = new Map<
          string,
          {
            email: string | null
            name: string | null
            assignmentIds: string[]
            projects: { id: string; title: string; teamMembers: { name: string | null; email: string }[] }[]
          }
        >()
        for (const a of pendingAssignments) {
          if (!a.mentor?.email) continue
          const bucket = perMentor.get(a.mentorId) ?? {
            email: a.mentor.email,
            name: a.mentor.name,
            assignmentIds: [],
            projects: [],
          }
          bucket.assignmentIds.push(a.id)
          bucket.projects.push({
            id: a.project.id,
            title: a.project.title,
            teamMembers: a.project.teamMembers
              .filter((tm) => tm.user?.email)
              .map((tm) => ({ name: tm.user.name, email: tm.user.email })),
          })
          perMentor.set(a.mentorId, bucket)
        }
        for (const bucket of perMentor.values()) {
          if (bucket.projects.length === 0 || !bucket.email) continue
          await sendMentorBulkAssignmentEmail(
            bucket.email,
            bucket.name,
            bucket.projects,
          )
          await prisma.mentorAssignment.updateMany({
            where: { id: { in: bucket.assignmentIds } },
            data: { notificationSentAt: new Date() },
          })
        }
        if (perMentor.size > 0) {
          console.log(
            `[RoundEngine] MENTORING round open: notified ${perMentor.size} mentor(s) about their assignments`,
          )
        }
      } catch (mentorEmailError) {
        console.error(
          '[RoundEngine] Mentor-side coalesced notification failed (non-fatal):',
          mentorEmailError,
        )
      }

      // Introduce teams to their mentors via email when the round opens.
      // Idempotent via MentorAssignment.teamIntroducedAt — separate from the
      // mentor-side notificationSentAt so the team email fires even when the
      // mentor was assigned (and notified) before the round opened.
      try {
        const projectsToIntroduce = await prisma.project.findMany({
          where: {
            projectRoundStates: { some: { roundId } },
            mentorAssignments: {
              some: { droppedAt: null, teamIntroducedAt: null },
            },
          },
          select: {
            id: true,
            title: true,
            mentorAssignments: {
              where: { droppedAt: null },
              select: {
                id: true,
                teamIntroducedAt: true,
                mentor: { select: { name: true, email: true } },
              },
            },
            teamMembers: {
              select: { user: { select: { name: true, email: true } } },
            },
            submittedByEmail: true,
            submittedBy: { select: { name: true } },
          },
        })
        for (const p of projectsToIntroduce) {
          const mentors = p.mentorAssignments
            .filter((a) => a.mentor?.email)
            .map((a) => ({
              name: a.mentor.name,
              email: a.mentor.email,
            }))
          if (mentors.length === 0) continue

          // Build a unique recipient set: team-member users with emails,
          // plus the original submitter (in case they're not on the team yet).
          const recipients = new Map<string, { name: string | null }>()
          for (const tm of p.teamMembers) {
            if (tm.user?.email) {
              recipients.set(tm.user.email, { name: tm.user.name })
            }
          }
          if (
            p.submittedByEmail &&
            !recipients.has(p.submittedByEmail)
          ) {
            recipients.set(p.submittedByEmail, {
              name: p.submittedBy?.name ?? null,
            })
          }

          const allMembers = p.teamMembers
            .filter((tm) => tm.user?.email)
            .map((tm) => ({ name: tm.user.name, email: tm.user.email }))

          for (const [email, { name }] of recipients) {
            const teammates = allMembers.filter((m) => m.email !== email)
            await sendTeamMentorIntroductionEmail(email, name, p.title, p.id, mentors, teammates)
          }

          // Stamp every mentor-assignment row so re-activation doesn't re-send.
          const idsToStamp = p.mentorAssignments
            .filter((a) => a.teamIntroducedAt == null)
            .map((a) => a.id)
          if (idsToStamp.length > 0) {
            await prisma.mentorAssignment.updateMany({
              where: { id: { in: idsToStamp } },
              data: { teamIntroducedAt: new Date() },
            })
          }
        }
        if (projectsToIntroduce.length > 0) {
          console.log(
            `[RoundEngine] MENTORING round open: introduced mentors for ${projectsToIntroduce.length} project(s)`,
          )
        }
      } catch (introError) {
        console.error('[RoundEngine] Team-mentor introduction failed (non-fatal):', introError)
      }
    }

    return {
      success: true,
      round: { id: updated.id, status: updated.status },
    }
  } catch (error) {
    console.error('[RoundEngine] activateRound failed:', error)
    return {
      success: false,
      errors: [error instanceof Error ? error.message : 'Unknown error during round activation'],
    }
  }
}

/**
 * Close a round: ROUND_ACTIVE → ROUND_CLOSED
 * Guards: all submission windows closed (if submission/mentoring round)
 * Side effects: expire all INTENT_PENDING for this round
 */
export async function closeRound(
  roundId: string,
  actorId: string,
  prisma: PrismaClient,
): Promise<RoundTransitionResult> {
  try {
    const round = await prisma.round.findUnique({
      where: { id: roundId },
      include: { submissionWindow: true },
    })

    if (!round) {
      return { success: false, errors: [`Round ${roundId} not found`] }
    }

    if (round.status !== 'ROUND_ACTIVE') {
      return {
        success: false,
        errors: [`Cannot close round: current status is ${round.status}, expected ROUND_ACTIVE`],
      }
    }

    // Guard: submission window must be closed/locked for submission/mentoring rounds
    if (
      (round.roundType === 'SUBMISSION' || round.roundType === 'MENTORING') &&
      round.submissionWindow
    ) {
      const sw = round.submissionWindow
      if (sw.windowCloseAt && new Date() < sw.windowCloseAt && !sw.isLocked) {
        return {
          success: false,
          errors: ['Cannot close round: linked submission window is still open'],
        }
      }
    }

    const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
      const result = await tx.round.update({
        where: { id: roundId },
        data: { status: 'ROUND_CLOSED' },
      })

      // Expire pending intents (using the transaction client)
      await expireIntentsForRound(roundId, actorId, tx)

      // Auto-close any preceding active rounds (lower sortOrder, same competition)
      const precedingActiveRounds = await tx.round.findMany({
        where: {
          competitionId: round.competitionId,
          sortOrder: { lt: round.sortOrder },
          status: 'ROUND_ACTIVE',
        },
        orderBy: { sortOrder: 'asc' },
      })

      for (const prev of precedingActiveRounds) {
        await tx.round.update({
          where: { id: prev.id },
          data: { status: 'ROUND_CLOSED' },
        })
        await tx.decisionAuditLog.create({
          data: {
            eventType: 'round.closed',
            entityType: 'Round',
            entityId: prev.id,
            actorId,
            detailsJson: {
              roundName: prev.name,
              roundType: prev.roundType,
              previousStatus: 'ROUND_ACTIVE',
              closedBy: 'cascade',
              triggeringRoundId: roundId,
            },
            snapshotJson: {
              timestamp: new Date().toISOString(),
              emittedBy: 'round-engine',
            },
          },
        })
      }

      await tx.decisionAuditLog.create({
        data: {
          eventType: 'round.closed',
          entityType: 'Round',
          entityId: roundId,
          actorId,
          detailsJson: {
            roundName: round.name,
            roundType: round.roundType,
            previousStatus: 'ROUND_ACTIVE',
            cascadeClosed: precedingActiveRounds.map((r: any) => r.name),
          },
          snapshotJson: {
            timestamp: new Date().toISOString(),
            emittedBy: 'round-engine',
          },
        },
      })

      return result
    })

    // Audit log outside transaction to avoid FK violations poisoning the tx
    await logAudit({
      userId: actorId,
      action: 'ROUND_CLOSE',
      entityType: 'Round',
      entityId: roundId,
      detailsJson: { name: round.name, roundType: round.roundType },
    })

    // Grace period / immediate finalization processing
    try {
      const config = round.configJson ? (round.configJson as Record<string, unknown>) : {}
      const gracePeriodHours = (config.gracePeriodHours as number) ?? 0

      if (gracePeriodHours > 0) {
        const gracePeriodEndsAt = new Date(Date.now() + gracePeriodHours * 60 * 60 * 1000)
        await prisma.round.update({
          where: { id: roundId },
          data: { gracePeriodEndsAt },
        })
        console.log(`[RoundEngine] Grace period set for round ${roundId}: ${gracePeriodHours}h (until ${gracePeriodEndsAt.toISOString()})`)
      } else {
        await processRoundClose(roundId, actorId, prisma)
        console.log(`[RoundEngine] Processed round close for ${roundId} (no grace period)`)
      }
    } catch (processError) {
      console.error('[RoundEngine] processRoundClose after close failed (non-fatal):', processError)
    }

    return {
      success: true,
      round: { id: updated.id, status: updated.status },
    }
  } catch (error) {
    console.error('[RoundEngine] closeRound failed:', error)
    return {
      success: false,
      errors: [error instanceof Error ? error.message : 'Unknown error during round close'],
    }
  }
}

/**
 * Archive a round: ROUND_CLOSED → ROUND_ARCHIVED
 * No guards.
 */
export async function archiveRound(
  roundId: string,
  actorId: string,
  prisma: PrismaClient,
): Promise<RoundTransitionResult> {
  try {
    const round = await prisma.round.findUnique({ where: { id: roundId } })

    if (!round) {
      return { success: false, errors: [`Round ${roundId} not found`] }
    }

    if (round.status !== 'ROUND_CLOSED') {
      return {
        success: false,
        errors: [`Cannot archive round: current status is ${round.status}, expected ROUND_CLOSED`],
      }
    }

    const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
      const result = await tx.round.update({
        where: { id: roundId },
        data: { status: 'ROUND_ARCHIVED' },
      })

      await tx.decisionAuditLog.create({
        data: {
          eventType: 'round.archived',
          entityType: 'Round',
          entityId: roundId,
          actorId,
          detailsJson: {
            roundName: round.name,
            previousStatus: 'ROUND_CLOSED',
          },
          snapshotJson: {
            timestamp: new Date().toISOString(),
            emittedBy: 'round-engine',
          },
        },
      })

      return result
    })

    // Audit log outside transaction to avoid FK violations poisoning the tx
    await logAudit({
      userId: actorId,
      action: 'ROUND_ARCHIVE',
      entityType: 'Round',
      entityId: roundId,
      detailsJson: { name: round.name },
    })

    return {
      success: true,
      round: { id: updated.id, status: updated.status },
    }
  } catch (error) {
    console.error('[RoundEngine] archiveRound failed:', error)
    return {
      success: false,
      errors: [error instanceof Error ? error.message : 'Unknown error during round archive'],
    }
  }
}

/**
 * Reopen a round: ROUND_CLOSED → ROUND_ACTIVE
 * Side effects: any subsequent rounds in the same competition that are
 * ROUND_ACTIVE will be paused (set to ROUND_CLOSED) to prevent two
 * active rounds overlapping.
 */
export async function reopenRound(
  roundId: string,
  actorId: string,
  prisma: PrismaClient,
): Promise<RoundTransitionResult & { pausedRounds?: string[] }> {
  try {
    const round = await prisma.round.findUnique({
      where: { id: roundId },
      include: { competition: true },
    })

    if (!round) {
      return { success: false, errors: [`Round ${roundId} not found`] }
    }

    if (round.status !== 'ROUND_CLOSED') {
      return {
        success: false,
        errors: [`Cannot reopen round: current status is ${round.status}, expected ROUND_CLOSED`],
      }
    }

    const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
      // Pause any subsequent active rounds in the same competition
      const subsequentActiveRounds = await tx.round.findMany({
        where: {
          competitionId: round.competitionId,
          sortOrder: { gt: round.sortOrder },
          status: 'ROUND_ACTIVE',
        },
        select: { id: true, name: true },
      })

      if (subsequentActiveRounds.length > 0) {
        await tx.round.updateMany({
          where: { id: { in: subsequentActiveRounds.map((r: any) => r.id) } },
          data: { status: 'ROUND_CLOSED' },
        })

        // Audit each paused round
        for (const paused of subsequentActiveRounds) {
          await tx.decisionAuditLog.create({
            data: {
              eventType: 'round.paused',
              entityType: 'Round',
              entityId: paused.id,
              actorId,
              detailsJson: {
                roundName: paused.name,
                reason: `Paused because prior round "${round.name}" was reopened`,
                previousStatus: 'ROUND_ACTIVE',
              },
              snapshotJson: {
                timestamp: new Date().toISOString(),
                emittedBy: 'round-engine',
              },
            },
          })
        }
      }

      // Reopen this round — clear windowCloseAt so the voting window check
      // doesn't reject submissions with "Voting window has closed".
      const updated = await tx.round.update({
        where: { id: roundId },
        data: { status: 'ROUND_ACTIVE', windowCloseAt: null },
      })

      await tx.decisionAuditLog.create({
        data: {
          eventType: 'round.reopened',
          entityType: 'Round',
          entityId: roundId,
          actorId,
          detailsJson: {
            roundName: round.name,
            previousStatus: 'ROUND_CLOSED',
            pausedRounds: subsequentActiveRounds.map((r: any) => r.name),
          },
          snapshotJson: {
            timestamp: new Date().toISOString(),
            emittedBy: 'round-engine',
          },
        },
      })

      return {
        updated,
        pausedRounds: subsequentActiveRounds.map((r: any) => r.name),
      }
    })

    // Audit log outside transaction to avoid FK violations poisoning the tx
    await logAudit({
      userId: actorId,
      action: 'ROUND_REOPEN',
      entityType: 'Round',
      entityId: roundId,
      detailsJson: {
        name: round.name,
        pausedRounds: result.pausedRounds,
      },
    })

    // Retroactive check: auto-PASS any projects that already have all required docs
    try {
      const projectStates = await prisma.projectRoundState.findMany({
        where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
        select: { projectId: true },
      })
      if (projectStates.length > 0) {
        const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
        const batchResult = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
        if (batchResult.transitionedCount > 0) {
          console.log(`[RoundEngine] On reopen: auto-passed ${batchResult.transitionedCount} projects with complete documents`)
        }
      }
    } catch (retroError) {
      console.error('[RoundEngine] Retroactive document check on reopen failed (non-fatal):', retroError)
    }

    return {
      success: true,
      round: { id: result.updated.id, status: result.updated.status },
      pausedRounds: result.pausedRounds,
    }
  } catch (error) {
    console.error('[RoundEngine] reopenRound failed:', error)
    return {
      success: false,
      errors: [error instanceof Error ? error.message : 'Unknown error during round reopen'],
    }
  }
}

// ─── Project-Level Transitions ──────────────────────────────────────────────

/**
 * Transition a project within a round.
 * Upserts ProjectRoundState: create if not exists, update if exists.
 * Validate: round must be ROUND_ACTIVE.
 * Dual audit trail (DecisionAuditLog + logAudit).
 */
export async function transitionProject(
  projectId: string,
  roundId: string,
  newState: ProjectRoundStateValue,
  actorId: string,
  prisma: PrismaClient,
  options?: { adminOverride?: boolean },
): Promise<ProjectRoundTransitionResult> {
  try {
    const round = await prisma.round.findUnique({ where: { id: roundId } })

    if (!round) {
      return { success: false, errors: [`Round ${roundId} not found`] }
    }

    if (round.status !== 'ROUND_ACTIVE' && round.status !== 'ROUND_CLOSED') {
      return {
        success: false,
        errors: [`Round is ${round.status}, must be ROUND_ACTIVE or ROUND_CLOSED to transition projects`],
      }
    }

    // Verify project exists
    const project = await prisma.project.findUnique({ where: { id: projectId } })
    if (!project) {
      return { success: false, errors: [`Project ${projectId} not found`] }
    }

    const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
      const now = new Date()

      // Upsert ProjectRoundState
      const existing = await tx.projectRoundState.findUnique({
        where: { projectId_roundId: { projectId, roundId } },
      })

      // Enforce project state transition whitelist (unless admin override)
      if (existing && !options?.adminOverride) {
        const currentState = existing.state as string
        const allowed = VALID_PROJECT_TRANSITIONS[currentState] ?? []
        if (!allowed.includes(newState)) {
          throw new Error(
            `Invalid project transition: ${currentState} → ${newState}. Allowed: ${allowed.join(', ') || 'none (terminal state)'}`,
          )
        }
      }

      let prs
      if (existing) {
        prs = await tx.projectRoundState.update({
          where: { id: existing.id },
          data: {
            state: newState,
            exitedAt: isTerminalState(newState) ? now : null,
          },
        })
      } else {
        prs = await tx.projectRoundState.create({
          data: {
            projectId,
            roundId,
            state: newState,
            enteredAt: now,
          },
        })
      }

      // Dual audit trail
      await tx.decisionAuditLog.create({
        data: {
          eventType: 'project_round.transitioned',
          entityType: 'ProjectRoundState',
          entityId: prs.id,
          actorId,
          detailsJson: {
            projectId,
            roundId,
            previousState: existing?.state ?? null,
            newState,
          } as Prisma.InputJsonValue,
          snapshotJson: {
            timestamp: now.toISOString(),
            emittedBy: 'round-engine',
          },
        },
      })

      return { prs, previousState: existing?.state ?? null }
    })

    // Audit log outside transaction to avoid FK violations poisoning the tx
    await logAudit({
      userId: actorId,
      action: 'PROJECT_ROUND_TRANSITION',
      entityType: 'ProjectRoundState',
      entityId: result.prs.id,
      detailsJson: { projectId, roundId, newState, previousState: result.previousState },
    })

    return {
      success: true,
      projectRoundState: {
        id: result.prs.id,
        projectId: result.prs.projectId,
        roundId: result.prs.roundId,
        state: result.prs.state,
      },
    }
  } catch (error) {
    console.error('[RoundEngine] transitionProject failed:', error)
    return {
      success: false,
      errors: [error instanceof Error ? error.message : 'Unknown error during project transition'],
    }
  }
}

/**
 * Batch transition projects in batches of BATCH_SIZE.
 * Each project is processed independently.
 */
export async function batchTransitionProjects(
  projectIds: string[],
  roundId: string,
  newState: ProjectRoundStateValue,
  actorId: string,
  prisma: PrismaClient,
  options?: { adminOverride?: boolean },
): Promise<BatchProjectTransitionResult> {
  const succeeded: string[] = []
  const failed: Array<{ projectId: string; errors: string[] }> = []

  for (let i = 0; i < projectIds.length; i += BATCH_SIZE) {
    const batch = projectIds.slice(i, i + BATCH_SIZE)

    const batchPromises = batch.map(async (projectId) => {
      const result = await transitionProject(projectId, roundId, newState, actorId, prisma, options)

      if (result.success) {
        succeeded.push(projectId)
      } else {
        failed.push({
          projectId,
          errors: result.errors ?? ['Transition failed'],
        })
      }
    })

    await Promise.all(batchPromises)
  }

  return { succeeded, failed, total: projectIds.length }
}

// ─── Query Helpers ──────────────────────────────────────────────────────────

export async function getProjectRoundStates(
  roundId: string,
  prisma: PrismaClient,
) {
  const states = await prisma.projectRoundState.findMany({
    where: { roundId },
    include: {
      project: {
        select: {
          id: true,
          title: true,
          teamName: true,
          competitionCategory: true,
          country: true,
          status: true,
          assignments: {
            where: { roundId },
            select: {
              id: true,
              isCompleted: true,
              evaluation: { select: { status: true } },
            },
          },
        },
      },
    },
    orderBy: { enteredAt: 'desc' },
  })

  // Compute evaluation progress per project
  return states.map((ps: any) => {
    const assignments = ps.project?.assignments ?? []
    const totalAssignments = assignments.length
    const submittedCount = assignments.filter(
      (a: any) => a.evaluation?.status === 'SUBMITTED'
    ).length
    return {
      ...ps,
      totalAssignments,
      submittedCount,
      project: {
        ...ps.project,
        assignments: undefined, // strip raw assignments from response
      },
    }
  })
}

export async function getProjectRoundState(
  projectId: string,
  roundId: string,
  prisma: PrismaClient,
) {
  return prisma.projectRoundState.findUnique({
    where: { projectId_roundId: { projectId, roundId } },
  })
}

// ─── Auto-Transition on Document Completion ─────────────────────────────────

/**
 * Check if a project has fulfilled all required FileRequirements for a round.
 * If yes, and the project is currently PENDING, transition it to PASSED.
 *
 * Called after file uploads (admin bulk upload or applicant upload).
 * Non-fatal: errors are logged but never propagated to callers.
 */
export async function checkRequirementsAndTransition(
  projectId: string,
  roundId: string,
  actorId: string,
  prisma: PrismaClient,
): Promise<{ transitioned: boolean; newState?: string }> {
  try {
    // Get all required FileRequirements for this round
    // Note: only FileRequirement (admin-managed via UI) is checked.
    // SubmissionFileRequirement (on SubmissionWindow) has no admin UI and is not checked.
    const requirements = await prisma.fileRequirement.findMany({
      where: { roundId, isRequired: true },
      select: { id: true },
    })

    // If the round has no file requirements, nothing to check
    if (requirements.length === 0) {
      return { transitioned: false }
    }

    // Check which requirements this project has satisfied
    const fulfilledFiles = await prisma.projectFile.findMany({
      where: {
        projectId,
        roundId,
        requirementId: { in: requirements.map((r: { id: string }) => r.id) },
      },
      select: { requirementId: true },
    })

    const fulfilledIds = new Set(
      fulfilledFiles
        .map((f: { requirementId: string | null }) => f.requirementId)
        .filter(Boolean)
    )

    if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) {
      return { transitioned: false }
    }

    // Check current state — only transition if PENDING or IN_PROGRESS
    const currentState = await prisma.projectRoundState.findUnique({
      where: { projectId_roundId: { projectId, roundId } },
      select: { state: true },
    })

    const eligibleStates = ['PENDING', 'IN_PROGRESS']
    if (!currentState || !eligibleStates.includes(currentState.state)) {
      return { transitioned: false }
    }

    // If PENDING, first transition to IN_PROGRESS so the state machine path is valid
    if (currentState.state === 'PENDING') {
      await triggerInProgressOnActivity(projectId, roundId, actorId, prisma)
    }

    // All requirements met — transition to COMPLETED (finalization will set PASSED/REJECTED)
    const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)

    if (result.success) {
      console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${requirements.length} requirements met)`)
      return { transitioned: true, newState: 'COMPLETED' }
    }

    return { transitioned: false }
  } catch (error) {
    // Non-fatal — log and continue
    console.error('[RoundEngine] checkRequirementsAndTransition failed:', error)
    return { transitioned: false }
  }
}

/**
 * Batch version: check all projects in a round and transition any that
 * have all required documents uploaded. Useful after bulk upload.
 */
export async function batchCheckRequirementsAndTransition(
  roundId: string,
  projectIds: string[],
  actorId: string,
  prisma: PrismaClient,
): Promise<{ transitionedCount: number; projectIds: string[] }> {
  if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] }

  // Pre-load all requirements for this round in batch (avoids per-project queries)
  // Note: only FileRequirement (admin-managed via UI) is checked.
  // SubmissionFileRequirement (on SubmissionWindow) has no admin UI and is not checked.
  const requirements = await prisma.fileRequirement.findMany({
    where: { roundId, isRequired: true },
    select: { id: true },
  })

  // If no requirements, nothing to check
  if (requirements.length === 0) {
    return { transitionedCount: 0, projectIds: [] }
  }

  // Pre-load all project files and current states in batch
  type FileRow = { projectId: string; requirementId: string | null }
  type StateRow = { projectId: string; state: string }

  const [allFiles, allStates] = await Promise.all([
    prisma.projectFile.findMany({
      where: {
        projectId: { in: projectIds },
        roundId,
      },
      select: { projectId: true, requirementId: true },
    }) as Promise<FileRow[]>,
    prisma.projectRoundState.findMany({
      where: { roundId, projectId: { in: projectIds } },
      select: { projectId: true, state: true },
    }) as Promise<StateRow[]>,
  ])

  // Build per-project lookup maps
  const filesByProject = new Map<string, FileRow[]>()
  for (const f of allFiles) {
    const arr = filesByProject.get(f.projectId) ?? []
    arr.push(f)
    filesByProject.set(f.projectId, arr)
  }
  const stateByProject = new Map(allStates.map((s) => [s.projectId, s.state]))

  // Determine which projects have all requirements met and are eligible for transition
  const eligibleStates = ['PENDING', 'IN_PROGRESS']
  const toTransition: string[] = []

  for (const projectId of projectIds) {
    const currentState = stateByProject.get(projectId)
    if (!currentState || !eligibleStates.includes(currentState)) continue

    const files = filesByProject.get(projectId) ?? []
    const fulfilledIds = new Set(files.map((f) => f.requirementId).filter(Boolean))
    if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) continue

    toTransition.push(projectId)
  }

  // Transition eligible projects (still uses transitionProject for state machine correctness)
  const transitioned: string[] = []
  for (const projectId of toTransition) {
    const currentState = stateByProject.get(projectId)
    // If PENDING, first move to IN_PROGRESS
    if (currentState === 'PENDING') {
      await triggerInProgressOnActivity(projectId, roundId, actorId, prisma)
    }
    const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
    if (result.success) {
      transitioned.push(projectId)
    }
  }

  if (transitioned.length > 0) {
    console.log(`[RoundEngine] Batch auto-transition: ${transitioned.length}/${projectIds.length} projects moved to COMPLETED in round ${roundId}`)
  }

  return { transitionedCount: transitioned.length, projectIds: transitioned }
}

// ─── Auto-Transition Hooks ──────────────────────────────────────────────────

/**
 * Trigger PENDING → IN_PROGRESS when a project has activity.
 * Non-fatal: if the project is not PENDING, this is a no-op.
 */
export async function triggerInProgressOnActivity(
  projectId: string,
  roundId: string,
  actorId: string,
  prisma: PrismaClient,
): Promise<void> {
  try {
    const prs = await prisma.projectRoundState.findUnique({
      where: { projectId_roundId: { projectId, roundId } },
      select: { state: true },
    })

    if (!prs || prs.state !== 'PENDING') return

    const result = await transitionProject(projectId, roundId, 'IN_PROGRESS' as ProjectRoundStateValue, actorId, prisma)
    if (result.success) {
      console.log(`[RoundEngine] Auto-transitioned project ${projectId} to IN_PROGRESS in round ${roundId}`)
    }
  } catch (error) {
    console.error('[RoundEngine] triggerInProgressOnActivity failed (non-fatal):', error)
  }
}

/**
 * Check if all jury assignments for a project in an evaluation round are completed.
 * If yes, transition from IN_PROGRESS → COMPLETED.
 */
export async function checkEvaluationCompletionAndTransition(
  projectId: string,
  roundId: string,
  actorId: string,
  prisma: PrismaClient,
): Promise<{ transitioned: boolean }> {
  try {
    const prs = await prisma.projectRoundState.findUnique({
      where: { projectId_roundId: { projectId, roundId } },
      select: { state: true },
    })

    if (!prs || prs.state !== 'IN_PROGRESS') return { transitioned: false }

    // Check all assignments for this project in this round
    const assignments = await prisma.assignment.findMany({
      where: { projectId, roundId },
      select: { isCompleted: true },
    })

    if (assignments.length === 0) return { transitioned: false }

    const allCompleted = assignments.every((a: { isCompleted: boolean }) => a.isCompleted)
    if (!allCompleted) return { transitioned: false }

    const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
    if (result.success) {
      console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${assignments.length} evaluations done)`)
      return { transitioned: true }
    }

    return { transitioned: false }
  } catch (error) {
    console.error('[RoundEngine] checkEvaluationCompletionAndTransition failed (non-fatal):', error)
    return { transitioned: false }
  }
}

// ─── Internals ──────────────────────────────────────────────────────────────

export function isTerminalState(state: ProjectRoundStateValue): boolean {
  return ['PASSED', 'REJECTED', 'WITHDRAWN'].includes(state)
}
