/**
 * CS2 Major Qualifying Points Simulator
 *
 * Monte Carlo simulation of the 2 CS2 Majors per year. Qualifying points (QP)
 * accumulate across both majors; final QP totals determine fantasy placements (1st–8th).
 *
 * CS2 Major format (32 teams, 3 Swiss stages + playoffs):
 *   Stage 1 (Opening Stage):     16 teams, Swiss, Bo1 matches
 *   Stage 2 (Elimination Stage): 16 teams (8 Challengers + 8 from Stage 1), Swiss, Bo1/Bo3
 *   Stage 3 (Decider Stage):     16 teams (8 Legends + 8 from Stage 2), Swiss, all Bo3
 *   Champions Stage:              8 teams, single-elimination (QF Bo3, SF Bo3, GF Bo5)
 *
 * Field selection (per iteration):
 *   - Top 12 participants by world ranking are always in the simulated field.
 *   - Remaining spots (up to 32 total) are sampled from the rest of the pool,
 *     weighted by 1/rank (lower rank = higher inclusion probability).
 *   - If cs2MajorStageResults records exist for an event, those explicit
 *     stage assignments are used instead of sampling/rank inference.
 *
 * Stage assignment within the 32-team field:
 *   - With explicit stage data: use stageEntry from cs2MajorStageResults
 *   - Without: top 8 by world ranking → Stage 3, next 8 → Stage 2, bottom 16 → Stage 1
 *
 * QP for Stage 3 exits (placements 9–16) is sub-ranked by W-L record:
 *   - 2-3 teams → higher placements within 9–16
 *   - 1-3 teams → middle placements
 *   - 0-3 teams → lowest placements within 9–16
 *   QP is tie-split (averaged) within each W-L group.
 *
 * Stages 1 and 2 exits (placements 17–32) earn 0 QP.
 */

import { database } from "~/database/context";
import { eq, and, inArray } from "drizzle-orm";
import * as schema from "~/database/schema";
import { getQPConfig } from "~/models/qualifying-points";
import { getCs2StageResultsMapForEvent } from "~/models/cs2-major-stage";
import type { Simulator, SimulationResult } from "./types";

// ─── Simulation parameters ────────────────────────────────────────────────────

const NUM_SIMULATIONS = 10_000;

/** Total field size per CS2 Major. */
const FIELD_SIZE = 32;

/** Number of teams guaranteed in the simulated field (always included). */
const GUARANTEED_COUNT = 12;

/**
 * Elo divisor for per-game win probability.
 * Standard chess Elo uses 400. CS2 maps well to single-game level.
 * 200-pt gap ≈ 76% win probability; 400-pt gap ≈ 91%.
 */
const ELO_DIVISOR = 400;

/** Fallback Elo for teams with no stored rating. */
const FALLBACK_ELO = 1500;

// ─── Math helpers ─────────────────────────────────────────────────────────────

/**
 * Per-game win probability for team 1 vs team 2.
 * Exported for unit testing.
 */
export function gameWinProb(elo1: number, elo2: number): number {
  return 1 / (1 + Math.pow(10, (elo2 - elo1) / ELO_DIVISOR));
}

/**
 * Match win probability using the Bernoulli series model.
 * For a best-of-(2S-1) match (first to S wins):
 *   P(win) = Σ_{k=0}^{S-1} C(S-1+k, k) × p^S × (1-p)^k
 * Exported for unit testing.
 */
export function seriesWinProb(p: number, winsNeeded: number): number {
  let prob = 0;
  for (let k = 0; k < winsNeeded; k++) {
    prob += binomialCoeff(winsNeeded - 1 + k, k) * Math.pow(p, winsNeeded) * Math.pow(1 - p, k);
  }
  return prob;
}

/** Binomial coefficient C(n, k). */
function binomialCoeff(n: number, k: number): number {
  if (k === 0) return 1;
  if (k > n - k) k = n - k;
  let result = 1;
  for (let i = 0; i < k; i++) {
    result = (result * (n - i)) / (i + 1);
  }
  return result;
}

/** Fisher-Yates in-place shuffle. Returns the array for chaining. */
function shuffle<T>(arr: T[]): T[] {
  for (let i = arr.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return arr;
}

// ─── Field selection ──────────────────────────────────────────────────────────

interface TeamWithElo {
  id: string;
  elo: number;
  rank: number;  // HLTV world ranking (lower = better)
}

/**
 * Sample a 32-team field from the pool.
 * - Top GUARANTEED_COUNT (12) by rank are always included.
 * - Remaining spots are weighted-randomly sampled from the rest, weight = 1/rank.
 * - If pool.length <= FIELD_SIZE, returns all teams.
 * Exported for unit testing.
 */
export function sampleField(
  pool: TeamWithElo[],
  fieldSize: number = FIELD_SIZE
): TeamWithElo[] {
  const sorted = [...pool].sort((a, b) => a.rank - b.rank);

  if (sorted.length <= fieldSize) return sorted;

  const guaranteed = sorted.slice(0, GUARANTEED_COUNT);
  const rest = sorted.slice(GUARANTEED_COUNT);
  const needed = fieldSize - guaranteed.length;

  if (needed <= 0) return guaranteed;
  if (rest.length <= needed) return [...guaranteed, ...rest];

  // Weighted sample without replacement, weight = 1/rank
  const weights = rest.map((t) => 1 / t.rank);
  const sampled = weightedSampleWithoutReplacement(rest, weights, needed);

  return [...guaranteed, ...sampled];
}

/** Weighted sampling without replacement using the Gumbel-max trick. */
function weightedSampleWithoutReplacement<T>(
  items: T[],
  weights: number[],
  count: number
): T[] {
  const keys = weights.map((w) => -Math.log(Math.random()) / w);
  const indexed = items.map((item, i) => ({ item, key: keys[i] }));
  indexed.sort((a, b) => a.key - b.key);
  return indexed.slice(0, count).map((x) => x.item);
}

// ─── Swiss stage simulation ───────────────────────────────────────────────────

interface SwissResult {
  /** Teams that advanced (3 wins). */
  advanced: Array<{ id: string; elo: number; rank: number }>;
  /** Teams eliminated, with their win count at time of elimination. */
  eliminated: Array<{ id: string; elo: number; rank: number; wins: number }>;
}

/**
 * Simulate one Swiss-format stage.
 * Teams are grouped by their (wins, losses) record each round. Teams are paired
 * randomly within each record group. First to 3 wins advances; first to 3 losses
 * is eliminated.
 *
 * @param teams - Teams entering this stage.
 * @param bo3   - If true, use Bo3 series win probability; otherwise Bo1 (single game).
 * @returns advanced and eliminated arrays.
 *
 * Exported for unit testing.
 */
export function simulateSwiss(teams: TeamWithElo[], bo3: boolean): SwissResult {
  const wins = new Map<string, number>(teams.map((t) => [t.id, 0]));
  const losses = new Map<string, number>(teams.map((t) => [t.id, 0]));
  const eloMap = new Map<string, number>(teams.map((t) => [t.id, t.elo]));

  const advanced: SwissResult["advanced"] = [];
  const eliminated: SwissResult["eliminated"] = [];
  const advancedIds = new Set<string>();
  const eliminatedIds = new Set<string>();

  const teamById = new Map<string, TeamWithElo>(teams.map((t) => [t.id, t]));

  // Run rounds until every team has reached 3W or 3L
  while (advancedIds.size + eliminatedIds.size < teams.length) {
    // Collect active teams grouped by (W, L) record
    const active = teams.filter((t) => !advancedIds.has(t.id) && !eliminatedIds.has(t.id));
    const groups = groupByRecord(active, wins, losses);

    // If a group has an odd number, move one team to the nearest adjacent group
    // (simplified: skip teams that can't be paired — they sit out this round)
    const pairs = pairGroups(groups);

    // Safety: if no pairs can be formed (shouldn't happen with even team counts
    // but guards against an infinite loop if an odd active count somehow occurs)
    if (pairs.length === 0) break;

    for (const [t1, t2] of pairs) {
      const e1 = eloMap.get(t1.id) ?? FALLBACK_ELO;
      const e2 = eloMap.get(t2.id) ?? FALLBACK_ELO;
      const p = bo3
        ? seriesWinProb(gameWinProb(e1, e2), 2)  // Bo3: first to 2
        : gameWinProb(e1, e2);                     // Bo1: single game

      const t1Wins = Math.random() < p;
      const winnerId = t1Wins ? t1.id : t2.id;
      const loserId = t1Wins ? t2.id : t1.id;

      wins.set(winnerId, (wins.get(winnerId) ?? 0) + 1);
      losses.set(loserId, (losses.get(loserId) ?? 0) + 1);

      if ((wins.get(winnerId) ?? 0) >= 3) {
        advancedIds.add(winnerId);
        const t = teamById.get(winnerId)!;
        advanced.push({ id: t.id, elo: t.elo, rank: t.rank });
      }
      if ((losses.get(loserId) ?? 0) >= 3) {
        eliminatedIds.add(loserId);
        const t = teamById.get(loserId)!;
        eliminated.push({ id: t.id, elo: t.elo, rank: t.rank, wins: wins.get(loserId) ?? 0 });
      }
    }
  }

  return { advanced, eliminated };
}

/** Group active teams by (wins, losses) record key. */
function groupByRecord(
  active: TeamWithElo[],
  wins: Map<string, number>,
  losses: Map<string, number>
): Map<string, TeamWithElo[]> {
  const groups = new Map<string, TeamWithElo[]>();
  for (const t of active) {
    const key = `${wins.get(t.id) ?? 0}-${losses.get(t.id) ?? 0}`;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key)!.push(t);
  }
  return groups;
}

/**
 * Pair teams within each record group. Teams in groups with an odd count
 * are handled by merging the leftover into an adjacent group (simpler: skip
 * them for this round — they sit out and play next round).
 */
function pairGroups(groups: Map<string, TeamWithElo[]>): Array<[TeamWithElo, TeamWithElo]> {
  const pairs: Array<[TeamWithElo, TeamWithElo]> = [];
  const leftover: TeamWithElo[] = [];

  for (const group of groups.values()) {
    const shuffled = shuffle([...group]);
    for (let i = 0; i + 1 < shuffled.length; i += 2) {
      pairs.push([shuffled[i], shuffled[i + 1]]);
    }
    if (shuffled.length % 2 !== 0) {
      leftover.push(shuffled[shuffled.length - 1]);
    }
  }

  // Pair leftover teams with each other (different records, cross-group match)
  for (let i = 0; i + 1 < leftover.length; i += 2) {
    pairs.push([leftover[i], leftover[i + 1]]);
  }

  return pairs;
}

// ─── Champions Stage (8-team single-elimination bracket) ─────────────────────

interface ChampionsResult {
  placements: Map<string, number>;  // participantId → placement (1–8)
}

/**
 * Simulate the Champions Stage 8-team single-elimination bracket.
 * Seeds are assigned by rank within the 8 advancing teams.
 * Rounds: QF (Bo3), SF (Bo3), GF (Bo5).
 * Exported for unit testing.
 */
export function simulateChampionsStage(teams: TeamWithElo[]): ChampionsResult {
  if (teams.length !== 8) {
    throw new Error(`simulateChampionsStage expects exactly 8 teams, got ${teams.length}`);
  }

  // Seed by rank ascending
  const seeded = [...teams].sort((a, b) => a.rank - b.rank);
  // Standard 8-team seeding: 1v8, 4v5, 3v6, 2v7
  const bracket: [TeamWithElo, TeamWithElo][] = [
    [seeded[0], seeded[7]],
    [seeded[3], seeded[4]],
    [seeded[2], seeded[5]],
    [seeded[1], seeded[6]],
  ];

  const placements = new Map<string, number>();

  const simMatch = (t1: TeamWithElo, t2: TeamWithElo, winsNeeded: number): TeamWithElo => {
    const p = seriesWinProb(gameWinProb(t1.elo, t2.elo), winsNeeded);
    return Math.random() < p ? t1 : t2;
  };

  // Quarterfinals (Bo3 = first to 2)
  const sfTeams: TeamWithElo[] = [];
  for (const [t1, t2] of bracket) {
    const winner = simMatch(t1, t2, 2);
    const loser = winner.id === t1.id ? t2 : t1;
    sfTeams.push(winner);
    placements.set(loser.id, 7);  // QF losers: 5th–8th (averaged to 6.5, use 7 for counting)
  }
  // Assign QF losers to placements 5–8
  const qfLosers = [...placements.keys()];
  qfLosers.forEach((id, i) => placements.set(id, 5 + i));

  // Semifinals (Bo3 = first to 2)
  const finalTeams: TeamWithElo[] = [];
  const sfLosers: TeamWithElo[] = [];
  for (let i = 0; i < sfTeams.length; i += 2) {
    const winner = simMatch(sfTeams[i], sfTeams[i + 1], 2);
    const loser = winner.id === sfTeams[i].id ? sfTeams[i + 1] : sfTeams[i];
    finalTeams.push(winner);
    sfLosers.push(loser);
  }
  sfLosers.forEach((t, i) => placements.set(t.id, 3 + i));

  // Grand Final (Bo5 = first to 3)
  const champion = simMatch(finalTeams[0], finalTeams[1], 3);
  const finalist = champion.id === finalTeams[0].id ? finalTeams[1] : finalTeams[0];
  placements.set(champion.id, 1);
  placements.set(finalist.id, 2);

  return { placements };
}

// ─── QP helpers ───────────────────────────────────────────────────────────────

/**
 * Calculate QP for Stage 3 exits based on their W-L record.
 * Teams are ranked within 9–16 by wins (2-3 > 1-3 > 0-3).
 * Within the same wins count, QP is tie-split (averaged) across placement slots.
 *
 * qpConfig: array indexed by placement (1-indexed), qpConfig[placement] = QP value.
 *           Placements 9–16 correspond to indices 9–16.
 */
function calcStage3ExitQP(
  elimTeams: Array<{ id: string; wins: number }>,
  qpConfig: Map<number, number>
): Map<string, number> {
  // Group teams by wins count (0, 1, 2)
  const byWins = new Map<number, string[]>();
  for (const t of elimTeams) {
    if (!byWins.has(t.wins)) byWins.set(t.wins, []);
    byWins.get(t.wins)!.push(t.id);
  }

  // Assign placement slots 9–16 from highest wins first
  const result = new Map<string, number>();
  const winsGroups = [...byWins.entries()].sort((a, b) => b[0] - a[0]);  // descending wins

  let slotStart = 9;
  for (const [, ids] of winsGroups) {
    const slots = Array.from({ length: ids.length }, (_, i) => slotStart + i);
    const avgQP = slots.reduce((sum, s) => sum + (qpConfig.get(s) ?? 0), 0) / slots.length;
    for (const id of ids) {
      result.set(id, avgQP);
    }
    slotStart += ids.length;
  }

  return result;
}

// ─── Simulator ────────────────────────────────────────────────────────────────

export class CSMajorSimulator implements Simulator {
  async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
    const db = database();

    // 1. Load all participants for this sports season.
    const allParticipants = await db
      .select({ id: schema.participants.id })
      .from(schema.participants)
      .where(eq(schema.participants.sportsSeasonId, sportsSeasonId));

    const participantIds = allParticipants.map((p) => p.id);

    if (participantIds.length === 0) {
      throw new Error(`No participants found for sports season ${sportsSeasonId}.`);
    }

    // 2. Load Elo ratings and world rankings from participantExpectedValues.
    const evRows = await db
      .select({
        participantId: schema.participantExpectedValues.participantId,
        sourceElo: schema.participantExpectedValues.sourceElo,
        worldRanking: schema.participantExpectedValues.worldRanking,
      })
      .from(schema.participantExpectedValues)
      .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));

    const eloMap = new Map<string, { elo: number; rank: number }>();
    for (const row of evRows) {
      if (row.sourceElo !== null) {
        eloMap.set(row.participantId, {
          elo: row.sourceElo,
          rank: row.worldRanking ?? 9999,
        });
      }
    }

    // Build pool: all participants with Elo ratings, sorted by rank
    const pool: TeamWithElo[] = participantIds
      .filter((id) => eloMap.has(id))
      .map((id) => {
        const e = eloMap.get(id)!;
        return { id, elo: e.elo, rank: e.rank };
      })
      .sort((a, b) => a.rank - b.rank);

    if (pool.length === 0) {
      throw new Error(
        `No participants with Elo ratings found for sports season ${sportsSeasonId}. ` +
        `Enter Elo ratings via the CS Elo admin page before simulating.`
      );
    }

    // 3. Load QP config (placements 1–16 earn QP; 17+ earn 0).
    const qpConfigArray = await getQPConfig(sportsSeasonId);
    const qpConfig = new Map<number, number>(
      qpConfigArray.map((c) => [c.placement, parseFloat(c.points)])
    );

    // 4. Load all CS major scoring events for this sports season.
    const events = await db.query.scoringEvents.findMany({
      where: and(
        eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
        eq(schema.scoringEvents.eventType, "major_tournament")
      ),
      orderBy: (e, { asc }) => [asc(e.eventDate)],
    });

    if (events.length === 0) {
      throw new Error(
        `No major_tournament scoring events found for sports season ${sportsSeasonId}. ` +
        `Create the CS Major scoring events first.`
      );
    }

    // 5. For completed events, read actual QP from eventResults.
    const completedEventIds = events.filter((e) => e.isComplete).map((e) => e.id);
    const actualQPMap = new Map<string, number>(participantIds.map((id) => [id, 0]));

    if (completedEventIds.length > 0) {
      const actualResults = await db
        .select({
          participantId: schema.eventResults.participantId,
          qualifyingPointsAwarded: schema.eventResults.qualifyingPointsAwarded,
        })
        .from(schema.eventResults)
        .where(inArray(schema.eventResults.scoringEventId, completedEventIds));

      for (const r of actualResults) {
        if (r.qualifyingPointsAwarded !== null) {
          const prev = actualQPMap.get(r.participantId) ?? 0;
          actualQPMap.set(r.participantId, prev + parseFloat(r.qualifyingPointsAwarded));
        }
      }
    }

    // 6. Load stage results for each event (for explicit field composition).
    const eventStageResults = new Map<string, Awaited<ReturnType<typeof getCs2StageResultsMapForEvent>>>();
    for (const event of events) {
      const stageResults = await getCs2StageResultsMapForEvent(event.id);
      if (stageResults.size > 0) {
        eventStageResults.set(event.id, stageResults);
      }
    }

    const incompleteEvents = events.filter((e) => !e.isComplete);

    // 7. Monte Carlo loop.
    const counts: number[][] = Array.from({ length: participantIds.length }, () => Array(8).fill(0));
    const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));

    for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
      const simQP = new Map<string, number>(actualQPMap);

      for (const event of incompleteEvents) {
        const stageResultsForEvent = eventStageResults.get(event.id);
        const eventQP = simulateOneMajor(pool, stageResultsForEvent, qpConfig);
        for (const [pid, qp] of eventQP) {
          simQP.set(pid, (simQP.get(pid) ?? 0) + qp);
        }
      }

      // Rank all participants by total QP descending.
      const ranked = [...simQP.entries()].sort((a, b) => b[1] - a[1]);
      for (let rank = 0; rank < Math.min(8, ranked.length); rank++) {
        const [pid] = ranked[rank];
        const idx = idToIndex.get(pid);
        if (idx !== undefined) counts[idx][rank]++;
      }
    }

    // 8. Convert counts to probabilities.
    return participantIds.map((participantId, i) => ({
      participantId,
      probabilities: {
        probFirst:   counts[i][0] / NUM_SIMULATIONS,
        probSecond:  counts[i][1] / NUM_SIMULATIONS,
        probThird:   counts[i][2] / NUM_SIMULATIONS,
        probFourth:  counts[i][3] / NUM_SIMULATIONS,
        probFifth:   counts[i][4] / NUM_SIMULATIONS,
        probSixth:   counts[i][5] / NUM_SIMULATIONS,
        probSeventh: counts[i][6] / NUM_SIMULATIONS,
        probEighth:  counts[i][7] / NUM_SIMULATIONS,
      },
      source: "cs2_major_qualifying_points_monte_carlo",
    }));
  }
}

// ─── Single major simulation ───────────────────────────────────────────────────

/**
 * Simulate one CS2 Major and return QP earned per participant.
 *
 * If stage results are provided, uses them to determine field composition
 * and to lock in results for any completed stages, only simulating the
 * remaining stages. A stage is considered complete when the expected 8
 * eliminations for that stage have been recorded.
 *
 * Returns a Map from participantId → QP earned in this major.
 */
function simulateOneMajor(
  pool: TeamWithElo[],
  stageResults: Map<string, { stageEntry: number; stageEliminated: number | null; stageEliminatedWins: number | null }> | undefined,
  qpConfig: Map<number, number>
): Map<string, number> {
  const qpMap = new Map<string, number>();
  const poolById = new Map<string, TeamWithElo>(pool.map((t) => [t.id, t]));

  // ── Determine field and stage assignments ─────────────────────────────────
  let stage1Teams: TeamWithElo[];
  let stage2Direct: TeamWithElo[];
  let stage3Direct: TeamWithElo[];

  if (stageResults && stageResults.size > 0) {
    const s1: TeamWithElo[] = [];
    const s2: TeamWithElo[] = [];
    const s3: TeamWithElo[] = [];
    for (const [participantId, result] of stageResults) {
      const team = poolById.get(participantId);
      if (!team) continue;
      if (result.stageEntry === 1) s1.push(team);
      else if (result.stageEntry === 2) s2.push(team);
      else if (result.stageEntry === 3) s3.push(team);
    }
    stage1Teams = s1;
    stage2Direct = s2;
    stage3Direct = s3;
  } else {
    const field = sampleField(pool, FIELD_SIZE);
    const sorted = [...field].sort((a, b) => a.rank - b.rank);
    stage3Direct = sorted.slice(0, 8);
    stage2Direct = sorted.slice(8, 16);
    stage1Teams  = sorted.slice(16, 32);
  }

  // ── Lock in known stage results ───────────────────────────────────────────
  // A stage is "complete" when exactly 8 teams have been recorded as
  // eliminated at that stage (the expected output of each Swiss stage).
  const eliminatedAtStage = (stageNum: number) =>
    stageResults
      ? [...stageResults.entries()]
          .filter(([, r]) => r.stageEliminated === stageNum)
          .map(([id, r]) => ({
            id,
            elo: poolById.get(id)?.elo ?? FALLBACK_ELO,
            rank: poolById.get(id)?.rank ?? 9999,
            wins: r.stageEliminatedWins ?? 0,
          }))
      : [];

  const stage1Elim = eliminatedAtStage(1);
  const stage2Elim = eliminatedAtStage(2);
  const stage3Elim = eliminatedAtStage(3);

  const stage1Complete = stage1Elim.length === 8;
  const stage2Complete = stage2Elim.length === 8;
  const stage3Complete = stage3Elim.length === 8;

  // ── Stage 1 ───────────────────────────────────────────────────────────────
  let stage1Advanced: TeamWithElo[];
  let stage1EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>;

  if (stage1Complete) {
    // Use known results: teams that entered Stage 1 but weren't eliminated there advanced
    const stage1EliminatedIds = new Set(stage1Elim.map((t) => t.id));
    stage1Advanced = stage1Teams.filter((t) => !stage1EliminatedIds.has(t.id));
    stage1EliminatedFinal = stage1Elim;
  } else {
    const result = simulateSwiss(stage1Teams, false);
    stage1Advanced = result.advanced;
    stage1EliminatedFinal = result.eliminated;
  }

  // ── Stage 2 ───────────────────────────────────────────────────────────────
  const stage2Teams = [...stage2Direct, ...stage1Advanced];
  let stage2Advanced: TeamWithElo[];
  let stage2EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>;

  if (stage2Complete) {
    const stage2EliminatedIds = new Set(stage2Elim.map((t) => t.id));
    stage2Advanced = stage2Teams.filter((t) => !stage2EliminatedIds.has(t.id));
    stage2EliminatedFinal = stage2Elim;
  } else {
    const result = simulateSwiss(stage2Teams, false);
    stage2Advanced = result.advanced;
    stage2EliminatedFinal = result.eliminated;
  }

  // ── Stage 3 ───────────────────────────────────────────────────────────────
  const stage3Teams = [...stage3Direct, ...stage2Advanced];
  let champTeams: TeamWithElo[];
  let stage3EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>;

  if (stage3Complete) {
    const stage3EliminatedIds = new Set(stage3Elim.map((t) => t.id));
    champTeams = stage3Teams.filter((t) => !stage3EliminatedIds.has(t.id));
    stage3EliminatedFinal = stage3Elim;
  } else {
    const result = simulateSwiss(stage3Teams, true);
    champTeams = result.advanced;
    stage3EliminatedFinal = result.eliminated;
  }

  // ── Champions Stage ───────────────────────────────────────────────────────
  const champResult = simulateChampionsStage(champTeams);

  // ── Assign QP ─────────────────────────────────────────────────────────────
  for (const [pid, placement] of champResult.placements) {
    qpMap.set(pid, qpConfig.get(placement) ?? 0);
  }

  const stage3ExitQP = calcStage3ExitQP(stage3EliminatedFinal, qpConfig);
  for (const [pid, qp] of stage3ExitQP) {
    qpMap.set(pid, qp);
  }

  for (const t of stage1EliminatedFinal) qpMap.set(t.id, 0);
  for (const t of stage2EliminatedFinal) qpMap.set(t.id, 0);

  return qpMap;
}
