/**
 * NHL Playoff Simulator
 *
 * Monte Carlo simulation of the NHL playoffs including seeding projection
 * for the current season (2025-26).
 *
 * Algorithm:
 *   1. Load all participants for the sports season from DB
 *   2. Match participant names to hardcoded team data (Elo + seeding probabilities)
 *   3. For each simulation:
 *      a. For each conference, draw division placements (1st/2nd/3rd per division)
 *         and two wildcard teams using weighted probability distributions
 *      b. Build the NHL bracket:
 *         - Higher-Elo division winner faces WC2; other division winner faces WC1
 *         - Each division's 2nd vs 3rd seeds play each other
 *      c. Simulate 4 rounds of best-of-7 series:
 *         Round 1 (Wild Card): m0=topDiv1st/WC2, m1=otherDiv1st/WC1,
 *                              m2=topDiv2nd/3rd, m3=otherDiv2nd/3rd
 *         Round 2 (Div Finals): m0w vs m2w, m1w vs m3w
 *         Round 3 (Conf Finals): two division-final winners per conference
 *         Stanley Cup Final: East champ vs West champ
 *   4. Track placement counts per scoring tier
 *   5. Convert counts to probability distributions
 *
 * Win probability (Elo, PARITY_FACTOR = 550):
 *   P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 550))
 *   NHL uses a higher parity factor than the standard 400 to dampen the Elo
 *   spread and reflect the high variance of hockey. 550 (down from an original
 *   800) was chosen to better match Vegas championship implied probabilities —
 *   800 compressed favorites too far toward 50/50 per game.
 *
 * Futures blending:
 *   If sourceOdds are stored in participantExpectedValues for this season,
 *   the per-game win probability is blended:
 *     P(game) = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb
 *   where oddsProb = normalizedOdds(A) / (normalizedOdds(A) + normalizedOdds(B)).
 *   Normalized odds are vig-removed futures win probabilities.
 *   ELO_WEIGHT = 0.7, ODDS_WEIGHT = 0.3 (same calibration as UCL simulator).
 *   Falls back to Elo-only when no odds are stored.
 *
 * Placement tiers → SimulationProbabilities mapping:
 *   probFirst              = Stanley Cup champion (1 per sim)
 *   probSecond             = Stanley Cup Final loser (1 per sim)
 *   probThird / probFourth = Conference Final losers (2 per sim — East + West)
 *   probFifth–probEighth   = Second Round losers (4 per sim)
 *   First Round losers     → all 0 (score 0 points)
 *   Missed playoffs        → all 0
 *
 * Seeding probability keys per team:
 *   p_div1  = prob of winning own division
 *   p_div2  = prob of finishing 2nd in own division
 *   p_div3  = prob of finishing 3rd in own division
 *   p_wc1   = prob of being conference wildcard 1 (higher-ranked WC)
 *   p_wc2   = prob of being conference wildcard 2 (lower-ranked WC)
 *   Sum of all p_* ≈ team's overall playoff probability.
 *   Teams with no p_* entries always miss the playoffs.
 *
 * Elo ratings and seeding probabilities are hardcoded below (March 2026 data).
 * Source: https://elo.harvitronix.com/nhl/2025-2026 (Elo ratings + playoff% calibration).
 * Seeding probabilities are hand-derived from current standings as of March 18, 2026.
 * Update at the start of each season.
 */

import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { logger } from "~/lib/logger";
import {
  convertAmericanOddsToProbability,
  normalizeProbabilities,
} from "~/services/probability-engine";

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

const NUM_SIMULATIONS = 50_000;

/**
 * Elo parity factor. NHL uses 1000 (higher than the standard 400) to reflect
 * the elevated game-to-game variance in hockey.
 */
const PARITY_FACTOR = 1000;

/**
 * Blend weights for Elo vs. Vegas futures odds when sourceOdds are available.
 * Same calibration as the UCL simulator (0.7 / 0.3).
 */
const ELO_WEIGHT  = 0.7;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;

// ─── Team data (2025-26 season, as of March 18, 2026) ────────────────────────
//
// elo:    Elo rating from elo.harvitronix.com/nhl/2025-2026.
// p_div1: Probability of finishing 1st in own division.
// p_div2: Probability of finishing 2nd in own division.
// p_div3: Probability of finishing 3rd in own division.
// p_wc1:  Probability of being conference wildcard 1 (higher seed).
// p_wc2:  Probability of being conference wildcard 2 (lower seed).
//
// These are weights used in sequential weighted sampling (not strict distributions).
// Sum of all p_* per team ≈ their overall playoff% from the Harvitronix site.
//
// Divisions:
//   Eastern — Atlantic:      BOS, BUF, DET, FLA, MTL, OTT, TBL, TOR
//   Eastern — Metropolitan:  CAR, CBJ, NJD, NYI, NYR, PHI, PIT, WSH
//   Western — Central:       CHI, COL, DAL, MIN, NSH, STL, UTA, WPG
//   Western — Pacific:       ANA, CGY, EDM, LAK, SJS, SEA, VAN, VGK

interface NhlTeamData {
  conference: "Eastern" | "Western";
  division: "Atlantic" | "Metropolitan" | "Central" | "Pacific";
  elo: number;
  p_div1?: number;
  p_div2?: number;
  p_div3?: number;
  p_wc1?: number;
  p_wc2?: number;
}

const TEAMS_DATA: Record<string, NhlTeamData> = {
  // ── Eastern Conference — Atlantic ──────────────────────────────────────────
  // BUF 108 pts, TBL 107 pts — neck-and-neck for 1st; both near-certain to make playoffs.
  // MTL 101 pts (86.7% PO) — almost certainly 3rd in Atlantic or WC.
  // OTT 96 pts (50.2%), DET 98 pts (63%), BOS 96 pts (51.1%) — WC bubble.
  // FLA 85 pts (0.5%), TOR 83 pts (0%) — eliminated.

  "Tampa Bay Lightning": {
    conference: "Eastern", division: "Atlantic", elo: 1587,
    p_div1: 0.50, p_div2: 0.40, p_div3: 0.06,
  },
  "Buffalo Sabres": {
    conference: "Eastern", division: "Atlantic", elo: 1572,
    p_div1: 0.45, p_div2: 0.42, p_div3: 0.07,
  },
  "Montreal Canadiens": {
    conference: "Eastern", division: "Atlantic", elo: 1527,
    p_div1: 0.01, p_div2: 0.05, p_div3: 0.60, p_wc1: 0.12, p_wc2: 0.06,
  },
  "Ottawa Senators": {
    conference: "Eastern", division: "Atlantic", elo: 1542,
    p_div1: 0.01, p_div2: 0.05, p_div3: 0.08, p_wc1: 0.22, p_wc2: 0.19,
  },
  "Detroit Red Wings": {
    conference: "Eastern", division: "Atlantic", elo: 1506,
    p_div3: 0.09, p_wc1: 0.27, p_wc2: 0.22,
  },
  "Boston Bruins": {
    conference: "Eastern", division: "Atlantic", elo: 1501,
    p_div3: 0.08, p_wc1: 0.22, p_wc2: 0.19,
  },
  "Florida Panthers": {
    conference: "Eastern", division: "Atlantic", elo: 1505,
    p_wc2: 0.005,
  },
  "Toronto Maple Leafs": {
    conference: "Eastern", division: "Atlantic", elo: 1479,
    // 0% playoff probability — always miss
  },

  // ── Eastern Conference — Metropolitan ─────────────────────────────────────
  // CAR 109 pts (99.9%) — clear 1st seed.
  // CBJ/PIT/NYI all tied at 99 pts — volatile 2nd/3rd/WC race.
  // WSH 89 pts (4.7%), PHI 90 pts (7.9%) — long shots.
  // NJD 86 pts (0.5%), NYR 80 pts (0%) — eliminated.

  "Carolina Hurricanes": {
    conference: "Eastern", division: "Metropolitan", elo: 1574,
    p_div1: 0.98, p_div2: 0.01,
  },
  "Columbus Blue Jackets": {
    conference: "Eastern", division: "Metropolitan", elo: 1530,
    p_div2: 0.33, p_div3: 0.33, p_wc1: 0.05, p_wc2: 0.04,
  },
  "Pittsburgh Penguins": {
    conference: "Eastern", division: "Metropolitan", elo: 1518,
    p_div2: 0.35, p_div3: 0.35, p_wc1: 0.07, p_wc2: 0.07,
  },
  "New York Islanders": {
    conference: "Eastern", division: "Metropolitan", elo: 1513,
    p_div2: 0.28, p_div3: 0.35, p_wc1: 0.07, p_wc2: 0.07,
  },
  "Philadelphia Flyers": {
    conference: "Eastern", division: "Metropolitan", elo: 1473,
    p_wc1: 0.02, p_wc2: 0.05,
  },
  "Washington Capitals": {
    conference: "Eastern", division: "Metropolitan", elo: 1506,
    p_wc1: 0.01, p_wc2: 0.03,
  },
  "New Jersey Devils": {
    conference: "Eastern", division: "Metropolitan", elo: 1488,
    p_wc2: 0.005,
  },
  "New York Rangers": {
    conference: "Eastern", division: "Metropolitan", elo: 1480,
    // 0% playoff probability — always miss
  },

  // ── Western Conference — Central ───────────────────────────────────────────
  // COL 119 pts, DAL 113 pts, MIN 106 pts — all locked in as top 3.
  // UTA 93 pts (92.8%) — leading WC candidate from Central.
  // NSH 85 pts (21.9%), WPG 82 pts (6.6%), STL 80 pts (2.2%) — long shots.
  // CHI 75 pts (0%) — eliminated.

  "Colorado Avalanche": {
    conference: "Western", division: "Central", elo: 1594,
    p_div1: 0.90, p_div2: 0.09, p_div3: 0.01,
  },
  "Dallas Stars": {
    conference: "Western", division: "Central", elo: 1581,
    p_div1: 0.08, p_div2: 0.80, p_div3: 0.11,
  },
  "Minnesota Wild": {
    conference: "Western", division: "Central", elo: 1548,
    p_div1: 0.02, p_div2: 0.10, p_div3: 0.86, p_wc1: 0.01,
  },
  "Utah Mammoth": {
    conference: "Western", division: "Central", elo: 1529,
    p_div3: 0.02, p_wc1: 0.55, p_wc2: 0.33,
  },
  "Nashville Predators": {
    conference: "Western", division: "Central", elo: 1471,
    p_wc1: 0.04, p_wc2: 0.17,
  },
  "Winnipeg Jets": {
    conference: "Western", division: "Central", elo: 1483,
    p_wc1: 0.01, p_wc2: 0.05,
  },
  "St. Louis Blues": {
    conference: "Western", division: "Central", elo: 1473,
    p_wc2: 0.02,
  },
  "Chicago Blackhawks": {
    conference: "Western", division: "Central", elo: 1411,
    // 0% playoff probability — always miss
  },

  // ── Western Conference — Pacific ───────────────────────────────────────────
  // ANA 94 pts, EDM 93 pts, VGK 93 pts — tight 3-way race for all 3 Pacific seeds.
  // LAK 87 pts (39.1%), SJS 87 pts (38.9%), SEA 86 pts (26.6%) — WC bubble.
  // CGY 73 pts (0%), VAN 63 pts (0%) — eliminated.

  "Anaheim Ducks": {
    conference: "Western", division: "Pacific", elo: 1490,
    p_div1: 0.40, p_div2: 0.33, p_div3: 0.17, p_wc1: 0.02, p_wc2: 0.01,
  },
  "Edmonton Oilers": {
    conference: "Western", division: "Pacific", elo: 1531,
    p_div1: 0.30, p_div2: 0.32, p_div3: 0.20, p_wc1: 0.04, p_wc2: 0.04,
  },
  "Vegas Golden Knights": {
    conference: "Western", division: "Pacific", elo: 1519,
    p_div1: 0.25, p_div2: 0.28, p_div3: 0.20, p_wc1: 0.06, p_wc2: 0.06,
  },
  "Los Angeles Kings": {
    conference: "Western", division: "Pacific", elo: 1482,
    p_div2: 0.02, p_div3: 0.09, p_wc1: 0.14, p_wc2: 0.12,
  },
  "San Jose Sharks": {
    conference: "Western", division: "Pacific", elo: 1455,
    p_div2: 0.01, p_div3: 0.08, p_wc1: 0.14, p_wc2: 0.12,
  },
  "Seattle Kraken": {
    conference: "Western", division: "Pacific", elo: 1469,
    p_div3: 0.07, p_wc1: 0.10, p_wc2: 0.10,
  },
  "Calgary Flames": {
    conference: "Western", division: "Pacific", elo: 1436,
    // 0% playoff probability — always miss
  },
  "Vancouver Canucks": {
    conference: "Western", division: "Pacific", elo: 1403,
    // 0% playoff probability — always miss
  },
};

// ─── Public helpers (exported for unit testing) ───────────────────────────────

/** Normalize a team name for lookup (lowercase, trimmed, collapsed whitespace). */
export function normalizeTeamName(name: string): string {
  return name.toLowerCase().trim().replace(/\s+/g, " ");
}

/** Look up team data by participant name (case-insensitive). */
export function getTeamData(name: string): NhlTeamData | 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))
 * Exported for unit testing.
 */
export function eloWinProbability(eloA: number, eloB: number): number {
  return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
}

// ─── Internal types ───────────────────────────────────────────────────────────

interface TeamEntry {
  id: string;
  name: string;
  data: NhlTeamData | undefined;
}

type DivisionSeedings = {
  first: TeamEntry;
  second: TeamEntry;
  third: TeamEntry;
};

/** Get Elo for a team entry. Fallback 1400 for unknown teams. */
function elo(entry: TeamEntry): number {
  return entry.data?.elo ?? 1400;
}

/**
 * Weighted pick without replacement.
 * Draws one team from `pool` using the given seeding weight key, excluding `excluded`.
 * Returns undefined if no eligible team has positive weight — the caller should
 * treat that as a degenerate draw and skip the iteration.
 */
function weightedPick(
  pool: TeamEntry[],
  weightKey: "p_div1" | "p_div2" | "p_div3" | "p_wc1" | "p_wc2",
  excluded: Set<TeamEntry>
): TeamEntry | undefined {
  const eligible = pool.filter((t) => !excluded.has(t));
  if (eligible.length === 0) return undefined;

  const weights = eligible.map((t) => t.data?.[weightKey] ?? 0);
  const total = weights.reduce((s, w) => s + w, 0);
  if (total === 0) return undefined;

  let r = Math.random() * total;
  for (let i = 0; i < eligible.length; i++) {
    r -= weights[i];
    if (r <= 0) return eligible[i];
  }
  return eligible[eligible.length - 1];
}

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

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

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

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

    const participantIds = participantRows.map((r) => r.id);

    const teams: TeamEntry[] = participantRows.map((r) => ({
      id: r.id,
      name: r.name,
      data: getTeamData(r.name),
    }));

    // Warn about participants that don't match any hardcoded team.
    // These are excluded entirely rather than silently landing in the wildcard pool.
    const unrecognized = teams.filter((t) => !t.data);
    if (unrecognized.length > 0) {
      logger.warn(
        `[NHLSimulator] ${unrecognized.length} participant(s) not found in TEAMS_DATA and will be excluded: ` +
          unrecognized.map((t) => t.name).join(", ")
      );
    }

    // Separate recognized teams by conference + division.
    const easternAtlantic = teams.filter((t) => t.data?.division === "Atlantic");
    const easternMetro    = teams.filter((t) => t.data?.division === "Metropolitan");
    const westernCentral  = teams.filter((t) => t.data?.division === "Central");
    const westernPacific  = teams.filter((t) => t.data?.division === "Pacific");

    // Only recognized teams enter the conference wildcard pools.
    const easternTeams = teams.filter((t) => t.data?.conference === "Eastern");
    const westernTeams = teams.filter((t) => t.data?.conference === "Western");

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

    // ─── Futures odds blending ─────────────────────────────────────────────────
    // Load sourceOdds (American format) from participantExpectedValues.
    // If any odds are present, blend them with Elo for per-game win probability.
    // Falls back to Elo-only when no odds are stored.

    const evRows = await db
      .select({
        participantId: schema.participantExpectedValues.participantId,
        sourceOdds: schema.participantExpectedValues.sourceOdds,
      })
      .from(schema.participantExpectedValues)
      .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));

    const participantIdSet = new Set(participantIds);
    const oddsRows = evRows.filter(
      (r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
    );
    const hasOdds = oddsRows.length > 0;

    // Build vig-removed win-probability map keyed by participant ID.
    const normalizedOddsMap = new Map<string, number>();
    if (hasOdds) {
      const rawProbs = oddsRows.map((r) =>
        convertAmericanOddsToProbability(r.sourceOdds ?? 0)
      );
      const normalized = normalizeProbabilities(rawProbs);
      oddsRows.forEach(({ participantId }, i) => {
        normalizedOddsMap.set(participantId, normalized[i]);
      });
    }

    // ─── Helpers (defined once, outside the hot loop) ─────────────────────────

    /**
     * Blended per-game win probability for team A over team B.
     * When odds are available: 70% Elo + 30% vig-removed futures head-to-head.
     * Falls back to pure Elo when no odds are stored.
     */
    const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
      const eloProb = eloWinProbability(elo(a), elo(b));
      if (!hasOdds) return eloProb;

      const o1 = normalizedOddsMap.get(a.id) ?? 0;
      const o2 = normalizedOddsMap.get(b.id) ?? 0;
      const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
      return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
    };

    /** Simulate a best-of-7 series. Returns winner and loser. */
    const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
      const winProb = gameWinProb(a, b);
      let winsA = 0;
      let winsB = 0;
      while (winsA < 4 && winsB < 4) {
        if (Math.random() < winProb) winsA++; else winsB++;
      }
      return winsA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
    };

    /**
     * Draw the top-3 playoff seeds for a single division.
     * Returns undefined for each position if no team has weight for that slot
     * (shouldn't happen with correct data, but handled gracefully).
     */
    const drawDivisionSeedings = (divTeams: TeamEntry[]): DivisionSeedings | undefined => {
      const taken = new Set<TeamEntry>();

      const first = weightedPick(divTeams, "p_div1", taken);
      if (!first) return undefined;
      taken.add(first);

      const second = weightedPick(divTeams, "p_div2", taken);
      if (!second) return undefined;
      taken.add(second);

      const third = weightedPick(divTeams, "p_div3", taken);
      if (!third) return undefined;

      return { first, second, third };
    };

    /**
     * Build the 8-team playoff bracket for one conference.
     * Returns [m0, m1, m2, m3] where each element is [higher-seed, lower-seed].
     *
     * Bracket:
     *   m0: topDiv_1st  vs WC2  (top division winner plays weaker wildcard)
     *   m1: otherDiv_1st vs WC1 (other division winner plays stronger wildcard)
     *   m2: topDiv_2nd  vs topDiv_3rd
     *   m3: otherDiv_2nd vs otherDiv_3rd
     *
     * "Top division" = whichever division winner has higher Elo. The actual NHL rule
     * uses regular season points, but Elo is a reasonable per-simulation proxy since
     * we don't simulate the regular season — it approximates the same ordering.
     */
    const buildConferenceBracket = (
      divATeams: TeamEntry[],
      divBTeams: TeamEntry[],
      allConfTeams: TeamEntry[]
    ): [[TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry]] | undefined => {
      const divA = drawDivisionSeedings(divATeams);
      const divB = drawDivisionSeedings(divBTeams);
      if (!divA || !divB) return undefined;

      const divisionWinners = new Set([divA.first, divA.second, divA.third, divB.first, divB.second, divB.third]);
      const wcPool = allConfTeams.filter((t) => !divisionWinners.has(t));
      const wcTaken = new Set<TeamEntry>();

      const wc1 = weightedPick(wcPool, "p_wc1", wcTaken);
      if (!wc1) return undefined;
      wcTaken.add(wc1);

      const wc2 = weightedPick(wcPool, "p_wc2", wcTaken);
      if (!wc2) return undefined;

      // Higher-Elo division winner faces WC2 (the weaker wildcard).
      const [topDiv, otherDiv] =
        elo(divA.first) >= elo(divB.first)
          ? [divA, divB]
          : [divB, divA];

      return [
        [topDiv.first,  wc2],           // m0
        [otherDiv.first, wc1],          // m1
        [topDiv.second,  topDiv.third], // m2
        [otherDiv.second, otherDiv.third], // m3
      ];
    };

    // ─── Placement count maps ──────────────────────────────────────────────────

    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 r2LoserCounts        = new Map<string, number>(participantIds.map((id) => [id, 0]));

    // ─── Monte Carlo simulation loop ───────────────────────────────────────────

    // Track effective iterations separately: degenerate bracket draws are skipped
    // and must not count toward the probability denominators.
    let effectiveN = 0;

    for (let s = 0; s < NUM_SIMULATIONS; s++) {
      // Build conference brackets.
      const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro, easternTeams);
      const westBracket = buildConferenceBracket(westernCentral, westernPacific, westernTeams);
      if (!eastBracket || !westBracket) continue; // degenerate draw — do not count
      effectiveN++;

      // ── Eastern Conference ────────────────────────────────────────────────────
      // Round 1: 4 series (Wild Card round).
      const eastR1Winners = eastBracket.map(([a, b]) => simSeries(a, b).winner);

      // Round 2 (Division Finals):
      //   m0w (topDiv1st/WC2 winner) vs m2w (topDiv 2nd/3rd winner)
      //   m1w (otherDiv1st/WC1 winner) vs m3w (otherDiv 2nd/3rd winner)
      const eastR2m1 = simSeries(eastR1Winners[0], eastR1Winners[2]);
      const eastR2m2 = simSeries(eastR1Winners[1], eastR1Winners[3]);

      // Conference Final.
      const eastCF   = simSeries(eastR2m1.winner, eastR2m2.winner);
      const eastChamp = eastCF.winner;
      confFinalLoserCounts.set(eastCF.loser.id, (confFinalLoserCounts.get(eastCF.loser.id) ?? 0) + 1);

      // ── Western Conference ────────────────────────────────────────────────────
      const westR1Winners = westBracket.map(([a, b]) => simSeries(a, b).winner);
      const westR2m1 = simSeries(westR1Winners[0], westR1Winners[2]);
      const westR2m2 = simSeries(westR1Winners[1], westR1Winners[3]);
      const westCF   = simSeries(westR2m1.winner, westR2m2.winner);
      const westChamp = westCF.winner;
      confFinalLoserCounts.set(westCF.loser.id, (confFinalLoserCounts.get(westCF.loser.id) ?? 0) + 1);

      // ── Stanley Cup Final ─────────────────────────────────────────────────────
      const final = simSeries(eastChamp, westChamp);
      championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1);
      finalistCounts.set(final.loser.id,  (finalistCounts.get(final.loser.id) ?? 0)  + 1);

      // Round 2 losers (4 per sim). Round 1 losers are not counted (0 points).
      for (const loser of [eastR2m1.loser, eastR2m2.loser, westR2m1.loser, westR2m2.loser]) {
        r2LoserCounts.set(loser.id, (r2LoserCounts.get(loser.id) ?? 0) + 1);
      }
    }

    if (effectiveN === 0) {
      throw new Error(
        "All simulations produced degenerate brackets. " +
          "Check that each division has teams with positive seeding probabilities."
      );
    }

    // ─── Convert counts to probability distributions ───────────────────────────
    //
    //   probFirst/Second    → count / N           (1 team per sim)
    //   probThird/Fourth    → count / (2 * N)     (2 conf final losers per sim)
    //   probFifth–Eighth    → count / (4 * N)     (4 R2 losers per sim)
    //
    const N = effectiveN;
    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 r2 = r2LoserCounts.get(participantId) ?? 0;
      return {
        participantId,
        probabilities: {
          probFirst:   c  / N,
          probSecond:  f  / N,
          probThird:   cf / (2 * N),
          probFourth:  cf / (2 * N),
          probFifth:   r2 / (4 * N),
          probSixth:   r2 / (4 * N),
          probSeventh: r2 / (4 * N),
          probEighth:  r2 / (4 * N),
        },
        source: "nhl_bracket_monte_carlo",
      };
    });

    // ─── Per-position normalization ────────────────────────────────────────────
    //
    // Belt-and-suspenders guard against floating-point residuals.
    // Columns are already near-exactly 1.0 by construction above.
    //
    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;
  }
}
