/**
 * 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. Load current regular season standings (wins, otLosses, gamesPlayed, conference, division)
 *   3. Match participant names to hardcoded team data (Elo ratings + conference/division fallback)
 *   4. For each simulation:
 *      a. For each team, simulate remaining regular season games (82 - gamesPlayed):
 *         - Win (2 pts) with eloWinProb vs. a league-average opponent (Elo 1500)
 *         - OT/SO loss (1 pt) at NHL_OT_RATE × (1 − winProb)
 *         - Regulation loss (0 pts) otherwise
 *         → projectedPoints = currentPoints + simulated extra points
 *      b. Per division: rank by projected points (random tiebreaker) → top 3 are division seeds
 *      c. Per conference: top 2 non-division-seed teams by projected points → WC1 and WC2
 *         (WC1 has more points than WC2)
 *      d. Build the 8-team bracket per conference:
 *         - Division winner with more points = 1st seed, faces WC2
 *         - Other division winner = 2nd seed, faces WC1
 *         - Each division's 2nd and 3rd seeds face each other
 *      e. Simulate 4 rounds of best-of-7 series:
 *         Round 1 (Wild Card): m0=1stSeed/WC2, m1=2ndSeed/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
 *   5. Track placement counts per scoring tier
 *   6. Convert counts to probability distributions
 *
 * Win probability (Elo, PARITY_FACTOR = 1000):
 *   P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 1000))
 *   NHL uses a higher parity factor than the standard 400 to reflect the high
 *   variance of hockey.
 *
 * 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
 *
 * Elo ratings are hardcoded (2025-26 season data).
 * Source: https://elo.harvitronix.com/nhl/2025-2026
 * Conference and division are read from the standings table (synced from the
 * NHL API); TEAMS_DATA values serve as fallbacks if standings are missing.
 * Update Elo values 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 { logger } from "~/lib/logger";
import {
  convertAmericanOddsToProbability,
  normalizeProbabilities,
} from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";

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

const NUM_SIMULATIONS = 50_000;

/** NHL regular season games per team. */
const NHL_REGULAR_SEASON_GAMES = 82;

/**
 * Fraction of NHL games that go to overtime / shootout.
 * The losing team earns 1 point (instead of 0) in these games.
 * Historical average: ~23% of games.
 */
const NHL_OT_RATE = 0.23;

/**
 * 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) ───────────────────────────────────────────────
//
// elo: Elo rating from elo.harvitronix.com/nhl/2025-2026.
//   Used for both regular-season game simulation (vs. avg opponent Elo 1500)
//   and for all playoff matchup win probabilities.
//
// conference / division: Used as fallbacks when the standings table has no
//   conference or division data for a participant. In normal operation these
//   are read from regularSeasonStandings (synced from the NHL API).
//
// 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;
}

const TEAMS_DATA: Record<string, NhlTeamData> = {
  // ── Eastern Conference — Atlantic ──────────────────────────────────────────
  "Tampa Bay Lightning":  { conference: "Eastern", division: "Atlantic",      elo: 1587 },
  "Buffalo Sabres":       { conference: "Eastern", division: "Atlantic",      elo: 1572 },
  "Montreal Canadiens":   { conference: "Eastern", division: "Atlantic",      elo: 1527 },
  "Ottawa Senators":      { conference: "Eastern", division: "Atlantic",      elo: 1542 },
  "Detroit Red Wings":    { conference: "Eastern", division: "Atlantic",      elo: 1506 },
  "Boston Bruins":        { conference: "Eastern", division: "Atlantic",      elo: 1501 },
  "Florida Panthers":     { conference: "Eastern", division: "Atlantic",      elo: 1505 },
  "Toronto Maple Leafs":  { conference: "Eastern", division: "Atlantic",      elo: 1479 },

  // ── Eastern Conference — Metropolitan ─────────────────────────────────────
  "Carolina Hurricanes":  { conference: "Eastern", division: "Metropolitan",  elo: 1574 },
  "Columbus Blue Jackets":{ conference: "Eastern", division: "Metropolitan",  elo: 1530 },
  "Pittsburgh Penguins":  { conference: "Eastern", division: "Metropolitan",  elo: 1518 },
  "New York Islanders":   { conference: "Eastern", division: "Metropolitan",  elo: 1513 },
  "Philadelphia Flyers":  { conference: "Eastern", division: "Metropolitan",  elo: 1473 },
  "Washington Capitals":  { conference: "Eastern", division: "Metropolitan",  elo: 1506 },
  "New Jersey Devils":    { conference: "Eastern", division: "Metropolitan",  elo: 1488 },
  "New York Rangers":     { conference: "Eastern", division: "Metropolitan",  elo: 1480 },

  // ── Western Conference — Central ───────────────────────────────────────────
  "Colorado Avalanche":   { conference: "Western", division: "Central",       elo: 1594 },
  "Dallas Stars":         { conference: "Western", division: "Central",       elo: 1581 },
  "Minnesota Wild":       { conference: "Western", division: "Central",       elo: 1548 },
  "Utah Mammoth":         { conference: "Western", division: "Central",       elo: 1529 },
  "Nashville Predators":  { conference: "Western", division: "Central",       elo: 1471 },
  "Winnipeg Jets":        { conference: "Western", division: "Central",       elo: 1483 },
  "St. Louis Blues":      { conference: "Western", division: "Central",       elo: 1473 },
  "Chicago Blackhawks":   { conference: "Western", division: "Central",       elo: 1411 },

  // ── Western Conference — Pacific ───────────────────────────────────────────
  "Anaheim Ducks":        { conference: "Western", division: "Pacific",       elo: 1490 },
  "Edmonton Oilers":      { conference: "Western", division: "Pacific",       elo: 1531 },
  "Vegas Golden Knights": { conference: "Western", division: "Pacific",       elo: 1519 },
  "Los Angeles Kings":    { conference: "Western", division: "Pacific",       elo: 1482 },
  "San Jose Sharks":      { conference: "Western", division: "Pacific",       elo: 1455 },
  "Seattle Kraken":       { conference: "Western", division: "Pacific",       elo: 1469 },
  "Calgary Flames":       { conference: "Western", division: "Pacific",       elo: 1436 },
  "Vancouver Canucks":    { conference: "Western", division: "Pacific",       elo: 1403 },
};

// ─── 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;
  conference: "Eastern" | "Western";
  division: string;
  /** Current regular season points: wins × 2 + OT losses × 1. */
  currentPoints: number;
  /** Remaining regular season games = 82 − gamesPlayed. */
  remainingGames: number;
  /** Win probability vs. a league-average opponent (Elo 1500). Pre-computed. */
  winProb: number;
  /** Resolved Elo: sourceElo from DB > hardcoded TEAMS_DATA > fallback 1400. */
  resolvedElo: number;
}

/** Get Elo for a team entry. */
function elo(entry: TeamEntry): number {
  return entry.resolvedElo;
}

/**
 * Simulate remaining regular season games for one team.
 * Each game:
 *   - Win (2 pts) with probability winProb
 *   - OT/SO loss (1 pt) with probability (1 − winProb) × NHL_OT_RATE
 *   - Regulation loss (0 pts) otherwise
 * Returns projected total points for the season.
 */
export function simulateProjectedPoints(entry: TeamEntry): number {
  let extra = 0;
  const { winProb, remainingGames } = entry;
  const otlProb = (1 - winProb) * NHL_OT_RATE;
  for (let g = 0; g < remainingGames; g++) {
    const r = Math.random();
    if (r < winProb) {
      extra += 2;
    } else if (r < winProb + otlProb) {
      extra += 1;
    }
  }
  return entry.currentPoints + extra;
}

// ─── Projected-points helpers (module-level for hot-loop efficiency) ──────────

interface ProjectedTeam {
  team: TeamEntry;
  pts: number;
  tb: number;
}

/** Project one team's end-of-season point total with a random tiebreaker. */
function projectTeam(t: TeamEntry): ProjectedTeam {
  return { team: t, pts: simulateProjectedPoints(t), tb: Math.random() };
}

/** Sort projected teams descending by pts, then by random tiebreaker. Mutates arr. */
function sortProjected(arr: ProjectedTeam[]): ProjectedTeam[] {
  return arr.toSorted((a, b) => b.pts - a.pts || b.tb - a.tb);
}

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

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

    // Check for a populated playoff bracket; if found, use bracket-aware simulation.
    const bracketEvent = await db.query.scoringEvents.findFirst({
      where: and(
        eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
        eq(schema.scoringEvents.eventType, "playoff_game")
      ),
    });

    if (bracketEvent) {
      const allMatches = await db.query.playoffMatches.findMany({
        where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
        orderBy: (m, { asc }) => [asc(m.matchNumber)],
      });
      const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id);
      if (bracketPopulated) {
        return this.simulateBracket(sportsSeasonId, allMatches);
      }
    }

    // 1. Load participants, standings, and sourceElo values in parallel.
    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,
          sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
        })
        .from(schema.seasonParticipantExpectedValues)
        .where(eq(schema.seasonParticipantExpectedValues.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);

    // 2. Build standings lookup, sourceElo map, and construct team entries.
    const standingsMap = new Map(standings.map((s) => [s.participantId, s]));

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

    if (standings.length === 0) {
      logger.warn(
        `[NHLSimulator] No standings data found for sports season ${sportsSeasonId}. ` +
          `Simulation will use 0 current points and 82 remaining games for all teams. ` +
          `Sync standings before running for accurate results.`
      );
    }

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

      // Conference and division: prefer standings table, fall back to TEAMS_DATA.
      const rawConf = standing?.conference;
      const conference: "Eastern" | "Western" =
        rawConf === "Eastern" || rawConf === "Western"
          ? rawConf
          : (data?.conference ?? "Eastern");

      const division: string = standing?.division ?? data?.division ?? "";

      const gamesPlayed = standing?.gamesPlayed ?? 0;
      const wins = standing?.wins ?? 0;
      const otLosses = standing?.otLosses ?? 0;
      const currentPoints = wins * 2 + otLosses;
      const remainingGames = Math.max(0, NHL_REGULAR_SEASON_GAMES - gamesPlayed);
      const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400;

      return {
        id: r.id,
        name: r.name,
        data,
        conference,
        division,
        currentPoints,
        remainingGames,
        winProb: eloWinProbability(resolvedElo, 1500),
        resolvedElo,
      };
    });

    // Warn about participants that don't match any hardcoded team.
    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 use fallback Elo 1400: ` +
          unrecognized.map((t) => t.name).join(", ")
      );
    }

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

    if (
      easternAtlantic.length < 3 ||
      easternMetro.length < 3 ||
      westernCentral.length < 3 ||
      westernPacific.length < 3
    ) {
      throw new Error(
        `Each division needs at least 3 participants ` +
          `(got Eastern/Atlantic: ${easternAtlantic.length}, Eastern/Metro: ${easternMetro.length}, ` +
          `Western/Central: ${westernCentral.length}, Western/Pacific: ${westernPacific.length}). ` +
          `Add all 32 NHL teams before running simulation.`
      );
    }

    // ─── Futures odds blending ─────────────────────────────────────────────────

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

    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.
     */
    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 };
    };

    /**
     * Build the 8-team playoff bracket for one conference.
     *
     * For each sim iteration:
     *   1. Simulate projected points for all teams in both divisions.
     *   2. Top 3 per division (by projected pts + random tiebreaker) → division seeds.
     *   3. Top 2 remaining conference teams → WC1 (more pts) and WC2 (fewer pts).
     *   4. Division winner with more pts = 1st seed (faces WC2);
     *      other division winner = 2nd seed (faces WC1).
     *
     * Returns [m0, m1, m2, m3] where each element is [higher-seed, lower-seed],
     * or undefined if the conference doesn't have enough teams for a valid bracket.
     */
    const buildConferenceBracket = (
      divATeams: TeamEntry[],
      divBTeams: TeamEntry[]
    ): [[TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry]] | undefined => {
      if (divATeams.length < 3 || divBTeams.length < 3) return undefined;

      // Project all conference teams exactly once (single simulateProjectedPoints call per team).
      const divASet = new Set(divATeams.map((t) => t.id));
      const allProjected = sortProjected([...divATeams, ...divBTeams].map(projectTeam));
      const divAProjected = allProjected.filter((p) => divASet.has(p.team.id));
      const divBProjected = allProjected.filter((p) => !divASet.has(p.team.id));

      const divASeeds = divAProjected.slice(0, 3);
      const divBSeeds = divBProjected.slice(0, 3);

      // Wildcard pool: non-division-seed teams, already sorted by projected pts.
      const divisionSeedIds = new Set([...divASeeds, ...divBSeeds].map((p) => p.team.id));
      const wcPool = allProjected.filter((p) => !divisionSeedIds.has(p.team.id));

      if (wcPool.length < 2) return undefined;
      const [wc1, wc2] = wcPool; // wc1 has more pts → stronger wildcard

      // Division winner with more projected points is the top seed (plays WC2).
      const [topDiv, otherDiv] =
        divASeeds[0].pts >= divBSeeds[0].pts
          ? [divASeeds, divBSeeds]
          : [divBSeeds, divASeeds];

      return [
        [topDiv[0].team,   wc2.team],          // m0: 1st seed vs WC2
        [otherDiv[0].team, wc1.team],           // m1: 2nd seed vs WC1
        [topDiv[1].team,   topDiv[2].team],     // m2: top div 2nd vs 3rd
        [otherDiv[1].team, otherDiv[2].team],   // m3: other div 2nd vs 3rd
      ];
    };

    // ─── 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 ───────────────────────────────────────────

    let effectiveN = 0;

    for (let s = 0; s < NUM_SIMULATIONS; s++) {
      const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro);
      const westBracket = buildConferenceBracket(westernCentral, westernPacific);
      if (!eastBracket || !westBracket) continue;
      effectiveN++;

      // ── Eastern Conference ────────────────────────────────────────────────────
      const eastR1Winners = eastBracket.map(([a, b]) => simSeries(a, b).winner);
      const eastR2m1 = simSeries(eastR1Winners[0], eastR1Winners[2]);
      const eastR2m2 = simSeries(eastR1Winners[1], eastR1Winners[3]);
      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);

      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 at least 3 participants with standings data."
      );
    }

    // ─── Convert counts to probability distributions ───────────────────────────

    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 ────────────────────────────────────────────

    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;
  }

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

  private async simulateBracket(
    sportsSeasonId: string,
    allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>
  ): Promise<SimulationResult[]> {
    const db = database();

    // Group matches by round, sorted by match count descending (R1 first → Final last).
    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 sortedRoundMatches = [...byRound.values()]
      .toSorted((a, b) => b.length - a.length)
      .map((matches) => matches.toSorted((a, b) => a.matchNumber - b.matchNumber));

    if (sortedRoundMatches.length < 4) {
      throw new Error(
        `NHL bracket has unexpected structure: expected 4 rounds, found ${sortedRoundMatches.length}. ` +
          `Check the bracket template.`
      );
    }

    const r1Matches    = sortedRoundMatches[0]; // 8 matches (Round of 16 / Wild Card)
    const r2Matches    = sortedRoundMatches[1]; // 4 matches (Quarterfinals / Divisional)
    const r3Matches    = sortedRoundMatches[2]; // 2 matches (Semifinals / Conf Finals)
    const finalMatches = sortedRoundMatches[3]; // 1 match  (Finals / Stanley Cup)

    if (r1Matches.length !== 8) {
      throw new Error(
        `Expected 8 first-round matches for NHL bracket, found ${r1Matches.length}.`
      );
    }

    // Validate all R1 matches have participants before entering the hot loop.
    for (const m of r1Matches) {
      if (!m.participant1Id || !m.participant2Id) {
        throw new Error(
          `Round 1 match ${m.matchNumber} is missing participants. ` +
            `Seed all 16 teams into the bracket before running simulation.`
        );
      }
    }

    // Load all participants and EV data 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,
          sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
          sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
        })
        .from(schema.seasonParticipantExpectedValues)
        .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
    ]);

    const allParticipantIds = 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);
      }
    }

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

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

    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]);
      });
    }

    // Per-round lookup maps for O(1) access.
    const r1ByNum    = new Map(r1Matches.map((m) => [m.matchNumber, m]));
    const r2ByNum    = new Map(r2Matches.map((m) => [m.matchNumber, m]));
    const r3ByNum    = new Map(r3Matches.map((m) => [m.matchNumber, m]));
    const finalMatch = finalMatches[0];

    // ── Helpers ──────────────────────────────────────────────────────────────────

    const gameWinProb = (aId: string, bId: string): number => {
      const eloProb = eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400);
      if (!hasOdds) return eloProb;
      const o1 = normalizedOddsMap.get(aId) ?? 0;
      const o2 = normalizedOddsMap.get(bId) ?? 0;
      const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
      return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
    };

    const simSeries = (aId: string, bId: string): { winner: string; loser: string } => {
      const winProb = gameWinProb(aId, bId);
      let wA = 0;
      let wB = 0;
      while (wA < 4 && wB < 4) {
        if (Math.random() < winProb) wA++; else wB++;
      }
      return wA === 4 ? { winner: aId, loser: bId } : { winner: bId, loser: aId };
    };

    const resolveSeries = (
      match: (typeof allMatches)[0] | undefined,
      p1Fallback?: string,
      p2Fallback?: string
    ): { winner: string; loser: string } => {
      if (match?.isComplete && match.winnerId && match.loserId) {
        return { winner: match.winnerId, loser: match.loserId };
      }
      const p1 = match?.participant1Id ?? p1Fallback;
      const p2 = match?.participant2Id ?? p2Fallback;
      if (!p1 || !p2) {
        throw new Error(
          `Cannot resolve NHL bracket match ${match?.id ?? "(undefined)"}: ` +
            `missing participants (p1=${p1 ?? "null"}, p2=${p2 ?? "null"}).`
        );
      }
      return simSeries(p1, p2);
    };

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

    const championCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
    const finalistCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
    const r3LoserCounts  = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
    const r2LoserCounts  = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));

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

    for (let s = 0; s < NUM_SIMULATIONS; s++) {
      // Round 1: R1 losers score 0 points — not tracked.
      const r1Winners: string[] = [];
      for (let i = 1; i <= 8; i++) {
        const { winner } = resolveSeries(r1ByNum.get(i));
        r1Winners.push(winner);
      }

      // Round 2 (Quarterfinals / Divisional): losers → 5th–8th.
      const r2Winners: string[] = [];
      for (let i = 1; i <= 4; i++) {
        const { winner, loser } = resolveSeries(
          r2ByNum.get(i),
          r1Winners[(i - 1) * 2],
          r1Winners[(i - 1) * 2 + 1]
        );
        r2Winners.push(winner);
        r2LoserCounts.set(loser, (r2LoserCounts.get(loser) ?? 0) + 1);
      }

      // Round 3 (Semifinals / Conference Finals): losers → 3rd–4th.
      const r3Winners: string[] = [];
      for (let i = 1; i <= 2; i++) {
        const { winner, loser } = resolveSeries(
          r3ByNum.get(i),
          r2Winners[(i - 1) * 2],
          r2Winners[(i - 1) * 2 + 1]
        );
        r3Winners.push(winner);
        r3LoserCounts.set(loser, (r3LoserCounts.get(loser) ?? 0) + 1);
      }

      // Final (Stanley Cup): winner → 1st, loser → 2nd.
      const { winner, loser } = resolveSeries(finalMatch, r3Winners[0], r3Winners[1]);
      championCounts.set(winner, (championCounts.get(winner) ?? 0) + 1);
      finalistCounts.set(loser,  (finalistCounts.get(loser)  ?? 0) + 1);
    }

    // ── Convert counts to probability distributions ───────────────────────────────

    const N = NUM_SIMULATIONS;
    const results: SimulationResult[] = allParticipantIds.map((participantId) => {
      const c  = championCounts.get(participantId) ?? 0;
      const f  = finalistCounts.get(participantId) ?? 0;
      const r3 = r3LoserCounts.get(participantId)  ?? 0;
      const r2 = r2LoserCounts.get(participantId)  ?? 0;
      return {
        participantId,
        probabilities: {
          probFirst:   c  / N,
          probSecond:  f  / N,
          probThird:   r3 / (2 * N),
          probFourth:  r3 / (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.
    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;
  }
}
