/**
 * NCAAM Tournament Bracket Simulator
 *
 * Monte Carlo simulation of the NCAA Men's Basketball Tournament (64-team bracket).
 * Win probability is derived from KenPom Adjusted Efficiency Margin (AEM).
 *
 * Algorithm:
 *   1. Load the bracket scoring event and all playoff matches from DB
 *      (6 rounds: R64, R32, S16, E8, FF, Final — 63 total matches)
 *   2. Load participant names from DB; look up KenPom net rating by name
 *      from the hardcoded KENPOM_NET_RATINGS map (updated each season)
 *   3. Per-match win probability = 1 / (1 + exp(-(netrtgA - netrtgB) / 7.5))
 *      (neutral-court logistic formula, calibrated to KenPom AEM scale)
 *   4. Simulate 50,000 tournaments, honoring completed match results
 *   5. Track placements only for point-scoring rounds:
 *        - Champion (1st)
 *        - Finalist (2nd)
 *        - Final Four losers (3rd/4th) — 2 per sim
 *        - Elite Eight losers (5th–8th) — 4 per sim
 *      R64 / R32 / S16 exits score 0 points → not tracked
 *
 * Probability output (8 slots — same SimulationProbabilities type as UCL):
 *   probFirst         = champion / N
 *   probSecond        = finalist / N
 *   probThird/Fourth  = ffLoser  / (2 * N)   — 2 FF losers per sim
 *   probFifth–Eighth  = e8Loser  / (4 * N)   — 4 E8 losers per sim
 *   All pre-E8 exits                          → 0 (no points scored)
 *
 * Column sums are guaranteed to equal 1.0 by construction:
 *   probFirst/Second   — 1 per sim, N total → sums to 1
 *   probThird/Fourth   — 2 per sim, each column = total/2N → sum = 1
 *   probFifth–Eighth   — 4 per sim, each column = total/4N → sum = 1
 *
 * KenPom net ratings are hardcoded in KENPOM_NET_RATINGS below (2025-26 season).
 * Update this map each season. Participant names must match DB records
 * (lookup is case-insensitive, whitespace-normalized).
 * Unknown teams fall back to netrtg = 0.0 (near-average strength).
 *
 * Bracket advancement path (same as UCL):
 *   nextMatchNumber = Math.ceil(matchNumber / 2)
 *   i.e. R64 matches 1+2 → R32 match 1, R64 matches 3+4 → R32 match 2, …
 */

import { database } from "~/database/context";
import { eq, and, inArray } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { BracketRegion } from "~/lib/bracket-templates";
import { buildNCAA68SlotMap, matchIndexForSeedSlot, NCAA_68 } from "~/lib/bracket-templates";
import type { Simulator, SimulationResult } from "./types";
import { logger } from "~/lib/logger";

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

const NUM_SIMULATIONS = 50_000;

/**
 * KenPom scale factor for the neutral-court logistic win probability formula.
 * A difference of 7.5 AEM points crosses the logit 50% mark.
 * Source: KenPom documentation; widely used in academic NCAAM models.
 */
const KENPOM_SCALE_FACTOR = 7.5;

// ─── KenPom net rating data (2025-26 season) ─────────────────────────────────
//
// Update this map at the start of each tournament season with current
// KenPom Adjusted Efficiency Margin values.
// Source: kenpom.com, data through March 15, 2026 (6,195 games).
//
// Keys: lowercase, whitespace-normalized team names (normalizeTeamName output).
// Multiple aliases are included for common abbreviation variants.
// If a participant name is not found, it falls back to 0.0 (average strength).

const KENPOM_NET_RATINGS: Record<string, number> = {
  // ── 1–10 ────────────────────────────────────────────────────────────────────
  "duke": 38.90,
  "arizona": 37.66,
  "michigan": 37.59,
  "florida": 33.79,
  "houston": 33.43,
  "iowa st.": 32.42,    "iowa state": 32.42,
  "illinois": 32.10,
  "purdue": 31.20,
  "michigan st.": 28.31, "michigan state": 28.31,
  "gonzaga": 28.10,

  // ── 11–30 ───────────────────────────────────────────────────────────────────
  "connecticut": 27.87, "uconn": 27.87,
  "vanderbilt": 27.51,
  "virginia": 26.71,
  "nebraska": 26.16,
  "arkansas": 26.05,
  "tennessee": 26.02,
  "st. john's": 25.91,  "st johns": 25.91,
  "alabama": 25.72,
  "louisville": 25.42,
  "texas tech": 25.22,
  "kansas": 24.44,
  "wisconsin": 23.39,
  "byu": 23.25,
  "saint mary's": 23.07,
  "iowa": 22.44,
  "ohio st.": 22.24,    "ohio state": 22.24,
  "ucla": 21.67,
  "kentucky": 21.48,
  "north carolina": 20.84,

  // ── 30–50 ───────────────────────────────────────────────────────────────────
  "utah st.": 20.76,    "utah state": 20.76,
  "miami fl": 20.68,    "miami (fl)": 20.68,    "miami": 20.68,
  "georgia": 20.48,
  "villanova": 19.97,
  "nc state": 19.60,    "n.c. state": 19.60,
  "santa clara": 19.40,
  "clemson": 19.24,
  "texas": 19.03,
  "auburn": 19.02,
  "texas a&m": 18.67,
  "oklahoma": 18.37,
  "saint louis": 18.32,
  "smu": 18.09,
  "tcu": 17.59,
  "cincinnati": 17.49,
  "vcu": 17.21,
  "indiana": 17.18,
  "south florida": 16.39, "usf": 16.39,
  "san diego st.": 16.39, "san diego state": 16.39,
  "baylor": 16.00,

  // ── 50–80 ───────────────────────────────────────────────────────────────────
  "new mexico": 15.81,
  "seton hall": 15.71,
  "missouri": 15.39,
  "washington": 15.12,
  "ucf": 15.04,
  "virginia tech": 13.69,
  "florida st.": 13.48, "florida state": 13.48,
  "northwestern": 13.41,
  "stanford": 13.37,
  "west virginia": 13.27,
  "lsu": 13.23,
  "grand canyon": 13.19,
  "boise st.": 13.18,   "boise state": 13.18,
  "tulsa": 12.97,
  "akron": 12.80,
  "ole miss": 12.62,    "mississippi": 12.62,
  "oklahoma st.": 12.58, "oklahoma state": 12.58,
  "arizona st.": 12.52, "arizona state": 12.52,
  "mcneese": 12.48,     "mcneese st.": 12.48,   "mcneese state": 12.48,
  "belmont": 12.26,
  "colorado": 12.11,
  "providence": 11.81,
  "northern iowa": 11.81,
  "california": 11.43,  "cal": 11.43,
  "wake forest": 11.39,
  "nevada": 11.30,
  "creighton": 11.01,
  "minnesota": 10.91,
  "dayton": 10.91,
  "georgetown": 10.89,
  "usc": 10.81,

  // ── 81–120 ──────────────────────────────────────────────────────────────────
  "yale": 10.65,
  "wichita st.": 9.78,  "wichita state": 9.78,
  "syracuse": 9.74,
  "marquette": 9.66,
  "george washington": 9.64, "gwu": 9.64,
  "butler": 9.49,
  "hofstra": 9.49,
  "colorado st.": 9.49, "colorado state": 9.49,
  "notre dame": 8.88,
  "utah valley": 8.82,
  "stephen f. austin": 8.43, "sf austin": 8.43,
  "high point": 8.40,
  "miami oh": 8.26,     "miami (oh)": 8.26,
  "pittsburgh": 7.60,   "pitt": 7.60,
  "south carolina": 7.54,
  "george mason": 7.50,
  "xavier": 7.47,
  "wyoming": 7.40,
  "oregon": 7.03,
  "mississippi st.": 7.00, "mississippi state": 7.00,
  "kansas st.": 6.98,   "kansas state": 6.98,
  "depaul": 6.83,
  "illinois st.": 6.66, "illinois state": 6.66,
  "uc irvine": 6.21,
  "illinois chicago": 6.16, "uic": 6.16,
  "cal baptist": 5.99,
  "unlv": 5.97,
  "hawaii": 5.97,       "hawai'i": 5.97,
  "st. thomas": 5.88,
  "unc wilmington": 5.79, "uncw": 5.79,           "nc wilmington": 5.79,
  "sam houston": 5.56,  "sam houston st.": 5.56,  "sam houston state": 5.56,
  "pacific": 5.42,
  "north dakota st.": 5.13, "north dakota state": 5.13,
  "davidson": 4.94,
  "saint joseph's": 4.56,
  "southern illinois": 4.55,
  "uc san diego": 4.53, "ucsd": 4.53,
  "seattle": 4.49,
  "maryland": 4.25,
  "san francisco": 4.20,
  "murray st.": 4.12,   "murray state": 4.12,
  "bradley": 3.96,
  "rutgers": 3.95,
  "liberty": 3.90,
  "utah": 3.65,
  "uab": 3.46,
  "duquesne": 3.45,
  "florida atlantic": 3.31,
  "uc santa barbara": 3.17, "ucsb": 3.17,
  "toledo": 3.15,
  "fresno st.": 3.09,   "fresno state": 3.09,
  "montana st.": 3.00,  "montana state": 3.00,

  // ── 134–170 (tournament bubble / auto-bid range) ─────────────────────────
  "memphis": 2.52,
  "rhode island": 2.47,
  "north texas": 2.46,
  "washington st.": 2.32, "washington state": 2.32,
  "penn st.": 2.18,     "penn state": 2.18,
  "st. bonaventure": 2.17,
  "wright st.": 2.04,   "wright state": 2.04,
  "northern colorado": 1.87,
  "navy": 1.84,
  "troy": 1.72,
  "robert morris": 1.69,
  "idaho": 1.53,
  "portland st.": 1.52, "portland state": 1.52,
  "bowling green": 1.51,
  "kent st.": 1.50,     "kent state": 1.50,
  "william & mary": 1.49,
  "penn": 1.47,
  "arkansas st.": 1.39, "arkansas state": 1.39,
  "harvard": 1.26,
  "central arkansas": 1.25, "c arkansas": 1.25,
  "winthrop": 1.13,
  "western kentucky": 0.02, "western ky.": 0.02,
  "kennesaw st.": 0.57, "kennesaw state": 0.57,
  "cornell": 0.51,
  "east tennessee st.": 0.44, "etsu": 0.44,
  "eastern washington": 0.00, "east washington": 0.00,
  "charleston": -0.19,
  "austin peay": -0.28,
  "merrimack": -0.83,

  // ── 170–220 (low seeds / play-in range) ─────────────────────────────────
  "montana": -1.72,
  "umbc": -1.67,
  "tennessee st.": -1.83, "tennessee state": -1.83,
  "furman": -1.98,
  "siena": -2.10,
  "appalachian state": -2.33, "app state": -2.33,
  "south alabama": -3.14,
  "howard": -3.19,
  "marshall": -3.19,
  "samford": -3.93,
  "liu": -3.95,
  "lipscomb": -2.84,
  "queens": -1.44,
  "quinnipiac": -4.25,
  "south dakota st.": -4.31, "south dakota state": -4.31,
  "se missouri st.": -5.84, "southeast missouri state": -5.84,
  "tennessee martin": -4.81, "ut martin": -4.81,
  "vermont": -6.45,
  "bethune": -6.60,      "bethune-cookman": -6.60,
  "colgate": -7.02,
  "saint peter's": -7.51, "st. peter's": -7.51, "st peters": -7.51,
  "mercer": -1.97,
  "morehead st.": -10.35, "morehead state": -10.35,
  "lehigh": -10.37,
  "prairie view a&m": -10.69, "prairie view": -10.69,
  "grambling": -11.78,   "grambling st.": -11.78,
  "central connecticut": -12.71, "c connecticut": -12.71,
  "wagner": -12.68,
  "norfolk st.": -15.38, "norfolk state": -15.38,
};

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

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

/** Look up KenPom AEM for a participant name. Returns 0.0 if not found. */
export function getNetRating(name: string): number {
  return KENPOM_NET_RATINGS[normalizeTeamName(name)] ?? 0.0;
}

/**
 * NCAAM neutral-court win probability using KenPom Adjusted Efficiency Margin.
 *   P(A beats B) = 1 / (1 + exp(-(netrtgA - netrtgB) / KENPOM_SCALE_FACTOR))
 * Exported for unit testing.
 */
export function kenpomWinProbability(netrtgA: number, netrtgB: number): number {
  return 1 / (1 + Math.exp(-(netrtgA - netrtgB) / KENPOM_SCALE_FACTOR));
}

function sortByMatchNumber<T extends { matchNumber: number }>(matches: T[]): T[] {
  return [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber);
}

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

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

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

    if (!bracketEvent) {
      throw new Error(
        `No bracket event found for sports season ${sportsSeasonId}. ` +
          `Create a playoff_game scoring event and set up the 64-team bracket first.`
      );
    }

    // 2. Load all playoff matches for this bracket event.
    const allMatches = await db.query.playoffMatches.findMany({
      where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
      orderBy: (m, { asc }) => [asc(m.matchNumber)],
    });

    if (allMatches.length === 0) {
      throw new Error(
        `No playoff matches found for the bracket event. ` +
          `Generate the 64-team bracket from the admin panel first.`
      );
    }

    // 3. Group matches by round name.
    //    Rounds: "First Four" (optional, 4) | "Round of 64" (32) | "Round of 32" (16)
    //          | "Sweet Sixteen" (8) | "Elite Eight" (4) | "Final Four" (2) | "Championship" (1)
    //
    //    We look up by name rather than sorting by count to avoid the ambiguity between
    //    "First Four" and "Elite Eight" (both 4 matches).
    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 rawFirstFour  = byRound.get("First Four");
    const rawR64        = byRound.get("Round of 64");
    const rawR32        = byRound.get("Round of 32");
    const rawS16        = byRound.get("Sweet Sixteen");
    const rawE8         = byRound.get("Elite Eight");
    const rawFF         = byRound.get("Final Four");
    const rawChamp      = byRound.get("Championship");

    const firstFourMatches = rawFirstFour ? sortByMatchNumber(rawFirstFour) : [];
    const r64Matches  = rawR64  ? sortByMatchNumber(rawR64)  : null;
    const r32Matches  = rawR32  ? sortByMatchNumber(rawR32)  : null;
    const s16Matches  = rawS16  ? sortByMatchNumber(rawS16)  : null;
    const e8Matches   = rawE8   ? sortByMatchNumber(rawE8)   : null;
    const ffMatches   = rawFF   ? sortByMatchNumber(rawFF)   : null;
    const champMatches = rawChamp ? sortByMatchNumber(rawChamp) : null;

    if (!r64Matches || !r32Matches || !s16Matches || !e8Matches || !ffMatches || !champMatches) {
      const found = [...byRound.keys()].join(", ");
      throw new Error(
        `Missing expected rounds. Found: [${found}]. ` +
          `Required: Round of 64, Round of 32, Sweet Sixteen, Elite Eight, Final Four, Championship.`
      );
    }

    if (r64Matches.length !== 32) {
      throw new Error(
        `Expected 32 Round of 64 matches, found ${r64Matches.length}. ` +
          `This simulator only supports the standard 64-team NCAAM format.`
      );
    }
    if (r32Matches.length !== 16) {
      throw new Error(`Expected 16 Round of 32 matches, found ${r32Matches.length}.`);
    }
    if (s16Matches.length !== 8) {
      throw new Error(`Expected 8 Sweet Sixteen matches, found ${s16Matches.length}.`);
    }
    if (e8Matches.length !== 4) {
      throw new Error(`Expected 4 Elite Eight matches, found ${e8Matches.length}.`);
    }
    if (ffMatches.length !== 2) {
      throw new Error(`Expected 2 Final Four matches, found ${ffMatches.length}.`);
    }
    if (champMatches.length !== 1) {
      throw new Error(`Expected 1 Championship match, found ${champMatches.length}.`);
    }

    // 4. Build the First Four → Round of 64 slot mapping (if First Four games exist).
    //
    //    First Four match N feeds into R64 match:
    //      regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1
    //    This mirrors the advanceFirstFourWinner() logic in playoff-match.ts.
    //
    //    The region config is stored on the scoring event (bracketRegionConfig) so
    //    per-event overrides are respected. Falls back to the ncaa_68 template default.
    //
    //    ffToR64: Map<firstFourMatchNumber, r64MatchNumber>
    //    r64NullSlots: Set of R64 match numbers whose participant2Id is null (pending FF)
    const ffToR64 = new Map<number, number>();
    const r64NullSlots = new Set<number>(
      r64Matches.filter((m) => !m.participant2Id).map((m) => m.matchNumber)
    );

    if (firstFourMatches.length > 0) {
      const regions =
        (bracketEvent.bracketRegionConfig as BracketRegion[] | null) ??
        // Fall back to the template's built-in regions — single source of truth
        NCAA_68.regions ?? [];

      const slotMap = buildNCAA68SlotMap(regions);
      for (let i = 0; i < slotMap.playInOffsets.length; i++) {
        const { regionIndex, seedSlot } = slotMap.playInOffsets[i];
        const r64MatchNumber = regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1;
        ffToR64.set(i + 1, r64MatchNumber); // First Four matchNumbers are 1-based
      }

      // Validate First Four matches have participants before simulation
      for (const m of firstFourMatches) {
        if (!m.participant1Id || !m.participant2Id) {
          throw new Error(
            `First Four match ${m.matchNumber} is missing participants. ` +
              `Assign both teams before running simulation.`
          );
        }
      }

      // Validate First Four → R64 mapping covers all null R64 slots.
      // Note: FF-mapped slots may already be filled if the First Four game was completed
      // and the winner was advanced — that's expected and handled in the sim loop.
      const r64SlotsCoveredByFF = new Set(ffToR64.values());
      for (const nullSlot of r64NullSlots) {
        if (!r64SlotsCoveredByFF.has(nullSlot)) {
          throw new Error(
            `Round of 64 match ${nullSlot} has no participant2 but is not covered ` +
              `by any First Four mapping. Check the bracket configuration.`
          );
        }
      }
      for (const [ffMatchNum, ffR64Slot] of ffToR64) {
        if (!r64NullSlots.has(ffR64Slot)) {
          // Slot is already filled — only valid if the FF game is complete
          const ffMatch = firstFourMatches.find((m) => m.matchNumber === ffMatchNum);
          if (!ffMatch?.isComplete) {
            throw new Error(
              `First Four maps to Round of 64 match ${ffR64Slot}, but that slot is ` +
                `already filled. Check the bracket configuration.`
            );
          }
        }
      }
      // In the First Four path, participant1Id must always be set; participant2Id
      // may be null only for FF-pending slots (already validated above).
      for (const m of r64Matches) {
        if (!m.participant1Id) {
          throw new Error(
            `Round of 64 match ${m.matchNumber} is missing participant1. ` +
              `Check the bracket configuration.`
          );
        }
      }
    } else {
      // No First Four — all R64 slots must be directly filled
      for (const m of r64Matches) {
        if (!m.participant1Id || !m.participant2Id) {
          throw new Error(
            `Round of 64 match ${m.matchNumber} is missing participants. ` +
              `Assign all 64 teams to the bracket before running simulation.`
          );
        }
      }
    }

    // 5. Collect all participant IDs (up to 68 when First Four is present).
    //    R64 direct slots (60 or 64) + First Four teams (8) — First Four losers get
    //    all-zero probabilities since they don't enter the scored rounds.
    const participantIdSet = new Set<string>();
    for (const m of r64Matches) {
      if (m.participant1Id) participantIdSet.add(m.participant1Id);
      if (m.participant2Id) participantIdSet.add(m.participant2Id);
    }
    for (const m of firstFourMatches) {
      if (m.participant1Id) participantIdSet.add(m.participant1Id);
      if (m.participant2Id) participantIdSet.add(m.participant2Id);
    }
    const participantIds = [...participantIdSet];

    // 6. Load participant names from DB; build net rating lookup by ID.
    const participantRows = await db
      .select({ id: schema.participants.id, name: schema.participants.name })
      .from(schema.participants)
      .where(inArray(schema.participants.id, participantIds));

    if (participantRows.length < participantIds.length) {
      const foundIds = new Set(participantRows.map((r) => r.id));
      const missing = participantIds.filter((id) => !foundIds.has(id));
      logger.warn(
        `[NCAAMSimulator] ${missing.length} participant ID(s) not found in DB: ${missing.join(", ")}. ` +
          `They will be treated as average strength (KenPom 0.0).`
      );
    }

    const netRatingById = new Map<string, number>();
    for (const { id, name } of participantRows) {
      netRatingById.set(id, getNetRating(name));
    }

    // 7. Build per-round O(1) lookup maps keyed by matchNumber.
    const firstFourByNum = new Map(firstFourMatches.map((m) => [m.matchNumber, m]));
    const r64ByNum = new Map(r64Matches.map((m) => [m.matchNumber, m]));
    const r32ByNum = new Map(r32Matches.map((m) => [m.matchNumber, m]));
    const s16ByNum = new Map(s16Matches.map((m) => [m.matchNumber, m]));
    const e8ByNum  = new Map(e8Matches.map((m)  => [m.matchNumber, m]));
    const ffByNum  = new Map(ffMatches.map((m)   => [m.matchNumber, m]));
    const champMatch = champMatches[0];

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

    const simMatch = (p1: string, p2: string): { winner: string; loser: string } => {
      const r1 = netRatingById.get(p1) ?? 0;
      const r2 = netRatingById.get(p2) ?? 0;
      const w = Math.random() < kenpomWinProbability(r1, r2) ? p1 : p2;
      return { winner: w, loser: w === p1 ? p2 : p1 };
    };

    // 8. Integer placement count maps — initialized to 0 for all participants.
    const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
    const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
    const ffLoserCounts  = new Map<string, number>(participantIds.map((id) => [id, 0]));
    const e8LoserCounts  = new Map<string, number>(participantIds.map((id) => [id, 0]));

    // 9. Monte Carlo simulation loop.
    for (let s = 0; s < NUM_SIMULATIONS; s++) {
      // ── First Four (4 play-in games → 4 winners placed into R64 slots) ───
      // ffSimWinners: Map<r64MatchNumber, winnerId> for the null participant2Id slots
      const ffSimWinners = new Map<number, string>();
      for (const [ffMatchNum, r64MatchNum] of ffToR64) {
        const m = firstFourByNum.get(ffMatchNum);
        if (!m) continue;
        const winner = m.isComplete && m.winnerId
          ? m.winnerId
          : simMatch(m.participant1Id ?? "", m.participant2Id ?? "").winner;
        ffSimWinners.set(r64MatchNum, winner);
      }

      // ── Round of 64 (32 matches → 32 winners) ────────────────────────────
      const r64Winners: string[] = [];
      for (let i = 1; i <= 32; i++) {
        const m = r64ByNum.get(i);
        if (!m) continue;
        // participant2Id may be null if this slot is filled by a First Four winner
        const p2 = m.participant2Id ?? ffSimWinners.get(i) ?? null;
        if (m.isComplete && m.winnerId) {
          r64Winners.push(m.winnerId);
        } else {
          r64Winners.push(simMatch(m.participant1Id ?? "", p2 ?? "").winner);
        }
      }

      // ── Round of 32 (16 matches → 16 winners) ────────────────────────────
      // Match i uses r64Winners[(i-1)*2] and [(i-1)*2+1]
      const r32Winners: string[] = [];
      for (let i = 1; i <= 16; i++) {
        const dbMatch = r32ByNum.get(i);
        if (dbMatch?.isComplete && dbMatch.winnerId) {
          r32Winners.push(dbMatch.winnerId);
        } else {
          const p1 = r64Winners[(i - 1) * 2];
          const p2 = r64Winners[(i - 1) * 2 + 1];
          r32Winners.push(simMatch(p1, p2).winner);
        }
      }

      // ── Sweet 16 (8 matches → 8 winners) ─────────────────────────────────
      const s16Winners: string[] = [];
      for (let i = 1; i <= 8; i++) {
        const dbMatch = s16ByNum.get(i);
        if (dbMatch?.isComplete && dbMatch.winnerId) {
          s16Winners.push(dbMatch.winnerId);
        } else {
          const p1 = r32Winners[(i - 1) * 2];
          const p2 = r32Winners[(i - 1) * 2 + 1];
          s16Winners.push(simMatch(p1, p2).winner);
        }
      }

      // ── Elite Eight (4 matches → 4 winners + 4 tracked losers) ───────────
      const e8Winners: string[] = [];
      for (let i = 1; i <= 4; i++) {
        const dbMatch = e8ByNum.get(i);
        let winner: string;
        let loser: string;
        if (dbMatch?.isComplete && dbMatch.winnerId) {
          winner = dbMatch.winnerId;
          // Derive loser from stored participants if loserId is missing
          loser = dbMatch.loserId ??
            (dbMatch.participant1Id === dbMatch.winnerId
              ? dbMatch.participant2Id ?? ""
              : dbMatch.participant1Id ?? "");
        } else {
          const p1 = s16Winners[(i - 1) * 2];
          const p2 = s16Winners[(i - 1) * 2 + 1];
          ({ winner, loser } = simMatch(p1, p2));
        }
        e8Winners.push(winner);
        e8LoserCounts.set(loser, (e8LoserCounts.get(loser) ?? 0) + 1);
      }

      // ── Final Four (2 matches → 2 winners + 2 tracked losers) ────────────
      const ffWinners: string[] = [];
      for (let i = 1; i <= 2; i++) {
        const dbMatch = ffByNum.get(i);
        let winner: string;
        let loser: string;
        if (dbMatch?.isComplete && dbMatch.winnerId) {
          winner = dbMatch.winnerId;
          loser = dbMatch.loserId ??
            (dbMatch.participant1Id === dbMatch.winnerId
              ? dbMatch.participant2Id ?? ""
              : dbMatch.participant1Id ?? "");
        } else {
          const p1 = e8Winners[(i - 1) * 2];
          const p2 = e8Winners[(i - 1) * 2 + 1];
          ({ winner, loser } = simMatch(p1, p2));
        }
        ffWinners.push(winner);
        ffLoserCounts.set(loser, (ffLoserCounts.get(loser) ?? 0) + 1);
      }

      // ── Championship ──────────────────────────────────────────────────────
      let champion: string;
      let finalist: string;
      if (champMatch?.isComplete && champMatch.winnerId) {
        champion = champMatch.winnerId;
        finalist = champMatch.loserId ??
          (champMatch.participant1Id === champMatch.winnerId
            ? champMatch.participant2Id ?? ""
            : champMatch.participant1Id ?? "");
      } else {
        ({ winner: champion, loser: finalist } = simMatch(ffWinners[0], ffWinners[1]));
      }
      championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1);
      finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
    }

    // 10. Convert integer counts to probability distributions.
    //    Exact denominators guarantee column sums of 1.0 by construction:
    //      probFirst/Second  → N total per sim           → sum = 1
    //      probThird/Fourth  → ffLoserCounts / (2*N)     → 2 losers * 1/(2N) = 1
    //      probFifth–Eighth  → e8LoserCounts / (4*N)     → 4 losers * 1/(4N) = 1
    const N = NUM_SIMULATIONS;
    const results: SimulationResult[] = participantIds.map((participantId) => {
      const c  = championCounts.get(participantId) ?? 0;
      const f  = finalistCounts.get(participantId) ?? 0;
      const ff = ffLoserCounts.get(participantId) ?? 0;
      const e8 = e8LoserCounts.get(participantId) ?? 0;
      return {
        participantId,
        probabilities: {
          probFirst:   c  / N,
          probSecond:  f  / N,
          probThird:   ff / (2 * N),
          probFourth:  ff / (2 * N),
          probFifth:   e8 / (4 * N),
          probSixth:   e8 / (4 * N),
          probSeventh: e8 / (4 * N),
          probEighth:  e8 / (4 * N),
        },
        source: "ncaam_bracket_kenpom",
      };
    });

    // 10. Per-position normalization — belt-and-suspenders guard against
    //     floating-point residuals. Column sums are already near-exactly 1.0
    //     after step 9, but this guarantees the invariant before persisting.
    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;
  }
}
