/**
 * AI-Powered Assignment Service (Hybrid Approach)
 *
 * Phase 1 — AI Scoring: ONE API call asks GPT to score each juror's affinity
 *   for each project (expertise match, reasoning). Returns a preference matrix.
 * Phase 2 — Algorithm: Uses the AI scores to assign N reviewers per project
 *   with even workload distribution, respecting caps and COI constraints.
 *
 * GDPR Compliance:
 * - All data anonymized before AI processing
 * - IDs replaced with sequential identifiers
 * - No personal information sent to OpenAI
 */

import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError, createParseError, logAIError } from './ai-errors'
import {
  anonymizeForAI,
  deanonymizeResults,
  validateAnonymization,
  DESCRIPTION_LIMITS,
  truncateAndSanitize,
  type AnonymizationResult,
} from './anonymization'

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

export interface AIAssignmentSuggestion {
  jurorId: string
  projectId: string
  confidenceScore: number // 0-1
  reasoning: string
  expertiseMatchScore: number // 0-1
}

export interface AIAssignmentResult {
  success: boolean
  suggestions: AIAssignmentSuggestion[]
  error?: string
  tokensUsed?: number
  fallbackUsed?: boolean
}

interface JurorForAssignment {
  id: string
  name?: string | null
  email: string
  expertiseTags: string[]
  bio?: string | null
  country?: string | null
  maxAssignments?: number | null
  _count?: {
    assignments: number
  }
}

interface ProjectForAssignment {
  id: string
  title: string
  description?: string | null
  tags: string[]
  tagConfidences?: Array<{ name: string; confidence: number }>
  teamName?: string | null
  competitionCategory?: string | null
  oceanIssue?: string | null
  country?: string | null
  institution?: string | null
  teamSize?: number
  fileTypes?: string[]
  _count?: {
    assignments: number
  }
}

interface AssignmentConstraints {
  requiredReviewsPerProject: number
  minAssignmentsPerJuror?: number
  maxAssignmentsPerJuror?: number
  jurorLimits?: Record<string, number> // userId -> personal max assignments
  existingAssignments: Array<{
    jurorId: string
    projectId: string
  }>
  /** Ideal target assignments per juror (for balanced distribution hint) */
  _targetPerJuror?: number
}

export interface AssignmentProgressCallback {
  (progress: {
    currentBatch: number
    totalBatches: number
    processedCount: number
    totalProjects: number
  }): Promise<void>
}

/** Per-juror ranking from AI: which projects they should review */
interface JurorAffinityRow {
  jurorId: string // anonymous ID
  rankings: Array<{
    projectId: string // anonymous ID
    score: number // 0-100
    reasoning: string
  }>
}

// ─── System Prompt ──────────────────────────────────────────────────────────

const AFFINITY_SYSTEM_PROMPT = `You are an expert jury assignment optimizer for an ocean conservation competition.

## Your Task
Score how well each juror matches each project. Return a compact affinity matrix.

## Scoring Criteria (100-point scale)
- **Expertise Match (60 pts)**: Tag overlap, bio background relevance, ocean issue alignment
- **Diversity Benefit (25 pts)**: Different country from project, different expertise angle from other jurors
- **Category Fit (15 pts)**: Experience with startup vs concept evaluation, institutional familiarity

## Output Format
Return JSON with this exact structure:
{
  "affinities": [
    {
      "juror_id": "JUROR_001",
      "rankings": [
        {"project_id": "PROJECT_001", "score": 85, "reason": "Strong coral reef expertise matches project focus"},
        {"project_id": "PROJECT_005", "score": 72, "reason": "Marine biology background relevant to biodiversity project"}
      ]
    }
  ]
}

## Rules
- For each juror, list their TOP project matches (at least the top 50% of projects, more is better)
- Scores must be integers 0-100
- Keep "reason" to one short sentence (under 20 words)
- A juror with no matching expertise should still get scores (based on general competence), just lower ones (30-50 range)
- Do NOT include projects that a juror has zero relevance for (score would be under 20)
- Return VALID JSON only`

// ─── AI Scoring Phase ───────────────────────────────────────────────────────

/**
 * Build the user prompt for the single AI affinity call
 */
function buildAffinityPrompt(
  anonymizedData: AnonymizationResult,
  existingPairs: Set<string>,
): string {
  // Compact juror representation
  const jurorLines = anonymizedData.jurors.map((j) => {
    const parts = [j.anonymousId]
    if (j.expertiseTags.length > 0) parts.push(`tags:[${j.expertiseTags.join(',')}]`)
    if (j.bio) parts.push(`bio:"${j.bio.slice(0, 150)}"`)
    if (j.country) parts.push(`country:${j.country}`)
    return parts.join(' | ')
  })

  // Compact project representation
  const projectLines = anonymizedData.projects.map((p) => {
    const parts = [p.anonymousId, `"${p.title}"`]
    if (p.tags.length > 0) parts.push(`tags:[${p.tags.map((t) => t.name).join(',')}]`)
    if (p.category) parts.push(`cat:${p.category}`)
    if (p.oceanIssue) parts.push(`issue:${p.oceanIssue}`)
    if (p.country) parts.push(`country:${p.country}`)
    if (p.description) parts.push(`desc:"${p.description.slice(0, 100)}"`)
    return parts.join(' | ')
  })

  // Note existing assignments to avoid
  let existingNote = ''
  if (existingPairs.size > 0) {
    existingNote = `\nALREADY_ASSIGNED (do NOT score these pairs): ${[...existingPairs].join(', ')}`
  }

  return `## JURORS (${jurorLines.length})
${jurorLines.join('\n')}

## PROJECTS (${projectLines.length})
${projectLines.join('\n')}
${existingNote}

Score each juror's affinity for the projects. For each juror, return their top project matches with scores (0-100) and a short reason.`
}

/**
 * Call AI once to get the full affinity matrix
 */
async function getAIAffinityMatrix(
  openai: NonNullable<Awaited<ReturnType<typeof getOpenAI>>>,
  model: string,
  anonymizedData: AnonymizationResult,
  existingPairs: Set<string>,
  userId?: string,
  entityId?: string,
): Promise<{
  affinities: JurorAffinityRow[]
  tokensUsed: number
}> {
  const userPrompt = buildAffinityPrompt(anonymizedData, existingPairs)

  // Estimate tokens: ~50 tokens per juror-project score entry
  // For 15 jurors × 99 projects top 60% = ~890 entries × 50 = ~44500 output tokens
  // Cap at a reasonable limit
  const estimatedEntries = anonymizedData.jurors.length * Math.ceil(anonymizedData.projects.length * 0.6)
  const estimatedTokens = Math.min(64000, Math.max(8000, estimatedEntries * 50 + 500))

  console.log(`[AI Assignment] Affinity call: ${anonymizedData.jurors.length} jurors × ${anonymizedData.projects.length} projects, est. ${estimatedEntries} entries, maxTokens=${estimatedTokens}`)

  const params = buildCompletionParams(model, {
    messages: [
      { role: 'system', content: AFFINITY_SYSTEM_PROMPT },
      { role: 'user', content: userPrompt },
    ],
    jsonMode: true,
    temperature: 0.1,
    maxTokens: estimatedTokens,
  })

  let response: Awaited<ReturnType<typeof openai.chat.completions.create>>

  try {
    response = await openai.chat.completions.create(params)
  } catch (apiError) {
    const errorMsg = apiError instanceof Error ? apiError.message : String(apiError)
    if (errorMsg.includes('model') || errorMsg.includes('does not exist')) {
      throw new Error(`Invalid AI model "${model}". Please check the model name in Settings > AI Configuration.`)
    }
    throw apiError
  }

  const usage = extractTokenUsage(response)

  await logAIUsage({
    userId,
    action: 'ASSIGNMENT',
    entityType: 'Round',
    entityId,
    model,
    promptTokens: usage.promptTokens,
    completionTokens: usage.completionTokens,
    totalTokens: usage.totalTokens,
    batchSize: anonymizedData.projects.length,
    itemsProcessed: anonymizedData.projects.length,
    status: 'SUCCESS',
  })

  // Parse response
  const content = response.choices[0]?.message?.content
  if (!content) {
    const finishReason = response.choices[0]?.finish_reason
    if (finishReason === 'content_filter') {
      throw new Error('AI response was filtered. Try a different model or simplify the project descriptions.')
    }
    if (finishReason === 'length') {
      console.warn('[AI Assignment] Response truncated (hit token limit). Will proceed with partial data + algorithm gap-fill.')
    } else {
      throw new Error(`Empty response from AI model "${model}".`)
    }
  }

  let parsed: {
    affinities: Array<{
      juror_id: string
      rankings: Array<{
        project_id: string
        score: number
        reason: string
      }>
    }>
  }

  try {
    // Handle potentially truncated JSON by attempting repair
    let jsonStr = content || '{}'

    // If truncated, try to close the JSON structure
    if (!jsonStr.trim().endsWith('}')) {
      console.warn('[AI Assignment] Response appears truncated, attempting JSON repair')
      jsonStr = repairTruncatedJSON(jsonStr)
    }

    parsed = JSON.parse(jsonStr)
  } catch (parseError) {
    // Try extracting JSON from markdown code blocks
    const jsonMatch = (content || '').match(/```(?:json)?\s*([\s\S]*?)```/)
    if (jsonMatch) {
      try {
        parsed = JSON.parse(jsonMatch[1])
      } catch {
        throw createParseError(`Failed to parse AI affinity response: ${(parseError as Error).message}`)
      }
    } else {
      throw createParseError(`Failed to parse AI affinity response: ${(parseError as Error).message}`)
    }
  }

  // Normalize to our internal format
  const affinities: JurorAffinityRow[] = (parsed.affinities || []).map((a) => ({
    jurorId: a.juror_id,
    rankings: (a.rankings || []).map((r) => ({
      projectId: r.project_id,
      score: Math.min(100, Math.max(0, r.score)),
      reasoning: r.reason || '',
    })),
  }))

  console.log(`[AI Assignment] Got affinities for ${affinities.length} jurors, total entries: ${affinities.reduce((sum, a) => sum + a.rankings.length, 0)}`)

  return { affinities, tokensUsed: usage.totalTokens }
}

/**
 * Attempt to repair truncated JSON by closing open structures
 */
function repairTruncatedJSON(json: string): string {
  let s = json.trim()

  // Remove any trailing incomplete entry (cut mid-object)
  const lastCompleteEntry = s.lastIndexOf('}')
  if (lastCompleteEntry > 0) {
    s = s.slice(0, lastCompleteEntry + 1)
  }

  // Count open/close brackets
  let openBrackets = 0
  let openBraces = 0
  for (const ch of s) {
    if (ch === '[') openBrackets++
    else if (ch === ']') openBrackets--
    else if (ch === '{') openBraces++
    else if (ch === '}') openBraces--
  }

  // Close everything
  while (openBrackets > 0) { s += ']'; openBrackets-- }
  while (openBraces > 0) { s += '}'; openBraces-- }

  return s
}

// ─── Algorithm Phase ────────────────────────────────────────────────────────

/**
 * Build a full score matrix from AI affinities, filling gaps with fallback scores
 */
function buildScoreMatrix(
  affinities: JurorAffinityRow[],
  jurors: JurorForAssignment[],
  projects: ProjectForAssignment[],
  anonymizedData: AnonymizationResult,
): Map<string, Map<string, { score: number; reasoning: string }>> {
  // Create reverse mapping: anonymous ID → real ID
  const jurorAnonToReal = new Map(anonymizedData.jurorMappings.map((m) => [m.anonymousId, m.realId]))
  const projectAnonToReal = new Map(anonymizedData.projectMappings.map((m) => [m.anonymousId, m.realId]))

  // Matrix: realJurorId → realProjectId → { score, reasoning }
  const matrix = new Map<string, Map<string, { score: number; reasoning: string }>>()

  // Initialize with AI scores
  for (const row of affinities) {
    const realJurorId = jurorAnonToReal.get(row.jurorId)
    if (!realJurorId) continue

    const jurorScores = new Map<string, { score: number; reasoning: string }>()
    for (const r of row.rankings) {
      const realProjectId = projectAnonToReal.get(r.projectId)
      if (!realProjectId) continue
      jurorScores.set(realProjectId, {
        score: r.score / 100, // normalize to 0-1
        reasoning: r.reasoning,
      })
    }
    matrix.set(realJurorId, jurorScores)
  }

  // Fill gaps: for juror-project pairs not scored by AI, use tag-based fallback
  for (const juror of jurors) {
    if (!matrix.has(juror.id)) {
      matrix.set(juror.id, new Map())
    }
    const jurorScores = matrix.get(juror.id)!

    for (const project of projects) {
      if (!jurorScores.has(project.id)) {
        const tagScore = calculateExpertiseScore(juror.expertiseTags, project.tags, project.tagConfidences)
        jurorScores.set(project.id, {
          score: tagScore * 0.5, // Scale down fallback scores
          reasoning: generateFallbackReasoning(juror.expertiseTags, project.tags, tagScore),
        })
      }
    }
  }

  return matrix
}

/**
 * Balanced assignment algorithm using AI affinity scores.
 *
 * Strategy: iteratively assign the best available juror to each under-covered
 * project, always preferring the least-loaded juror among those with decent scores.
 */
function assignFromScores(
  scoreMatrix: Map<string, Map<string, { score: number; reasoning: string }>>,
  jurors: JurorForAssignment[],
  projects: ProjectForAssignment[],
  constraints: AssignmentConstraints,
  maxCap: number,
): AIAssignmentSuggestion[] {
  const result: AIAssignmentSuggestion[] = []

  // Track state
  const assignedPairs = new Set<string>()
  const jurorLoad = new Map<string, number>() // total load (existing + new)
  const projectCoverage = new Map<string, number>() // how many reviewers assigned

  // Initialize from existing assignments
  for (const ea of constraints.existingAssignments) {
    assignedPairs.add(`${ea.jurorId}:${ea.projectId}`)
    jurorLoad.set(ea.jurorId, (jurorLoad.get(ea.jurorId) || 0) + 1)
    projectCoverage.set(ea.projectId, (projectCoverage.get(ea.projectId) || 0) + 1)
  }

  // Also count existing DB assignments from _count
  for (const j of jurors) {
    const dbCount = j._count?.assignments || 0
    jurorLoad.set(j.id, Math.max(jurorLoad.get(j.id) || 0, dbCount))
  }
  for (const p of projects) {
    const dbCount = p._count?.assignments || 0
    projectCoverage.set(p.id, Math.max(projectCoverage.get(p.id) || 0, dbCount))
  }

  const getEffectiveCap = (jurorId: string) => {
    if (constraints.jurorLimits?.[jurorId]) return constraints.jurorLimits[jurorId]
    const juror = jurors.find((j) => j.id === jurorId)
    return juror?.maxAssignments ?? maxCap
  }

  // Ideal target: distribute evenly
  const totalNeeded = projects.reduce((sum, p) => {
    const current = projectCoverage.get(p.id) || 0
    return sum + Math.max(0, constraints.requiredReviewsPerProject - current)
  }, 0)
  const idealPerJuror = Math.ceil(totalNeeded / jurors.length)

  console.log(`[AI Assignment] Algorithm: ${totalNeeded} slots to fill, ideal ${idealPerJuror}/juror, cap ${maxCap}/juror`)

  // Iterative assignment: repeat until all projects are covered or no more capacity
  for (let pass = 0; pass < constraints.requiredReviewsPerProject; pass++) {
    // Sort projects by coverage gap (most under-covered first)
    const projectsByNeed = [...projects]
      .map((p) => ({
        project: p,
        current: projectCoverage.get(p.id) || 0,
        needed: constraints.requiredReviewsPerProject,
      }))
      .filter((pp) => pp.current < pp.needed)
      .sort((a, b) => (a.current - a.needed) - (b.current - b.needed))

    if (projectsByNeed.length === 0) break

    for (const { project } of projectsByNeed) {
      const currentCoverage = projectCoverage.get(project.id) || 0
      if (currentCoverage >= constraints.requiredReviewsPerProject) continue

      // Find best available juror: weighted by AI score AND workload balance
      const candidates = jurors
        .filter((j) => {
          const pairKey = `${j.id}:${project.id}`
          if (assignedPairs.has(pairKey)) return false
          const load = jurorLoad.get(j.id) || 0
          return load < getEffectiveCap(j.id)
        })
        .map((j) => {
          const load = jurorLoad.get(j.id) || 0
          const aiData = scoreMatrix.get(j.id)?.get(project.id)
          const aiScore = aiData?.score ?? 0.3
          const reasoning = aiData?.reasoning ?? 'Assigned for coverage'

          // Workload penalty: heavily penalize jurors above ideal target
          // This ensures even distribution
          const loadRatio = load / Math.max(1, idealPerJuror)
          const loadPenalty = loadRatio > 1
            ? 0.3 * Math.pow(0.5, loadRatio - 1) // Steep drop-off above ideal
            : 1 - (loadRatio * 0.4) // Gentle linear decrease up to ideal

          // Combined score: 55% AI score, 45% workload balance
          const combinedScore = aiScore * 0.55 + loadPenalty * 0.45

          return { juror: j, aiScore, combinedScore, reasoning, load }
        })
        .sort((a, b) => b.combinedScore - a.combinedScore)

      if (candidates.length === 0) continue

      const best = candidates[0]

      result.push({
        jurorId: best.juror.id,
        projectId: project.id,
        confidenceScore: best.aiScore,
        expertiseMatchScore: best.aiScore,
        reasoning: best.reasoning,
      })

      assignedPairs.add(`${best.juror.id}:${project.id}`)
      jurorLoad.set(best.juror.id, (best.load) + 1)
      projectCoverage.set(project.id, (currentCoverage) + 1)
    }
  }

  // Log final distribution
  const newAssignmentsPerJuror = new Map<string, number>()
  for (const s of result) {
    newAssignmentsPerJuror.set(s.jurorId, (newAssignmentsPerJuror.get(s.jurorId) || 0) + 1)
  }
  const loads = [...newAssignmentsPerJuror.values()]
  if (loads.length > 0) {
    console.log(`[AI Assignment] Distribution: min=${Math.min(...loads)}, max=${Math.max(...loads)}, avg=${(loads.reduce((a, b) => a + b, 0) / loads.length).toFixed(1)}`)
  }

  const uncovered = projects.filter((p) => (projectCoverage.get(p.id) || 0) < constraints.requiredReviewsPerProject)
  if (uncovered.length > 0) {
    console.warn(`[AI Assignment] ${uncovered.length} projects still under-covered after assignment`)
  }

  return result
}

// ─── Main Entry Point ───────────────────────────────────────────────────────

/**
 * Generate AI-powered assignment suggestions (hybrid approach).
 *
 * 1. ONE AI call: get affinity scores for all juror-project pairs
 * 2. Algorithm: assign N reviewers per project using AI scores + workload balancing
 */
export async function generateAIAssignments(
  jurors: JurorForAssignment[],
  projects: ProjectForAssignment[],
  constraints: AssignmentConstraints,
  userId?: string,
  entityId?: string,
  _onProgress?: AssignmentProgressCallback
): Promise<AIAssignmentResult> {
  // Truncate descriptions before anonymization
  const truncatedProjects = projects.map((p) => ({
    ...p,
    description: truncateAndSanitize(p.description, DESCRIPTION_LIMITS.ASSIGNMENT),
  }))

  // Anonymize data before sending to AI
  const anonymizedData = anonymizeForAI(jurors, truncatedProjects)

  // Validate anonymization
  if (!validateAnonymization(anonymizedData)) {
    console.error('[AI Assignment] Anonymization validation failed, falling back to algorithm')
    return generateFallbackAssignments(jurors, projects, constraints)
  }

  // Build existing pair set for AI (anonymous IDs)
  const jurorRealToAnon = new Map(anonymizedData.jurorMappings.map((m) => [m.realId, m.anonymousId]))
  const projectRealToAnon = new Map(anonymizedData.projectMappings.map((m) => [m.realId, m.anonymousId]))
  const existingAnonPairs = new Set<string>()
  for (const ea of constraints.existingAssignments) {
    const aJ = jurorRealToAnon.get(ea.jurorId)
    const aP = projectRealToAnon.get(ea.projectId)
    if (aJ && aP) existingAnonPairs.add(`${aJ}:${aP}`)
  }

  // Calculate caps
  const totalNeeded = projects.length * constraints.requiredReviewsPerProject
  const maxCap = constraints.maxAssignmentsPerJuror ?? Math.ceil(totalNeeded / jurors.length) + 2

  try {
    const openai = await getOpenAI()

    if (!openai) {
      console.log('[AI Assignment] OpenAI not configured, using fallback algorithm')
      return generateFallbackAssignments(jurors, projects, constraints)
    }

    const model = await getConfiguredModel()
    console.log(`[AI Assignment] Hybrid approach: ${projects.length} projects, ${jurors.length} jurors, ${constraints.requiredReviewsPerProject} reviews/project, model: ${model}`)

    // ── Phase 1: AI Scoring (single call) ──
    console.log('[AI Assignment] Phase 1: Getting AI affinity scores...')
    const { affinities, tokensUsed } = await getAIAffinityMatrix(
      openai,
      model,
      anonymizedData,
      existingAnonPairs,
      userId,
      entityId,
    )

    // ── Phase 2: Build score matrix and run algorithm ──
    console.log('[AI Assignment] Phase 2: Running balanced assignment algorithm...')
    const scoreMatrix = buildScoreMatrix(affinities, jurors, projects, anonymizedData)
    const suggestions = assignFromScores(scoreMatrix, jurors, projects, constraints, maxCap)

    console.log(`[AI Assignment] Complete: ${suggestions.length} assignments, ${tokensUsed} tokens used`)

    return {
      success: true,
      suggestions,
      tokensUsed,
      fallbackUsed: false,
    }

  } catch (error) {
    const classified = classifyAIError(error)
    logAIError('Assignment', 'generateAIAssignments', classified)

    await logAIUsage({
      userId,
      action: 'ASSIGNMENT',
      entityType: 'Round',
      entityId,
      model: 'unknown',
      promptTokens: 0,
      completionTokens: 0,
      totalTokens: 0,
      batchSize: projects.length,
      itemsProcessed: 0,
      status: 'ERROR',
      errorMessage: classified.message,
    })

    console.error('[AI Assignment] AI failed, falling back to algorithm:', classified.message)
    const fallback = generateFallbackAssignments(jurors, projects, constraints)
    return {
      ...fallback,
      error: `AI scoring failed (${classified.message}). Used algorithmic fallback.`,
    }
  }
}

// ─── Fallback Algorithm ──────────────────────────────────────────────────────

/**
 * Fallback algorithm-based assignment when AI is unavailable
 */
export function generateFallbackAssignments(
  jurors: JurorForAssignment[],
  projects: ProjectForAssignment[],
  constraints: AssignmentConstraints
): AIAssignmentResult {
  const suggestions: AIAssignmentSuggestion[] = []
  const existingSet = new Set(
    constraints.existingAssignments.map((a) => `${a.jurorId}:${a.projectId}`)
  )

  // Track assignments per juror and project
  const jurorAssignments = new Map<string, number>()
  const projectAssignments = new Map<string, number>()

  // Initialize counts from existing assignments
  for (const assignment of constraints.existingAssignments) {
    jurorAssignments.set(
      assignment.jurorId,
      (jurorAssignments.get(assignment.jurorId) || 0) + 1
    )
    projectAssignments.set(
      assignment.projectId,
      (projectAssignments.get(assignment.projectId) || 0) + 1
    )
  }

  // Also include current assignment counts
  for (const juror of jurors) {
    const current = juror._count?.assignments || 0
    jurorAssignments.set(
      juror.id,
      Math.max(jurorAssignments.get(juror.id) || 0, current)
    )
  }

  for (const project of projects) {
    const current = project._count?.assignments || 0
    projectAssignments.set(
      project.id,
      Math.max(projectAssignments.get(project.id) || 0, current)
    )
  }

  const totalNeeded = projects.length * constraints.requiredReviewsPerProject
  const maxCap = constraints.maxAssignmentsPerJuror ?? Math.ceil(totalNeeded / jurors.length) + 2
  const idealPerJuror = Math.ceil(totalNeeded / jurors.length)

  // Iterative: for each pass, assign one more reviewer per under-covered project
  for (let pass = 0; pass < constraints.requiredReviewsPerProject; pass++) {
    // Sort projects by need (fewest assignments first)
    const sortedProjects = [...projects].sort((a, b) => {
      const aCount = projectAssignments.get(a.id) || 0
      const bCount = projectAssignments.get(b.id) || 0
      return aCount - bCount
    })

    for (const project of sortedProjects) {
      const currentProjectAssignments = projectAssignments.get(project.id) || 0
      if (currentProjectAssignments >= constraints.requiredReviewsPerProject) continue

      // Score jurors with heavy workload emphasis
      const scoredJurors = jurors
        .filter((juror) => {
          if (existingSet.has(`${juror.id}:${project.id}`)) return false
          const currentLoad = jurorAssignments.get(juror.id) || 0
          const cap = juror.maxAssignments ?? maxCap
          return currentLoad < cap
        })
        .map((juror) => {
          const currentLoad = jurorAssignments.get(juror.id) || 0
          const expertiseScore = calculateExpertiseScore(
            juror.expertiseTags,
            project.tags,
            project.tagConfidences,
          )

          // Heavy workload balance weight
          const loadRatio = currentLoad / Math.max(1, idealPerJuror)
          const loadPenalty = loadRatio > 1
            ? 0.3 * Math.pow(0.5, loadRatio - 1)
            : 1 - (loadRatio * 0.4)

          return {
            juror,
            expertiseScore,
            combinedScore: expertiseScore * 0.45 + loadPenalty * 0.55,
          }
        })
        .sort((a, b) => b.combinedScore - a.combinedScore)

      if (scoredJurors.length === 0) continue

      const { juror, expertiseScore } = scoredJurors[0]

      suggestions.push({
        jurorId: juror.id,
        projectId: project.id,
        confidenceScore: expertiseScore,
        expertiseMatchScore: expertiseScore,
        reasoning: generateFallbackReasoning(juror.expertiseTags, project.tags, expertiseScore),
      })

      existingSet.add(`${juror.id}:${project.id}`)
      jurorAssignments.set(juror.id, (jurorAssignments.get(juror.id) || 0) + 1)
      projectAssignments.set(project.id, currentProjectAssignments + 1)
    }
  }

  return {
    success: true,
    suggestions,
    fallbackUsed: true,
  }
}

// ─── Scoring Helpers ────────────────────────────────────────────────────────

/**
 * Calculate expertise match score based on tag overlap
 * When tagConfidences are available, weights matches by confidence
 */
function calculateExpertiseScore(
  jurorTags: string[],
  projectTags: string[],
  tagConfidences?: Array<{ name: string; confidence: number }>
): number {
  if (jurorTags.length === 0 || projectTags.length === 0) {
    return 0.5 // Neutral score if no tags
  }

  const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))

  // If we have confidence data, use weighted scoring
  if (tagConfidences && tagConfidences.length > 0) {
    let weightedMatches = 0
    let totalWeight = 0

    for (const tc of tagConfidences) {
      totalWeight += tc.confidence
      if (jurorTagsLower.has(tc.name.toLowerCase())) {
        weightedMatches += tc.confidence
      }
    }

    if (totalWeight === 0) return 0.5

    const weightedRatio = weightedMatches / totalWeight
    const hasExpertise = weightedMatches > 0 ? 0.2 : 0
    return Math.min(1, weightedRatio * 0.8 + hasExpertise)
  }

  // Fallback: unweighted matching using flat tags
  const matchingTags = projectTags.filter((t) =>
    jurorTagsLower.has(t.toLowerCase())
  )

  const matchRatio = matchingTags.length / projectTags.length
  const hasExpertise = matchingTags.length > 0 ? 0.2 : 0

  return Math.min(1, matchRatio * 0.8 + hasExpertise)
}

/**
 * Generate reasoning for fallback assignments
 */
function generateFallbackReasoning(
  jurorTags: string[],
  projectTags: string[],
  score: number
): string {
  const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
  const matchingTags = projectTags.filter((t) =>
    jurorTagsLower.has(t.toLowerCase())
  )

  if (matchingTags.length > 0) {
    return `Expertise match: ${matchingTags.join(', ')}. Match score: ${(score * 100).toFixed(0)}%.`
  }

  if (score >= 0.5) {
    return `Assigned for workload balance. No direct expertise match but available capacity.`
  }

  return `Assigned to ensure project coverage.`
}
