// ════════════════════════════════════════════════════════════════════════════
// UR2D DATA STORE — one shared, normalized source of truth for every page.
//
// LAYERS (see CLAUDE.md "Architecture decision — the STORE"):
//   1. Source of truth = each league's published Google Sheet.
//   2. THIS module — per-league ADAPTERS turn a messy sheet into ONE canonical
//      shape, which the store normalizes + caches. Live fetch isn't wired yet:
//      UL is seeded from a hand-entered SNAPSHOT that flows through the SAME
//      normalize step a live fetch will, so the seam is already real. Swapping
//      in the network later changes only the adapter, never the pages.
//   3. Pages are pure consumers:  useStandings('ul') → { status, season, teams, drivers }.
//
// CANONICAL READ-MODEL (flattened from the relational schema in CLAUDE.md):
//   Season         { league, number, suffix, year, numRaces, hasSprints, teamBased, isCurrent, label }
//   TeamStanding   { pos, short, name, color, secondaryColor, livery, liveriesByRound, minilogo, pts }
//                    color = primary; secondaryColor = null when not configured.
//                    livery = default (main) image path; liveriesByRound = { roundN: path }
//                    for round-specific overrides — null when no overrides exist.
//                    Consumer: `team.liveriesByRound?.[round] ?? team.livery`
//   DriverStanding { pos, name, team, country, carNumber, pts, wins, podiums, poles, fastestLaps, dnfs, avgFinish }
//                    country  = flagcdn ISO code resolved in the normalizer.
//                    carNumber = career number from registry; null if unregistered.
//   Standings      { status, league, season, teams: TeamStanding[] | null, drivers: DriverStanding[] }
//                    status ∈ 'ready' | 'empty'   (live fetch will add 'loading' | 'error')
//   NOTE: teams is null when a league isn't team-based (AL) — pages must handle that.
// ════════════════════════════════════════════════════════════════════════════

const STORE_DEFAULT_TEAM_COLOR = '#9AA0A6';

// ── NORMALIZER ───────────────────────────────────────────────────────────────
// Takes a raw snapshot (or, later, fetched + parsed rows) and the season meta,
// returns the canonical Standings payload: sorted, positioned, colour-attached.
function normalizeStandings(snapshot, season) {
  // Resolve a driver's car number from the career registry.
  // Fuzzy match: exact lowercase → strip trailing digits+teamcodes → strip spaces.
  const driverNumber = (name) => {
    const map = window.UR_DRIVER_NUMBER;
    if (!map) return null;
    const lc = String(name || '').trim().toLowerCase();
    if (map[lc] != null) return map[lc];
    // strip trailing digits and uppercase team codes (e.g. 'peto95' → 'peto', 'gaelprad29' → 'gaelprad')
    const stripped = lc.replace(/[\d]+[a-z]*$/i, '').trim();
    if (stripped && map[stripped] != null) return map[stripped];
    // strip spaces (e.g. 'mr. krunk' → 'mr.krunk')
    const nospace = lc.replace(/\s+/g, '');
    if (map[nospace] != null) return map[nospace];
    return null;
  };
  // Resolve a driver's flag once, here in the data layer, so pages just render
  // d.country. Source order: explicit snapshot field → UR_DRIVER_NATIONALITY
  // (keyed by exact lowercased sheet name) → roster country fallback → null
  // (renders the neutral "unknown" placeholder — do NOT default to a real country).
  const driverCountry = (name) => {
    const n = String(name || '').trim().toLowerCase();
    const map = window.UR_DRIVER_NATIONALITY;
    if (map && map[n]) return map[n];
    if (window.getRosterCountry) return window.getRosterCountry(name) || null;
    return null;
  };
  const drivers = [...(snapshot.drivers || [])].
  sort((a, b) => b.pts - a.pts).
  map((d, i) => ({
    pos: i + 1,
    name: d.name,
    team: d.team || null,
    livery: d.livery || null, // per-DRIVER livery (non-team leagues, e.g. AL rank-assigned)
    country: d.country || driverCountry(d.name),
    carNumber: d.carNumber ?? driverNumber(d.name),
    pts: d.pts,
    wins: d.wins ?? 0,
    podiums: d.podiums ?? 0,
    poles: d.poles ?? 0,
    fastestLaps: d.fastestLaps ?? 0,
    dnfs: d.dnfs ?? null, // null = unknown (not in source yet)
    avgFinish: d.avgFinish ?? null,
    disputed: d.disputed || null, // { mt, db, shown } — Mega Table ≠ results DB (ur2d-megacheck.jsx)
    erTeams: d.erTeams || null // Independent because they only started as an Emergency Reserve → teams they drove for (newest first)
  }));

  let teams = null;
  if (season.teamBased && snapshot.teams) {
    teams = [...snapshot.teams].
    sort((a, b) => b.pts - a.pts).
    map((t, i) => ({
      pos: i + 1,
      short: t.short,
      name: t.name,
      color: t.color || STORE_DEFAULT_TEAM_COLOR,
      secondaryColor: t.secondaryColor || null,
      livery: t.livery || null,          // default (main) livery image path
      liveriesByRound: t.liveriesByRound || null, // { roundN: path } — round overrides
      minilogo: t.minilogo || null,      // small square team mark
      disputed: t.disputed || null,      // { mt, db, shown } — Mega Table ≠ results DB
      pts: t.pts
    }));
  }
  return { season, teams, drivers, lineups: snapshot.lineups || null };
}

// ── LIVE FETCH (gviz) ─────────────────────────────────────────────────────────
// The published-sheet gviz endpoint works cross-origin for link-shared sheets
// (verified against the live S24 sheet). Returns the parsed `table` object.
// Only works over http(s); opened as a local file it throws → caller keeps the snapshot.
async function fetchGviz(sheetId, tab) {
  const url = `https://docs.google.com/spreadsheets/d/${sheetId}/gviz/tq?tqx=out:json&sheet=${encodeURIComponent(tab)}`;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`gviz HTTP ${res.status}`);
  const txt = await res.text();
  // Strip the `/*O_o*/\ngoogle.visualization.Query.setResponse(… )` wrapper.
  return JSON.parse(txt.slice(txt.indexOf('(') + 1, txt.lastIndexOf(')'))).table;
}

// Parse a UL-style "Leaderboard" tab → raw snapshot { teams, drivers }.
// Column indices verified against the live S24 sheet (see CLAUDE.md). The adapter's
// `source` supplies the column map so the same parser serves any same-layout league.
async function loadLeaderboardStandings(source) {
  const table = await fetchGviz(source.sheetId, source.tab);
  const rows = table.rows.map((r) => r.c.map((c) => (c ? c.v : null)));
  const dc = source.driverCols, tc = source.teamCols;

  // A stat column spec can be a single index OR an array of indices to SUM
  // (HL has no dedicated wins/podiums column — podiums = P1+P2+P3 finish counts).
  const pick = (r, spec) => {
    if (spec == null) return 0;
    if (Array.isArray(spec)) return spec.reduce((sum, i) => sum + (Number(r[i]) || 0), 0);
    return Number(r[spec]) || 0;
  };

  // Drivers — a row with numeric pos + name + numeric pts.
  const drivers = [];
  const driverNames = new Set();
  for (const r of rows) {
    const pos = r[dc.pos], name = r[dc.name], pts = r[dc.pts];
    if (typeof pos === 'number' && typeof name === 'string' && name.trim() && typeof pts === 'number') {
      const nm = name.trim();
      drivers.push({ name: nm, pts, wins: pick(r, dc.wins), podiums: pick(r, dc.podiums), poles: pick(r, dc.poles), fastestLaps: pick(r, dc.fastestLaps), team: null });
      driverNames.add(nm);
    }
  }

  // Constructors — acronym + numeric pts (+ optional position col). Full name + colour
  // from config. Skipped entirely when the source has no teamCols (e.g. AL, not team-based).
  // When teamCols has no `pos`, team rows are detected by acronym+pts alone (JL lists them
  // in points order, no rank column) — the normalizer assigns positions by sort.
  const teams = [];
  if (tc) {
    for (const r of rows) {
      const short = r[tc.short], pts = r[tc.pts];
      const posOk = tc.pos == null || typeof r[tc.pos] === 'number';
      if (posOk && typeof short === 'string' && short.trim() && typeof pts === 'number') {
        const sh = short.trim();
        teams.push({
          short: sh,
          name:   (source.teamNames            && source.teamNames[sh])            || sh,
          pts,
          color:  (source.teamColors           && source.teamColors[sh])           || STORE_DEFAULT_TEAM_COLOR,
          secondaryColor:  (source.teamSecondaryColors  && source.teamSecondaryColors[sh])  || null,
          livery:          (source.teamLiveries         && source.teamLiveries[sh])         || null,
          liveriesByRound: (source.teamLiveriesByRound  && source.teamLiveriesByRound[sh])  || null,
          minilogo:        (source.teamMinilogos        && source.teamMinilogos[sh])        || null,
        });
      }
    }
  }

  // Lineups → driver→team. Walk each lineup column: a cell matching a known driver
  // name belongs to the current team header; anything else starts a new team block.
  // Matching is normalized (lower-case + trimmed) so minor casing/spacing differences
  // between the standings table and the lineup list don't drop a driver's team.
  const norm = (s) => String(s).trim().toLowerCase();
  const fullToShort = {};
  Object.entries(source.teamNames || {}).forEach(([sh, full]) => { fullToShort[norm(full)] = sh; });
  // The lineup section's team-header cell sometimes uses a DIFFERENT string than the
  // canonical display name (e.g. JL S22A's 8-Star team lists "8-Star Racing" in the
  // lineup header but shows as "Elysian Stride 8-Star"). `teamAliases` maps each such
  // alias string → short code so the walk still recognizes the block boundary. Also
  // fold in each short code itself as an alias (some sheets head the block with the
  // acronym) — harmless, and covers acronym-only headers.
  Object.keys(source.teamNames || {}).forEach((sh) => { fullToShort[norm(sh)] = sh; });
  Object.entries(source.teamAliases || {}).forEach(([alias, sh]) => { fullToShort[norm(alias)] = sh; });
  // Independent block: the per-round "STARTING DRIVERS" grid has an "Independent"
  // header; map it (and, below, any unsigned driver) to the independent sentinel.
  if (source.independentShort) fullToShort['independent'] = source.independentShort;
  const driverKey = {}; // normalized name → canonical driver name
  driverNames.forEach((n) => { driverKey[norm(n)] = n; });
  const driverToShort = {};
  // Walk one lineup block: a cell matching a known driver name belongs to the
  // current team header; any other known team-name string starts a new block.
  const walkLineup = (lrows, cols) => {
    for (const col of cols) {
      let current = null;
      for (const r of lrows) {
        const v = r[col];
        if (typeof v !== 'string' || !v.trim()) continue;
        const key = norm(v);
        if (driverKey[key]) { if (current) driverToShort[driverKey[key]] = current; }
        else if (fullToShort[key]) { current = fullToShort[key]; }
        // else: unknown string (stray label) — leave current as-is
      }
    }
  };
  if (source.lineupRounds && source.lineupRounds.length) {
    // Per-race lineups live on the round tabs (e.g. HL's STARTING DRIVERS at AA2:AE22).
    // Walk each round in order so a LATER round's lineup overrides an earlier one —
    // this naturally captures mid-season swaps and reserves who actually raced.
    // Failed/empty round fetches are skipped.
    const cols = source.lineupRoundCols || source.lineupCols || [];
    const tables = await Promise.all(source.lineupRounds.map((tab) =>
      fetchGviz(source.sheetId, tab)
        .then((t) => t.rows.map((r) => r.c.map((c) => (c ? c.v : null))))
        .catch(() => null)));
    tables.forEach((lrows) => { if (lrows) walkLineup(lrows, cols); });
  } else if (source.lineupCols) {
    walkLineup(rows, source.lineupCols);
  }

  // AUTHORITATIVE driver→team source: the per-round tabs carry an explicit
  // `Driver | Team` table (name in one col, team ACRONYM in the next) — a declared
  // mapping, not a header-walk guess. When `driverTeamTable` is configured we read
  // these tables and let them OVERRIDE the lineup-block walk, because the summary
  // "LINEUPS" block on the Leaderboard tab is unreliable (e.g. JL S22A visually groups
  // Peto under "Triple Crown Racing" even though he actually raced for Dinoco/DCR — the
  // round table is the only place that gets it right). Later rounds override earlier so
  // mid-season team swaps are captured. Empty/future/failed round fetches are skipped.
  // AUTHORITATIVE per-round starts: the round tabs carry an explicit `Driver | Team` table (F/G). Read once,
  // for both the legacy driver→team override (driverTeamTable) and the lineup rule below (roundTeamTable too).
  const validShort = {}; // normalized acronym → canonical short code
  Object.keys(source.teamNames || {}).forEach((sh) => { validShort[norm(sh)] = sh; });
  const rtCfg = source.driverTeamTable || source.roundTeamTable;
  const roundRows = rtCfg ? await Promise.all((rtCfg.rounds || []).map((tab) =>
    fetchGviz(source.sheetId, tab)
      .then((t) => t.rows.map((r) => r.c.map((c) => (c ? c.v : null))))
      .catch(() => null))) : null;
  if (source.driverTeamTable && roundRows) {
    const dt = source.driverTeamTable;
    const nameCol = dt.nameCol, shortCol = dt.shortCol;
    roundRows.forEach((trows) => {
      if (!trows) return;
      for (const r of trows) {
        const nm = r[nameCol], sh = r[shortCol];
        if (typeof nm !== 'string' || !nm.trim() || typeof sh !== 'string' || !sh.trim()) continue;
        const canonName = driverKey[norm(nm)];
        const canonShort = validShort[norm(sh)] || (source.independentShort && norm(sh) === 'independent' ? source.independentShort : null);
        if (canonName && canonShort) driverToShort[canonName] = canonShort;
      }
    });
  }
  // RULE: a driver not resolved to a team via the lineup (i.e. not signed as a main
  // or a reserve who raced) is shown as an Independent when the source opts in via
  // `independentShort`; otherwise their team stays null.
  drivers.forEach((d) => { d.team = driverToShort[d.name] || (source.independentShort || null); });

  // ── LINEUP RULE (user, 2026-10-05) ── LINEUPS = who is contracted to a team (slots 1-2 mains, 3-4
  // reserves); STARTING/F-G = who actually started for whom. A start counts for the team only when the
  // driver is in THAT team's lineup that round (Round tab LINEUPS block, else the Leaderboard lineup).
  // Anyone else who starts for a team is an EMERGENCY RESERVE → shown as Independent in the driver
  // standings (erTeams = the teams they drove for, newest first). Constructor points stay as the sheet has them.
  const nrm = (s) => String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]/g, '');
  const codeByName = {};
  const addName = (full, sh) => { const k = nrm(full); if (k) codeByName[k] = sh; };
  Object.entries(source.sheetTeamNames || {}).forEach(([sh, full]) => addName(full, sh));
  Object.entries(source.teamNames || {}).forEach(([sh, full]) => addName(full, sh));
  Object.entries(source.teamAliases || {}).forEach(([alias, sh]) => addName(alias, sh));
  const teamCodeOf = (s) => {
    const k = nrm(s); if (!k) return null;
    if (codeByName[k]) return codeByName[k];
    const hit = Object.keys(codeByName).find((n) => n.length >= 5 && k.length >= 5 && (n.includes(k) || k.includes(n)));
    return hit ? codeByName[hit] : null;
  };
  // Lineup blocks: a cell naming a known team = block header; the next rows are its SLOTS by position
  // (slot 1-2 mains, 3-4 reserves; a blank row = vacant slot, kept as null so positions stay true).
  const LINEUP_SLOTS = 4;
  const lineupBlocks = (lrows, cols) => {
    const out = {};
    for (const col of cols || []) {
      let cur = null, at = -1;
      lrows.forEach((r, ri) => {
        const v = r[col], s = v == null ? '' : String(v).trim();
        const code = s ? teamCodeOf(s) : null;
        if (code) { cur = code; at = ri; if (!out[cur]) out[cur] = []; return; }
        if (!cur || ri - at > LINEUP_SLOTS) { cur = null; return; }
        const slot = ri - at - 1;
        if (s && !/^\[?(blank|vacant)\]?$/i.test(s)) out[cur][slot] = s;
      });
    }
    Object.keys(out).forEach((k) => { for (let j = 0; j < out[k].length; j++) if (out[k][j] === undefined) out[k][j] = null; });
    return out;
  };
  const seasonLineups = source.lineupCols ? lineupBlocks(rows, source.lineupCols) : {};
  const indCode = source.independentShort || 'IND';
  const roundLineups = [], roundStarts = [];
  if (rtCfg && roundRows) roundRows.forEach((trows, i) => {
    if (!trows) return;
    const st = {};
    for (const r of trows) {
      const nm = r[rtCfg.nameCol], sh = r[rtCfg.shortCol];
      if (typeof nm !== 'string' || !nm.trim() || typeof sh !== 'string' || !sh.trim()) continue;
      const cs = validShort[norm(sh)] || (/^(ind|independent)$/.test(norm(sh)) ? indCode : null);
      if (cs) st[nrm(nm)] = cs;
    }
    if (!Object.keys(st).length) return; // round not raced yet
    const lu = lineupBlocks(trows, source.roundLineupCols || [18, 21]);
    roundLineups[i] = Object.keys(lu).length ? lu : seasonLineups;
    roundStarts.push({ i, st });
  });
  if (roundStarts.length && source.erRule !== false) {
    // Contracted = sheet lineup that round ∪ the season roster (Discord signings: mains + reserve, every name form).
    const rl = source.rosterLineups || {};
    const inLineup = (lu, sh, k) => ((lu && lu[sh]) || []).some((x) => x && nrm(x) === k) || (rl[sh] || []).some((x) => nrm(x) === k);
    drivers.forEach((d) => {
      const k = nrm(d.name);
      let team = null, started = false;
      const er = [];
      roundStarts.forEach(({ i, st }) => {
        const sh = st[k];
        if (!sh) return;
        started = true;
        if (sh === indCode) return;
        if (inLineup(roundLineups[i], sh, k)) team = sh;
        else { const j = er.indexOf(sh); if (j >= 0) er.splice(j, 1); er.unshift(sh); }
      });
      if (team) d.team = team;
      else if (started) { d.team = indCode; d.erTeams = er.length ? er : null; }
    });
  }
  const lastLineup = [...roundLineups].reverse().find((x) => x && Object.keys(x).length);
  const lineups = lastLineup || (Object.keys(seasonLineups).length ? seasonLineups : null);

  drivers.sort((a, b) => b.pts - a.pts);
  // Per-driver liveries assigned by championship rank (non-team leagues — AL):
  // index 0 = P1 car, etc. Drivers beyond the array get null → defaultLivery fallback.
  if (source.driverLiveriesByRank) drivers.forEach((d, i) => { d.livery = source.driverLiveriesByRank[i] || null; });
  return { teams, lineups, drivers: source.topN ? drivers.slice(0, source.topN) : drivers };
}

// ── UL · S24 SNAPSHOT ──────────────────────────────────────────────────────
// Hand-entered from the Leaderboard tab (UL Driver Standings + TEAMS + Lineups).
// Stand-in for a live fetch; team colours are placeholders. wins/podiums are real.
const UL_S24_SNAPSHOT = {
  teams: [
    { short: 'ONE', name: 'OneTime',             pts: 395, color: '#8B2FE0', minilogo: 'assets/minilogos/ONE.png' },
    { short: 'LSA', name: 'Leviosso Sports Art', pts: 319, color: '#FF2800', minilogo: 'assets/minilogos/LSA.png' },
    { short: 'FLF', name: 'Flow Force',          pts: 263, color: '#F857A6', minilogo: 'assets/minilogos/FLF.png' },
    { short: 'RMC', name: 'Rapid Motorsport',    pts: 145, color: '#FF6A1A', minilogo: 'assets/minilogos/RMC.png' },
    { short: 'MAY', name: 'Mayhem Racing',       pts: 134, color: '#12C6E0', minilogo: 'assets/minilogos/MAY.png' },
    { short: 'RKA', name: 'Rykar Auto',          pts: 123, color: '#B8902E', minilogo: 'assets/minilogos/RKA.png' },
    { short: 'SCP', name: 'Scuderia Paracarro',  pts: 110, color: '#0E7A3F', minilogo: 'assets/minilogos/SCP.png' },
    { short: 'SGP', name: 'StarGoat GP',         pts:  63, color: '#F5B301', minilogo: 'assets/minilogos/SGP.png' },
    { short: 'LYR', name: 'Lynx Racing',         pts:  40, color: '#1C2D54', minilogo: 'assets/minilogos/LYR.png' },
  ],
  drivers: [
    { name: 'figoeira',  team: 'ONE', pts: 232, wins: 6, podiums: 7 },
    { name: 'F1Fan',     team: 'LSA', pts: 169, wins: 0, podiums: 6 },
    { name: 'Ozi',       team: 'ONE', pts: 147, wins: 1, podiums: 5 },
    { name: 'Fantomy',   team: 'LSA', pts: 139, wins: 1, podiums: 3 },
    { name: 'M. Ludwig', team: 'FLF', pts: 125, wins: 0, podiums: 2 },
    { name: 'youa',      team: 'FLF', pts:  99, wins: 0, podiums: 1 },
    { name: 'Corvid',    team: 'RMC', pts:  72, wins: 0, podiums: 0 },
    { name: 'Justin',    team: 'RMC', pts:  70, wins: 0, podiums: 0 },
    { name: 'Strudl',    team: 'SCP', pts:  66, wins: 0, podiums: 0 },
    { name: 'Bituko',    team: 'MAY', pts:  62, wins: 0, podiums: 0 },
    { name: 'cola',      team: 'MAY', pts:  59, wins: 0, podiums: 0 },
    { name: 'MatiMar',   team: 'RKA', pts:  41, wins: 0, podiums: 0 },
    { name: 'PIGGY',     team: 'RKA', pts:  35, wins: 0, podiums: 0 },
    { name: 'Crustunet', team: 'FLF', pts:  32, wins: 0, podiums: 0 },
    { name: 'Albono',    team: 'SGP', pts:  32, wins: 0, podiums: 0 },
    { name: 'hnandi',    team: 'LSA', pts:  30, wins: 0, podiums: 0 },
    { name: 'kalitka',   team: 'LYR', pts:  22, wins: 0, podiums: 0 },
    { name: 'Nikhil',    team: 'SCP', pts:  22, wins: 0, podiums: 0 },
  ],
};

// ── PER-LEAGUE ADAPTERS ──────────────────────────────────────────────────────
// season  = canonical season meta (drives teamBased / sprint behaviour).
// snapshot= per-SEASON hand-entered data (null ⇒ store reports 'empty' → "coming soon").
//           Only UL S24 is loaded; the archived seasons (from the .db) are listed so the
//           dropdown is real, and get snapshots attached as we load them.
// source  = LIVE FETCH config, DORMANT for now. Column maps lifted straight from
//           extract_complete.py (Data gatherer tab) — see CLAUDE.md. These came
//           from FINISHED seasons; verify against the live workbook before wiring.
//
// League-level props (teamBased / hasSprints / numRaces / source) apply to ALL that
// league's seasons; each `seasons[]` entry carries its own identity + snapshot.
// AL car files car_1…car_10 → AL team codes (DB). Same set in S21 and S23A.
const AL_CARS = (dir) => Object.fromEntries(["APM","TIG","FOI","ICL","LOT","HAA","BAR","OBB","RKY","REN"].map((code, i) => [code, `${dir}/car_${i + 1}.png`]));
const UR2D_ADAPTERS = {
  ul: {
    teamBased: true, hasSprints: false, numRaces: 10,
    source: {
      sheetId: '1vivMgSTZMF7j08-sfF6O-eKJkBo_tmotpWMhilgIOlI',
      tab: 'Data gatherer',
      driverNameCol: 5,
      raceBase: (n) => 10 + (n - 1) * 3, // P=base, Q=base+1, R=base+2
      statCols: { champPos: 0, points: 6, podiums: 23, fastestLaps: 24, poles: 25, wins: 26, posGained: 27, avgFinish: 28 },
    },
    seasons: [
      { number: 26, suffix: null, label: 'S26', year: 2026, isCurrent: true, snapshot: null, // AUTUMN 2026 — LIVE (workbook wired 2026-10-05)
        // New copied workbook, verified live 2026-10-05 after R1: SAME Leaderboard / Data gatherer / Round-tab
        // geometry as S25 (123 cols; drivers 3/5/6, podiums 39, wins 42; teams 69/72/73; lineups 91/94; F/G round
        // tables). Sheet's row0col1 still reads 24 (stale) — label from the operator rollover, never the cell.
        liveSource: {
          sheetId: '1R0XEtOLAg5DlHXKwWYn5xz3ZV9QF8T1ppbXl9avqrAM',
          tab: 'Leaderboard',
          driverCols: { pos: 3, name: 5, pts: 6, podiums: 39, wins: 42 },
          teamCols:   { pos: 69, short: 72, pts: 73 },
          lineupCols: [91, 94],
          driverTeamTable: { rounds: ['Round 1', 'Round 2', 'Round 3', 'Round 4', 'Round 5', 'Round 6', 'Round 7', 'Round 8', 'Round 9', 'Round 10'], nameCol: 5, shortCol: 6 },
          topN: 18,
          // Sheet strings (lineup headers). Display names/colours/liveries come from the season-level maps below.
          teamNames:  { ONE: 'OneTime', LSA: 'Leviosso Sports Art', FLF: 'Flow Force', KMP: 'Kenzer Motorsport', MZM: 'Mazzar Motorsport', MAY: 'Mayhem Racing', ASX: 'Andromeda Ariane SpacEx', SCP: 'Scuderia Paracarro', SGP: 'StarGoat GP' },
          teamMinilogos: {
            ONE: 'assets/minilogos/ONE.png', LSA: 'assets/minilogos/LSA.png', FLF: 'assets/minilogos/FLF.png',
            MAY: 'assets/minilogos/MAY.png', SCP: 'assets/minilogos/SCP.png', SGP: 'assets/minilogos/SGP.png',
            KMP: 'assets/minilogos/KMP.png', MZM: 'assets/minilogos/MZM.png',
          },
        },
        // Season-level liveries (2026-10-02, user-supplied). Merged over liveSource.teamLiveries
        // once the workbook is wired. All 9 in (FLF added 2026-10-04 — its R1 livery, "ULS26R1").
        teamLiveries: {
          KMP: 'assets/UL S26 Liveries/UL_S26_KMP.png',
          LSA: 'assets/UL S26 Liveries/UL_S26_LSA.png',
          SCP: 'assets/UL S26 Liveries/UL_S26_SCP.png',
          MAY: 'assets/UL S26 Liveries/UL_S26_MAY.png',
          ONE: 'assets/UL S26 Liveries/UL_S26_ONE.png',
          MZM: 'assets/UL S26 Liveries/UL_S26_MZM.png',
          SGP: 'assets/UL S26 Liveries/UL_S26_SGP.png',
          ASX: 'assets/UL S26 Liveries/UL_S26_ASX.png',
          FLF: 'assets/UL S26 Liveries/UL_S26_FLF.png',
        },
        liveryNames: { MAY: 'MAY-26', ONE: 'OT V7', LSA: 'LSA 26/4', MZM: 'Mazzar KEBEK', SGP: 'ST-6', ASX: 'ASX-01-reborn' }, // operator-given livery names (RKA purple = Ariane SpacEx, named 2026-10-04)
        specialLiveries: { MAY: [{ rounds: '1', name: 'TOMYYY', file: 'assets/UL S26 Liveries/UL_S26_MAY_R1.png' }] },
        // Lineups (2026-10-02): roster 'ul:S26' in ur2d-roster.jsx — same 9 teams as S25.
        // 2026-10-04 (user): RKA (Rykar Auto) REBRANDED → ASX = Ariane SpacEx (code from livery "ASX-01-reborn").
        // Old code kept as FDT_ALIAS ASX↔RKA so the team page still links its UL S24/S25 history.
        teamNames: { ASX: 'Andromeda Ariane SpacEx' }, // full name per the S26 workbook's lineup header (2026-10-05)
        teamMinilogos: { ASX: 'assets/minilogos/ASX.png' }, // placeholder — the only ASX logo we have (60px)
        // Team colours sampled from each S26 livery (2026-10-04, user: "if you don't know, take from livery").
        teamColors: { ONE: '#4C0ADB', LSA: '#950D0C', FLF: '#DE449E', KMP: '#C02323', MZM: '#E42577', MAY: '#C55710', ASX: '#8105DC', SCP: '#9F1919', SGP: '#D9C501' },
      },
      { number: 25, suffix: null, label: 'S25', year: 2026, isCurrent: false, snapshot: null,
        // SUMMER 2026 — live from the NEW copied workbook (verified 2026-07-06 with the
        // same Leaderboard column map as S24). Roster shuffle: KMP + MZM promoted up from
        // HL into UL; RMC + LYR relegated out. tabGid omitted (new workbook's per-round
        // constructor sub-table GID unconfirmed → roundTeamMap stays empty until verified).
        // Liveries added 2026-07-06 (user-supplied renders).
        liveSource: {
          sheetId: '1G7OtZn4UHB58l-0f-r9S2Y17AGB8uW_mKZC4Qed_DAs',
          tab: 'Leaderboard',
          driverCols: { pos: 3, name: 5, pts: 6, podiums: 39, wins: 42 },
          teamCols:   { pos: 69, short: 72, pts: 73 },
          lineupCols: [91, 94],
          // AUTHORITATIVE per-round driver→team: each "Round N" tab has an explicit
          // Driver|Team table at cols F/G (operator-confirmed, verified live 2026-08-06).
          // Feeds BOTH the season-long team override in loadLeaderboardStandings AND the
          // name-keyed round map (loadRoundDriverTeams) used for round-exact liveries.
          driverTeamTable: { rounds: ['Round 1', 'Round 2', 'Round 3', 'Round 4', 'Round 5', 'Round 6', 'Round 7', 'Round 8', 'Round 9', 'Round 10'], nameCol: 5, shortCol: 6 },
          topN: 18,
          teamNames:  { ONE: 'OneTime', LSA: 'Leviosso Sports Art', FLF: 'Flow Force', KMP: 'Kenzer Motorsport', MZM: 'Mazzar Motorsport', MAY: 'Mayhem Racing', RKA: 'Rykar Auto', SCP: 'Scuderia Paracarro', SGP: 'StarGoat GP' },
          teamColors: { ONE: '#B25FFF', LSA: '#FF2800', FLF: '#F857A6', KMP: '#E10600', MZM: '#FF2E9A', MAY: '#12C6E0', RKA: '#B8902E', SCP: '#0E7A3F', SGP: '#F5B301' },
          teamMinilogos: {
            ONE: 'assets/minilogos/ONE.png',
            LSA: 'assets/minilogos/LSA.png',
            FLF: 'assets/minilogos/FLF.png',
            MAY: 'assets/minilogos/MAY.png',
            RKA: 'assets/minilogos/RKA.png',
            SCP: 'assets/minilogos/SCP.png',
            SGP: 'assets/minilogos/SGP.png',
            KMP: 'assets/minilogos/KMP.png',
            MZM: 'assets/minilogos/MZM.png',
          },
          teamLiveries: {
            ONE: 'assets/UL S25 Liveries/UL_S25_ONE.png',
            LSA: 'assets/UL S25 Liveries/UL_S25_LSA.png',
            FLF: 'assets/UL S25 Liveries/UL_S25_FLF.png',
            MAY: 'assets/UL S25 Liveries/UL_S25_MAY.png',
            RKA: 'assets/UL S25 Liveries/UL_S25_RKA.png',
            SCP: 'assets/UL S25 Liveries/UL_S25_SCP.png',
            SGP: 'assets/UL S25 Liveries/UL_S25_SGP.png',
            KMP: 'assets/UL S25 Liveries/UL_S25_KMP.png',
            MZM: 'assets/UL S25 Liveries/UL_S25_MZM.png',
          },
        },
      },
      { number: 24, suffix: null, label: 'S24', year: 2026, isCurrent: false,  snapshot: UL_S24_SNAPSHOT,
        // liveSource = verified Leaderboard-tab column map (confirmed against live S24 sheet).
        // Parser reads drivers + teams + lineups from 3 separate tables on this one tab.
        liveSource: {
          sheetId: '1vivMgSTZMF7j08-sfF6O-eKJkBo_tmotpWMhilgIOlI',
          tab: 'Leaderboard',
          tabGid: '1421054132', // legacy position-indexed round map (loadRoundTeamMap) — kept as fallback
          driverCols: { pos: 3, name: 5, pts: 6, podiums: 39, wins: 42 },
          teamCols:   { pos: 69, short: 72, pts: 73 },
          lineupCols: [91, 94],
          // Per-round Driver|Team tables at cols F/G on the "Round N" tabs (verified live
          // 2026-08-06) — name-keyed, so subs/one-off reserves resolve to their real team.
          driverTeamTable: { rounds: ['Round 1', 'Round 2', 'Round 3', 'Round 4', 'Round 5', 'Round 6', 'Round 7', 'Round 8', 'Round 9', 'Round 10'], nameCol: 5, shortCol: 6 },
          topN: 18,
          teamNames:  { ONE: 'OneTime', LSA: 'Leviosso Sports Art', FLF: 'Flow Force', RMC: 'Rapid Motorsport', MAY: 'Mayhem Racing', RKA: 'Rykar Auto', SCP: 'Scuderia Paracarro', SGP: 'StarGoat GP', LYR: 'Lynx Racing' },
          teamColors: { ONE: '#8B2FE0', LSA: '#FF2800', FLF: '#F857A6', RMC: '#FF6A1A', MAY: '#12C6E0', RKA: '#B8902E', SCP: '#0E7A3F', SGP: '#F5B301', LYR: '#1C2D54' },
          teamLiveries: {
            ONE: 'assets/UL Liveries/car1_UL_S24_ONE.png',
            FLF: 'assets/UL Liveries/car2_UL_S24_FLF.png',
            LSA: 'assets/UL Liveries/car3_UL_S24_LSA.png',
            SGP: 'assets/UL Liveries/car4_UL_S24_SGP.png',
            RMC: 'assets/UL Liveries/car5_UL_S24_RMC.png',
            SCP: 'assets/UL Liveries/car6_UL_S24_SCP.png',
            RKA: 'assets/UL Liveries/car7_UL_S24_RKA.png',
            MAY: 'assets/UL Liveries/car8_UL_S24_MAY.png',
            LYR: 'assets/UL Liveries/car9_UL_S24_LYR.png',
          },
          teamMinilogos: {
            ONE: 'assets/minilogos/ONE.png',
            LSA: 'assets/minilogos/LSA.png',
            FLF: 'assets/minilogos/FLF.png',
            RMC: 'assets/minilogos/RMC.png',
            MAY: 'assets/minilogos/MAY.png',
            RKA: 'assets/minilogos/RKA.png',
            SCP: 'assets/minilogos/SCP.png',
            SGP: 'assets/minilogos/SGP.png',
            LYR: 'assets/minilogos/LYR.png',
          },
        },
      },
      // Archived seasons (results from the DB). Liveries from #season-N Discord channels (user, 2026-10-08):
      // assets/UL S<N> Liveries/UL_S<N>_<CODE>.png = main car; ..._<CODE>_R<n>[-m].png = round special.
      // specialLiveries is stored for the planned specials strip — not rendered yet.
      { number: 23, suffix: null, label: 'S23', year: 2025, isCurrent: false, snapshot: null,
        teamLiveries: _ulArchLiveries(23, ['ONE', 'LSA', 'FLF', 'RMC', 'MAY', 'RKA', 'SCP', 'LYR', 'SGP']),
        liveryNames: { ONE: 'OT V4', LSA: 'LSA 26/1', FLF: 'FLF 23', RMC: 'RMC-16', MAY: 'MAY-23', RKA: 'RKA23', SCP: 'SCP23', LYR: 'LYR 23', SGP: 'ST-3' },
        specialLiveries: _ulArchSpecials(23, { RMC: { 2: 'RMC 23 Christmas', 5: 'RMC-16 South Korean' }, SGP: { 4: 'SGP Netherlands', 7: 'StarCubo 100', 10: 'ST-France' }, RKA: { 6: 'RKA R6' }, ONE: { 6: 'OT V4 Lake Shore', 10: 'OT V4 Batman' }, FLF: { 8: 'FLF 23 French Special' }, SCP: { 10: 'SCP Joni' } }) },
      { number: 22, suffix: null, label: 'S22', year: 2025, isCurrent: false, snapshot: null,
        teamLiveries: _ulArchLiveries(22, ['ONE', 'LSA', 'FLF', 'RMC', 'MAY', 'RKA', 'SCP', 'LYR', 'SGP']),
        liveryNames: { ONE: 'OT V3', LSA: 'LSA 25/4', FLF: 'FLF 22', RMC: 'RMC-15', MAY: 'MAY-22', RKA: 'BFYMA-22', SCP: 'SCP22', LYR: 'LYR 22', SGP: 'ST-2' },
        specialLiveries: _ulArchSpecials(22, { LSA: { 1: 'LSA 25/4' }, ONE: { 4: 'OT V3 Send Off', 9: 'OT V3 Silverstone', 10: 'OT V3 Batman' }, RKA: { 4: 'RKA BMFYA' }, RMC: { 9: 'RMC-15 Silverstone' }, MAY: { 10: 'May 22 Lutetia' } }) },
      // S21/S20 would otherwise be appended by ur2d-db (urDbRegisterSeasons) — listed here only to carry liveries,
      // so they keep dbOnly (calendar-index consumers skip them, same as the DB-appended entries).
      { number: 21, suffix: null, label: 'S21', year: null, isCurrent: false, snapshot: null, dbOnly: true,
        teamLiveries: _ulArchLiveries(21, ['ONE', 'LSA', 'FLF', 'RMC', 'MAY', 'RKA', 'SCP', 'LYR', 'SGP']),
        liveryNames: { ONE: 'OT V2', LSA: 'LSA 25/3', FLF: 'FLF 21', RMC: 'RMC-14', MAY: 'MAY-21', RKA: 'RKA-MBTF-XXI', SCP: 'SCP 21', LYR: 'THE LYR 17', SGP: 'ST-1' },
        specialLiveries: _ulArchSpecials(21, { SGP: { 2: 'ST1 Demonkin' }, ONE: { 4: 'OT V2 Japan', 7: 'OT V2 Kazegaura', 10: 'OT V2 Batman' }, RMC: { 4: 'Wagyu-14', 9: 'RMC-ToxicGreen' }, LYR: { 4: 'LYR 17 Japan' }, SCP: { 10: 'SCP Singapore Throwback' }, LSA: { 10: 'LSA hnandi special' } }) },
      { number: 20, suffix: null, label: 'S20', year: null, isCurrent: false, snapshot: null, dbOnly: true,
        teamLiveries: _ulArchLiveries(20, ['ONE', 'LSA', 'FLF', 'RMC', 'MAY', 'RKA', 'SCP', 'LYR', 'FGR']),
        liveryNames: { ONE: 'OT V1', LSA: 'LS20', FLF: 'FLF 20', RMC: 'RMC-13', MAY: 'MAY-20', RKA: 'RKA-Lotus-MBT XX', SCP: 'SCP20', LYR: 'LYR-20', FGR: 'FGR20' },
        specialLiveries: _ulArchSpecials(20, { LSA: { 2: 'LSA 25/2 Hungary special', 10: 'LSA 25/2 Monaco special' }, ONE: { 3: 'OT V1 Japan', 5: 'OT V1 Tyrrell', '6-7': 'OT V1 Aussie', 9: 'OT V1 Brazil', 10: 'OT V1 Monaco' }, FGR: { 5: 'FGR Youa impact', 9: 'Novo FGR Plano', 10: 'FGRenault' }, FLF: { '6-7': 'FLF Aussie', 10: 'FLF Homage' }, LYR: { 8: 'LYR Pride', 10: 'LFW 03' }, RMC: { '9-10': 'Aztec temple' }, MAY: { 10: 'Mayhem Special Monaco' }, RKA: { 10: 'RKA-R.P-MBTN 1974' }, SCP: { 10: 'SCP Monaco Special' } }) },
    ],
  },
  hl: {
    teamBased: true, hasSprints: true, numRaces: 10,
    source: {
      sheetId: '1C3GzC4GZepDNuLJDfZolW39PSFz2BfNQX5UMPaVJNtM',
      tab: 'Data gatherer',
      driverNameCol: 5,
      raceBase: (n) => 8 + (n - 1) * 5, // SQ, S, Q, R(feature), P  (sprint format)
      statCols: { champPos: 0, points: 6, racesStarted: 58, podiums: 59, fastestLaps: 62, poles: 63, wins: 64, posGained: 67, avgFinish: 68 },
    },
    // Archived under the old "CL" name (S19/S20); current season is S21 (first under HL name).
    seasons: [
      { number: 23, suffix: null, label: 'S23', year: 2026, isCurrent: true, snapshot: null, // AUTUMN 2026 — first season under the CL name (was HL); S23 follows HL S22
        // LIVE (wired 2026-10-05). ⚠ CL DROPPED SPRINTS: the S23 workbook is the UL template (123-col Leaderboard,
        // 94-col Data gatherer, Q/R per round, no S column; R1 winner = 30 pts). So this season uses UL GEOMETRY,
        // overriding the league-level HL sprint maps via liveSource.dataGatherer / .conRounds + hasSprints:false.
        // Supernova re-acronymed RSR → ASN "Andromeda Supernova" in the sheet (FDT_ALIAS keeps RSR history).
        hasSprints: false,
        liveSource: {
          sheetId: '18QH5tmJ1kYqIAGP_tiOvWCpeL5MnlRPMgzQrSfJNT04',
          tab: 'Leaderboard',
          driverCols: { pos: 3, name: 5, pts: 6, podiums: 39, wins: 42 },
          teamCols:   { pos: 69, short: 72, pts: 73 },
          lineupCols: [91, 94],
          driverTeamTable: { rounds: ['Round 1', 'Round 2', 'Round 3', 'Round 4', 'Round 5', 'Round 6', 'Round 7', 'Round 8', 'Round 9', 'Round 10'], nameCol: 5, shortCol: 6 },
          independentShort: 'IND',
          topN: 18,
          teamNames:  { SWR: 'S25M Williams Racing', HKB: 'HK Blues', ASN: 'Andromeda Supernova', VLP: 'Velocity PowerTeam', PRO: 'HRT Push Pro', RGT: 'RisingGoat', CPR: 'Carpathians Peak Racing', ARR: 'Archon Racing', IND: 'Independent' },
          teamAliases: { 'Velocity PowerTeam': 'VLP' }, // sheet header vs season-level display name
          teamColors: { IND: '#8A9099' },
          dataGatherer: { raceBase: 8, raceStride: 3, finishOffset: 1, sprintOffset: null }, // = UL
          conRounds:    { short: 72, ptsBase: 74, posShort: 98, posBase: 99, posStride: 1, posOffset: 0 }, // = UL
        },
        teamLiveries: { PRO: 'assets/CL S23 Liveries/CL_S23_PRO.png', ASN: 'assets/CL S23 Liveries/CL_S23_ASN.png', SWR: 'assets/CL S23 Liveries/CL_S23_SWR.png', VLP: 'assets/CL S23 Liveries/CL_S23_VLP.png', CPR: 'assets/CL S23 Liveries/CL_S23_CPR.png', RGT: 'assets/CL S23 Liveries/CL_S23_RGT.png', HKB: 'assets/CL S23 Liveries/CL_S23_HKB.png', ARR: 'assets/CL S23 Liveries/CL_S23_ARR.png' }, // 2026-10-02/04; rest pending
        liveryNames: { ASN: 'SNR-REBORN-01', SWR: 'Williams S25M CL Autumn 2026', VLP: 'VLP-Manor', CPR: 'CPR-CL23 "Rafita"', RGT: 'RGT-S26', HKB: 'HKB 3.0', ARR: 'Arr-Star' },
        liveryCredits: { VLP: '@somehowimadeit' }, // livery designer — stored, not yet displayed
        // Lineups (2026-10-02): roster 'hl:S23'. Same 8 teams as S22; renames per signing message.
        teamNames: { VLP: 'Velocity PowerTeam Rykar', ASN: 'Andromeda Supernova' },
        // Colours sampled from liveries (CL S23 cars; PRO/ASN from their HL S22 cars until S23 ones land).
        teamColors: { SWR: '#0728C7', VLP: '#134094', CPR: '#710629', RGT: '#CC7608', HKB: '#18207F', ARR: '#1F7A3A', PRO: '#D19C06', ASN: '#7D34F1' },
      },
      { number: 22, suffix: null, label: 'S22', year: 2026, isCurrent: false, snapshot: null,
        // SUMMER 2026 — live from the NEW copied HL workbook (verified 2026-07-06 with the
        // same sprint-format Leaderboard column map as S21). Roster shuffle: SWR + RSR
        // promoted up from JL into HL; KMP + MZM promoted out (into UL). wins/podiums still
        // derived from the finish histogram (cols 64-66). Liveries added 2026-07-06
        // (user-supplied renders; VLP's file was unbranded "car_5" — no acronym visible).
        liveSource: {
          sheetId: '1biB0Ae-TiKIJpaHj5iqfW4YiWVDD-s4nBnbO8FPkBdw',
          tab: 'Leaderboard',
          driverCols: { pos: 0, name: 5, pts: 6, wins: 64, podiums: [64, 65, 66] },
          teamCols:   { pos: 85, short: 88, pts: 89 },
          // Per-race lineups: the "STARTING DRIVERS" grid at AA2:AE22 on each Round tab.
          // Two lineup columns (AB=27 left, AE=30 right), team headers with drivers
          // below + an "Independent" block. Walked newest-last so mid-season swaps win.
          lineupRounds: ['Round 1', 'Round 2', 'Round 3', 'Round 4', 'Round 5', 'Round 6', 'Round 7', 'Round 8', 'Round 9', 'Round 10'],
          lineupRoundCols: [27, 30],
          // Per-round Driver|Team table at F/G on the same Round tabs (verified live 2026-10-02 —
          // same layout as UL/JL). Feeds loadRoundDriverTeams (team pages) AND the lineup/ER rule in
          // loadLeaderboardStandings. HL S22 round tabs put LINEUPS at cols 21/24 (STARTING at 27/30).
          roundLineupCols: [21, 24],
          roundTeamTable: { rounds: ['Round 1', 'Round 2', 'Round 3', 'Round 4', 'Round 5', 'Round 6', 'Round 7', 'Round 8', 'Round 9', 'Round 10'], nameCol: 5, shortCol: 6 },
          independentShort: 'IND',
          topN: 18,
          teamNames:  { VLP: 'Velocity PowerTeam', ARR: 'Archon Racing', PRO: 'HRT Push Pro', HKB: 'HK Blues', CPR: 'Carpathians Peak Racing', RGT: 'RisingGoat', SWR: 'S25M Williams E-Racing', RSR: 'Rykar Supernova', IND: 'Independent' },
          teamColors: { VLP: '#2E86FF', ARR: '#20A64A', PRO: '#19C3B2', HKB: '#1B3A8F', CPR: '#9AA3AD', RGT: '#FFCE1F', SWR: '#2EA0FF', RSR: '#A98BE0', IND: '#8A9099' },
          teamLiveries: {
            VLP: 'assets/HL S22 Liveries/HL_S22_VLP.png',
            ARR: 'assets/HL S22 Liveries/HL_S22_ARR.png',
            PRO: 'assets/HL S22 Liveries/HL_S22_PRO.png',
            HKB: 'assets/HL S22 Liveries/HL_S22_HKB.png',
            CPR: 'assets/HL S22 Liveries/HL_S22_CPR.png',
            RGT: 'assets/HL S22 Liveries/HL_S22_RGT.png',
            SWR: 'assets/HL S22 Liveries/HL_S22_SWR.png',
            RSR: 'assets/HL S22 Liveries/HL_S22_RSR.png',
          },
        },
      },
      { number: 21, suffix: null, label: 'S21', year: 2026, isCurrent: false, snapshot: null,
        // liveSource — verified live against the HL Leaderboard tab. Sprint format shifts
        // every block right of UL. No dedicated wins/podiums cols: derived from the
        // finishing-position histogram (cols 64-83 = count of P1..P20 finishes) →
        // wins = P1 count (col 64); podiums = P1+P2+P3 (cols 64,65,66). Spot-check these.
        liveSource: {
          sheetId: '1C3GzC4GZepDNuLJDfZolW39PSFz2BfNQX5UMPaVJNtM',
          tab: 'Leaderboard',
          driverCols: { pos: 0, name: 5, pts: 6, wins: 64, podiums: [64, 65, 66] },
          teamCols:   { pos: 85, short: 88, pts: 89 },
          lineupCols: [111, 114],
          topN: 18,
          teamNames:  { KMP: 'Kenzer Motorsport', VLP: 'Velocity PowerTeam', MZM: 'Mazzar Motorsport', ARR: 'Archon Racing', PRO: 'HRT Push Pro', HKB: 'HK Blues', CPR: 'Carpathians Peak Racing', RGT: 'RisingGoat' },
          teamColors: { KMP: '#E10600', VLP: '#2E86FF', MZM: '#FF2E9A', ARR: '#20A64A', PRO: '#19C3B2', HKB: '#1B3A8F', CPR: '#9AA3AD', RGT: '#FFCE1F' },
          teamLiveries: {
            KMP: 'assets/HL Liveries/HL_S21_KMP.png',
            VLP: 'assets/HL Liveries/HL_S21_VLP.png',
            MZM: 'assets/HL Liveries/HL_S21_MZM.png',
            ARR: 'assets/HL Liveries/HL_S21_ARR.png',
            PRO: 'assets/HL Liveries/HL_S21_PRO.png',
            HKB: 'assets/HL Liveries/HL_S21_HKB.png',
            CPR: 'assets/HL Liveries/HL_S21_CPR.png',
            RGT: 'assets/HL Liveries/HL_S21_RGT.png',
          },
        },
      },
      { number: 20, suffix: null, label: 'S20', year: 2025, isCurrent: false, snapshot: null },
      { number: 19, suffix: null, label: 'S19', year: 2025, isCurrent: false, snapshot: null },
    ],
  },
  jl: {
    teamBased: true, hasSprints: false, numRaces: 5,
    source: {
      sheetId: '1iJObjF_i2359ps7ViorOc_dQ_fpNG8tKi_ESBm-yAY8',
      tab: 'Data gatherer',
      driverNameCol: 5,
      raceBase: (n) => 7 + (n - 1) * 3, // Q=base, R=base+1, P=base+2
      statCols: { champPos: 0, points: 6, podiums: 23, fastestLaps: 24, poles: 25, wins: 26, posGained: 27, avgFinish: 28 },
    },
    // JL splits into A/B half-seasons: …S21A, S21B, S22A, S22B, S23A (each half = one season label).
    seasons: [
      { number: 23, suffix: 'A', label: 'S23A', year: 2026, isCurrent: true, snapshot: null, // AUTUMN 2026 — R1-5; S23B = R6-10
        // LIVE (wired 2026-10-05): new workbook, SAME geometry as S22A (verified after R1). Round tabs' F/G mark
        // independents as "IND" (5 drivers in R1) → independentShort shows them as Independent, as in HL S22.
        liveSource: {
          sheetId: '1FZahB7JeqjSi32LwkIcsPahghCLk51YEddNK3LZBKrU',
          tab: 'Leaderboard',
          driverCols: { pos: 0, name: 5, pts: 6, wins: 30, podiums: [30, 31, 32] },
          teamCols:   { short: 54, pts: 55 },
          lineupCols: [68, 71],
          driverTeamTable: { rounds: ['Round 1', 'Round 2', 'Round 3', 'Round 4', 'Round 5'], nameCol: 5, shortCol: 6 },
          independentShort: 'IND',
          topN: 18,
          teamNames:  { ALR: 'Alfa Romeo Racing', AJR: 'Andromeda Jaguar Racing', SPR: 'Sparta Racing', BRR: 'British Rapid Racing', DCR: 'Dinoco Racing', TCR: 'Triple Crown Racing', '8SR': '8-Star Racing', IND: 'Independent' },
          teamColors: { IND: '#8A9099' },
          teamMinilogos: { TCR: 'assets/minilogos/TCR.png' },
        },
        // Lineups (2026-10-02): roster 'jl:S23A'. 7 teams: DCR ALR SPR BRR ASE 8SR TCR (ZIP gone).
        // ASE = old ASR re-acronymed (same role ID); 8SR renamed. BRR = NEW TEAM this season.
        // 2026-10-03 (user): the JL Ariane SpacEx team (ASE) is renamed Jaguar — the Ariane name moved to UL RKA.
        // 2026-10-04 (user): rebranded ANDROMEDA JAGUAR RACING, code ASE → AJR (FDT_ALIAS links AJR↔ASE↔ASR history).
        teamNames: { BRR: 'British Rapid Racing', AJR: 'Andromeda Jaguar Racing', '8SR': '8-Star Racing' },
        // Colours sampled from liveries (AJR = logo green; DCR/SPR/TCR/8SR from their S22A cars until S23A ones land).
        teamColors: { AJR: '#1E6B38', BRR: '#10830F', ALR: '#CC0917', DCR: '#E30E04', SPR: '#821D19', TCR: '#B8945D', '8SR': '#F36604' },
        teamMinilogos: { AJR: 'assets/minilogos/AJR.png', BRR: 'assets/minilogos/BRR.png' },
        teamLiveries: { BRR: 'assets/JL S23A Liveries/JL_S23A_BRR.png', AJR: 'assets/JL S23A Liveries/JL_S23A_ASE.png', ALR: 'assets/JL S23A Liveries/JL_S23A_ALR.png', SPR: 'assets/JL S23A Liveries/JL_S23A_SPR.png', '8SR': 'assets/JL S23A Liveries/JL_S23A_8SR.png', DCR: 'assets/JL S23A Liveries/JL_S23A_DCR.png', TCR: 'assets/JL S23A Liveries/JL_S23A_TCR.png' }, // 2026-10-02/04/07 — all 7 in
        liveryNames: { BRR: 'BRR-3', AJR: 'AJR-SDMrKMY-01', ALR: 'ALR-C05', SPR: 'SPR-09WCC', DCR: 'DCR~10 "Skeleton Rexy"' },
        // Independents (no team) pick one of 3 JL independent cars (user 2026-10-07). Assign per driver handle as they're known.
        independentCars: { red: 'assets/JL S23A Liveries/JL_S23A_IND_red.png', gold: 'assets/JL S23A Liveries/JL_S23A_IND_gold.png', green: 'assets/JL S23A Liveries/JL_S23A_IND_green.png' },
        independentLiveries: { g4: 'assets/JL S23A Liveries/JL_S23A_IND_green.png' },
      },
      { number: 22, suffix: 'A', label: 'S22A', year: 2026, isCurrent: false, snapshot: null,
        // EARLY SUMMER 2026 — CONFIRMED 'S22A' from the operator's Discord signing message
        // ("Season 22 A - Early Summer 2026"). Live from the NEW copied JL workbook (verified
        // 2026-07-06 with the same no-sprint Leaderboard column map as S20). Roster shuffle:
        // SWR + RSR promoted out (up to HL); NEW entrants TCR (Triple Crown Racing) + ASR
        // (Ariane SpacEx Rykar); 8SM re-acronymed to 8SR (Elysian Stride, sheet name still
        // "Elysian Stride 8-Star"). Teams listed in points order (no rank col). wins/podiums
        // from finish histogram (cols 30-32). Liveries added 2026-07-07.
        liveSource: {
          sheetId: '1m1xBFBtuDDrKMPgpaqtt6mohh2PbLcwMGHtfynKKcqo',
          tab: 'Leaderboard',
          driverCols: { pos: 0, name: 5, pts: 6, wins: 30, podiums: [30, 31, 32] },
          teamCols:   { short: 54, pts: 55 },
          lineupCols: [68, 71],
          topN: 18,
          teamNames:  { SPR: 'Sparta Racing', DCR: 'Dinoco Racing', ALR: 'Alfa Romeo Racing', ZIP: 'Zipex GP', TCR: 'Triple Crown Racing', ASR: 'Ariane SpacEx Rykar', '8SR': 'Elysian Stride 8-Star' },
          teamAliases: { '8-Star Racing': '8SR' }, // lineup-block header string
          // AUTHORITATIVE driver→team: each Round tab has an explicit `Driver | Team`
          // (name col 5 → acronym col 6) table. This overrides the Leaderboard "LINEUPS"
          // block (cols 68/71), which mis-groups drivers — e.g. it lists Peto under Triple
          // Crown Racing, but the round table correctly assigns him to DCR (Dinoco).
          driverTeamTable: { rounds: ['Round 1', 'Round 2', 'Round 3', 'Round 4', 'Round 5'], nameCol: 5, shortCol: 6 },
          teamColors: { SPR: '#9A1B1B', DCR: '#0B5BA6', ALR: '#8E1B2E', ZIP: '#F6A8C6', TCR: '#C9A227', ASR: '#1FA6A0', '8SR': '#5B8DEF' },
          teamMinilogos: {
            TCR: 'assets/minilogos/TCR.png',
            ASR: 'assets/minilogos/ASR.png',
          },
          teamLiveries: {
            SPR: 'assets/JL S22A Liveries/JL_S22A_SPR.png',
            DCR: 'assets/JL S22A Liveries/JL_S22A_DCR.png',
            ALR: 'assets/JL S22A Liveries/JL_S22A_ALR.png',
            ZIP: 'assets/JL S22A Liveries/JL_S22A_ZIP.png',
            TCR: 'assets/JL S22A Liveries/JL_S22A_TCR.png',
            ASR: 'assets/JL S22A Liveries/JL_S22A_ASR.png',
            '8SR': 'assets/JL S22A Liveries/JL_S22A_8SR.png',
          },
        },
      },
      { number: 21, suffix: 'B', label: 'S21B', year: 2026, isCurrent: false, snapshot: null,
        // PREVIOUS JL season. JL splits into A/B half-seasons, so the season immediately
        // before the current S22A is S21B (per operator, 2026-07-06). Relabeled from the
        // earlier bare "S20" guess. NOTE: its livery asset files still carry the old
        // "JL_S20B_*" filename stem — paths still resolve; rename the files if you want the
        // stem to match. Data still reads the OLD JL workbook (distinct from S22A's).
        // liveSource — verified live against the JL Leaderboard tab (no sprints).
        // No dedicated wins/podiums cols → derived from finish histogram (cols 30-83):
        // wins = col 30 (P1 count), podiums = cols 30+31+32. Teams listed in points order
        // (no rank column) → teamCols has no `pos`. Lineups at cols 68 & 71.
        liveSource: {
          sheetId: '1iJObjF_i2359ps7ViorOc_dQ_fpNG8tKi_ESBm-yAY8',
          tab: 'Leaderboard',
          driverCols: { pos: 0, name: 5, pts: 6, wins: 30, podiums: [30, 31, 32] },
          teamCols:   { short: 54, pts: 55 },
          lineupCols: [68, 71],
          topN: 18,
          teamNames:  { RSR: 'Rykar Supernova', SWR: 'S25M Williams Racing', SPR: 'Sparta Racing', '8SM': 'Elysian Stride 8-Star', DCR: 'Dinoco Racing', ALR: 'Alfa Romeo Racing', ZIP: 'Zipex GP' },
          teamColors: { RSR: '#A98BE0', SWR: '#2EA0FF', SPR: '#9A1B1B', '8SM': '#5B8DEF', DCR: '#0B5BA6', ALR: '#8E1B2E', ZIP: '#F6A8C6' },
          teamMinilogos: {
            '8SM': 'assets/minilogos/8SM.png',
          },
          teamLiveries: {
            ALR: 'assets/JL Liveries/JL_S20B_ALR.png',
            '8SM': 'assets/JL Liveries/JL_S20B_ESM.png',
            RSR: 'assets/JL Liveries/JL_S20B_RSR.png',
            SPR: 'assets/JL Liveries/JL_S20B_SPR.png',
            SWR: 'assets/JL Liveries/JL_S20B_SWR.png',
            ZIP: 'assets/JL Liveries/JL_S20B_ZIP.png',
            DCR: 'assets/JL Liveries/JL_S20B_DCR.png',
          },
        },
      },
      { number: 19, suffix: 'B', label: 'S19B', year: 2025, isCurrent: false, snapshot: null },
      { number: 19, suffix: 'A', label: 'S19A', year: 2025, isCurrent: false, snapshot: null },
    ],
  },
  al: {
    // AL is NOT team-based → no constructor standings (teams stays null). Also A/B splits.
    teamBased: false, hasSprints: false, numRaces: 5,
    defaultLivery: 'uploads/car_2.png', // Finguala's car
    source: {
      sheetId: '1wRC9aflXNFumfVE1D3MBq4M-LgWB0fhgWRDSAFJZiEE',
      tab: 'Data gatherer',
      driverNameCol: 5,
      raceBase: (n) => 7 + (n - 1) * 3,
      statCols: { champPos: 0, points: 6, podiums: 23, fastestLaps: 24, poles: 25, wins: 26, posGained: 27, avgFinish: 28 },
    },
    seasons: [
      { number: 23, suffix: 'A', label: 'S23A', year: 2026, isCurrent: true, snapshot: null, // AUTUMN 2026 — S23A CONFIRMED (R1 grid header "ALS23AR1"). R1 starters in roster 'al:S23A'.
        // LIVE (wired 2026-10-05): new workbook, same map as S21 (72-col Leaderboard, histogram 30-32). Still non-team.
        liveSource: {
          sheetId: '1_YE81iv9TcAY0amwbFIQUYmVLukMqahLKJPnUIhfJoY',
          tab: 'Leaderboard',
          driverCols: { pos: 0, name: 5, pts: 6, wins: 30, podiums: [30, 31, 32] },
          topN: 18,
        },
        // Liveries (2026-10-03, user): 10 cars, no driver mapping yet → by CHAMPIONSHIP ORDER like S21
        // (pre-season: roster grid order). P11+ → defaultLivery. Replace with a per-driver map when supplied.
        driverLiveriesByRank: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => `assets/AL S23A Liveries/car_${n}.png`), // fallback only
        // Cars ARE the AL teams (DB team = car livery). Matched visually 2026-10-05: 1 Apprentice Machine · 2 Tiger · 3 Force India ·
        // 4 Iced Lolly · 5 Lotus · 6 HAAS VF-25 Japan · 7 BAR 1999 · 8 One Blue Bull · 9 Rakyr Nebula 01 · 10 Renault. Each driver gets
        // the car of the team they started for (Bituko DB, via dbDriverTeamCodes); rank order is only the fallback.
        driverLiveriesByTeam: AL_CARS('assets/AL S23A Liveries'),
      },
      { number: 21, suffix: null, label: 'S21', year: 2026, isCurrent: false, snapshot: null,
        // liveSource — new copied workbook (2026-08-06). Sheet's own header cell reads
        // "Season 12B" (AL's internal numbering, independent of the S-series label used
        // here) — stale/irrelevant, per the same pattern as other leagues' rollovers.
        // Same column map as S20 (topN 18, no teamCols/lineupCols — AL stays non-team-based).
        // Liveries: user-supplied per-driver cars, assigned by CHAMPIONSHIP ORDER (P1 → car_1
        // … P10 → car_10; P11+ fall back to defaultLivery). Driver mapping to come later.
        liveSource: {
          sheetId: '1mXPfN26MW9qhsPUbwFTTsI6qLpLCOQFql5khSxIB-Og',
          tab: 'Leaderboard',
          driverCols: { pos: 0, name: 5, pts: 6, wins: 30, podiums: [30, 31, 32] },
          topN: 18,
          driverLiveriesByTeam: AL_CARS('uploads/AL S21 Liveries'), // same 10 cars as S23A (see S23A note)
          driverLiveriesByRank: [
            'uploads/AL S21 Liveries/car_1.png', 'uploads/AL S21 Liveries/car_2.png',
            'uploads/AL S21 Liveries/car_3.png', 'uploads/AL S21 Liveries/car_4.png',
            'uploads/AL S21 Liveries/car_5.png', 'uploads/AL S21 Liveries/car_6.png',
            'uploads/AL S21 Liveries/car_7.png', 'uploads/AL S21 Liveries/car_8.png',
            'uploads/AL S21 Liveries/car_9.png', 'uploads/AL S21 Liveries/car_10.png',
          ],
        },
      },
      { number: 20, suffix: null, label: 'S20', year: 2026, isCurrent: false, snapshot: null,
        // liveSource — verified live against the AL Leaderboard tab (no sprints).
        // teamBased:false (user decision) → NO teamCols / lineupCols, so the normalizer
        // returns teams=null (no constructor table) and drivers show no team. NOTE: the AL
        // sheet DOES contain a team-points section (RFA/BLT/BLZ…) but has no lineup section
        // to map drivers→teams. Label set to 'S20' per operator (sheet header cell read 19
        // but the real season is S20 — header cell is stale/unreliable; ignore it).
        liveSource: {
          sheetId: '1wRC9aflXNFumfVE1D3MBq4M-LgWB0fhgWRDSAFJZiEE',
          tab: 'Leaderboard',
          driverCols: { pos: 0, name: 5, pts: 6, wins: 30, podiums: [30, 31, 32] },
          topN: 18,
        },
      },
      { number: 19, suffix: null, label: 'S19', year: 2025, isCurrent: false, snapshot: null },
      { number: 18, suffix: null, label: 'S18', year: 2025, isCurrent: false, snapshot: null },
      { number: 17, suffix: null, label: 'S17', year: 2024, isCurrent: false, snapshot: null },
      { number: 16, suffix: null, label: 'S16', year: 2024, isCurrent: false, snapshot: null },
      { number: 15, suffix: null, label: 'S15', year: 2024, isCurrent: false, snapshot: null },
    ],
  },
};

// ── STORE API ────────────────────────────────────────────────────────────────
// Cached so every page/section shares ONE computed result per (league, season).
const _standingsCache = {};

// Module-level live-fetch state — persists across React re-renders / remounts.
const _liveStandingsCache = {}; // key → resolved Standings (after successful fetch)
const _liveFetchInFlight  = {}; // key → Promise (while fetch is running)

// Build the canonical Season object from adapter + season def.
function _buildSeason(adapter, sdef, league) {
  return {
    league, number: sdef.number, suffix: sdef.suffix, label: sdef.label, year: sdef.year,
    numRaces: adapter.numRaces, hasSprints: sdef.hasSprints != null ? sdef.hasSprints : adapter.hasSprints, teamBased: adapter.teamBased,
    isCurrent: !!sdef.isCurrent,
  };
}

function _ulArchLiveries(n, codes) {
  const o = {}; codes.forEach((c) => { o[c] = `assets/UL S${n} Liveries/UL_S${n}_${c}.png`; }); return o;
}
function _ulArchSpecials(n, byTeam) {
  const o = {};
  Object.keys(byTeam).forEach((c) => { o[c] = Object.keys(byTeam[c]).map((r) => ({ rounds: String(r), name: byTeam[c][r], file: `assets/UL S${n} Liveries/UL_S${n}_${c}_R${r}.png` })); });
  return o;
}
// Season list for a league's dropdown: [{ id, label, isCurrent, hasData }] (most recent first).
function getSeasons(league) {
  const adapter = UR2D_ADAPTERS[league];
  if (!adapter) return [];
  return adapter.seasons.map((s) => ({ id: s.label, label: s.label, isCurrent: !!s.isCurrent, hasData: !!(s.snapshot || s.liveSource || s.db), dbOnly: !!s.dbOnly }));
}

// Default season for DATA pages (Standings / Teams / Drivers). The `isCurrent` season
// can be an announced-but-no-data season (e.g. S25 — schedule out, standings TBA); landing
// a data page there shows a bare "coming soon". So prefer the current season only when it
// actually has data, otherwise fall to the most recent season that does. Once the current
// season's data lands (snapshot or liveSource), this auto-advances to it. Takes a league id
// or an already-built getSeasons() array.
// 2026-10-03 (user): the site now defaults to the UPCOMING/current season everywhere, even before
// its workbook exists — getStandings fills it with a pre-season view built from the roster
// (teams + signed mains, 0 pts, `preseason: true`). Name kept so every consumer switches at once.
function defaultDataSeasonId(leagueOrSeasons) {
  const seasons = Array.isArray(leagueOrSeasons) ? leagueOrSeasons : getSeasons(leagueOrSeasons);
  const pick = seasons.find((s) => s.isCurrent) || seasons.find((s) => s.hasData) || seasons[0];
  return pick && pick.id;
}

// Pre-season standings for a current season with no data yet: roster teams + mains, all 0 pts.
// Team name/colour/minilogo walk back through the league's seasons (season-level, then liveSource);
// liveries come ONLY from this season (no stale cars). Returns null when no roster is loaded.
function _preseasonStandings(adapter, sdef, league, season) {
  const r = typeof window.getRoster === 'function' ? window.getRoster(league, sdef.label) : null;
  if (!r) return null;
  const i0 = Math.max(0, adapter.seasons.indexOf(sdef));
  const meta = (field, k) => {
    for (const s of adapter.seasons.slice(i0)) for (const o of [s, s.liveSource || {}]) if (o[field] && o[field][k]) return o[field][k];
    return null;
  };
  const pName = (h) => { const p = (r.people && r.people[h]) || {}; return p.sheetName || p.display || h; };
  const shorts = Object.keys(r.teams || {});
  const teams = adapter.teamBased && shorts.length ? shorts.map((sh) => ({
    short: sh, name: meta('teamNames', sh) || sh, pts: 0,
    color: meta('teamColors', sh), secondaryColor: meta('teamSecondaryColors', sh),
    livery: (sdef.teamLiveries && sdef.teamLiveries[sh]) || null, minilogo: meta('teamMinilogos', sh),
  })) : null;
  const drivers = [];
  shorts.forEach((sh) => (r.teams[sh].mains || []).filter(Boolean).forEach((h) => drivers.push({ name: pName(h), team: adapter.teamBased ? sh : null, pts: 0 })));
  (r.independents || []).forEach((h) => drivers.push({ name: pName(h), team: null, pts: 0 }));
  const seen = new Set();
  const uniq = drivers.filter((d) => { const k = String(d.name).toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; });
  if (sdef.driverLiveriesByRank) uniq.forEach((d, i) => { d.livery = sdef.driverLiveriesByRank[i] || null; }); // pre-season: roster/grid order
  return { status: 'ready', preseason: true, league, ...normalizeStandings({ teams, drivers: uniq }, season) };
}

// Shared season pick (per league) — Standings ↔ matrix keep the season you last clicked. Session-scoped.
function useSharedSeason(league) {
  const key = 'ur2d-season:' + league;
  const read = () => { try { return sessionStorage.getItem(key); } catch (e) { return null; } };
  const [val, setVal] = React.useState(read);
  React.useEffect(() => { setVal(read()); }, [league]);
  // Live sync: a pick in any instance (standings selector → points graph, etc.) updates the others.
  React.useEffect(() => {
    const on = (e) => { if (e.detail && e.detail.key === key) setVal(e.detail.v); };
    window.addEventListener('ur2d-season', on);
    return () => window.removeEventListener('ur2d-season', on);
  }, [key]);
  const set = React.useCallback((v) => { try { sessionStorage.setItem(key, v); } catch (e) {} setVal(v); window.dispatchEvent(new CustomEvent('ur2d-season', { detail: { key, v } })); }, [key]);
  return [val, set];
}

// Resolve which season to show: explicit id → current → first listed.
// Roster lineups for the ER rule: { short: [every name form of the signed mains + reserve] } (null = no roster).
function _rosterLineupNames(league, label) {
  const r = typeof window.getRoster === 'function' ? window.getRoster(league, label) : null;
  if (!r || !r.teams) return null;
  const out = {};
  Object.entries(r.teams).forEach(([sh, t]) => {
    out[sh] = [...(t.mains || []), t.reserve].filter(Boolean).flatMap((h) => {
      const p = (r.people && r.people[h]) || (typeof window.getPerson === 'function' ? window.getPerson(h) : null);
      return [h, p && p.sheetName, p && p.display, ...((p && p.aliases) || [])].filter(Boolean);
    });
  });
  return out;
}
function _resolveSeasonDef(adapter, seasonId) {
  return (
    adapter.seasons.find((s) => s.label === seasonId) ||
    adapter.seasons.find((s) => s.isCurrent) ||
    adapter.seasons[0]);
}

// getStandings — SYNC. Always returns snapshot (or empty). Used as the baseline/fallback.
function getStandings(league, seasonId) {
  const adapter = UR2D_ADAPTERS[league];
  if (!adapter || !adapter.seasons.length) {
    return { status: 'empty', league, season: null, teams: null, drivers: [] };
  }
  const sdef = _resolveSeasonDef(adapter, seasonId);
  const cacheKey = `${league}:${sdef.label}`;
  if (_standingsCache[cacheKey]) return _standingsCache[cacheKey];

  const season = _buildSeason(adapter, sdef, league);
  if (!sdef.snapshot && !sdef.liveSource && sdef.isCurrent) {
    const pre = _preseasonStandings(adapter, sdef, league, season);
    if (pre) { _standingsCache[cacheKey] = pre; return pre; }
    return { status: 'empty', league, season, teams: null, drivers: [] }; // roster not loaded yet — don't cache
  }
  const result = !sdef.snapshot
    ? { status: 'empty', league, season, teams: null, drivers: [] }
    : { status: 'ready', league, ...normalizeStandings(sdef.snapshot, season) };
  _standingsCache[cacheKey] = result;
  return result;
}

// useStandings — React hook. Tries live fetch first (if the season has a liveSource),
// falls back to snapshot on error. Pages only see status changes.
//
// status values:
//   'loading' — fetch in flight; snapshot shown beneath (or spinner if no snapshot)
//   'ready'   — data confirmed (live or snapshot)
//   'empty'   — no data for this season (snapshot is null, no liveSource)
//   'error'   — fetch failed; snapshot is shown; page may show a stale indicator
function useStandings(league, seasonId) {
  const adapter = UR2D_ADAPTERS[league];
  const sdef    = adapter ? _resolveSeasonDef(adapter, seasonId) : null;
  const cacheKey = sdef ? `${league}:${sdef.label}` : `${league}:none`;

  // Sync baseline — snapshot or empty. Shown immediately and used as fallback.
  const baseline = React.useMemo(() => getStandings(league, seasonId), [league, seasonId]);

  const [liveResult,  setLiveResult]  = React.useState(() => _liveStandingsCache[cacheKey] || null);
  const [fetchStatus, setFetchStatus] = React.useState(() =>
    _liveStandingsCache[cacheKey] ? 'ready' :
    _liveFetchInFlight[cacheKey]  ? 'loading' : 'idle'
  );

  React.useEffect(() => {
    if (!sdef?.liveSource && !sdef?.db) return; // no sheet / DB source for this season — stay on baseline

    // Already resolved from a previous mount
    if (_liveStandingsCache[cacheKey]) {
      setLiveResult(_liveStandingsCache[cacheKey]);
      setFetchStatus('ready');
      return;
    }

    // Already in-flight — subscribe to its completion without firing a second fetch
    if (_liveFetchInFlight[cacheKey]) {
      setFetchStatus('loading');
      _liveFetchInFlight[cacheKey]
        .then((r) => { setLiveResult(r || null); setFetchStatus(r ? 'ready' : 'error'); })
        .catch(() => setFetchStatus('error'));
      return;
    }

    // Start a fresh fetch
    setFetchStatus('loading');
    const season = _buildSeason(adapter, sdef, league);
    // THE SPLIT: a finished season flagged `db` (ur2d-db.jsx) reads Bituko's database; all others read their sheet.
    const useDb = !!(sdef.db && typeof window.loadDbStandings === 'function');
    const src = useDb ? null : { ...sdef.liveSource,
      teamLiveries: { ...(sdef.liveSource.teamLiveries || {}), ...(sdef.teamLiveries || {}) },
      teamNames:    { ...(sdef.liveSource.teamNames || {}),    ...(sdef.teamNames || {}) },
      sheetTeamNames: sdef.liveSource.teamNames || {}, // raw sheet strings — lineup-header matching
      rosterLineups: _rosterLineupNames(league, sdef.label),
      teamColors:   { ...(sdef.liveSource.teamColors || {}),   ...(sdef.teamColors || {}) },
      teamMinilogos: { ...(sdef.liveSource.teamMinilogos || {}), ...(sdef.teamMinilogos || {}) },
      driverLiveriesByRank: sdef.driverLiveriesByRank || sdef.liveSource.driverLiveriesByRank };
    const byTeam = sdef.driverLiveriesByTeam || (sdef.liveSource && sdef.liveSource.driverLiveriesByTeam);
    const p = (useDb ? window.loadDbStandings(league, sdef) : loadLeaderboardStandings(src))
      .then(async (raw) => {
        // AL: each driver's car = the team they started for (DB). Unmatched drivers keep the rank-order fallback.
        if (byTeam && typeof window.dbDriverTeamCodes === 'function') {
          try {
            const tm = await window.dbDriverTeamCodes(league, sdef);
            const nk = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
            (raw.drivers || []).forEach((d) => { const c = tm[nk(d.name)]; if (c && byTeam[c]) { d.livery = byTeam[c]; d.carTeam = c; } });
          } catch (e) { console.warn('[ur2d-store] AL car mapping failed:', e.message); }
        }
        // Independents' own cars this season (season-level `independentLiveries`: roster handle → file).
        const indep = sdef.independentLiveries || (sdef.liveSource && sdef.liveSource.independentLiveries);
        if (indep && typeof window.resolvePerson === 'function') {
          (raw.drivers || []).forEach((d) => { if (d.livery) return; const p = window.resolvePerson(d.name); const f = p && indep[p.handle]; if (f) d.livery = f; });
        }
        const result = { status: 'ready', league, ...normalizeStandings(raw, season) };
        _liveStandingsCache[cacheKey] = result;
        return result;
      })
      .catch((err) => {
        console.warn('[ur2d-store] live fetch failed, using snapshot:', err.message);
        return null;
      });
    _liveFetchInFlight[cacheKey] = p;
    p.then((r) => { setLiveResult(r || null); setFetchStatus(r ? 'ready' : 'error'); })
     .catch(() => setFetchStatus('error'));
    // No cleanup — the in-flight promise is module-level, so remounts re-subscribe
    // to the same promise rather than firing a second fetch.
  }, [cacheKey]);

  if (liveResult)               return liveResult;
  if (fetchStatus === 'loading') return { ...baseline, status: 'loading' };
  if (fetchStatus === 'error')   return { ...baseline, status: 'error' };
  return baseline;
}

// ─────────────────────────────────────────────────────────────────────────
// CALENDAR — 2026 season schedule (shared track per raceday across leagues)
// ─────────────────────────────────────────────────────────────────────────
const UR2D_CALENDAR_TIMES = { ul: '17:50', hl: '16:10', jl: '15:15', al: '14:30' }; // all UTC

// CALENDAR SEASONS — each is one shared raceday schedule (same tracks across all four
// leagues; only the start time differs per league). The Calendar page is UL-centric, so
// seasons are keyed by the UL season label. Most-recent first; `isCurrent` is the default.
// Index N here aligns with index N of every league's standings seasons[] (leagues roll
// forward together), so a calendar-season index resolves each league's matching season.
const UR2D_CALENDARS = [
  {
    // AUTUMN 2026 — FULL SCHEDULE (operator, 2026-10-02): "26° LEAGUE - AUTUMN 2026 SEASON - RETROS".
    // UL S26 / CL (ex-HL) S23 / JL S23A (R1-5) + S23B (R6-10) and AL S23A share these tracks (AL confirmed same schedule, 2026-10-04).
    // `laps` = UL count; `lapsByLeague` = CL (75%) / JL (55%) counts where announced (rest '??').
    // Start times shift +1h from R4 (clocks go back): `timesFrom` overrides the base times.
    label: 'S26', period: 'Autumn 2026', year: 2026, isCurrent: true,
    midSeasonBreakAfter: 5,
    times: { ul: '17:50', hl: '16:10', jl: '15:15', al: '14:30' },
    timesFrom: { round: 4, times: { ul: '18:50', hl: '17:10', jl: '16:15', al: '15:30' } },
    races: [
      { round: 1,  track: 'Lutetia', trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3548574750', creator: 'Ellenoire', grandPrix: 'French Grand Prix', date: '2026-10-04', laps: 43, lapsByLeague: { hl: 32, jl: 24 }, minimap: 'assets/minimaps/sm/lutetia.png', trackImage: 'assets/tracks/sm/lutetia.jpg' },
      { round: 2,  track: 'Barcelona', trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3786393876', creator: 'Vincy', grandPrix: 'Spanish Grand Prix', date: '2026-10-11', laps: 44, lapsByLeague: { hl: 33, jl: 24 }, minimap: 'assets/minimaps/sm/barcelona.png', trackImage: 'assets/tracks/sm/barcelona.jpg' },
      { round: 3,  track: 'Assen', trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3663935043', creator: 'ManBearGoat', grandPrix: 'Dutch Grand Prix', date: '2026-10-18', laps: null, minimap: 'assets/minimaps/sm/assen.png', trackImage: 'assets/tracks/sm/assen.jpg' },
      { round: 4,  track: 'NRing', trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3803305092', creator: 'Bituko', grandPrix: 'Russian Grand Prix', date: '2026-10-25', laps: null, minimap: 'assets/minimaps/sm/nring.png', trackImage: 'assets/tracks/sm/nring.jpg' },
      { round: 5,  track: 'Belgium', trackUrl: 'https://www.youtube.com/watch?v=7qVIwT33Oek', creator: 'Base Game', grandPrix: 'Belgian Grand Prix', date: '2026-11-01', laps: 64, lapsByLeague: { hl: 48, jl: 35 }, minimap: 'assets/minimaps/sm/belgium.png', trackImage: 'assets/tracks/sm/belgium.jpg', note: '6 Year Anniversary of UL', special: '6 years of UL' },
      { round: 6,  track: 'Verdesca', trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3476861595', creator: 'Guzz1man', grandPrix: 'Italian Grand Prix', date: '2026-11-15', laps: null, minimap: 'assets/minimaps/sm/verdesca.png', trackImage: 'assets/tracks/sm/verdesca.jpg' },
      { round: 7,  track: 'Niagara Falls', trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=2864688154', creator: 'Danimator', grandPrix: 'United States Grand Prix', date: '2026-11-22', laps: null, minimap: 'assets/minimaps/sm/niagara.png', trackImage: 'assets/tracks/sm/niagara.jpg' },
      { round: 8,  track: 'Prejmer Raceway', trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3614553801', creator: 'Bituko', grandPrix: 'Romanian Grand Prix', date: '2026-11-29', laps: null, minimap: 'assets/minimaps/sm/prejmer.png', trackImage: 'assets/tracks/sm/prejmer.jpg' },
      { round: 9,  track: 'Canary Island', trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3756077203', creator: 'Guzz1man', grandPrix: 'Canarian Grand Prix', date: '2026-12-06', laps: null, minimap: 'assets/minimaps/sm/canary.png', trackImage: 'assets/tracks/sm/canary.jpg' },
      { round: 10, track: 'Monaco', trackUrl: 'https://www.youtube.com/watch?v=frExHKWWeg8', creator: 'Base Game', grandPrix: 'Monaco Grand Prix', date: '2026-12-13', laps: null, minimap: 'assets/minimaps/sm/monaco.png', trackImage: 'assets/tracks/sm/monaco.jpg', note: '250th UL Race & 750th FeS Leagues Round', special: '250th UL race' },
    ],
  },
  {
    label: 'S25', period: 'Summer 2026', year: 2026, isCurrent: false,
    midSeasonBreakAfter: 5, // break inserted after round 5
    // Full 10-round schedule confirmed (UL S25 / HL S22 / JL S22A+S22B share these tracks;
    // only start times differ per league). `laps` holds the UL feature-race lap count.
    // Track images/minimaps wired for all 10 rounds.
    races: [
      { round: 1,  track: 'Suzuka',            trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3733747319', creator: 'Guzz1man',  grandPrix: 'Japanese Grand Prix',            date: '2026-07-05', laps: 40, minimap: null, trackImage: 'assets/tracks/sm/suzuka.jpg' },
      { round: 2,  track: 'Spa-Francorchamps',  trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3500315138', creator: 'Guzz1man',  grandPrix: 'Belgian Grand Prix',             date: '2026-07-12', laps: 35, minimap: null, trackImage: 'assets/tracks/sm/spa.jpg' },
      { round: 3,  track: 'Jakobsberg',        trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3742075688', creator: 'vincyyy',   grandPrix: 'Swedish Grand Prix',             date: '2026-07-19', laps: 39, minimap: null, trackImage: 'assets/tracks/sm/jakobsberg.jpg' },
      { round: 4,  track: 'Friesland',         trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3482948854', creator: 'lil_wunn',  grandPrix: 'Dutch Grand Prix',               date: '2026-07-26', laps: 49, minimap: null, trackImage: 'assets/tracks/sm/friesland.jpg' },
      { round: 5,  track: 'Imola',             trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3746646064', creator: 'Bituko',    grandPrix: 'Italian Grand Prix',             date: '2026-08-02', laps: 46, minimap: null, trackImage: 'assets/tracks/sm/imola.jpg' },
      { round: 6,  track: 'Highlands Park',    trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3747926907', creator: 'Bituko',    grandPrix: 'Gold Coast Grand Prix', iso: 'au-qld',          date: '2026-08-09', laps: 32, minimap: null, trackImage: 'assets/tracks/sm/highlands.jpg' },
      { round: 7,  track: 'Sapporo',           trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3733338287', creator: 'vincyyy',   grandPrix: 'Sapporo International Grand Prix', iso: 'jp-01', date: '2026-08-16', laps: 39, minimap: null, trackImage: 'assets/tracks/sm/sapporo.jpg' },
      { round: 8,  track: "Winnipeg's Pass",   trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3715397779', creator: 'vincyyy',   grandPrix: 'Canadian Grand Prix',            date: '2026-08-23', laps: 40, minimap: null, trackImage: 'assets/tracks/sm/winnipeg.jpg' },
      { round: 9,  track: 'Albert Park',       trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3498829103', creator: 'Guzz1man',  grandPrix: 'Australian Grand Prix',          date: '2026-08-30', laps: 42, minimap: null, trackImage: 'assets/tracks/sm/albertpark.jpg' },
      { round: 10, track: 'Allenbeek',         trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3653098466', creator: 'elleno1re', grandPrix: 'Arcana East Flanders Grand Prix', iso: 'be-vov', date: '2026-09-06', laps: 41, minimap: null, trackImage: 'assets/tracks/sm/allenbeek.jpg' },
    ],
  },
  {
    // Previous season (Spring 2026) — the completed 10-round schedule. UL ran this as S24,
    // HL as S21, JL as S20, AL as S20. Selectable from the Calendar page season dropdown.
    label: 'S24', period: 'Spring 2026', year: 2026, isCurrent: false,
    midSeasonBreakAfter: 5,
    races: [
      { round: 1,  track: 'Qatar',            trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3623643447', creator: 'Guzz1man',  grandPrix: 'Qatar Grand Prix',        date: '2026-04-05', laps: 41, minimap: null, trackImage: 'assets/tracks/sm/lusail.jpg' },
      { round: 2,  track: 'Kyalami',          trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3429129271', creator: 'Guzz1man',  grandPrix: 'South African Grand Prix', date: '2026-04-12', laps: 43, minimap: null, trackImage: 'assets/tracks/sm/kyalami.jpg' },
      { round: 3,  track: 'Jarama',           trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3686927713', creator: 'Jeruniy',   grandPrix: 'Spanish Grand Prix',      date: '2026-04-19', laps: 44, minimap: null, trackImage: 'assets/tracks/sm/jarama.jpg' },
      { round: 4,  track: 'Chernobyl',        trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3662736714', creator: 'Hnandi',    grandPrix: 'Ukrainian Grand Prix',    date: '2026-04-26', laps: 38, minimap: null, trackImage: 'assets/tracks/sm/chernobyl.jpg' },
      { round: 5,  track: 'Hockenheim',       trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3662902021', creator: 'Albono',    grandPrix: 'German Grand Prix',       date: '2026-05-03', laps: 48, minimap: null, trackImage: 'assets/tracks/sm/hockenheim.jpg' },
      { round: 6,  track: 'Batavia Park',     trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3338947403', creator: 'Elleno1re', grandPrix: 'Indonesian Grand Prix',   date: '2026-05-17', laps: 28, minimap: null, trackImage: 'assets/tracks/sm/batavia.jpg' },
      { round: 7,  track: 'Fatra',            trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3660026036', creator: 'Hnandi',    grandPrix: 'Slovakian Grand Prix',    date: '2026-05-24', laps: 42, minimap: null, trackImage: 'assets/tracks/sm/fatra.jpg' },
      { round: 8,  track: 'Tenbridge',        trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3539979242', creator: 'Elleno1re', grandPrix: 'British Grand Prix',      date: '2026-05-31', laps: 40, minimap: null, trackImage: 'assets/tracks/sm/tenbridge.jpg' },
      { round: 9,  track: 'Sepang',           trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3640067695', creator: 'Guzz1man',  grandPrix: 'Malaysian Grand Prix',    date: '2026-06-07', laps: 36, minimap: null, trackImage: 'assets/tracks/sm/sepang.jpg' },
      { round: 10, track: 'Circuit of Wales', trackUrl: 'https://steamcommunity.com/sharedfiles/filedetails/?id=3658033851', creator: 'Bituko',    grandPrix: 'Welsh Grand Prix',        date: '2026-06-14', laps: 38, minimap: null, trackImage: 'assets/tracks/sm/wales.jpg' },
    ],
  },
];

// Back-compat alias — several modules read UR2D_CALENDAR.times / .races / .season directly
// (raceday brain, home countdown, hero schedule). Always points at the CURRENT season.
const _UR2D_CAL_CURRENT = UR2D_CALENDARS.find((c) => c.isCurrent) || UR2D_CALENDARS[0];
const UR2D_CALENDAR = {
  season: _UR2D_CAL_CURRENT.label,
  defaultTime: '17:50 UTC',
  midSeasonBreakAfter: _UR2D_CAL_CURRENT.midSeasonBreakAfter,
  times: _UR2D_CAL_CURRENT.times || UR2D_CALENDAR_TIMES,
  races: _UR2D_CAL_CURRENT.races,
};

// Season list for the Calendar page dropdown: [{ id, label, period, isCurrent, hasData }].
function getCalendarSeasons() {
  return UR2D_CALENDARS.map((c) => ({ id: c.label, label: c.label, period: c.period, year: c.year || null, isCurrent: !!c.isCurrent, hasData: true, fromDb: !!c.fromDb }));
}

function getCalendar(league, seasonId) {
  const leagueKey = (league || 'ul').toLowerCase();
  const cal =
    (seasonId && UR2D_CALENDARS.find((c) => c.label === seasonId)) ||
    UR2D_CALENDARS.find((c) => c.isCurrent) ||
    UR2D_CALENDARS[0];
  const today = new Date();
  today.setHours(0, 0, 0, 0);

  let nextAssigned = false;
  const baseTimes = cal.times || UR2D_CALENDAR_TIMES;
  const races = cal.races.map((race) => {
    const tf = cal.timesFrom;
    const utcHHMM = (race.utcByLeague && race.utcByLeague[leagueKey]) || ((tf && race.round >= tf.round) ? tf.times[leagueKey] : baseTimes[leagueKey]) || baseTimes[leagueKey] || '17:50';
    // TBA rounds (no confirmed date) never count as 'done'; the first one still in play
    // becomes 'next'. The time slot is fixed per league, so utcTime is always known.
    let status, localTime = 'TBA', localDate = 'TBA';
    if (race.date) {
      const raceDate = new Date(`${race.date}T${utcHHMM.padStart(5,'0')}:00Z`);
      // Local midnight of the raceday (do NOT use new Date('YYYY-MM-DD') — that parses as
      // UTC and lands on the previous day for negative-offset visitors, retiring the round
      // a day early). A round stays 'next' all through its own raceday (Sunday) and only
      // flips to 'done' at local midnight, so the next track appears on Monday.
      const [ry, rm, rd] = race.date.split('-').map(Number);
      const raceDateMidnight = new Date(ry, rm - 1, rd, 0, 0, 0, 0);

      if (raceDateMidnight < today) {
        status = 'done';
      } else if (!nextAssigned) {
        status = 'next';
        nextAssigned = true;
      } else {
        status = 'upcoming';
      }

      // Format to visitor's local timezone
      localTime = raceDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' });
      localDate = raceDate.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
    } else if (!nextAssigned) {
      status = 'next';
      nextAssigned = true;
    } else {
      status = 'upcoming';
    }

    return { ...race, utcHHMM, utcTime: `${utcHHMM} UTC`, localTime, localDate, status };
  });

  return {
    season: cal.label,
    seasonPeriod: cal.period,
    year: cal.year,
    midSeasonBreakAfter: cal.midSeasonBreakAfter,
    fromDb: !!cal.fromDb, // older season loaded from Bituko's DB (ur2d-db.jsx) + data/ul-schedules.js extras
    ulTimeLabel: cal.ulTimeLabel || null, // archive seasons: UL start time(s) as posted, e.g. '17:50 / 18:50'
    races,
  };
}

// ── DRIVER CAR NUMBERS (career registry) ─────────────────────────────────────
// Source: docs.google.com/spreadsheets/d/1Yc0cBeZuDQCM43WS767uWT5ro2WEA-RS
// Keyed by lowercased registry name (asterisks stripped). Numbers are career-
// allocated, not season-specific. Normalizer applies fuzzy matching:
// exact → strip trailing digits/team-codes → strip spaces — so sheet-name
// variants like 'Peto95', 'gaelprad29', 'Juan53RMC' resolve correctly.
// Source of truth (2026-10-09): soltari's "tariChart™️" — published sheet, PC (UL) numbers, "updated: 07/25":
// docs.google.com/spreadsheets/d/e/2PACX-1vQTQoSNXKBMaBmQhRYpfYRubhe8KK8b7uXFTIxB98vYIzXFl3_qmAuQ6mKdimufxaI_yiFNhsSLaper/pub?gid=0&single=true&output=csv
// Grid: number row then name row (cols 3-23); #1 = reserved for the WDC, ∞ = Kay. Numbers free after 5 UL seasons without driving.
// LEGACY = the old career registry (1Yc0cBeZu…) for names NOT on the tariChart whose number nobody holds now (history only).
// Dropped legacy (number now held by someone else): ax1s=4, manbeargoa=6, gorlomi=14, gamer-15=15, majumdar=28, kochinski=29, pemucius=44, matimar=46, cr4f=48, sewasd=57, jonifu36=62, _cadr=71, frozeni=72, alex_1632.=87, dziuraura=88, blank6050=101, 74vzs=777, julian5545=787.
const UR_DRIVER_NUMBER = {
  'jakebrad': 2, 'glow': 3, 'axis': 4, 'fantomy': 5, 'mbg': 6, 'dogamer': 7, 'polak23': 8, 'fedef1-11': 11, 'nunothe2nd': 12, 'jeruniy': 13, 'finguala': 14, 'gamer15': 15, 'cubo': 17, 'sudjuche': 20, 'urist': 23, 'il_biscione': 24, 'hnandi': 25, 'flyx': 26, 'tomatoes': 27, 'justin': 28, 'nomscar': 29, 'bituko': 33, 'corvid': 36, 'eloksian': 37, 'bananaboi': 38, 'g4': 44, 'mrkrunk': 46, 'soltari': 48, "kad'o": 51, 'sejc': 53, 'guzz1man': 55, 'strudl': 57, 'jonifu': 62, 'toaster64': 64, 'cola': 67, 'cadr': 71, 'sigmabob': 72, 'youa': 74, 'nosvw': 77, 'r.ber': 83, 'albono': 86, 'f1fan': 87, 'dziura': 88, 'peto': 95, 'figoeira': 99, 'blank': 101, 'nikhil': 103, 'lexus112': 112, 'dimartino': 117, 'mika2mika': 222, 'crustunet': 404, 'az0t': 407, 'kalitka': 555, 'm. ludwig': 671, 'pp_kratochvil': 711, 'ozi': 777, 'kiato': 787, 'rikkieboy12': 920,
  // ── legacy ──
  'nikkel09': 9, 'himppo': 16, 'gay_m0ngoose': 18, 'scip': 19, 'sobel': 22, 'nicominardi': 30, 'istolethesewords': 31, 'alexeydrozdov': 32, 'piggy': 34, 'x6ten': 35, 'fen1x': 39, 'daromasgames': 40, 'suzakuserizawa': 41, 'dpriwan': 42, 'furkhang.552': 47, 'guetzli': 50, 'ophiuko': 52, 'bunyoitsjames': 58, 'illyrian_03': 59, 'mrkimster': 60, 'sylisse': 61, 'simonsawthat': 63, 'russzki': 66, 'rikkieboy': 69, 'drizo17': 70, 'palmbyrosia': 73, 'emaboxe': 75, 'ethan8174': 76, 'drazerr': 78, 'mikey (wtf?!)': 79, 'vlad0204': 80, 'mr.krunk': 81, 'nandosgude': 82, 'kangekcr (dsz)': 84, 'gregokaj': 85, 'meeyck97': 90, 'ronni': 91, 'wearnest': 92, 'rafita': 93, 'xilverwolf': 96, 'emeryaku': 97, 'foxyline7': 98, 'bukudiddi_65471': 100, 'skibidi': 111, 'horizontrack': 114, 'punkf4': 116, 'vincy gt is life': 120, 'barkor': 121, 'bisbis_25': 125, 'gaelprad': 129, '3xie': 135, 'go14': 147, 'adrivv15': 150, 'umbelino._48719': 157, 'mystic': 171, 'foxofthemoon': 200, 'ka1pro': 203, 'ferry_20': 220, 'tolyww': 226, 'ilyadc': 228, 'hand_saniztizer': 234, 'macimovic': 255, 'felixlotus': 256, 'jetyx': 278, 'ezmyi': 296, 'karlitos31h': 331, 'hipp0cat': 337, 'mascabrevas': 338, 'mathegyl': 359, 'thewiz16': 420, '.anthonyparis51': 441, 'thehypersoft': 449, 'maiko': 474, 'anthonyparis51': 551, '.amoir': 552, 'zekisverse': 553, 'indigo_sirgaming': 554, 'asyeet12': 559, 'sundaydriver': 654, 'despacito7': 666, '.smileforyou': 667, 'maxtrix (kmp)': 668, 'yt_inferrad': 717, 'rachel windsor': 727, 'sebas': 779, 'aaaron_124': 802, 'studious (arr)': 803, 'lumaperone': 820, 'monacoggrandprix': 883, 'monomaster': 887, 'drazer | 88 (cpr)': 888, 'lewpick36': 909, 'igiava': 944, 'alr_mikesans': 957, 'tixmanthefirst': 991, 'edvarditos': 996, 'dbhp': 998,
};

// ── DRIVER NATIONALITIES ──────────────────────────────────────────────────────
// Keyed in lowercase so lookups are case-insensitive.
// Values are flagcdn ISO-3166-1 alpha-2 codes (or alpha-2 + subdivision like 'gb-sct').
// Add entries here as nationalities are confirmed; the roster's country field is the
// other source (getRosterCountry), consulted as a fallback by getDriverFlag.
const UR_DRIVER_NATIONALITY = {
  // ── UL S24 ──────────────────────────────────────
  'justin':       'nl',  // Netherlands
  'blank':        'in',  // India
  // ── HL S21 ──────────────────────────────────────
  'dimartino':    'fr',  // France
  'mika2mika':    'et',  // Ethiopia
  'lexus112':     'sk',  // Slovakia
  'mr. krunk':    'ca',  // Canada
  'glow':         'fr',  // France
  'finguala':     'tr',  // Turkey
  'coemsvel':     've',  // Venezuela
  'gaelprad':     'fr',  // France
  // ── JL / AL ─────────────────────────────────────
  'juan':         'mx',  // Mexico
  'peto':         'ge',  // Georgia
  'kozeel':       'cz',  // Czech Republic
  'sigmabob':     'it',  // Italy
  'dziuaura':     'pl',  // Poland
  'fede':         'it',  // Italy
  'flak':         'it',  // Italy — also known as Kalf
  'kalf':         'it',  // Italy — also known as Flak
  'derman':       'lv',  // Latvia
  // ── Exact sheet-name variants (names carry team/number suffixes or spelling
  //    differences in the live Leaderboard — keyed verbatim, lowercased) ──────
  'juan53rmc':    'mx',  // Juan — Mexico
  'peto95':       'ge',  // Peto (HL) — Georgia
  'peto33':       'ge',  // Peto (JL) — Georgia
  'kozeeel':      'cz',  // Kozeel — Czech Republic
  'dziuraura':    'pl',  // Dziuaura — Poland
  'gaelprad29':   'fr',  // Gaelprad — France
  'fedef1-11':    'it',  // Fede — Italy
  // ── More confirmed nationalities ─────────────────────────────────────
  'titou':        'be',  // Belgium
  'mathegyl':     'fr',  // France
  'ax1s':         'gb',  // UK
  'cigla':        'hr',  // Croatia
  'mystic':       'us',  // USA
  'fen1x':        'ua',  // Ukraine
  'az0t':         'pl',  // Poland
};

Object.assign(window, {
  loadLeaderboardStandings,
  getStandings, getSeasons, defaultDataSeasonId, useSharedSeason, useStandings, normalizeStandings, UR2D_ADAPTERS,
  UR2D_CALENDAR, UR2D_CALENDARS, getCalendar, getCalendarSeasons,
  usePodiumMap, loadPodiumMap,
  useRaceResults, loadRaceResults,
  useRoundTeamMap, loadRoundTeamMap,
  useRoundDriverTeams, loadRoundDriverTeams,
  useConstructorRounds, loadConstructorRounds, UR_CON_ROUND_SOURCES,
  UR_DRIVER_NATIONALITY, UR_DRIVER_NUMBER,
});

// ── PODIUMS BY ROUND (Data gatherer tab) ─────────────────────────────────────
// LIVE layout verified against UL S24 Data gatherer: per race N (1-based) a triple
// [quali, finish, points] sits at cols [base, base+1, base+2], base = 8+(N-1)*3;
// driver name at nameCol=5. A podium = finish position 1..3. (NOTE: this differs
// from the archived-.xlsx column map in CLAUDE.md — the live sheet leads with quali.)
// Duplicate name rows at the bottom are zero rows → first non-empty row wins.
const UR_PODIUM_SOURCES = {
  ul: { sheetId: '1vivMgSTZMF7j08-sfF6O-eKJkBo_tmotpWMhilgIOlI', tab: 'Data gatherer',
        nameCol: 5, raceBase: 8, raceStride: 3, finishOffset: 1, numRaces: 10 },
  // HL has sprints → 4-col blocks [quali, sprint, feature, points]; feature finish = offset 2.
  // (col7 'points' here is feature-only, so it won't match the sprint-inclusive Leaderboard total —
  //  that's fine: we only read finish positions. Verified finishes against the live S21 sheet.)
  hl: { sheetId: '1C3GzC4GZepDNuLJDfZolW39PSFz2BfNQX5UMPaVJNtM', tab: 'Data gatherer',
        nameCol: 5, raceBase: 8, raceStride: 4, finishOffset: 2, sprintOffset: 1, numRaces: 10 },
  // JL/AL: 3-col blocks [quali, finish, points], no sprints, 5 races. dgPts === Leaderboard pts (verified).
  jl: { sheetId: '1iJObjF_i2359ps7ViorOc_dQ_fpNG8tKi_ESBm-yAY8', tab: 'Data gatherer',
        nameCol: 5, raceBase: 7, raceStride: 3, finishOffset: 1, numRaces: 5 },
  al: { sheetId: '1wRC9aflXNFumfVE1D3MBq4M-LgWB0fhgWRDSAFJZiEE', tab: 'Data gatherer',
        nameCol: 5, raceBase: 7, raceStride: 3, finishOffset: 1, numRaces: 5 },
};
const _podiumCache = {};
// Resolve which workbook to read the Data gatherer tab from for a given season: the
// selected season's own liveSource.sheetId when it has one (so each season reads ITS OWN
// copied workbook), else the league-default sheetId in UR_PODIUM_SOURCES. The per-race
// column geometry (raceBase/stride/offset) is league-level and stays from UR_PODIUM_SOURCES.
function _podiumSheetId(league, seasonLabel) {
  const adapter = UR2D_ADAPTERS[league];
  const sdef = adapter ? _resolveSeasonDef(adapter, seasonLabel) : null;
  if (sdef && sdef.liveSource && sdef.liveSource.sheetId) return sdef.liveSource.sheetId;
  if (sdef && sdef.db) return null; // finished season served by the DB (ur2d-db.jsx) — never fall back to another season's workbook
  if (sdef && sdef.isCurrent && !sdef.snapshot) return null; // upcoming season, no workbook yet — never read last season's sheet
  const src = UR_PODIUM_SOURCES[league];
  return src ? src.sheetId : null;
}
// Per-season geometry override: a season whose workbook layout differs from its league's (CL S23 = UL
// template, no sprints) carries liveSource[key] = partial geometry, merged over the league-level map.
function _seasonGeom(league, seasonLabel, base, key) {
  const adapter = UR2D_ADAPTERS[league];
  const sdef = adapter ? _resolveSeasonDef(adapter, seasonLabel) : null;
  const o = sdef && sdef.liveSource && sdef.liveSource[key];
  return base && o ? { ...base, ...o } : base;
}
async function loadPodiumMap(league, seasonLabel) {
  const src = _seasonGeom(league, seasonLabel, UR_PODIUM_SOURCES[league], 'dataGatherer');
  const sid = src && _podiumSheetId(league, seasonLabel);
  if (!sid) return {};
  const table = await fetchGviz(sid, src.tab);
  const rows = table.rows.map((r) => r.c.map((c) => (c ? c.v : null)));
  const map = {};
  for (const r of rows) {
    const nm = r[src.nameCol];
    if (typeof nm !== 'string' || !nm.trim()) continue;
    const key = nm.trim();
    if (map[key]) continue; // first non-empty row for a name wins
    const list = [];
    for (let n = 1; n <= src.numRaces; n++) {
      const finish = r[src.raceBase + (n - 1) * src.raceStride + src.finishOffset];
      if (typeof finish === 'number' && finish >= 1 && finish <= 3) list.push({ round: n, pos: finish });
    }
    if (list.length) map[key] = list;
  }
  return map;
}
// React hook: fetch once per league:season, cache, return name→[{round,pos}] map ({} while loading).
function usePodiumMap(league, seasonLabel) {
  const key = `${league}:${seasonLabel}`;
  const [state, setState] = React.useState(() => _podiumCache[key] || null);
  React.useEffect(() => {
    let alive = true;
    if (_podiumCache[key]) { setState(_podiumCache[key]); return; }
    loadPodiumMap(league, seasonLabel)
      .then((m) => { _podiumCache[key] = m; if (alive) setState(m); })
      .catch(() => { if (alive) setState({}); });
    return () => { alive = false; };
  }, [key]);
  return state || {};
}

// ── FULL RACE RESULTS BY ROUND (Data gatherer) ───────────────────────────────
// Same column maps as podiums, but keeps EVERY finisher with quali + finish + points.
// Returns { [round]: [{ name, quali, finish, points }] } sorted by finish position.
const _resultsCache = {};
async function loadRaceResults(league, seasonLabel) {
  const src = _seasonGeom(league, seasonLabel, UR_PODIUM_SOURCES[league], 'dataGatherer');
  const sid = src && _podiumSheetId(league, seasonLabel);
  if (!sid) return {};
  const table = await fetchGviz(sid, src.tab);
  const rows = table.rows.map((r) => r.c.map((c) => (c ? c.v : null)));
  const byRound = {};
  for (let n = 1; n <= src.numRaces; n++) {
    const base = src.raceBase + (n - 1) * src.raceStride;
    const qCol = base;                          // quali = first col of block
    const fCol = base + src.finishOffset;       // feature finish position
    const sCol = src.sprintOffset != null ? base + src.sprintOffset : null; // sprint (HL only)
    const pCol = base + src.raceStride - 1;     // points = last col of block
    const seen = new Set();
    const list = [];
    for (const r of rows) {
      const nm = r[src.nameCol];
      if (typeof nm !== 'string' || !nm.trim()) continue;
      const name = nm.trim();
      if (seen.has(name)) continue;             // first occurrence (skip zero dupes)
      const finish = r[fCol];
      if (typeof finish !== 'number' || finish < 1) continue;
      seen.add(name);
      const quali = r[qCol], pts = r[pCol];
      const sprint = sCol != null && typeof r[sCol] === 'number' ? r[sCol] : null;
      list.push({ name, quali: typeof quali === 'number' ? quali : null, sprint, finish, points: typeof pts === 'number' ? pts : null });
    }
    if (list.length) byRound[n] = list.sort((a, b) => a.finish - b.finish);
  }
  return byRound;
}
// React hook: fetch once per league:season, cache, return { [round]: results[] } ({} while loading).
function useRaceResults(league, seasonLabel) {
  const key = `${league}:${seasonLabel}`;
  const [state, setState] = React.useState(() => _resultsCache[key] || null);
  React.useEffect(() => {
    let alive = true;
    if (_resultsCache[key]) { setState(_resultsCache[key]); return; }
    loadRaceResults(league, seasonLabel)
      .then((m) => { _resultsCache[key] = m; if (alive) setState(m); })
      .catch(() => { if (alive) setState({}); });
    return () => { alive = false; };
  }, [key]);
  return state || {};
}

// ── CONSTRUCTOR POINTS BY ROUND (Leaderboard tab) ────────────────────────────
// The Leaderboard's constructor block already carries each team's per-WEEKEND points
// total (they sum exactly to the season total) plus a second block with the team's
// BEST CAR finishing position per round (values run to the full grid size, e.g. 1..18
// for a 9-team field — it is NOT a 1..N constructor rank). Verified live 2026-07-26
// on UL S25 / HL S22 / JL S22A.
//   ptsBase  : first round-points col, one col per round (stride 1)
//   posShort : acronym col of the per-round best-car-finish block
//   posBase / posStride / posOffset : that block's per-round geometry
//               (HL splits each round into [sprint, feature] → stride 2, offset 1)
const UR_CON_ROUND_SOURCES = {
  ul: { short: 72, ptsBase: 74, numRaces: 10, posShort: 98,  posBase: 99,  posStride: 1, posOffset: 0 },
  hl: { short: 88, ptsBase: 90, numRaces: 10, posShort: 116, posBase: 117, posStride: 2, posOffset: 1 },
  jl: { short: 54, ptsBase: 56, numRaces: 5,  posShort: 75,  posBase: 76,  posStride: 1, posOffset: 0 },
};
const _conRoundsCache = {};
// → { [teamShort]: { [round]: { pts, pos } } }
async function loadConstructorRounds(league, seasonLabel) {
  const src = _seasonGeom(league, seasonLabel, UR_CON_ROUND_SOURCES[league], 'conRounds');
  if (!src) return {};
  const sheetId = _podiumSheetId(league, seasonLabel);
  if (!sheetId) return {};
  const adapter = UR2D_ADAPTERS[league];
  const sdef = adapter ? _resolveSeasonDef(adapter, seasonLabel) : null;
  const tab = (sdef && sdef.liveSource && sdef.liveSource.tab) || 'Leaderboard';
  const table = await fetchGviz(sheetId, tab);
  const rows = table.rows.map((r) => r.c.map((c) => (c ? c.v : null)));
  const out = {};
  for (const r of rows) {
    const short = r[src.short];
    if (typeof short !== 'string' || !short.trim() || short.trim().toLowerCase() === 'team') continue;
    const key = short.trim();
    if (out[key]) continue;
    const rounds = {};
    for (let n = 1; n <= src.numRaces; n++) {
      const pts = r[src.ptsBase + (n - 1)];
      if (typeof pts === 'number') rounds[n] = { pts, pos: null };
    }
    out[key] = rounds;
  }
  for (const r of rows) {
    const short = r[src.posShort];
    if (typeof short !== 'string' || !short.trim() || short.trim().toLowerCase() === 'team') continue;
    const rounds = out[short.trim()];
    if (!rounds) continue;
    for (let n = 1; n <= src.numRaces; n++) {
      const pos = r[src.posBase + (n - 1) * src.posStride + src.posOffset];
      if (typeof pos === 'number' && pos > 0 && rounds[n]) rounds[n].pos = pos;
    }
  }
  return out;
}
// React hook: fetch once per league:season, cache, {} while loading or unavailable.
function useConstructorRounds(league, seasonLabel) {
  const key = `${league}:${seasonLabel}`;
  const [state, setState] = React.useState(() => _conRoundsCache[key] || null);
  React.useEffect(() => {
    let alive = true;
    if (_conRoundsCache[key]) { setState(_conRoundsCache[key]); return; }
    if (!UR_CON_ROUND_SOURCES[league] || !seasonLabel) { setState({}); return; }
    loadConstructorRounds(league, seasonLabel)
      .then((m) => { _conRoundsCache[key] = m; if (alive) setState(m); })
      .catch((err) => { console.warn('[ur2d-store] constructorRounds fetch failed:', err.message); if (alive) setState({}); });
    return () => { alive = false; };
  }, [key]);
  return state || {};
}

// ── ROUND TEAM MAP (Leaderboard tab, gviz by GID) ────────────────────────────
// The Leaderboard tab embeds a per-round constructor result sub-table at cols
// CR–DL (gviz 0-indexed: 95–113). Each of the 10 rows (rounds 1–10) has:
//   col 95  = round number (1-based)
//   cols 96–113 = team short ("ONE", "LSA", …) at finish positions P1–P18
// Returns { [round]: string[] } where result[N] is an 18-element array,
// index 0 = P1 team, index 1 = P2 team, … index 17 = P18 team.
// Fetched by gid (not sheet name) since the tab name is shared with the
// main Leaderboard fetch — gviz allows both ?sheet=Name and ?gid=NUM.
// Add tabGid to a season's liveSource to enable; other leagues TBD once
// their GIDs are confirmed.
const _roundTeamMapCache = {};

async function loadRoundTeamMap(sheetId, tabGid) {
  const url = `https://docs.google.com/spreadsheets/d/${sheetId}/gviz/tq?tqx=out:json&gid=${tabGid}`;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`gviz HTTP ${res.status}`);
  const txt = await res.text();
  const table = JSON.parse(txt.slice(txt.indexOf('(') + 1, txt.lastIndexOf(')'))).table;
  const rows = table.rows.map((r) => r.c.map((c) => (c ? c.v : null)));

  const map = {};
  for (const r of rows) {
    const rn = r[95]; // col CR (0-indexed 95) = round number
    if (typeof rn !== 'number' || rn < 1) continue;
    // cols CS–DK (96–113) = team at P1..P18 for this round
    const teams = r.slice(96, 114).map((v) => (typeof v === 'string' ? v.trim() : null));
    map[rn] = teams;
  }
  return map; // e.g. map[1][0] = "ONE" means P1 in round 1 was driven by an ONE car
}

// ── ROUND DRIVER→TEAM MAP (per-round "Round N" tabs, cols F/G) ───────────────
// Each round tab carries an explicit Driver|Team result table (name col F=5,
// team ACRONYM col G=6) — the AUTHORITATIVE per-round mapping. Name-keyed, so
// substitutes who never appear in the season lineup still resolve, and
// mid-season swaps are round-exact. Reads liveSource.driverTeamTable (same
// config loadLeaderboardStandings uses for its season-long override).
// Returns { [round]: { [normalizedName]: short } }. The embedded Team|Pts
// block on the same cols is skipped (team cell must be a known acronym);
// empty/future/failed round tabs are skipped.
const _roundDriverTeamsCache = {};
async function loadRoundDriverTeams(league, seasonLabel) {
  const adapter = UR2D_ADAPTERS[league];
  const sdef = adapter ? _resolveSeasonDef(adapter, seasonLabel) : null;
  const src = sdef && sdef.liveSource;
  const dt = src && (src.driverTeamTable || src.roundTeamTable); // roundTeamTable = per-round map only (HL)
  if (!dt) return {};
  const nrm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
  const validShort = {};
  Object.keys(src.teamNames || {}).forEach((sh) => { validShort[nrm(sh)] = sh; });
  const tables = await Promise.all((dt.rounds || []).map((tab) =>
    fetchGviz(src.sheetId, tab)
      .then((t) => t.rows.map((r) => r.c.map((c) => (c ? c.v : null))))
      .catch(() => null)));
  const map = {};
  tables.forEach((trows, i) => {
    if (!trows) return;
    const mm = (dt.rounds[i].match(/(\d+)/) || [])[1];
    const round = mm ? Number(mm) : i + 1;
    const perRound = {};
    for (const r of trows) {
      const nm = r[dt.nameCol], sh = r[dt.shortCol];
      if (typeof nm !== 'string' || !nm.trim() || typeof sh !== 'string') continue;
      const canonShort = validShort[nrm(sh)];
      if (canonShort) perRound[nrm(nm)] = canonShort;
    }
    if (Object.keys(perRound).length) map[round] = perRound;
  });
  return map;
}
// React hook: fetch once per league:season, cache, {} while loading / on error / unconfigured.
function useRoundDriverTeams(league, seasonLabel) {
  const key = `${league}:${seasonLabel}`;
  const [state, setState] = React.useState(() => _roundDriverTeamsCache[key] || null);
  React.useEffect(() => {
    let alive = true;
    if (_roundDriverTeamsCache[key]) { setState(_roundDriverTeamsCache[key]); return; }
    loadRoundDriverTeams(league, seasonLabel)
      .then((m) => { _roundDriverTeamsCache[key] = m; if (alive) setState(m); })
      .catch((err) => { console.warn('[ur2d-store] roundDriverTeams fetch failed:', err.message); if (alive) setState({}); });
    return () => { alive = false; };
  }, [key]);
  return state || {};
}

// React hook: fetch once per league:season, cache, return {} while loading or on error.
function useRoundTeamMap(league, seasonLabel) {
  const key = `${league}:${seasonLabel}`;
  const [state, setState] = React.useState(() => _roundTeamMapCache[key] || null);
  React.useEffect(() => {
    let alive = true;
    if (_roundTeamMapCache[key]) { setState(_roundTeamMapCache[key]); return; }
    const adapter = UR2D_ADAPTERS[league];
    const sdef = adapter ? _resolveSeasonDef(adapter, seasonLabel) : null;
    const src = sdef && sdef.liveSource;
    if (!src || !src.tabGid) return; // no GID configured for this season
    loadRoundTeamMap(src.sheetId, src.tabGid)
      .then((m) => { _roundTeamMapCache[key] = m; if (alive) setState(m); })
      .catch((err) => { console.warn('[ur2d-store] roundTeamMap fetch failed:', err.message); if (alive) setState({}); });
    return () => { alive = false; };
  }, [key]);
  return state || {};
}
