/**
 * NBA Playoff Simulator
 *
 * Two modes, auto-detected at runtime:
 *
 * ── Mode 1: Bracket-Aware (preferred) ─────────────────────────────────────────
 * Used when a playoff_game scoring event with generated matches exists for the
 * sports season. Reads the actual bracket state from the DB and simulates only
 * the remaining rounds forward.
 *
 * Algorithm:
 *   1. Find the playoff_game scoring event; load all playoffMatches
 *   2. Group matches by round: Play-In Round 1/2, First Round, Conference
 *      Semifinals, Conference Finals, NBA Finals
 *   3. Build Elo map from TEAMS_DATA (name-matched from participants table)
 *   4. For each of 50k simulations:
 *      a. Play-In Round 1 (4 single games): use real result if isComplete, else sim
 *         → derives E7/E8-candidate/E9-E10-winner and their West equivalents
 *      b. Play-In Round 2 (2 single games): uses PIR1 results as participant
 *         fallbacks when DB slots are still null
 *         → derives E8 and W8 seeds
 *      c. First Round (8 best-of-7 series): uses simmed seeds as participant
 *         fallbacks for the 4 play-in slots; real results respected
 *      d. Conference Semifinals → Conference Finals → NBA Finals: same
 *         completed-vs-simulate pattern; standard ceil bracket path
 *   5. Track integer placement counts per tier (champion/finalist/CF loser/CS loser)
 *   6. Convert to probability distributions with exact denominators
 *
 * In-progress series: treated as a fresh best-of-7 (partial game scores are not
 * used). A series only locks once isComplete + winnerId + loserId are all set.
 *
 * ── Mode 2: Season Projection (fallback) ──────────────────────────────────────
 * Used when no bracket event / matches exist yet (pre-playoffs). Projects
 * remaining regular season games → seeds → play-in → playoffs from scratch.
 * See inline comments for details.
 *
 * Placement tiers (both modes):
 *   probFirst         = NBA champion
 *   probSecond        = NBA Finals loser
 *   probThird/Fourth  = Conference Finals losers (2 per sim)
 *   probFifth–Eighth  = Conference Semis losers (4 per sim)
 *   First Round / Play-In losers → all 0 (score 0 points)
 *   Missed playoffs (Mode 2 only) → all 0
 *
 * Elo ratings are hardcoded (March 2026 data).
 * Source: Neil Paine Substack playoff Elo estimates (last 110 games, no regression,
 *         postseason games 3× weight). Update at the start of each season.
 */

import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { normalizeTeamName } from "~/lib/normalize-team-name";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";

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

const NUM_SIMULATIONS = 50_000;

/**
 * Elo parity factor. NBA uses 400 (standard formula).
 * A 400-point Elo difference → ~90.9% win probability per game.
 */
const PARITY_FACTOR = 400;

/** NBA regular season games per team. */
const NBA_REGULAR_SEASON_GAMES = 82;

// ─── Team data (2025-26 season, as of March 2026) ─────────────────────────────

interface NbaTeamData {
  conference: "Eastern" | "Western";
  elo: number;
}

const TEAMS_DATA: Record<string, NbaTeamData> = {
  // ── Eastern Conference ──────────────────────────────────────────────────────

  "Detroit Pistons":       { conference: "Eastern", elo: 1558 },
  "Boston Celtics":        { conference: "Eastern", elo: 1699 },
  "New York Knicks":       { conference: "Eastern", elo: 1626 },
  "Cleveland Cavaliers":   { conference: "Eastern", elo: 1628 },
  "Orlando Magic":         { conference: "Eastern", elo: 1508 },
  "Miami Heat":            { conference: "Eastern", elo: 1530 },
  "Toronto Raptors":       { conference: "Eastern", elo: 1467 },
  "Atlanta Hawks":         { conference: "Eastern", elo: 1496 },
  "Philadelphia 76ers":    { conference: "Eastern", elo: 1471 },
  "Charlotte Hornets":     { conference: "Eastern", elo: 1496 },
  "Milwaukee Bucks":       { conference: "Eastern", elo: 1442 },
  "Chicago Bulls":         { conference: "Eastern", elo: 1381 },
  "Brooklyn Nets":         { conference: "Eastern", elo: 1334 },
  "Indiana Pacers":        { conference: "Eastern", elo: 1433 },
  "Washington Wizards":    { conference: "Eastern", elo: 1255 },

  // ── Western Conference ──────────────────────────────────────────────────────

  "Oklahoma City Thunder": { conference: "Western", elo: 1731 },
  "San Antonio Spurs":     { conference: "Western", elo: 1599 },
  "Houston Rockets":       { conference: "Western", elo: 1564 },
  "Denver Nuggets":        { conference: "Western", elo: 1618 },
  "LA Lakers":             { conference: "Western", elo: 1569 },
  "Minnesota Timberwolves":{ conference: "Western", elo: 1603 },
  "Phoenix Suns":          { conference: "Western", elo: 1500 },
  "LA Clippers":           { conference: "Western", elo: 1573 },
  "Golden State Warriors": { conference: "Western", elo: 1530 },
  "Portland Trail Blazers":{ conference: "Western", elo: 1426 },
  "Dallas Mavericks":      { conference: "Western", elo: 1473 },
  "Memphis Grizzlies":     { conference: "Western", elo: 1417 },
  "New Orleans Pelicans":  { conference: "Western", elo: 1380 },
  "Sacramento Kings":      { conference: "Western", elo: 1352 },
  "Utah Jazz":             { conference: "Western", elo: 1334 },
};

// ─── Public helpers ───────────────────────────────────────────────────────────

export { normalizeTeamName };

/** Look up team data by participant name (case-insensitive). */
export function getTeamData(name: string): NbaTeamData | undefined {
  const normalized = normalizeTeamName(name);
  for (const [teamName, data] of Object.entries(TEAMS_DATA)) {
    if (normalizeTeamName(teamName) === normalized) return data;
  }
  return undefined;
}

/**
 * Elo win probability for team A over team B.
 *   P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
 */
export function eloWinProbability(eloA: number, eloB: number): number {
  return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
}

// ─── Per-position normalization ───────────────────────────────────────────────

const POSITION_KEYS = [
  "probFirst", "probSecond", "probThird", "probFourth",
  "probFifth", "probSixth", "probSeventh", "probEighth",
] as const;

function normalizeColumns(results: SimulationResult[]): void {
  for (const key of POSITION_KEYS) {
    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;
    }
  }
}

// ─── Bracket-aware helpers ────────────────────────────────────────────────────

type DbMatch = typeof schema.playoffMatches.$inferSelect;

/** Simulate a single-game matchup. Returns winner and loser. */
function simGame(
  eloMap: Map<string, number>,
  a: string,
  b: string
): { winner: string; loser: string } {
  const eloA = eloMap.get(a) ?? 1400;
  const eloB = eloMap.get(b) ?? 1400;
  const winner = Math.random() < eloWinProbability(eloA, eloB) ? a : b;
  return { winner, loser: winner === a ? b : a };
}

/** Simulate a best-of-7 series. Returns winner and loser. */
function simSeries(
  eloMap: Map<string, number>,
  a: string,
  b: string
): { winner: string; loser: string } {
  const winProb = eloWinProbability(eloMap.get(a) ?? 1400, eloMap.get(b) ?? 1400);
  let wA = 0;
  let wB = 0;
  while (wA < 4 && wB < 4) {
    if (Math.random() < winProb) wA++; else wB++;
  }
  return wA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
}

/**
 * Resolve a play-in single game.
 * Uses the real result if the match is complete; otherwise simulates.
 * p1Override / p2Override are used when the DB participant slot is still null
 * (i.e. filled in by the previous round's simulated result).
 */
function resolveGame(
  eloMap: Map<string, number>,
  match: DbMatch | undefined,
  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(
      `Cannot resolve game: missing participant(s) on match ${match?.id ?? "(undefined)"} ` +
      `(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` +
      `Ensure the bracket is fully generated before simulating.`
    );
  }
  return simGame(eloMap, p1, p2);
}

/**
 * Resolve a playoff series.
 * Uses the real result if the match is complete; otherwise simulates best-of-7.
 * p1Override / p2Override fill null DB participant slots with the simulated seed.
 */
function resolveSeries(
  eloMap: Map<string, number>,
  match: DbMatch | undefined,
  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(
      `Cannot resolve series: missing participant(s) on match ${match?.id ?? "(undefined)"} ` +
      `(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` +
      `Ensure the bracket is fully generated before simulating.`
    );
  }
  return simSeries(eloMap, p1, p2);
}

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

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

    // ── Mode detection: bracket-aware if a playoff 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(sportsSeasonId, bracketMatches);
      }
    }

    // ── Fall back to season-projection mode ───────────────────────────────────
    return this.simulateSeasonProjection(sportsSeasonId);
  }

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

  private async simulateBracketAware(
    sportsSeasonId: string,
    allMatches: DbMatch[]
  ): Promise<SimulationResult[]> {
    const db = database();

    // Group matches by round.
    const pir1 = allMatches.filter((m) => m.round === "Play-In Round 1")
      .toSorted((a, b) => a.matchNumber - b.matchNumber);
    const pir2 = allMatches.filter((m) => m.round === "Play-In Round 2")
      .toSorted((a, b) => a.matchNumber - b.matchNumber);
    const fr   = allMatches.filter((m) => m.round === "First Round")
      .toSorted((a, b) => a.matchNumber - b.matchNumber);
    const cs   = allMatches.filter((m) => m.round === "Conference Semifinals")
      .toSorted((a, b) => a.matchNumber - b.matchNumber);
    const cf   = allMatches.filter((m) => m.round === "Conference Finals")
      .toSorted((a, b) => a.matchNumber - b.matchNumber);
    const finals = allMatches.filter((m) => m.round === "NBA Finals");

    if (
      pir1.length !== 4 || pir2.length !== 2 || fr.length !== 8 ||
      cs.length !== 4 || cf.length !== 2 || finals.length !== 1
    ) {
      throw new Error(
        `NBA bracket has unexpected structure. Expected PIR1×4, PIR2×2, FR×8, CS×4, CF×2, Finals×1. ` +
        `Got PIR1×${pir1.length}, PIR2×${pir2.length}, FR×${fr.length}, ` +
        `CS×${cs.length}, CF×${cf.length}, Finals×${finals.length}. ` +
        `Ensure the bracket was generated using the nba_20 template.`
      );
    }

    // Collect all participant IDs from the bracket.
    const participantIdSet = new Set<string>();
    for (const m of allMatches) {
      if (m.participant1Id) participantIdSet.add(m.participant1Id);
      if (m.participant2Id) participantIdSet.add(m.participant2Id);
      if (m.winnerId) participantIdSet.add(m.winnerId);
      if (m.loserId) participantIdSet.add(m.loserId);
    }
    const participantIds = [...participantIdSet];

    // Load participant names and sourceElo values in parallel.
    const [participantRows, evRows] = await Promise.all([
      db
        .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
        .from(schema.seasonParticipants)
        .where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)),
      db
        .select({
          participantId: schema.seasonParticipantExpectedValues.participantId,
          sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
        })
        .from(schema.seasonParticipantExpectedValues)
        .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
    ]);

    const nameById = new Map(participantRows.map((r) => [r.id, r.name]));

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

    // Build Elo map: sourceElo > TEAMS_DATA > fallback 1400.
    const eloMap = new Map<string, number>();
    for (const id of participantIds) {
      const name = nameById.get(id) ?? "";
      eloMap.set(id, dbEloMap.get(id) ?? getTeamData(name)?.elo ?? 1400);
    }

    // Build per-round lookup maps for O(1) access in the hot loop.
    const pir1ByNum = new Map(pir1.map((m) => [m.matchNumber, m]));
    const pir2ByNum = new Map(pir2.map((m) => [m.matchNumber, m]));
    const frByNum   = new Map(fr.map((m) => [m.matchNumber, m]));
    const csByNum   = new Map(cs.map((m) => [m.matchNumber, m]));
    const cfByNum   = new Map(cf.map((m) => [m.matchNumber, m]));
    const finalMatch = finals[0];

    // Integer placement counts — avoids fractional accumulation error.
    const championCounts = new Map(participantIds.map((id) => [id, 0]));
    const finalistCounts = new Map(participantIds.map((id) => [id, 0]));
    const cfLoserCounts  = new Map(participantIds.map((id) => [id, 0]));
    const csLoserCounts  = new Map(participantIds.map((id) => [id, 0]));

    // ── Monte Carlo loop ──────────────────────────────────────────────────────
    for (let s = 0; s < NUM_SIMULATIONS; s++) {

      // ── Play-In Round 1 (4 single games) ─────────────────────────────────
      // M1: East 7 vs 8 → winner = E7 seed; loser enters PIR2 as p1
      // M2: East 9 vs 10 → winner enters PIR2 as p2; loser eliminated
      // M3: West 7 vs 8 → winner = W7 seed; loser enters PIR2 as p1
      // M4: West 9 vs 10 → winner enters PIR2 as p2; loser eliminated
      const pir1M1 = resolveGame(eloMap, pir1ByNum.get(1));
      const pir1M2 = resolveGame(eloMap, pir1ByNum.get(2));
      const pir1M3 = resolveGame(eloMap, pir1ByNum.get(3));
      const pir1M4 = resolveGame(eloMap, pir1ByNum.get(4));

      const e7Seed = pir1M1.winner;
      const e8Cand = pir1M1.loser;   // plays PIR2 M1 p1
      const e9e10W = pir1M2.winner;  // plays PIR2 M1 p2
      const w7Seed = pir1M3.winner;
      const w8Cand = pir1M3.loser;   // plays PIR2 M2 p1
      const w9w10W = pir1M4.winner;  // plays PIR2 M2 p2

      // ── Play-In Round 2 (2 single games) ─────────────────────────────────
      // M1: E8 candidate vs E9/10 winner → winner = E8 seed
      // M2: W8 candidate vs W9/10 winner → winner = W8 seed
      // Use DB participant slots when already filled (after PIR1 locked in),
      // otherwise fall back to simulated values from above.
      const pir2M1 = resolveGame(eloMap, pir2ByNum.get(1), e8Cand, e9e10W);
      const pir2M2 = resolveGame(eloMap, pir2ByNum.get(2), w8Cand, w9w10W);

      const e8Seed = pir2M1.winner;
      const w8Seed = pir2M2.winner;

      // ── First Round (8 best-of-7 series) ─────────────────────────────────
      // Bracket order (matches correct Conference Semis pairings):
      //   M1: E1 vs E8   M2: E4 vs E5   M3: E2 vs E7   M4: E3 vs E6
      //   M5: W1 vs W8   M6: W4 vs W5   M7: W2 vs W7   M8: W3 vs W6
      //
      // Slots that stay null until play-in resolves use simmed seeds as fallback.
      // Once the play-in result is recorded, the DB slot is filled, so participant
      // coalescence (DB ?? simmed) always produces the correct team.
      const frM1 = resolveSeries(eloMap, frByNum.get(1), undefined, e8Seed); // E1(DB) vs E8
      const frM2 = resolveSeries(eloMap, frByNum.get(2));                    // E4(DB) vs E5(DB)
      const frM3 = resolveSeries(eloMap, frByNum.get(3), undefined, e7Seed); // E2(DB) vs E7
      const frM4 = resolveSeries(eloMap, frByNum.get(4));                    // E3(DB) vs E6(DB)
      const frM5 = resolveSeries(eloMap, frByNum.get(5), undefined, w8Seed); // W1(DB) vs W8
      const frM6 = resolveSeries(eloMap, frByNum.get(6));                    // W4(DB) vs W5(DB)
      const frM7 = resolveSeries(eloMap, frByNum.get(7), undefined, w7Seed); // W2(DB) vs W7
      const frM8 = resolveSeries(eloMap, frByNum.get(8));                    // W3(DB) vs W6(DB)

      // ── Conference Semifinals (4 best-of-7 series) ────────────────────────
      // Standard ceil bracket path:
      //   CS M1: FR M1/M2 winners (East upper: E1/8 vs E4/5)
      //   CS M2: FR M3/M4 winners (East lower: E2/7 vs E3/6)
      //   CS M3: FR M5/M6 winners (West upper: W1/8 vs W4/5)
      //   CS M4: FR M7/M8 winners (West lower: W2/7 vs W3/6)
      const csM1 = resolveSeries(eloMap, csByNum.get(1), frM1.winner, frM2.winner);
      const csM2 = resolveSeries(eloMap, csByNum.get(2), frM3.winner, frM4.winner);
      const csM3 = resolveSeries(eloMap, csByNum.get(3), frM5.winner, frM6.winner);
      const csM4 = resolveSeries(eloMap, csByNum.get(4), frM7.winner, frM8.winner);

      csLoserCounts.set(csM1.loser, (csLoserCounts.get(csM1.loser) ?? 0) + 1);
      csLoserCounts.set(csM2.loser, (csLoserCounts.get(csM2.loser) ?? 0) + 1);
      csLoserCounts.set(csM3.loser, (csLoserCounts.get(csM3.loser) ?? 0) + 1);
      csLoserCounts.set(csM4.loser, (csLoserCounts.get(csM4.loser) ?? 0) + 1);

      // ── Conference Finals (2 best-of-7 series) ────────────────────────────
      // CF M1: East champion (CS M1/M2 winners)
      // CF M2: West champion (CS M3/M4 winners)
      const cfM1 = resolveSeries(eloMap, cfByNum.get(1), csM1.winner, csM2.winner);
      const cfM2 = resolveSeries(eloMap, cfByNum.get(2), csM3.winner, csM4.winner);

      cfLoserCounts.set(cfM1.loser, (cfLoserCounts.get(cfM1.loser) ?? 0) + 1);
      cfLoserCounts.set(cfM2.loser, (cfLoserCounts.get(cfM2.loser) ?? 0) + 1);

      // ── NBA Finals ────────────────────────────────────────────────────────
      const nbaFinals = resolveSeries(eloMap, finalMatch, cfM1.winner, cfM2.winner);

      championCounts.set(nbaFinals.winner, (championCounts.get(nbaFinals.winner) ?? 0) + 1);
      finalistCounts.set(nbaFinals.loser,  (finalistCounts.get(nbaFinals.loser)  ?? 0) + 1);
    }

    // ── Convert counts to probability distributions ───────────────────────────
    // Exact denominators guarantee column sums of 1.0 by construction:
    //   probFirst/Second  → N total (1 per sim)
    //   probThird/Fourth  → cfLoserCounts / (2×N) — 2 CF losers per sim
    //   probFifth–Eighth  → csLoserCounts / (4×N) — 4 CS losers per sim
    const N = NUM_SIMULATIONS;
    const results: SimulationResult[] = participantIds.map((participantId) => {
      const c  = championCounts.get(participantId) ?? 0;
      const f  = finalistCounts.get(participantId) ?? 0;
      const cfCount = cfLoserCounts.get(participantId) ?? 0;
      const csCount = csLoserCounts.get(participantId) ?? 0;
      return {
        participantId,
        probabilities: {
          probFirst:   c        / N,
          probSecond:  f        / N,
          probThird:   cfCount  / (2 * N),
          probFourth:  cfCount  / (2 * N),
          probFifth:   csCount  / (4 * N),
          probSixth:   csCount  / (4 * N),
          probSeventh: csCount  / (4 * N),
          probEighth:  csCount  / (4 * N),
        },
        source: "nba_bracket_monte_carlo",
      };
    });

    normalizeColumns(results);
    return results;
  }

  // ── Mode 2: Season Projection ──────────────────────────────────────────────

  private async simulateSeasonProjection(sportsSeasonId: string): Promise<SimulationResult[]> {
    const db = database();

    const [participantRows, standings, evRows] = await Promise.all([
      db
        .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
        .from(schema.seasonParticipants)
        .where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)),
      getRegularSeasonStandings(sportsSeasonId),
      db
        .select({
          participantId: schema.seasonParticipantExpectedValues.participantId,
          sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
        })
        .from(schema.seasonParticipantExpectedValues)
        .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
    ]);

    if (participantRows.length === 0) {
      throw new Error(
        `No participants found for sports season ${sportsSeasonId}. ` +
          `Add NBA teams as participants before running simulation.`
      );
    }

    const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
    const participantIds = participantRows.map((r) => r.id);

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

    interface TeamEntry {
      id: string;
      name: string;
      data: NbaTeamData | undefined;
      conference: "Eastern" | "Western";
      currentWins: number;
      remainingGames: number;
      winProb: number;
      resolvedElo: number;
    }

    const teams: TeamEntry[] = participantRows.map((r) => {
      const standing = standingsMap.get(r.id);
      const data = getTeamData(r.name);
      const gamesPlayed = standing?.gamesPlayed ?? 0;
      const conf = standing?.conference;
      const conference: "Eastern" | "Western" =
        conf === "Eastern" || conf === "Western"
          ? conf
          : (data?.conference ?? "Eastern");
      const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400;
      return {
        id: r.id,
        name: r.name,
        data,
        conference,
        currentWins: standing?.wins ?? 0,
        remainingGames: Math.max(0, NBA_REGULAR_SEASON_GAMES - gamesPlayed),
        winProb: eloWinProbability(resolvedElo, 1500),
        resolvedElo,
      };
    });

    const easternTeams = teams.filter((t) => t.conference === "Eastern");
    const westernTeams = teams.filter((t) => t.conference === "Western");

    if (easternTeams.length < 10 || westernTeams.length < 10) {
      throw new Error(
        `Each conference needs at least 10 participants (got East: ${easternTeams.length}, ` +
          `West: ${westernTeams.length}). Add all 30 NBA teams before running simulation.`
      );
    }

    const simTeamGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
      Math.random() < eloWinProbability(eloOfEntry(a), eloOfEntry(b)) ? a : b;

    const simTeamSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
      const winProb = eloWinProbability(eloOfEntry(a), eloOfEntry(b));
      let wA = 0;
      let wB = 0;
      while (wA < 4 && wB < 4) {
        if (Math.random() < winProb) wA++; else wB++;
      }
      return wA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
    };

    const simPlayIn = ([s7, s8, s9, s10]: [TeamEntry, TeamEntry, TeamEntry, TeamEntry]): [TeamEntry, TeamEntry] => {
      const game1Winner = simTeamGame(s7, s8);
      const game1Loser  = game1Winner === s7 ? s8 : s7;
      const game2Winner = simTeamGame(s9, s10);
      return [game1Winner, simTeamGame(game1Loser, game2Winner)];
    };

    const buildConferenceBracket = (confTeams: TeamEntry[]): TeamEntry[] => {
      const projected = confTeams.map((t) => ({
        team: t,
        projectedWins: simulateProjectedWins(t),
        tiebreaker: Math.random(),
      }));
      const ranked = projected.toSorted((a, b) => b.projectedWins - a.projectedWins || b.tiebreaker - a.tiebreaker);
      const top6 = ranked.slice(0, 6).map((x) => x.team);
      const playIn = ranked.slice(6, 10).map((x) => x.team) as
        [TeamEntry, TeamEntry, TeamEntry, TeamEntry];
      const [seed7, seed8] = simPlayIn(playIn);
      return [...top6, seed7, seed8];
    };

    const simR1 = ([s1, s2, s3, s4, s5, s6, s7, s8]: TeamEntry[]): TeamEntry[] => [
      simTeamSeries(s1, s8).winner,
      simTeamSeries(s4, s5).winner,
      simTeamSeries(s2, s7).winner,
      simTeamSeries(s3, s6).winner,
    ];

    const simR2 = ([w0, w1, w2, w3]: TeamEntry[]): { winners: TeamEntry[]; losers: TeamEntry[] } => {
      const m1 = simTeamSeries(w0, w1);
      const m2 = simTeamSeries(w2, w3);
      return { winners: [m1.winner, m2.winner], losers: [m1.loser, m2.loser] };
    };

    const championCounts       = new Map<string, number>(participantIds.map((id) => [id, 0]));
    const finalistCounts       = new Map<string, number>(participantIds.map((id) => [id, 0]));
    const confFinalLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
    const confSemiLoserCounts  = new Map<string, number>(participantIds.map((id) => [id, 0]));

    for (let s = 0; s < NUM_SIMULATIONS; s++) {
      const eastBracket = buildConferenceBracket(easternTeams);
      const westBracket = buildConferenceBracket(westernTeams);

      const { winners: eastR2Winners, losers: eastR2Losers } = simR2(simR1(eastBracket));
      const { winner: eastChamp, loser: eastCFLoser } = simTeamSeries(eastR2Winners[0], eastR2Winners[1]);

      const { winners: westR2Winners, losers: westR2Losers } = simR2(simR1(westBracket));
      const { winner: westChamp, loser: westCFLoser } = simTeamSeries(westR2Winners[0], westR2Winners[1]);

      const { winner: champion, loser: finalist } = simTeamSeries(eastChamp, westChamp);

      championCounts.set(champion.id,          (championCounts.get(champion.id) ?? 0)          + 1);
      finalistCounts.set(finalist.id,          (finalistCounts.get(finalist.id) ?? 0)          + 1);
      confFinalLoserCounts.set(eastCFLoser.id, (confFinalLoserCounts.get(eastCFLoser.id) ?? 0) + 1);
      confFinalLoserCounts.set(westCFLoser.id, (confFinalLoserCounts.get(westCFLoser.id) ?? 0) + 1);
      for (const loser of [...eastR2Losers, ...westR2Losers]) {
        confSemiLoserCounts.set(loser.id, (confSemiLoserCounts.get(loser.id) ?? 0) + 1);
      }
    }

    const N = NUM_SIMULATIONS;
    const results: SimulationResult[] = participantIds.map((participantId) => {
      const c  = championCounts.get(participantId) ?? 0;
      const f  = finalistCounts.get(participantId) ?? 0;
      const cf = confFinalLoserCounts.get(participantId) ?? 0;
      const cs = confSemiLoserCounts.get(participantId) ?? 0;
      return {
        participantId,
        probabilities: {
          probFirst:   c  / N,
          probSecond:  f  / N,
          probThird:   cf / (2 * N),
          probFourth:  cf / (2 * N),
          probFifth:   cs / (4 * N),
          probSixth:   cs / (4 * N),
          probSeventh: cs / (4 * N),
          probEighth:  cs / (4 * N),
        },
        source: "nba_bracket_monte_carlo",
      };
    });

    normalizeColumns(results);
    return results;
  }
}

// ─── Season-projection helpers (used only in Mode 2) ─────────────────────────

// TeamEntryBase is a structural subset of TeamEntry (the private interface defined inside
// simulateSeasonProjection). eloOfEntry is module-level so the linter doesn't flag it for
// "consistent-function-scoping" (it doesn't close over any local variables).
interface TeamEntryBase {
  resolvedElo: number;
}

function eloOfEntry(entry: TeamEntryBase): number {
  return entry.resolvedElo;
}

interface TeamForProjection {
  currentWins: number;
  remainingGames: number;
  winProb: number;
}

function simulateProjectedWins(entry: TeamForProjection): number {
  let extra = 0;
  for (let g = 0; g < entry.remainingGames; g++) {
    if (Math.random() < entry.winProb) extra++;
  }
  return entry.currentWins + extra;
}
