/**
 * Snooker World Championship Simulator
 *
 * Monte Carlo simulation of the World Snooker Championship (Crucible, Sheffield).
 * The tournament is a 32-player single-elimination bracket with 5 rounds, using
 * best-of frame formats that increase in length each round.
 *
 * Algorithm:
 *   1. Load the bracket scoring event and all playoff matches from DB.
 *   2. Load Elo ratings from participantExpectedValues.sourceElo.
 *   3. Compute per-match win probability using the Bernoulli frame model:
 *        p_frame = 1 / (1 + e^(-(Elo1 - Elo2) / ELO_DIVISOR))
 *        P(win match) = sum_{w2=0}^{F-1} C(F-1+w2, w2) * p^F * (1-p)^w2
 *      where F = frames to win, which varies by round.
 *   4. Two simulation paths:
 *      a. Bracket populated: simulate from actual draw, respecting completed matches.
 *      b. No bracket: simulate qualifying (ranks 17-48 play one round best-of-19),
 *         randomly pair 16 winners vs top 16 seeds, then simulate 5-round bracket.
 *   5. Track integer placement counts per tier across 50,000 simulations.
 *   6. Convert to probability distributions using exact denominators (column sums = 1.0).
 *
 * Round format (Crucible main draw):
 *   First Round  (R32): best-of-19, first to 10
 *   Second Round (R16): best-of-25, first to 13
 *   Quarter-Finals (QF): best-of-25, first to 13
 *   Semi-Finals   (SF): best-of-33, first to 17
 *   Final:              best-of-35, first to 18
 *
 * Placement bucketing (8-slot probability model):
 *   probFirst          → Champion
 *   probSecond         → Finalist
 *   probThird/Fourth   → SF losers (2/sim)
 *   probFifth–Eighth   → QF losers (4/sim)
 *   R16 losers         → all 0
 *   R32 losers         → all 0
 *
 * World rankings (for pre-bracket path): hardcoded below. Update annually.
 */

import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";

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

const NUM_SIMULATIONS = 50000;

/**
 * Controls how much Elo gaps affect per-frame win probability.
 * Higher = softer probabilities (more randomness, less Elo dominance).
 * Lower  = sharper probabilities (Elo differences matter more).
 *
 * Standard chess Elo uses 400. Snooker frames are more random than chess
 * games, so a higher value is appropriate. Tune this until tournament win
 * probabilities for the top seed feel realistic (~15% for a clear favourite
 * in a 32-player field).
 *
 * Reference calibration points at ELO_DIVISOR=700:
 *   100-pt gap → 53.5% per frame
 *   300-pt gap → 60.6% per frame
 * At ELO_DIVISOR=400 (standard chess):
 *   100-pt gap → 56.2% per frame
 *   300-pt gap → 67.9% per frame
 */
const ELO_DIVISOR = 700;

/**
 * Frames needed to win per round, indexed by round order (most matches first).
 * Index 0 = R32 (16 matches, best-of-19, need 10)
 * Index 1 = R16 (8 matches, best-of-25, need 13)
 * Index 2 = QF  (4 matches, best-of-25, need 13)
 * Index 3 = SF  (2 matches, best-of-33, need 17)
 * Index 4 = Final (1 match, best-of-35, need 18)
 */
const FRAMES_TO_WIN = [10, 13, 13, 17, 18] as const;

// ─── Tournament seedings (2025 World Snooker Championship) ────────────────────
// Seed 1 = defending champion; seeds 2-16 = world ranking order (skipping champion).
// Seeds 17+ = qualifiers — only their relative order here matters (all > 16).
// Update this map each year when a new snooker season is added.
// Players not in this map default to seed 999 (treated as qualifiers).

const SEEDINGS_2026: Record<string, number> = {
  "Judd Trump": 1,
  "Kyren Wilson": 2,
  "Neil Robertson": 3,
  "Mark Williams": 4,
  "Zhao Xintong": 5,
  "John Higgins": 6,
  "Mark Selby": 7,
  "Shaun Murphy": 8,
  "Xiao Guodong": 9,
  "Ronnie O'Sullivan": 10,
  "Barry Hawkins": 11,
  "Wu Yize": 12,
  "Chris Wakelin": 13,
  "Mark Allen": 14,
  "Si Jiahui": 15,
  "Ding Junhui": 16,
  "Stuart Bingham": 17,
  "Jack Lisowski": 18,
  "Jak Jones": 19,
  "Zhang Anda": 20,
  "Elliot Slessor": 21,
  "Thepchaiya Un-Nooh": 22,
  "Ali Carter": 23,
  "Gary Wilson": 24,
  "Zhou Yuelong": 25,
  "David Gilbert": 26,
  "Stephen Maguire": 27,
  "Joe O'Connor": 28,
  "Pang Junxu": 29,
  "Lei Peifan": 30,
  "Yuan Sijun": 31,
  "Tom Ford": 32,
  "Hossein Vafaei": 33,
  "Jimmy Robertson": 34,
  "Ryan Day": 35,
  "Jackson Page": 36,
  "Matthew Selt": 37,
  "Xu Si": 38,
  "Ben Woollaston": 39,
  "Aaron Hill": 40,
  "Daniel Wells": 41,
  "Anthony McGill": 42,
  "Zak Surety": 43,
  "Stan Moody": 44,
  "Noppon Saengkham": 45,
  "Luca Brecel": 46,
  "He Guoqiang": 47,
  "Matthew Stevens": 48,
};

/**
 * Standard seeded R32 bracket for 32-player single-elimination.
 * Each entry is [seedA, seedB] for one R32 match, in bracket order.
 * Consecutive pairs of R32 winners form R16 matches (bracket structure preserved).
 * Ensures seed 1 and seed 2 can only meet in the Final.
 *
 * Structure (seeds 1-16 are fixed; seeds 17-32 are randomly drawn qualifiers):
 *   Top half:    1v32, 16v17, 9v24, 8v25, 5v28, 12v21, 13v20, 4v29
 *   Bottom half: 3v30, 14v19, 11v22, 6v27, 7v26, 10v23, 15v18, 2v31
 */
const R32_BRACKET: Array<[number, number]> = [
  [1, 32], [16, 17],
  [9, 24], [8, 25],
  [5, 28], [12, 21],
  [13, 20], [4, 29],
  [3, 30], [14, 19],
  [11, 22], [6, 27],
  [7, 26], [10, 23],
  [15, 18], [2, 31],
];

// ─── Name normalization ──────────────────────────────────────────────────────
// Normalize unicode apostrophes/quotes so DB names (which may use curly quotes
// like U+2019 ') match the ASCII apostrophes (U+0027 ') in SEEDINGS keys.

function normalizeApostrophes(name: string): string {
  return name.replace(/[\u2018\u2019\u2032\u0060]/g, "'");
}

const SEEDINGS_NORMALIZED = new Map(
  Object.entries(SEEDINGS_2026).map(([name, seed]) => [normalizeApostrophes(name), seed])
);

function getSeedingForName(name: string): number {
  return SEEDINGS_NORMALIZED.get(normalizeApostrophes(name)) ?? 999;
}

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

/**
 * Per-frame win probability for player 1 vs player 2 based on their Elo ratings.
 *
 * Uses the logistic sigmoid: p = 1 / (1 + e^(-(R1 - R2) / ELO_DIVISOR))
 *
 * ELO_DIVISOR is set higher than standard chess (400) to reflect the greater
 * randomness of snooker frames vs chess games. At ELO_DIVISOR=700:
 *   100-pt gap → ~53.5% per frame
 *   300-pt gap → ~60.6% per frame
 *
 * Exported for unit testing.
 */
export function frameWinProb(elo1: number, elo2: number): number {
  return 1 / (1 + Math.exp(-(elo1 - elo2) / ELO_DIVISOR));
}

/**
 * Match win probability for player 1 using the Bernoulli frame model.
 *
 * For a best-of-(2F-1) match (first to F frames wins):
 *   P(win) = sum_{w2=0}^{F-1} C(F-1+w2, w2) * p^F * (1-p)^w2
 *
 * where p = per-frame win probability and F = framesToWin.
 * This sums over all winning score combinations (F-0, F-1, ..., F-(F-1)).
 * Exported for unit testing.
 */
export function matchWinProb(p: number, framesToWin: number): number {
  const F = framesToWin;
  let prob = 0;
  for (let w2 = 0; w2 < F; w2++) {
    prob += binomialCoeff(F - 1 + w2, w2) * Math.pow(p, F) * Math.pow(1 - p, w2);
  }
  return prob;
}

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

/** Fisher-Yates shuffle (in-place). */
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;
}

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

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

    // 1. Find the bracket scoring event for this sports season.
    const bracketEvent = await db.query.scoringEvents.findFirst({
      where: and(
        eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
        eq(schema.scoringEvents.eventType, "playoff_game")
      ),
    });

    // 2. Load playoff matches (may be empty if bracket hasn't been drawn yet).
    const allMatches = bracketEvent
      ? await db.query.playoffMatches.findMany({
          where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
          orderBy: (m, { asc }) => [asc(m.matchNumber)],
        })
      : [];

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

    // Build Elo map; fall back to 1500 for any participant with no stored rating.
    const eloMap = new Map<string, number>();
    for (const r of evRows) {
      if (r.sourceElo !== null && r.sourceElo !== undefined) {
        eloMap.set(r.participantId, r.sourceElo);
      } else if (!eloMap.has(r.participantId)) {
        eloMap.set(r.participantId, 1500);
      }
    }

    // Determine which simulation path to take.
    const bracketPopulated =
      allMatches.some((m) => m.participant1Id && m.participant2Id);

    if (bracketPopulated) {
      return this.simulateBracket(allMatches, eloMap);
    } else {
      return this.simulatePreBracket(sportsSeasonId, eloMap, db);
    }
  }

  // ─── Path A: Full bracket simulation ────────────────────────────────────────

  private async simulateBracket(
    allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
    eloMap: Map<string, number>
  ): Promise<SimulationResult[]> {
    // Group matches by round, sorted by match count descending (R32 first).
    const byRound = new Map<string, typeof allMatches>();
    for (const m of allMatches) {
      if (!byRound.has(m.round)) byRound.set(m.round, []);
      byRound.get(m.round)?.push(m);
    }

    const sortedRounds = [...byRound.values()]
      .toSorted((a, b) => b.length - a.length)
      .map((matches) => matches.sort((a, b) => a.matchNumber - b.matchNumber));

    if (sortedRounds.length !== 5) {
      throw new Error(
        `Expected 5 rounds for World Snooker Championship, found ${sortedRounds.length}. ` +
          `Rounds: ${[...byRound.keys()].join(", ")}`
      );
    }

    const [r32Matches, r16Matches, qfMatches, sfMatches, finalMatches] = sortedRounds;

    if (r32Matches.length !== 16) {
      throw new Error(
        `Expected 16 First Round matches, found ${r32Matches.length}.`
      );
    }

    // Collect all 32 participant IDs from R32.
    const participantIds: string[] = [];
    for (const m of r32Matches) {
      if (!m.participant1Id || !m.participant2Id) {
        throw new Error(
          `First Round match ${m.matchNumber} is missing participants. ` +
            `Assign all 32 players to the bracket before running simulation.`
        );
      }
      participantIds.push(m.participant1Id, m.participant2Id);
    }

    // Build lookup maps by matchNumber for O(1) access in the hot loop.
    const r32ByNum = new Map(r32Matches.map((m) => [m.matchNumber, m]));
    const r16ByNum = new Map(r16Matches.map((m) => [m.matchNumber, m]));
    const qfByNum  = new Map(qfMatches.map((m) => [m.matchNumber, m]));
    const sfByNum  = new Map(sfMatches.map((m) => [m.matchNumber, m]));
    const finalMatch = finalMatches[0];

    const fallbackElo = 1500;

    // Cache matchWinProb results — the Elo values and framesToWin are fixed across
    // all simulations, so the same (elo1, elo2, framesToWin) triple always yields
    // the same probability. Avoids recomputing the Bernoulli sum ~2M times per run.
    const matchProbCache = new Map<string, number>();
    const simMatch = (p1: string, p2: string, framesToWin: number): { winner: string; loser: string } => {
      const elo1 = eloMap.get(p1) ?? fallbackElo;
      const elo2 = eloMap.get(p2) ?? fallbackElo;
      const cacheKey = `${elo1},${elo2},${framesToWin}`;
      let winProb = matchProbCache.get(cacheKey);
      if (winProb === undefined) {
        winProb = matchWinProb(frameWinProb(elo1, elo2), framesToWin);
        matchProbCache.set(cacheKey, winProb);
      }
      const winner = Math.random() < winProb ? p1 : p2;
      return { winner, loser: winner === p1 ? p2 : p1 };
    };

    // Integer placement counts.
    const championCounts  = new Map<string, number>(participantIds.map((id) => [id, 0]));
    const finalistCounts  = new Map<string, number>(participantIds.map((id) => [id, 0]));
    const sfLoserCounts   = new Map<string, number>(participantIds.map((id) => [id, 0]));
    const qfLoserCounts   = new Map<string, number>(participantIds.map((id) => [id, 0]));

    for (let s = 0; s < NUM_SIMULATIONS; s++) {
      // ── First Round (R32) ──────────────────────────────────────────────────
      const r32Winners: string[] = [];
      for (let i = 1; i <= 16; i++) {
        const m = r32ByNum.get(i);
        if (!m) continue;
        if (m.isComplete && m.winnerId) {
          r32Winners.push(m.winnerId);
        } else {
          const { winner } = simMatch(m.participant1Id ?? "", m.participant2Id ?? "", FRAMES_TO_WIN[0]);
          r32Winners.push(winner);
        }
      }

      // ── Second Round (R16) ────────────────────────────────────────────────
      // R16 losers score 0 — only the winner is tracked.
      const r16Winners: string[] = [];
      for (let i = 1; i <= 8; i++) {
        const dbMatch = r16ByNum.get(i);
        let winner: string;
        if (dbMatch?.isComplete && dbMatch.winnerId) {
          winner = dbMatch.winnerId;
        } else {
          const p1 = r32Winners[(i - 1) * 2];
          const p2 = r32Winners[(i - 1) * 2 + 1];
          ({ winner } = simMatch(p1, p2, FRAMES_TO_WIN[1]));
        }
        r16Winners.push(winner);
      }

      // ── Quarter-Finals ────────────────────────────────────────────────────
      const qfWinners: string[] = [];
      for (let i = 1; i <= 4; i++) {
        const dbMatch = qfByNum.get(i);
        let winner: string;
        let loser: string;
        if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) {
          winner = dbMatch.winnerId;
          loser = dbMatch.loserId;
        } else {
          const p1 = r16Winners[(i - 1) * 2];
          const p2 = r16Winners[(i - 1) * 2 + 1];
          ({ winner, loser } = simMatch(p1, p2, FRAMES_TO_WIN[2]));
        }
        qfWinners.push(winner);
        qfLoserCounts.set(loser, (qfLoserCounts.get(loser) ?? 0) + 1);
      }

      // ── Semi-Finals ───────────────────────────────────────────────────────
      const sfWinners: string[] = [];
      for (let i = 1; i <= 2; i++) {
        const dbMatch = sfByNum.get(i);
        let winner: string;
        let loser: string;
        if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) {
          winner = dbMatch.winnerId;
          loser = dbMatch.loserId;
        } else {
          const p1 = qfWinners[(i - 1) * 2];
          const p2 = qfWinners[(i - 1) * 2 + 1];
          ({ winner, loser } = simMatch(p1, p2, FRAMES_TO_WIN[3]));
        }
        sfWinners.push(winner);
        sfLoserCounts.set(loser, (sfLoserCounts.get(loser) ?? 0) + 1);
      }

      // ── Final ─────────────────────────────────────────────────────────────
      let champion: string;
      let finalist: string;
      if (finalMatch?.isComplete && finalMatch.winnerId && finalMatch.loserId) {
        champion = finalMatch.winnerId;
        finalist = finalMatch.loserId;
      } else {
        ({ winner: champion, loser: finalist } = simMatch(sfWinners[0], sfWinners[1], FRAMES_TO_WIN[4]));
      }
      championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1);
      finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
    }

    return buildResults(participantIds, NUM_SIMULATIONS, {
      championCounts,
      finalistCounts,
      sfLoserCounts,
      qfLoserCounts,
    });
  }

  // ─── Path B: Pre-bracket simulation ─────────────────────────────────────────
  // Players 17-48 haven't qualified yet. Each simulation:
  //   1. Randomly pairs the qualifiers (17-48) and simulates 16 best-of-19 matches.
  //   2. The 16 winners are shuffled and randomly drawn into seed slots 17-32.
  //   3. The full 32-player bracket is then simulated using the proper seeded structure.

  private async simulatePreBracket(
    sportsSeasonId: string,
    eloMap: Map<string, number>,
    db: ReturnType<typeof database>
  ): Promise<SimulationResult[]> {
    const allParticipants = await db
      .select({ id: schema.participants.id, name: schema.participants.name })
      .from(schema.participants)
      .where(eq(schema.participants.sportsSeasonId, sportsSeasonId));

    if (allParticipants.length < 17) {
      throw new Error(
        `Pre-bracket simulation requires at least 17 participants (got ${allParticipants.length}). ` +
          `Add players to this sports season first.`
      );
    }

    // Sort by seeding. Unranked players default to 999 (treated as qualifiers).
    const ranked = allParticipants.toSorted((a, b) => {
      return getSeedingForName(a.name) - getSeedingForName(b.name);
    });

    const topSeeds = ranked.slice(0, 16);   // Fixed seed positions 1-16
    const qualifiers = ranked.slice(16);    // Randomly drawn into positions 17-32

    const fallbackElo = 1500;
    const matchProbCache = new Map<string, number>();
    const simMatch = (p1Id: string, p2Id: string, framesToWin: number): string => {
      const elo1 = eloMap.get(p1Id) ?? fallbackElo;
      const elo2 = eloMap.get(p2Id) ?? fallbackElo;
      const cacheKey = `${elo1},${elo2},${framesToWin}`;
      let winProb = matchProbCache.get(cacheKey);
      if (winProb === undefined) {
        winProb = matchWinProb(frameWinProb(elo1, elo2), framesToWin);
        matchProbCache.set(cacheKey, winProb);
      }
      return Math.random() < winProb ? p1Id : p2Id;
    };

    // Pre-extract qualifier IDs once — reused (with a fresh shuffle) each iteration.
    const qualifierIds = qualifiers.map((p) => p.id);

    const allParticipantIds = allParticipants.map((p) => p.id);
    const championCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
    const finalistCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
    const sfLoserCounts  = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
    const qfLoserCounts  = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));

    // Whether qualifying needs to be simulated (>16 players competing for 16 bracket slots).
    // If exactly 16 qualifiers are already loaded (the common case when only 32 total players
    // are in the DB), skip the qualifying simulation and use them directly as seeds 17-32.
    const needsQualifying = qualifiers.length > 16;

    for (let s = 0; s < NUM_SIMULATIONS; s++) {
      // ── Qualifying: run elimination rounds until exactly 16 qualifiers remain ──
      let drawnQualifiers: string[];
      if (!needsQualifying) {
        // All 16 qualifiers go straight through — just shuffle the draw order.
        drawnQualifiers = shuffle([...qualifierIds]);
      } else {
        // Multiple qualifying rounds (best-of-19 each) to whittle down to 16.
        let pool = shuffle([...qualifierIds]);
        while (pool.length > 16) {
          const winners: string[] = [];
          for (let i = 0; i + 1 < pool.length; i += 2) {
            winners.push(simMatch(pool[i], pool[i + 1], FRAMES_TO_WIN[0]));
          }
          // Odd player out gets a bye.
          if (pool.length % 2 !== 0) {
            winners.push(pool[pool.length - 1]);
          }
          pool = shuffle(winners);
        }
        drawnQualifiers = pool;
      }

      // Seeds 5-16: randomly swap adjacent pairs to reflect uncertainty in
      // mid-tier seedings (top 4 are locked in).
      const midSeeds = topSeeds.slice(4).map((p) => p.id);
      for (let i = 0; i < midSeeds.length - 1; i++) {
        if (Math.random() < 0.5) {
          [midSeeds[i], midSeeds[i + 1]] = [midSeeds[i + 1], midSeeds[i]];
        }
      }

      // Map seed number (1-indexed) → participant ID.
      const seedToId = new Map<number, string>();
      topSeeds.slice(0, 4).forEach((p, i) => seedToId.set(i + 1, p.id));
      midSeeds.forEach((id, i) => seedToId.set(i + 5, id));
      drawnQualifiers.forEach((id, i) => seedToId.set(i + 17, id));

      // ── First Round (R32) — proper seeded bracket ─────────────────────────
      // R32_BRACKET defines which seeds meet in each match, in bracket order so
      // consecutive R32 winner pairs form the correct R16 matchups.
      const r32Winners: string[] = [];
      for (const [s1, s2] of R32_BRACKET) {
        const p1 = seedToId.get(s1) ?? "";
        const p2 = seedToId.get(s2) ?? "";
        r32Winners.push(simMatch(p1, p2, FRAMES_TO_WIN[0]));
      }

      // ── Second Round (R16) ────────────────────────────────────────────────
      const r16Winners: string[] = [];
      for (let i = 0; i < r32Winners.length; i += 2) {
        r16Winners.push(simMatch(r32Winners[i], r32Winners[i + 1], FRAMES_TO_WIN[1]));
      }

      // ── Quarter-Finals ────────────────────────────────────────────────────
      const qfWinners: string[] = [];
      for (let i = 0; i < r16Winners.length; i += 2) {
        const p1 = r16Winners[i], p2 = r16Winners[i + 1];
        const winner = simMatch(p1, p2, FRAMES_TO_WIN[2]);
        qfWinners.push(winner);
        qfLoserCounts.set(winner === p1 ? p2 : p1, (qfLoserCounts.get(winner === p1 ? p2 : p1) ?? 0) + 1);
      }

      // ── Semi-Finals ───────────────────────────────────────────────────────
      const sfWinners: string[] = [];
      for (let i = 0; i < qfWinners.length; i += 2) {
        const p1 = qfWinners[i], p2 = qfWinners[i + 1];
        const winner = simMatch(p1, p2, FRAMES_TO_WIN[3]);
        sfWinners.push(winner);
        sfLoserCounts.set(winner === p1 ? p2 : p1, (sfLoserCounts.get(winner === p1 ? p2 : p1) ?? 0) + 1);
      }

      // ── Final ─────────────────────────────────────────────────────────────
      const champion = simMatch(sfWinners[0], sfWinners[1], FRAMES_TO_WIN[4]);
      const finalist = champion === sfWinners[0] ? sfWinners[1] : sfWinners[0];
      championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1);
      finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
    }

    return buildResults(allParticipantIds, NUM_SIMULATIONS, {
      championCounts,
      finalistCounts,
      sfLoserCounts,
      qfLoserCounts,
    });
  }
}

// ─── Shared result builder ─────────────────────────────────────────────────────

function buildResults(
  participantIds: string[],
  N: number,
  counts: {
    championCounts: Map<string, number>;
    finalistCounts: Map<string, number>;
    sfLoserCounts: Map<string, number>;
    qfLoserCounts: Map<string, number>;
  }
): SimulationResult[] {
  const { championCounts, finalistCounts, sfLoserCounts, qfLoserCounts } = counts;

  const results: SimulationResult[] = participantIds.map((participantId) => {
    const c  = championCounts.get(participantId)  ?? 0;
    const f  = finalistCounts.get(participantId)  ?? 0;
    const sf = sfLoserCounts.get(participantId)   ?? 0;
    const qf = qfLoserCounts.get(participantId)   ?? 0;
    return {
      participantId,
      probabilities: {
        probFirst:   c  / N,
        probSecond:  f  / N,
        probThird:   sf / (2 * N),
        probFourth:  sf / (2 * N),
        probFifth:   qf / (4 * N),
        probSixth:   qf / (4 * N),
        probSeventh: qf / (4 * N),
        probEighth:  qf / (4 * N),
      },
      source: "snooker_world_championship_monte_carlo",
    };
  });

  // Per-position column normalization — ensures sums are exactly 1.0.
  const positionKeys: Array<keyof typeof results[0]["probabilities"]> = [
    "probFirst", "probSecond", "probThird", "probFourth",
    "probFifth", "probSixth", "probSeventh", "probEighth",
  ];
  for (const key of positionKeys) {
    const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
    const residual = 1.0 - colSum;
    if (residual !== 0) {
      const maxResult = results.reduce((best, r) =>
        r.probabilities[key] > best.probabilities[key] ? r : best
      );
      maxResult.probabilities[key] += residual;
    }
  }

  return results;
}
