/**
 * NCAAW Tournament Bracket Simulator
 *
 * Monte Carlo simulation of the NCAA Women's Basketball Tournament (64-team bracket).
 * Win probability is derived from Barttorvik Barthag ratings using the Log5 formula.
 *
 * 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 Barthag rating by name
 *      from the hardcoded BARTHAG_RATINGS map (updated each season)
 *   3. Per-match win probability = Log5: A*(1-B) / (A*(1-B) + B*(1-A))
 *      (Barthag is on a 0–1 scale: probability of beating an average D-I opponent)
 *   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 NCAAM/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 (same invariant as NCAAM).
 *
 * Barthag ratings are hardcoded in BARTHAG_RATINGS below (2025-26 season).
 * Source: barttorvik.com/ncaaw — update each season before running simulations.
 * Keys are lowercase, whitespace-normalized team names.
 * Unknown teams fall back to BARTHAG_FALLBACK = 0.5 (average D-I strength).
 *
 * Win probability formula — Log5 (Bill James):
 *   P(A beats B) = A*(1−B) / (A*(1−B) + B*(1−A))
 *   Property: barthagWinProbability(x, 0.5) === x  (definition of Barthag)
 *   Property: barthagWinProbability(A, B) + barthagWinProbability(B, A) === 1
 */

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;

/**
 * Fallback Barthag for unknown team names.
 * 0.5 = exactly average D-I opponent (unlike KenPom AEM where 0 = average).
 */
const BARTHAG_FALLBACK = 0.5;

// ─── Barttorvik Barthag ratings (2025-26 NCAAW season) ────────────────────────
//
// Source: barttorvik.com/ncaaw, data through March 15, 2026.
// Update this map at the start of each tournament season.
//
// Barthag is on a 0–1 scale: the probability of beating an average D-I team.
// Strong teams are near 1.0; weak teams near 0.0; average is 0.5.
//
// Keys: lowercase, whitespace-normalized (same as normalizeTeamName output).
// Multiple aliases for common abbreviation variants.

const BARTHAG_RATINGS: Record<string, number> = {
  // ── 1–10 ────────────────────────────────────────────────────────────────────
  "connecticut": 0.9996,  "uconn": 0.9996, "connecticut (uconn)": 0.9996,
  "ucla": 0.9991,
  "texas": 0.9988,
  "south carolina": 0.9986,
  "lsu": 0.9977,
  "michigan": 0.9952,
  "duke": 0.9936,
  "minnesota": 0.9914,
  "iowa": 0.9907,
  "vanderbilt": 0.9906,

  // ── 11–30 ───────────────────────────────────────────────────────────────────
  "louisville": 0.9904,
  "maryland": 0.9893,
  "ohio st.": 0.9892,     "ohio state": 0.9892,
  "oklahoma": 0.9886,
  "tcu": 0.9885,
  "west virginia": 0.9882,
  "kentucky": 0.9880,
  "north carolina": 0.9864,
  "washington": 0.9834,
  "usc": 0.9824,
  "mississippi": 0.9821,  "ole miss": 0.9821,
  "michigan st.": 0.9817, "michigan state": 0.9817,
  "notre dame": 0.9803,
  "tennessee": 0.9799,
  "n.c. state": 0.9766,   "nc state": 0.9766,
  "nebraska": 0.9751,
  "villanova": 0.9745,
  "oregon": 0.9741,
  "alabama": 0.9720,
  "texas tech": 0.9717,

  // ── 31–50 ───────────────────────────────────────────────────────────────────
  "illinois": 0.9710,
  "georgia": 0.9710,
  "oklahoma st.": 0.9672, "oklahoma state": 0.9672,
  "iowa st.": 0.9666,     "iowa state": 0.9666,
  "baylor": 0.9639,
  "colorado": 0.9615,
  "virginia tech": 0.9589,
  "florida": 0.9519,
  "syracuse": 0.9502,
  "virginia": 0.9437,
  "stanford": 0.9407,
  "mississippi st.": 0.9407, "mississippi state": 0.9407,
  "james madison": 0.9374,
  "richmond": 0.9341,
  "arizona st.": 0.9331,  "arizona state": 0.9331,
  "indiana": 0.9309,
  "california": 0.9268,   "cal": 0.9268,
  "kansas": 0.9259,
  "clemson": 0.9219,
  "princeton": 0.9202,

  // ── 51–70 ───────────────────────────────────────────────────────────────────
  "texas a&m": 0.9201,
  "south dakota st.": 0.9187, "south dakota state": 0.9187,
  "kansas st.": 0.9122,   "kansas state": 0.9122,
  "utah": 0.9076,
  "byu": 0.8991,
  "seton hall": 0.8978,
  "marquette": 0.8922,
  "rhode island": 0.8912,
  "fairfield": 0.8902,
  "georgia tech": 0.8887,
  "columbia": 0.8884,
  "gonzaga": 0.8849,
  "george mason": 0.8834,
  "north dakota st.": 0.8796, "north dakota state": 0.8796,
  "miami fl": 0.8772,     "miami (fl)": 0.8772,   "miami": 0.8772,
  "montana st.": 0.8643,  "montana state": 0.8643,
  "harvard": 0.8604,
  "creighton": 0.8594,
  "wisconsin": 0.8585,
  "colorado st.": 0.8563, "colorado state": 0.8563,

  // ── 71–100 ──────────────────────────────────────────────────────────────────
  "penn st.": 0.8471,     "penn state": 0.8471,
  "miami oh": 0.8420,     "miami (oh)": 0.8420,
  "san diego st.": 0.8362, "san diego state": 0.8362,
  "purdue": 0.8357,
  "south florida": 0.8343, "usf": 0.8343,
  "georgetown": 0.8325,
  "missouri": 0.8318,
  "georgia southern": 0.8275,
  "oregon st.": 0.8200,   "oregon state": 0.8200,
  "unlv": 0.8117,
  "rice": 0.8109,
  "st. john's": 0.8095,   "st johns": 0.8095,
  "davidson": 0.8093,
  "ball st.": 0.8071,     "ball state": 0.8071,
  "auburn": 0.7978,
  "louisiana tech": 0.7977,
  "troy": 0.7932,
  "green bay": 0.7872,    "wi-green bay": 0.7872,
  "idaho": 0.7859,
  "santa clara": 0.7824,

  // ── 101–130 ─────────────────────────────────────────────────────────────────
  "uc irvine": 0.7788,
  "mcneese st.": 0.7716,  "mcneese state": 0.7716,  "mcneese": 0.7716,
  "cincinnati": 0.7677,
  "vermont": 0.7658,
  "quinnipiac": 0.7633,
  "saint joseph's": 0.7627,
  "loyola marymount": 0.7600, "lmu": 0.7600,
  "north texas": 0.7509,
  "florida st.": 0.7498,  "florida state": 0.7498,
  "massachusetts": 0.7479, "umass": 0.7479,
  "murray st.": 0.7431,   "murray state": 0.7431,
  "arkansas st.": 0.7412, "arkansas state": 0.7412,
  "arkansas": 0.7353,
  "lindenwood": 0.7312,
  "butler": 0.7254,
  "western illinois": 0.7238,
  "arizona": 0.7023,
  "abilene christian": 0.7016,
  "hawaii": 0.6995,       "hawai'i": 0.6995,
  "new mexico": 0.6988,

  // ── 131–160 ─────────────────────────────────────────────────────────────────
  "northwestern": 0.6979,
  "uc san diego": 0.6846, "ucsd": 0.6846,
  "cal baptist": 0.6822,
  "boise st.": 0.6809,    "boise state": 0.6809,
  "central arkansas": 0.6775,
  "providence": 0.6717,
  "southern indiana": 0.6702,
  "belmont": 0.6688,
  "portland": 0.6683,
  "pepperdine": 0.6626,
  "eastern kentucky": 0.6587,
  "wake forest": 0.6571,
  "charleston": 0.6484,
  "marshall": 0.6442,
  "utsa": 0.6431,
  "navy": 0.6349,
  "missouri st.": 0.6208, "missouri state": 0.6208,
  "fairleigh dickinson": 0.6201,
  "penn": 0.6159,
  "east carolina": 0.6154,

  // ── 161–200 ─────────────────────────────────────────────────────────────────
  "south dakota": 0.6144,
  "purdue fort wayne": 0.6117,
  "grand canyon": 0.6115,
  "northern colorado": 0.6111,
  "rutgers": 0.6040,
  "liberty": 0.5964,
  "northern iowa": 0.5920,
  "temple": 0.5911,
  "uc santa barbara": 0.5869, "ucsb": 0.5869,
  "brown": 0.5869,
  "ohio": 0.5867,
  "xavier": 0.5837,
  "central michigan": 0.5804,
  "idaho st.": 0.5792,    "idaho state": 0.5792,
  "army": 0.5724,
  "jacksonville": 0.5719,
  "cleveland st.": 0.5710, "cleveland state": 0.5710,
  "ucf": 0.5702,
  "old dominion": 0.5666,
  "youngstown st.": 0.5643, "youngstown state": 0.5643,

  // ── 200+ (low seeds / play-in range) ────────────────────────────────────
  "holy cross": 0.5153,
  "high point": 0.5091,
  "howard": 0.4623,
  "stephen f. austin": 0.4435, "sf austin": 0.4435,
  "norfolk st.": 0.3468,  "norfolk state": 0.3468,
};

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

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

/**
 * Look up Barttorvik Barthag rating for a participant name.
 * Returns BARTHAG_FALLBACK (0.5 = average strength) if not found.
 */
export function getBarthagRating(name: string): number {
  return BARTHAG_RATINGS[normalizeTeamName(name)] ?? BARTHAG_FALLBACK;
}

/**
 * NCAAW neutral-court win probability using Barttorvik Barthag (Log5 formula).
 *   P(A beats B) = A*(1−B) / (A*(1−B) + B*(1−A))
 *
 * Key properties:
 *   barthagWinProbability(x, 0.5) === x          (Barthag definition)
 *   barthagWinProbability(A, B) + barthagWinProbability(B, A) === 1  (symmetric)
 *
 * Exported for unit testing.
 */
export function barthagWinProbability(barthagA: number, barthagB: number): number {
  const pA = barthagA * (1 - barthagB);
  const pB = barthagB * (1 - barthagA);
  const total = pA + pB;
  // Degenerate case: both teams identical at 0 or 1 → coin flip
  if (total === 0) return 0.5;
  return pA / total;
}

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

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

export class NCAAWSimulator 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)
    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 NCAAW 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 First Four → Round of 64 slot mapping (if First Four games exist).
    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);
      }

      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 {
      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).
    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 Barthag 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(
        `[NCAAWSimulator] ${missing.length} participant ID(s) not found in DB: ${missing.join(", ")}. ` +
          `They will be treated as average strength (Barthag ${BARTHAG_FALLBACK}).`
      );
    }

    const barthagById = new Map<string, number>();
    for (const { id, name } of participantRows) {
      barthagById.set(id, getBarthagRating(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 b1 = barthagById.get(p1) ?? BARTHAG_FALLBACK;
      const b2 = barthagById.get(p2) ?? BARTHAG_FALLBACK;
      const w = Math.random() < barthagWinProbability(b1, b2) ? 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 ────────────────────────────────────────────────────────
      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 ───────────────────────────────────────────────────────
      const r64Winners: string[] = [];
      for (let i = 1; i <= 32; i++) {
        const m = r64ByNum.get(i);
        if (!m) continue;
        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 ───────────────────────────────────────────────────────
      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 ──────────────────────────────────────────────────────────
      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 (tracked losers → 5th–8th) ────────────────────────────
      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 (tracked losers → 3rd/4th) ─────────────────────────────
      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.
    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: "ncaaw_bracket_barthag",
      };
    });

    // 11. Per-position normalization — belt-and-suspenders guard against
    //     floating-point residuals. Column sums are already near-exactly 1.0.
    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;
  }
}
