/**
 * NLL Season + Playoffs Simulator
 *
 * Monte Carlo simulation of the NLL regular season and playoffs.
 *
 * Three modes, auto-detected at runtime:
 *
 * ── Mode 1: Bracket-Aware ─────────────────────────────────────────────────────
 * Used when a playoff_game scoring event with generated matches exists.
 * Reads completed QF/SF/Finals results; simulates remaining games and series.
 * Best-of-3 series respect completed playoff_match_games rows.
 *
 * ── Mode 2: Known-Seed ────────────────────────────────────────────────────────
 * Used when no bracket exists but all 8 seeds 1-8 are set via the seed input.
 * Simulates the playoff bracket directly from those seeds.
 *
 * ── Mode 3: Regular-Season Projection (default) ───────────────────────────────
 * Simulates remaining regular season games per team using Elo vs average (1500),
 * blending with projectedWins when provided. Top 8 teams qualify. Then simulates
 * the NLL playoff bracket.
 *
 * NLL playoff bracket format:
 *   Quarterfinals (single game): 1v8, 2v7, 3v6, 4v5
 *   Semifinal arms: (1/8 winner vs 4/5 winner), (2/7 winner vs 3/6 winner)
 *   Semifinals (best-of-3) and Finals (best-of-3)
 *
 * Probability mapping:
 *   probFirst          = NLL champion (1 per sim)
 *   probSecond         = Finals loser (1 per sim)
 *   probThird/Fourth   = Semifinal losers (2 per sim — split evenly)
 *   probFifth–Eighth   = Quarterfinal losers (4 per sim — split evenly)
 *   Missed playoffs    → all 0
 *
 * Round name constants (ROUND_*) must match the bracket template used to
 * generate playoff matches. If the template changes round names, update these.
 */

import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { eloWinProbabilityWithParity } from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getParticipantSimulatorInputs } from "~/models/simulator";
import { logger } from "~/lib/logger";

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

const NUM_SIMULATIONS = 50_000;
const NLL_REGULAR_SEASON_GAMES = 18;
const NLL_PLAYOFF_TEAMS = 8;
const PARITY_FACTOR = 400;
const AVERAGE_OPPONENT_ELO = 1500;

/** Maximum preseason weight given to projectedWins in the blend. Decays to 0 by season end. */
const PROJECTED_WINS_WEIGHT = 0.65;

/** Fallback Elo used when a bracket participant has no sourceElo on record. */
const FALLBACK_ELO = 1400;

// ─── Round name constants ─────────────────────────────────────────────────────
// These must match the round strings generated by the nll_bracket bracket template.

export const ROUND_QUARTERFINALS = "Quarterfinals" as const;
export const ROUND_SEMIFINALS = "Semifinals" as const;
export const ROUND_FINALS = "Finals" as const;

// ─── Structural types used by resolve helpers ─────────────────────────────────

/** Minimal match fields needed by the resolve helpers. */
export interface MatchSnapshot {
  id: string;
  isComplete: boolean;
  winnerId: string | null;
  loserId: string | null;
  participant1Id: string | null;
  participant2Id: string | null;
}

/** Minimal game result needed by the resolve helpers. */
export interface GameSnapshot {
  winnerId: string | null;
}

// ─── Public helpers (exported for testability) ────────────────────────────────

export function nllGameWinProbability(
  eloA: number,
  eloB: number,
  parityFactor = PARITY_FACTOR
): number {
  return eloWinProbabilityWithParity(eloA, eloB, parityFactor);
}

export interface SimTeam {
  participantId: string;
  elo: number;
  seed?: number;
}

export function simulateNllGame(
  teamA: SimTeam,
  teamB: SimTeam,
  parityFactor = PARITY_FACTOR
): { winner: SimTeam; loser: SimTeam } {
  const pA = nllGameWinProbability(teamA.elo, teamB.elo, parityFactor);
  if (Math.random() < pA) return { winner: teamA, loser: teamB };
  return { winner: teamB, loser: teamA };
}

export interface BestOfThreeState {
  winsA: number;
  winsB: number;
}

/** Simulate a best-of-3 series from an optional existing state. First to 2 wins. */
export function simulateBestOfThree(
  teamA: SimTeam,
  teamB: SimTeam,
  parityFactor = PARITY_FACTOR,
  existing: BestOfThreeState = { winsA: 0, winsB: 0 }
): { winner: SimTeam; loser: SimTeam; gamesPlayed: number } {
  let wA = existing.winsA;
  let wB = existing.winsB;
  let gamesPlayed = 0;
  const pA = nllGameWinProbability(teamA.elo, teamB.elo, parityFactor);
  while (wA < 2 && wB < 2) {
    if (Math.random() < pA) wA++; else wB++;
    gamesPlayed++;
  }
  return wA === 2
    ? { winner: teamA, loser: teamB, gamesPlayed }
    : { winner: teamB, loser: teamA, gamesPlayed };
}

export interface SeedEntry {
  participantId: string;
  elo: number;
  seed: number;
}

/**
 * Build the 4 NLL Quarterfinal matchups from seeds 1-8.
 * Bracket arms are fixed:
 *   QF1: 1 vs 8  → SF arm A
 *   QF2: 4 vs 5  → SF arm A
 *   QF3: 2 vs 7  → SF arm B
 *   QF4: 3 vs 6  → SF arm B
 */
export function buildNllBracketFromSeeds(seeds: SeedEntry[]): [
  [SeedEntry, SeedEntry], // QF1: 1 vs 8
  [SeedEntry, SeedEntry], // QF2: 4 vs 5
  [SeedEntry, SeedEntry], // QF3: 2 vs 7
  [SeedEntry, SeedEntry], // QF4: 3 vs 6
] {
  const bySeeds = new Map(seeds.map((s) => [s.seed, s]));
  const s = (n: number) => {
    const entry = bySeeds.get(n);
    if (!entry) throw new Error(`Seed ${n} not found in bracket seeds.`);
    return entry;
  };
  return [
    [s(1), s(8)],
    [s(4), s(5)],
    [s(2), s(7)],
    [s(3), s(6)],
  ];
}

export interface NllPlayoffResult {
  champion: string;
  finalist: string;
  sfLosers: [string, string];
  qfLosers: [string, string, string, string];
}

/** Simulate the full NLL playoff bracket from 8 seeded teams. */
export function simulateNllPlayoffs(
  seeds: SeedEntry[],
  parityFactor = PARITY_FACTOR
): NllPlayoffResult {
  const [[qf1a, qf1b], [qf2a, qf2b], [qf3a, qf3b], [qf4a, qf4b]] =
    buildNllBracketFromSeeds(seeds);

  const qf1 = simulateNllGame(qf1a, qf1b, parityFactor);
  const qf2 = simulateNllGame(qf2a, qf2b, parityFactor);
  const qf3 = simulateNllGame(qf3a, qf3b, parityFactor);
  const qf4 = simulateNllGame(qf4a, qf4b, parityFactor);

  // SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5)
  const sfA = simulateBestOfThree(qf1.winner, qf2.winner, parityFactor);
  // SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6)
  const sfB = simulateBestOfThree(qf3.winner, qf4.winner, parityFactor);

  const final = simulateBestOfThree(sfA.winner, sfB.winner, parityFactor);

  return {
    champion: final.winner.participantId,
    finalist: final.loser.participantId,
    sfLosers: [sfA.loser.participantId, sfB.loser.participantId],
    qfLosers: [
      qf1.loser.participantId,
      qf2.loser.participantId,
      qf3.loser.participantId,
      qf4.loser.participantId,
    ],
  };
}

// ─── Bracket-aware resolve helpers (exported for testability) ─────────────────

/**
 * Resolve a single-game QF match.
 * Returns the stored result deterministically when the match is complete;
 * simulates a single game otherwise.
 */
export function resolveQfMatch(
  match: MatchSnapshot | undefined,
  eloMap: Map<string, number>
): { winner: string; loser: string } {
  if (match?.isComplete && match.winnerId && match.loserId) {
    return { winner: match.winnerId, loser: match.loserId };
  }
  const p1 = match?.participant1Id;
  const p2 = match?.participant2Id;
  if (!p1 || !p2) {
    throw new Error(
      `NLL QF match ${match?.id ?? "(undefined)"} is missing participant slots. ` +
        `Ensure the bracket is fully generated before simulating.`
    );
  }
  const result = simulateNllGame(
    { participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
    { participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO }
  );
  return { winner: result.winner.participantId, loser: result.loser.participantId };
}

/**
 * Resolve a best-of-3 SF or Finals match.
 * Returns the stored result deterministically when the match is complete.
 * Counts completed game rows (GameSnapshot[]) for partial series state; if a
 * team already has 2 wins in the game rows, treats the series as done even if
 * playoffMatches.isComplete has not been toggled yet.
 * Simulates remaining games from the current series score otherwise.
 */
export function resolveBo3Match(
  match: MatchSnapshot | undefined,
  games: GameSnapshot[],
  eloMap: Map<string, number>,
  p1Override?: string,
  p2Override?: string
): { winner: string; loser: string } {
  if (match?.isComplete && match.winnerId && match.loserId) {
    return { winner: match.winnerId, loser: match.loserId };
  }
  const p1 = match?.participant1Id ?? p1Override;
  const p2 = match?.participant2Id ?? p2Override;
  if (!p1 || !p2) {
    throw new Error(
      `NLL series match ${match?.id ?? "(undefined)"} is missing participant slots. ` +
        `Ensure the bracket is fully generated before simulating.`
    );
  }

  let winsP1 = 0;
  let winsP2 = 0;
  for (const g of games) {
    if (g.winnerId === null) continue;
    if (g.winnerId === p1) winsP1++;
    else if (g.winnerId === p2) winsP2++;
  }

  // A team with 2 game wins has clinched the series.
  if (winsP1 >= 2) return { winner: p1, loser: p2 };
  if (winsP2 >= 2) return { winner: p2, loser: p1 };

  const { winner, loser } = simulateBestOfThree(
    { participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
    { participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO },
    PARITY_FACTOR,
    { winsA: winsP1, winsB: winsP2 }
  );
  return { winner: winner.participantId, loser: loser.participantId };
}

// ─── Regular-season projection helper ────────────────────────────────────────

export interface TeamProjection {
  participantId: string;
  elo: number;
  currentWins: number;
  gamesPlayed: number;
  projectedWins: number | null;
}

/** Simulate remaining regular season games and return the top-8 seeds. */
export function simulateRegularSeasonSeeds(
  teams: TeamProjection[],
  parityFactor = PARITY_FACTOR
): SeedEntry[] {
  // Pre-compute tiebreaker jitter once per simulation for sort stability.
  const jitter = new Map(teams.map((t) => [t.participantId, Math.random()]));

  const projected = teams.map((t) => {
    const remaining = Math.max(0, NLL_REGULAR_SEASON_GAMES - t.gamesPlayed);
    const eloRate = eloWinProbabilityWithParity(t.elo, AVERAGE_OPPONENT_ELO, parityFactor);

    // Decay the preseason projectedWins prior linearly as the season progresses.
    // At gamesPlayed=0 it carries PROJECTED_WINS_WEIGHT (65%); by gamesPlayed=18
    // it carries 0% so late-season projections rely purely on Elo. This prevents
    // a stale preseason estimate from dominating when actual standings are available.
    //
    // The prior rate is computed over *remaining* games (not the full season) so
    // that wins already accumulated are subtracted from the preseason expectation.
    // If currentWins already meets or exceeds the projection, the prior is clamped
    // to 0 and Elo takes over fully.
    const completionFraction = t.gamesPlayed / NLL_REGULAR_SEASON_GAMES;
    const projectedBlend =
      t.projectedWins !== null ? PROJECTED_WINS_WEIGHT * (1 - completionFraction) : 0;
    const priorRate =
      t.projectedWins !== null && remaining > 0
        ? Math.min(1, Math.max(0, t.projectedWins - t.currentWins) / remaining)
        : 0;
    const winRate =
      projectedBlend > 0 && priorRate > 0
        ? projectedBlend * priorRate + (1 - projectedBlend) * eloRate
        : eloRate;

    let additionalWins = 0;
    for (let g = 0; g < remaining; g++) {
      if (Math.random() < winRate) additionalWins++;
    }
    return {
      participantId: t.participantId,
      elo: t.elo,
      finalWins: t.currentWins + additionalWins,
    };
  });

  const sorted = projected.toSorted(
    (a, b) =>
      b.finalWins - a.finalWins ||
      (jitter.get(b.participantId) ?? 0) - (jitter.get(a.participantId) ?? 0)
  );

  return sorted.slice(0, NLL_PLAYOFF_TEAMS).map((t, i) => ({
    participantId: t.participantId,
    elo: t.elo,
    seed: i + 1,
  }));
}

// ─── Simulator class ──────────────────────────────────────────────────────────

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

    // ── Load participants ─────────────────────────────────────────────────────
    const participants = await db.query.seasonParticipants.findMany({
      where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
    });

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

    // ── Load Elo ratings from EV table (populated by prepareSimulatorInputsForRun) ──
    const evRows = await db
      .select({
        participantId: schema.seasonParticipantExpectedValues.participantId,
        sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
      })
      .from(schema.seasonParticipantExpectedValues)
      .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));

    const eloMap = new Map<string, number>();
    for (const r of evRows) {
      if (r.sourceElo !== null && r.sourceElo !== undefined) {
        eloMap.set(r.participantId, r.sourceElo);
      }
    }

    if (eloMap.size === 0) {
      throw new Error(
        `No Elo ratings found for sports season ${sportsSeasonId}. ` +
          `Enter sourceElo or projectedWins via Admin → Elo Ratings before simulating.`
      );
    }

    // ── Load simulator inputs (projectedWins + seed) ──────────────────────────
    const simInputs = await getParticipantSimulatorInputs(sportsSeasonId);
    const projectedWinsMap = new Map<string, number | null>();
    const seedMap = new Map<string, number>();

    for (const input of simInputs) {
      projectedWinsMap.set(input.participantId, input.projectedWins);
      if (input.seed !== null && input.seed !== undefined) {
        seedMap.set(input.participantId, input.seed);
      }
    }

    // ── All participant IDs for counting ─────────────────────────────────────
    const allIds = participants.map((p) => p.id);

    // ── Mode detection ────────────────────────────────────────────────────────

    // Mode 1: bracket-aware — a playoff_game scoring event with matches exists.
    const bracketEvent = await db.query.scoringEvents.findFirst({
      where: and(
        eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
        eq(schema.scoringEvents.eventType, "playoff_game")
      ),
    });

    if (bracketEvent) {
      const bracketMatches = await db.query.playoffMatches.findMany({
        where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
        orderBy: (m, { asc }) => [asc(m.matchNumber)],
      });

      if (bracketMatches.length > 0) {
        return this.simulateBracketAware(allIds, eloMap, bracketMatches);
      }
    }

    // Mode 2: known-seed — all 8 seeds 1-8 are explicitly set.
    const knownSeeds: SeedEntry[] = [];
    for (const p of participants) {
      const elo = eloMap.get(p.id);
      const seed = seedMap.get(p.id);
      if (elo !== undefined && seed !== undefined && seed >= 1 && seed <= 8) {
        knownSeeds.push({ participantId: p.id, elo, seed });
      }
    }

    if (
      knownSeeds.length === NLL_PLAYOFF_TEAMS &&
      new Set(knownSeeds.map((s) => s.seed)).size === NLL_PLAYOFF_TEAMS
    ) {
      return this.simulateKnownSeeds(allIds, knownSeeds);
    }

    // Mode 3: regular-season projection (default, including preseason).
    const standingsRows = await getRegularSeasonStandings(sportsSeasonId, db);
    const standingsMap = new Map(standingsRows.map((r) => [r.participantId, r]));

    const teams: TeamProjection[] = [];
    for (const p of participants) {
      const elo = eloMap.get(p.id);
      if (elo === undefined) continue;
      const standing = standingsMap.get(p.id);
      teams.push({
        participantId: p.id,
        elo,
        currentWins: standing?.wins ?? 0,
        gamesPlayed: standing?.gamesPlayed ?? 0,
        projectedWins: projectedWinsMap.get(p.id) ?? null,
      });
    }

    if (teams.length === 0) {
      throw new Error(
        `No participants with Elo ratings found for season ${sportsSeasonId}.`
      );
    }

    return this.simulateRegularSeason(allIds, teams);
  }

  // ── Mode 1: Bracket-Aware ─────────────────────────────────────────────────

  private async simulateBracketAware(
    allIds: string[],
    eloMap: Map<string, number>,
    allMatches: typeof schema.playoffMatches.$inferSelect[]
  ): Promise<SimulationResult[]> {
    const db = database();

    const qfMatches = allMatches
      .filter((m) => m.round === ROUND_QUARTERFINALS)
      .toSorted((a, b) => a.matchNumber - b.matchNumber);
    const sfMatches = allMatches
      .filter((m) => m.round === ROUND_SEMIFINALS)
      .toSorted((a, b) => a.matchNumber - b.matchNumber);
    const finalMatches = allMatches.filter((m) => m.round === ROUND_FINALS);

    if (qfMatches.length !== 4 || sfMatches.length !== 2 || finalMatches.length !== 1) {
      throw new Error(
        `NLL bracket has unexpected structure. Expected QF×4, SF×2, Finals×1. ` +
          `Got QF×${qfMatches.length}, SF×${sfMatches.length}, Finals×${finalMatches.length}. ` +
          `Ensure the bracket was generated with the nll_bracket template.`
      );
    }

    // Warn about any bracket participants without Elo ratings before the hot loop.
    const bracketParticipantIds = new Set<string>();
    for (const m of allMatches) {
      if (m.participant1Id) bracketParticipantIds.add(m.participant1Id);
      if (m.participant2Id) bracketParticipantIds.add(m.participant2Id);
      if (m.winnerId) bracketParticipantIds.add(m.winnerId);
      if (m.loserId) bracketParticipantIds.add(m.loserId);
    }
    for (const id of bracketParticipantIds) {
      if (!eloMap.has(id)) {
        logger.warn(
          `NLL bracket simulator: no Elo rating for participant ${id}. ` +
            `Using fallback Elo ${FALLBACK_ELO}. Enter sourceElo via Admin → Elo Ratings.`
        );
      }
    }

    // Load playoff_match_games for best-of-3 series state.
    const sfAndFinalIds = [...sfMatches, ...finalMatches].map((m) => m.id);
    const allGames = sfAndFinalIds.length > 0
      ? await db.query.playoffMatchGames.findMany({
          where: (g, { inArray }) => inArray(g.playoffMatchId, sfAndFinalIds),
        })
      : [];

    const gamesByMatch = new Map<string, typeof schema.playoffMatchGames.$inferSelect[]>();
    for (const game of allGames) {
      const list = gamesByMatch.get(game.playoffMatchId) ?? [];
      list.push(game);
      gamesByMatch.set(game.playoffMatchId, list);
    }

    const championCounts = new Map(allIds.map((id) => [id, 0]));
    const finalistCounts = new Map(allIds.map((id) => [id, 0]));
    const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
    const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));

    const [qf1, qf2, qf3, qf4] = qfMatches;
    const [sf1, sf2] = sfMatches;
    const finalMatch = finalMatches[0];

    for (let s = 0; s < NUM_SIMULATIONS; s++) {
      const r_qf1 = resolveQfMatch(qf1, eloMap);
      const r_qf2 = resolveQfMatch(qf2, eloMap);
      const r_qf3 = resolveQfMatch(qf3, eloMap);
      const r_qf4 = resolveQfMatch(qf4, eloMap);

      // SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5 bracket arm)
      const r_sf1 = resolveBo3Match(sf1, gamesByMatch.get(sf1.id) ?? [], eloMap, r_qf1.winner, r_qf2.winner);
      // SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6 bracket arm)
      const r_sf2 = resolveBo3Match(sf2, gamesByMatch.get(sf2.id) ?? [], eloMap, r_qf3.winner, r_qf4.winner);

      const r_final = resolveBo3Match(finalMatch, gamesByMatch.get(finalMatch.id) ?? [], eloMap, r_sf1.winner, r_sf2.winner);

      championCounts.set(r_final.winner, (championCounts.get(r_final.winner) ?? 0) + 1);
      finalistCounts.set(r_final.loser, (finalistCounts.get(r_final.loser) ?? 0) + 1);
      sfLoserCounts.set(r_sf1.loser, (sfLoserCounts.get(r_sf1.loser) ?? 0) + 1);
      sfLoserCounts.set(r_sf2.loser, (sfLoserCounts.get(r_sf2.loser) ?? 0) + 1);
      qfLoserCounts.set(r_qf1.loser, (qfLoserCounts.get(r_qf1.loser) ?? 0) + 1);
      qfLoserCounts.set(r_qf2.loser, (qfLoserCounts.get(r_qf2.loser) ?? 0) + 1);
      qfLoserCounts.set(r_qf3.loser, (qfLoserCounts.get(r_qf3.loser) ?? 0) + 1);
      qfLoserCounts.set(r_qf4.loser, (qfLoserCounts.get(r_qf4.loser) ?? 0) + 1);
    }

    return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
  }

  // ── Mode 2: Known-Seed ────────────────────────────────────────────────────

  private simulateKnownSeeds(
    allIds: string[],
    seeds: SeedEntry[]
  ): SimulationResult[] {
    const championCounts = new Map(allIds.map((id) => [id, 0]));
    const finalistCounts = new Map(allIds.map((id) => [id, 0]));
    const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
    const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));

    for (let s = 0; s < NUM_SIMULATIONS; s++) {
      const result = simulateNllPlayoffs(seeds);
      championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
      finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
      for (const id of result.sfLosers) {
        sfLoserCounts.set(id, (sfLoserCounts.get(id) ?? 0) + 1);
      }
      for (const id of result.qfLosers) {
        qfLoserCounts.set(id, (qfLoserCounts.get(id) ?? 0) + 1);
      }
    }

    return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
  }

  // ── Mode 3: Regular-Season Projection ────────────────────────────────────

  private simulateRegularSeason(
    allIds: string[],
    teams: TeamProjection[]
  ): SimulationResult[] {
    const championCounts = new Map(allIds.map((id) => [id, 0]));
    const finalistCounts = new Map(allIds.map((id) => [id, 0]));
    const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
    const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));

    for (let s = 0; s < NUM_SIMULATIONS; s++) {
      const seeds = simulateRegularSeasonSeeds(teams);
      const result = simulateNllPlayoffs(seeds);
      championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
      finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
      for (const id of result.sfLosers) {
        sfLoserCounts.set(id, (sfLoserCounts.get(id) ?? 0) + 1);
      }
      for (const id of result.qfLosers) {
        qfLoserCounts.set(id, (qfLoserCounts.get(id) ?? 0) + 1);
      }
    }

    return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
  }

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

  private buildResults(
    allIds: string[],
    championCounts: Map<string, number>,
    finalistCounts: Map<string, number>,
    sfLoserCounts: Map<string, number>,
    qfLoserCounts: Map<string, number>
  ): SimulationResult[] {
    const N = NUM_SIMULATIONS;
    return allIds.map((id) => ({
      participantId: id,
      probabilities: {
        probFirst: (championCounts.get(id) ?? 0) / N,
        probSecond: (finalistCounts.get(id) ?? 0) / N,
        // 2 SF losers per sim — split evenly across 3rd/4th
        probThird: (sfLoserCounts.get(id) ?? 0) / N / 2,
        probFourth: (sfLoserCounts.get(id) ?? 0) / N / 2,
        // 4 QF losers per sim — split evenly across 5th–8th
        probFifth: (qfLoserCounts.get(id) ?? 0) / N / 4,
        probSixth: (qfLoserCounts.get(id) ?? 0) / N / 4,
        probSeventh: (qfLoserCounts.get(id) ?? 0) / N / 4,
        probEighth: (qfLoserCounts.get(id) ?? 0) / N / 4,
      },
      source: "nll_bracket_monte_carlo",
    }));
  }
}
