/* QUANTA · Live data adapter
   Replaces mock-data.jsx with real WebSocket streams from the existing
   live_tennis (`/ws`) and live_football (`/football/ws`) backends.

   Shape rules:
   • Keeps the exact `window.MOCK` shape that the QUANTA components expect
     (BMS / TENNIS_MATCHES / FOOT_MATCHES / ALL_MATCHES / TOP_OPS / SPARK).
   • Arrays are mutated in place when a fresh snapshot arrives.
   • A `quanta-data-update` event is dispatched so QuantaAppAuthed bumps
     its render tick instantly (sub-second updates on goals, breaks, etc.).
   • Per-match rolling history (24 ticks per source per outcome) is built
     incrementally so the evolution chart in the detail view stays smooth
     between snapshots — the backend doesn't ship history; we synthesise it.

   No babel / no JSX — this file is plain JS so it loads before React.
*/

(function () {
  "use strict";

  /* ──────────────────────────────────────────────────────────
     Bookmaker palette — must match the keys used downstream
     (QUANTA reads window.MOCK.BMS[name].color in many places).
     ────────────────────────────────────────────────────────── */
  const BMS = {
    Betclic:    { short: "BCL", color: "#ef4444" },
    Winamax:    { short: "WMX", color: "#0f172a" },
    Yonibet:    { short: "YBT", color: "#f59e0b" },
    PMU:        { short: "PMU", color: "#10b981" },
    Unibet:     { short: "UNI", color: "#16a34a" },
    Betfair:    { short: "BF",  color: "#111111" },
    Polymarket: { short: "PM",  color: "#7c3aed" },
  };

  /* ──────────────────────────────────────────────────────────
     Initial shell — QUANTA reads this synchronously at boot.
     Empty arrays + neutral SPARK so the UI renders blank state
     without crashing while the first snapshot is in flight.
     ────────────────────────────────────────────────────────── */
  window.MOCK = {
    BMS,
    TENNIS_MATCHES: [],
    FOOT_MATCHES: [],
    BASEBALL_MATCHES: [],
    ALL_MATCHES: [],
    TOP_OPS: [],
    SPARK: { t1_p1: [1, 1, 1], t1_p2: [1, 1, 1] }, // legacy field
    makeSpark: (start, n = 24) => Array(n).fill(start),
  };

  const HISTORY_LEN = 24;

  /* ──────────────────────────────────────────────────────────
     History store keyed by `${sport}|${key}|${side}|${source}`
     Maintains a rolling deque of the last HISTORY_LEN odds.

     Side-effect: every NEW value (post-dedup) also lands in TICK_LOG
     so the stats ribbon can show real Ticks/min + Last tick figures.
     ────────────────────────────────────────────────────────── */
  const HISTORY = new Map();
  const TICK_LOG = { tennis: [], football: [], baseball: [] };
  const TICK_WINDOW_MS = 60_000;

  function pushHistory(sport, key, side, source, value) {
    if (value == null || !Number.isFinite(value) || value <= 1.0) return;
    const k = `${sport}|${key}|${side}|${source}`;
    let arr = HISTORY.get(k);
    if (!arr) {
      arr = new Array(HISTORY_LEN).fill(value);
      HISTORY.set(k, arr);
    }
    // Skip duplicates so the chart doesn't flatline on quiet matches
    if (arr[arr.length - 1] === value) return;
    arr.push(value);
    while (arr.length > HISTORY_LEN) arr.shift();
    // Tick log — "foot" key normalised to "football" so the UI can
    // query window.MOCK.getStats("football").
    const bucket = sport === "foot" ? "football" : sport;
    const log = TICK_LOG[bucket];
    if (log) {
      log.push(Date.now());
      const cutoff = Date.now() - TICK_WINDOW_MS;
      while (log.length && log[0] < cutoff) log.shift();
    }
  }

  /* Stats accessor consumed by QStatsRibbon. Prunes entries older than
     TICK_WINDOW_MS at read time so the moving window stays accurate
     without a separate timer. */
  window.MOCK.getStats = function(sport) {
    const log = TICK_LOG[sport];
    if (!log) return { ticksPerMin: 0, lastTickAgeS: null };
    const now = Date.now();
    const cutoff = now - TICK_WINDOW_MS;
    while (log.length && log[0] < cutoff) log.shift();
    const lastTickAt = log.length ? log[log.length - 1] : null;
    return {
      ticksPerMin: log.length,
      lastTickAgeS: lastTickAt != null ? (now - lastTickAt) / 1000 : null,
    };
  };
  function getHistory(sport, key, side, source) {
    const k = `${sport}|${key}|${side}|${source}`;
    const arr = HISTORY.get(k);
    if (!arr) return null;
    // Return a copy so React doesn't see mutation cycles
    return arr.slice();
  }

  /* ──────────────────────────────────────────────────────────
     Tennis score parser
     Backend ships strings like "6-4 7-5", "6-3 2-1 (15-30)", "1-0".
     We strip the (current game) part, split on whitespace, then
     pull the per-set numbers as strings to keep the scoreboard
     rendering simple.
     ────────────────────────────────────────────────────────── */
  function parseTennisScore(scoreStr) {
    if (!scoreStr) return { p1: ["0"], p2: ["0"], serving: null };
    const cleaned = String(scoreStr).replace(/\([^)]*\)/g, "").trim();
    const tokens = cleaned.split(/\s+/).filter(Boolean);
    const p1 = [], p2 = [];
    for (const t of tokens) {
      const m = t.match(/^(\d+)[-:](\d+)/);
      if (!m) continue;
      p1.push(m[1]);
      p2.push(m[2]);
    }
    if (p1.length === 0) { p1.push("0"); p2.push("0"); }
    return { p1, p2, serving: null };
  }

  /* ──────────────────────────────────────────────────────────
     Best-edge helper
     Returns { edge, bm, side } across all bookmaker entries
     where side is "p1" or "p2" (QUANTA's 2-way labelling).
     Guarantees `bm` is a real key present in the passed dict;
     callers must pre-filter empty dicts (returns null then).
     ────────────────────────────────────────────────────────── */
  function computeBestEdge(bm) {
    const keys = Object.keys(bm);
    if (!keys.length) return null;
    let best = { edge: -Infinity, bm: null, side: "p1" };
    for (const [name, b] of Object.entries(bm)) {
      if (b.e1 != null && b.e1 > best.edge)      best = { edge: b.e1,    bm: name, side: "p1" };
      if (b.e2 != null && b.e2 > best.edge)      best = { edge: b.e2,    bm: name, side: "p2" };
      // Draw outcome only exists for football (3-way market). Tennis dicts
      // never carry `eDraw` so this branch is naturally a no-op.
      if (b.eDraw != null && b.eDraw > best.edge) best = { edge: b.eDraw, bm: name, side: "draw" };
    }
    if (best.bm == null) {
      // No positive edges anywhere — pick the first BM as the placeholder so
      // downstream renders that index `m.bm[bestEdgeBm]` don't crash.
      return { edge: 0, bm: keys[0], side: "p1" };
    }
    return best;
  }

  /* Build a Betfair-shaped object straight from the upstream payload, with
     null preserved when the LAY isn't available. We deliberately do NOT
     synthesise a baseline from BM medians: a fake LAY would compute
     edges ≈ 0% against the very BMs it averaged and silently bury real
     missing-reference cases. Downstream components MUST handle null. */
  function rawBfBaseline(bfRaw, sideFields) {
    return {
      p1: bfRaw?.[sideFields.p1] ?? null,
      p2: bfRaw?.[sideFields.p2] ?? null,
      p1_lay_size: bfRaw?.[sideFields.p1_size] ?? null,
      p2_lay_size: bfRaw?.[sideFields.p2_size] ?? null,
      // Per-runner total matched (GBP) — depth on each side
      // independently of the at-best-price liquidity. The
      // server stores p{1,2}_matched / home_matched / away_matched
      // depending on sport; the caller supplies the right key
      // names via sideFields.
      p1_matched: sideFields.p1_matched ? (bfRaw?.[sideFields.p1_matched] ?? null) : null,
      p2_matched: sideFields.p2_matched ? (bfRaw?.[sideFields.p2_matched] ?? null) : null,
      // Volume matched AT the current at-best LAY price specifically.
      // More granular than the runner total: tells the user "how much
      // has been traded at this exact price right now". Goes stale on
      // every price move (the server doesn't carry it across ticks).
      p1_lay_matched: sideFields.p1_lay_matched ? (bfRaw?.[sideFields.p1_lay_matched] ?? null) : null,
      p2_lay_matched: sideFields.p2_lay_matched ? (bfRaw?.[sideFields.p2_lay_matched] ?? null) : null,
      last_seen: bfRaw?.last_seen,
      url: bfRaw?.url || "",
      // Market-level total matched (GBP). Stays null until Betfair's
      // stream has emitted at least one `tv` delta for this market.
      total_matched: bfRaw?.total_matched ?? null,
    };
  }

  /* ──────────────────────────────────────────────────────────
     Tennis match adapter
     ────────────────────────────────────────────────────────── */
  function adaptTennisMatch(raw) {
    const key = raw.key;
    const p1 = raw.player1 || raw.p1 || "—";
    const p2 = raw.player2 || raw.p2 || "—";
    const tournament = raw.tournament || raw.tour || "Tennis";
    const surface = raw.surface || "—";
    const score = parseTennisScore(raw.score);
    const decided = !!raw.decided;

    // Bookmakers → 2-way odds + edges
    const bm = {};
    const rawBms = raw.bookmakers || {};
    for (const [name, src] of Object.entries(rawBms)) {
      const p1Odds = src.p1_odds;
      const p2Odds = src.p2_odds;
      if (p1Odds == null && p2Odds == null) continue;
      bm[name] = {
        p1: p1Odds != null ? +p1Odds : null,
        p2: p2Odds != null ? +p2Odds : null,
        e1: src.edges?.p1 ?? null,
        e2: src.edges?.p2 ?? null,
        last_seen: src.last_seen,
        url: src.url || "",
      };
      pushHistory("tennis", key, "p1", name, p1Odds);
      pushHistory("tennis", key, "p2", name, p2Odds);
    }
    // A match with zero bookmaker quotes is useless to QUANTA's comparison
    // dashboard — drop it (returning null signals the caller to filter).
    if (Object.keys(bm).length === 0) return null;

    // Betfair LAY baseline — null when the real BF stream hasn't matched
    // this fixture. Components must render "—" in that case rather than
    // see a fake median-of-BMs that hides the absence of a reference.
    const bf = rawBfBaseline(raw.betfair, {
      p1: "p1_lay", p2: "p2_lay",
      p1_size: "p1_lay_size", p2_size: "p2_lay_size",
      p1_matched: "p1_matched", p2_matched: "p2_matched",
      p1_lay_matched: "p1_lay_matched", p2_lay_matched: "p2_lay_matched",
    });
    pushHistory("tennis", key, "p1", "Betfair", bf.p1);
    pushHistory("tennis", key, "p2", "Betfair", bf.p2);

    // Polymarket reference (informational)
    const pm = raw.polymarket && Object.keys(raw.polymarket).length ? {
      p1Buy:  raw.polymarket.p1_buy  ?? raw.polymarket.p1_lay  ?? null,
      p1Sell: raw.polymarket.p1_sell ?? raw.polymarket.p1_back ?? null,
      p2Buy:  raw.polymarket.p2_buy  ?? raw.polymarket.p2_lay  ?? null,
      p2Sell: raw.polymarket.p2_sell ?? raw.polymarket.p2_back ?? null,
      url:    raw.polymarket.url || "",
      last_seen: raw.polymarket.last_seen,
    } : null;

    const best = computeBestEdge(bm);

    // History payload used by QEvolutionChart
    const history = { p1: {}, p2: {} };
    history.p1.Betfair = getHistory("tennis", key, "p1", "Betfair") || [bf.p1 || 2.0];
    history.p2.Betfair = getHistory("tennis", key, "p2", "Betfair") || [bf.p2 || 2.0];
    for (const name of Object.keys(bm)) {
      history.p1[name] = getHistory("tennis", key, "p1", name) || [bm[name].p1 || 2.0];
      history.p2[name] = getHistory("tennis", key, "p2", name) || [bm[name].p2 || 2.0];
    }

    // Minimal events list — we don't have a real event feed; surface the
    // best-edge appearance as a single "edge" event so the panel isn't empty.
    const events = [];
    if (best.edge > 0 && bm[best.bm]) {
      events.push({
        ts: (raw.last_update || Date.now() / 1000),
        kind: "edge",
        bm: best.bm, side: best.side,
        odds: bm[best.bm][best.side],
        edge: best.edge,
        text: `${best.bm} → ${bm[best.bm][best.side]?.toFixed(2)} sur ${best.side === "p1" ? p1.split(",")[0] : p2.split(",")[0]} (edge ${(best.edge * 100).toFixed(1)}%)`,
      });
    }

    return {
      key, sport: "tennis",
      tournament, surface,
      p1, p2,
      score,
      minute: null,
      decided,
      bf, bm, pm,
      bestEdge: best.edge > 0 ? best.edge : 0,
      bestEdgeOn: best.side,
      bestEdgeBm: best.bm,
      history,
      events,
      last_update: raw.last_update,
    };
  }

  /* ──────────────────────────────────────────────────────────
     Football match adapter — 2-way representation (home/away)
     The draw outcome is preserved on bf.draw for the detail
     comparison table; the cards/grid show home vs away only.
     ────────────────────────────────────────────────────────── */
  function adaptFootballMatch(raw) {
    const key = raw.key;
    const p1 = raw.home_team || raw.p1 || "—";
    const p2 = raw.away_team || raw.p2 || "—";
    const tournament = raw.tournament || "Football";
    const decided = !!raw.decided;

    const scoreLine = `${raw.home_goals ?? 0} - ${raw.away_goals ?? 0}`;
    let minute = "";
    if (raw.minute != null && raw.minute !== "") minute = `${raw.minute}'`;
    if (raw.period && raw.period !== "1H") {
      minute = minute ? `${minute} ${raw.period}` : raw.period;
    }
    if (!minute) minute = raw.period || "LIVE";

    const bm = {};
    const rawBms = raw.bookmakers || {};
    for (const [name, src] of Object.entries(rawBms)) {
      const homeOdds = src.home_odds;
      const awayOdds = src.away_odds;
      const drawOdds = src.draw_odds;
      if (homeOdds == null && awayOdds == null && drawOdds == null) continue;
      bm[name] = {
        p1: homeOdds != null ? +homeOdds : null,
        p2: awayOdds != null ? +awayOdds : null,
        draw: drawOdds != null ? +drawOdds : null,
        e1: src.edges?.home ?? null,
        e2: src.edges?.away ?? null,
        eDraw: src.edges?.draw ?? null,
        last_seen: src.last_seen,
        url: src.url || "",
      };
      pushHistory("foot", key, "p1", name, homeOdds);
      pushHistory("foot", key, "p2", name, awayOdds);
    }
    if (Object.keys(bm).length === 0) return null;

    const bf = rawBfBaseline(raw.betfair, {
      p1: "home_lay", p2: "away_lay",
      p1_size: "home_lay_size", p2_size: "away_lay_size",
      p1_matched: "home_matched", p2_matched: "away_matched",
      p1_lay_matched: "home_lay_matched", p2_lay_matched: "away_lay_matched",
    });
    bf.draw = raw.betfair?.draw_lay ?? null;
    bf.draw_lay_size = raw.betfair?.draw_lay_size ?? null;
    bf.draw_matched = raw.betfair?.draw_matched ?? null;
    bf.draw_lay_matched = raw.betfair?.draw_lay_matched ?? null;
    pushHistory("foot", key, "p1", "Betfair", bf.p1);
    pushHistory("foot", key, "p2", "Betfair", bf.p2);

    const best = computeBestEdge(bm);

    const history = { p1: {}, p2: {} };
    history.p1.Betfair = getHistory("foot", key, "p1", "Betfair") || [bf.p1 || 2.0];
    history.p2.Betfair = getHistory("foot", key, "p2", "Betfair") || [bf.p2 || 2.0];
    for (const name of Object.keys(bm)) {
      history.p1[name] = getHistory("foot", key, "p1", name) || [bm[name].p1 || 2.0];
      history.p2[name] = getHistory("foot", key, "p2", name) || [bm[name].p2 || 2.0];
    }

    const events = [];
    if (best.edge > 0 && bm[best.bm]) {
      events.push({
        ts: (raw.last_update || Date.now() / 1000),
        kind: "edge",
        bm: best.bm, side: best.side,
        odds: bm[best.bm][best.side],
        edge: best.edge,
        text: `${best.bm} → ${bm[best.bm][best.side]?.toFixed(2)} sur ${best.side === "p1" ? p1 : p2} (edge ${(best.edge * 100).toFixed(1)}%)`,
      });
    }

    return {
      key, sport: "football",
      tournament,
      p1, p2,
      scoreLine, minute,
      home_goals: raw.home_goals,
      away_goals: raw.away_goals,
      period: raw.period,
      decided,
      bf, bm,
      pm: null,
      bestEdge: best.edge > 0 ? best.edge : 0,
      bestEdgeOn: best.side,
      bestEdgeBm: best.bm,
      score: { p1: [String(raw.home_goals ?? 0)], p2: [String(raw.away_goals ?? 0)] },
      history,
      events,
      last_update: raw.last_update,
    };
  }

  /* ──────────────────────────────────────────────────────────
     Stable display order
     ─────────────────────
     If we re-sorted by bestEdge on every snapshot, cards would jump
     around the screen every time an odds tick moved an edge by 0.1%
     — terrible UX when the user is about to click a card. Instead we
     keep a per-sport ordered key list:
       • existing matches keep their slot, no matter how their edge moves
       • dropped matches (no longer in the snapshot) are removed
       • new matches are inserted into the list at the position dictated
         by their current bestEdge
     The TOP_OPS leaderboard at the top of the page is computed fresh on
     every tick (it's a live leaderboard, jumping is expected there).
     ────────────────────────────────────────────────────────── */
  const STABLE_ORDER = { tennis: [], football: [], baseball: [] };

  function stabilizeOrder(sport, adapted) {
    const matchByKey = new Map(adapted.map(m => [m.key, m]));
    const live = new Set(matchByKey.keys());

    // 1. Existing order, minus matches that are gone
    const kept = STABLE_ORDER[sport].filter(k => live.has(k));
    const known = new Set(kept);

    // 2. New matches (first time we see this key)
    const newMatches = adapted.filter(m => !known.has(m.key));
    if (newMatches.length === 0) {
      STABLE_ORDER[sport] = kept;
      return kept.map(k => matchByKey.get(k));
    }

    // 3. Insert each new match in the position its current bestEdge dictates
    newMatches.sort((a, b) => b.bestEdge - a.bestEdge);
    const ordered = [...kept];
    for (const m of newMatches) {
      let insertAt = ordered.length;
      for (let i = 0; i < ordered.length; i++) {
        const existing = matchByKey.get(ordered[i]);
        if (!existing) continue;
        if (m.bestEdge > existing.bestEdge) { insertAt = i; break; }
      }
      ordered.splice(insertAt, 0, m.key);
    }
    STABLE_ORDER[sport] = ordered;
    return ordered.map(k => matchByKey.get(k));
  }

  /* ──────────────────────────────────────────────────────────
     Baseball match adapter — 2-way (home/away). MLB regular-season
     ties are rare enough that bookmakers post "Vainqueur Match" as a
     2-way market; no draw column.

     The raw `score` from the baseball server is a short string the
     bookmakers emit verbatim (e.g. "T5 0-2"); we surface it on
     `scoreLine` so the cards/detail view can render it straight.
     ────────────────────────────────────────────────────────── */
  function adaptBaseballMatch(raw) {
    const key = raw.key;
    const p1 = raw.home_team || raw.p1 || "—";
    const p2 = raw.away_team || raw.p2 || "—";
    const tournament = raw.tournament || "Baseball";
    const decided = !!raw.decided;

    const scoreLine = raw.score || "";
    const minute = raw.inning || "LIVE";

    const bm = {};
    const rawBms = raw.bookmakers || {};
    for (const [name, src] of Object.entries(rawBms)) {
      const homeOdds = src.home_odds;
      const awayOdds = src.away_odds;
      if (homeOdds == null && awayOdds == null) continue;
      bm[name] = {
        p1: homeOdds != null ? +homeOdds : null,
        p2: awayOdds != null ? +awayOdds : null,
        e1: src.edges?.home ?? null,
        e2: src.edges?.away ?? null,
        last_seen: src.last_seen,
        url: src.url || "",
      };
      pushHistory("baseball", key, "p1", name, homeOdds);
      pushHistory("baseball", key, "p2", name, awayOdds);
    }
    if (Object.keys(bm).length === 0) return null;

    const bf = rawBfBaseline(raw.betfair, {
      p1: "home_lay", p2: "away_lay",
      p1_size: "home_lay_size", p2_size: "away_lay_size",
      p1_matched: "home_matched", p2_matched: "away_matched",
      p1_lay_matched: "home_lay_matched", p2_lay_matched: "away_lay_matched",
    });
    pushHistory("baseball", key, "p1", "Betfair", bf.p1);
    pushHistory("baseball", key, "p2", "Betfair", bf.p2);

    const best = computeBestEdge(bm);

    const history = { p1: {}, p2: {} };
    history.p1.Betfair = getHistory("baseball", key, "p1", "Betfair") || [bf.p1 || 2.0];
    history.p2.Betfair = getHistory("baseball", key, "p2", "Betfair") || [bf.p2 || 2.0];
    for (const name of Object.keys(bm)) {
      history.p1[name] = getHistory("baseball", key, "p1", name) || [bm[name].p1 || 2.0];
      history.p2[name] = getHistory("baseball", key, "p2", name) || [bm[name].p2 || 2.0];
    }

    const events = [];
    if (best.edge > 0 && bm[best.bm]) {
      events.push({
        ts: (raw.last_update || Date.now() / 1000),
        kind: "edge",
        bm: best.bm, side: best.side,
        odds: bm[best.bm][best.side],
        edge: best.edge,
        text: `${best.bm} → ${bm[best.bm][best.side]?.toFixed(2)} sur ${best.side === "p1" ? p1 : p2} (edge ${(best.edge * 100).toFixed(1)}%)`,
      });
    }

    return {
      key, sport: "baseball",
      tournament,
      p1, p2,
      scoreLine, minute,
      decided,
      bf, bm,
      pm: null,
      bestEdge: best.edge > 0 ? best.edge : 0,
      bestEdgeOn: best.side,
      bestEdgeBm: best.bm,
      score: { p1: [scoreLine], p2: [] },
      history,
      events,
      last_update: raw.last_update,
    };
  }

  /* ──────────────────────────────────────────────────────────
     Snapshot ingest — mutates window.MOCK in place
     ────────────────────────────────────────────────────────── */
  function ingest(sport, snapshotData) {
    const matches = snapshotData?.matches || [];
    const adapter =
      sport === "tennis"   ? adaptTennisMatch :
      sport === "baseball" ? adaptBaseballMatch :
                             adaptFootballMatch;
    const adapted = matches
      .map(m => { try { return adapter(m); } catch (e) { /* drop bad match silently */ return null; } })
      .filter(Boolean);

    // Apply stable order so existing cards don't jump around on every tick
    const stable = stabilizeOrder(sport, adapted);

    if (sport === "tennis")        window.MOCK.TENNIS_MATCHES = stable;
    else if (sport === "baseball") window.MOCK.BASEBALL_MATCHES = stable;
    else                           window.MOCK.FOOT_MATCHES = stable;

    // Expose the most recent raw snapshot so QUANTA can consume server-side
    // event payloads (e.g. snapshotData.recent_events for edge alerts). This
    // is read once-per-tick by the alerts listener in quanta-app.jsx; we keep
    // only the latest snapshot per sport on a shared object to avoid mixing
    // tennis events into football pushes.
    if (!window.MOCK._lastSnapshotBySport) {
      window.MOCK._lastSnapshotBySport = {};
    }
    window.MOCK._lastSnapshotBySport[sport] = snapshotData;
    window.MOCK._lastSnapshot = snapshotData;

    window.MOCK.ALL_MATCHES = [
      ...window.MOCK.TENNIS_MATCHES,
      ...window.MOCK.FOOT_MATCHES,
      ...window.MOCK.BASEBALL_MATCHES,
    ];
    // TOP_OPS is the live leaderboard — it MUST re-rank on every tick
    // (that's the whole point of "top 3 opportunities right now"). It's
    // a 3-card strip at the top, not the main scrollable list, so the
    // re-ranking doesn't disturb scrolling.
    window.MOCK.TOP_OPS = [...window.MOCK.ALL_MATCHES]
      .sort((a, b) => b.bestEdge - a.bestEdge)
      .slice(0, 3);

    // Tell React to re-render
    window.dispatchEvent(new CustomEvent("quanta-data-update", { detail: { sport } }));
  }

  /* ──────────────────────────────────────────────────────────
     WebSocket client with exponential backoff reconnect
     ────────────────────────────────────────────────────────── */
  function wsUrl(path) {
    const proto = location.protocol === "https:" ? "wss:" : "ws:";
    return `${proto}//${location.host}${path}`;
  }

  function connectStream(sport, path) {
    let retry = 0;
    let ws = null;
    let alive = false;

    const open = () => {
      const url = wsUrl(path);
      try { ws = new WebSocket(url); }
      catch (e) {
        // ws constructor threw — scheduler will retry
        return schedule();
      }
      ws.onopen = () => {
        retry = 0; alive = true;
        // ws OPEN — status pill in header tracks this
        setStreamStatus(sport, "live");
      };
      ws.onclose = (ev) => {
        alive = false;
        setStreamStatus(sport, "down");
        if (ev && ev.code === 1008) {
          window.dispatchEvent(new CustomEvent("quanta-auth-lost"));
          return;
        }
        schedule();
      };
      ws.onerror = (e) => {
        // ws error — onclose will follow and trigger reconnect
      };
      ws.onmessage = (ev) => {
        try {
          const msg = JSON.parse(ev.data);
          if (msg && msg.type === "snapshot" && msg.data) {
            ingest(sport, msg.data);
          }
        } catch (e) {
          // malformed ws frame — skip silently
        }
      };
    };

    const schedule = () => {
      retry = Math.min(12, retry + 1);
      const delay = Math.min(15000, 500 * retry);
      setTimeout(open, delay);
    };

    open();

    // Fallback REST poll when WS is dead — keeps the dashboard fresh
    // even during a brief outage. We hit /api/matches (and /football/api/matches)
    // and synthesise a snapshot envelope for ingest().
    const restTimer = setInterval(async () => {
      if (alive) return;
      const restPath = path.replace(/\/ws$/, "/api/matches");
      try {
        const r = await fetch(restPath, { credentials: "include" });
        if (r.status === 401) {
          // Session révoquée — notifier QUANTA pour repasser au login
          window.dispatchEvent(new CustomEvent("quanta-auth-lost"));
          return;
        }
        if (!r.ok) return;
        const data = await r.json();
        ingest(sport, data);
        setStreamStatus(sport, "rest");
      } catch (e) { /* swallow */ }
    }, 5000);

    return { close: () => { try { ws && ws.close(); } catch {} clearInterval(restTimer); } };
  }

  /* Stream status exposed on window.MOCK so the header can render a pill
     per stream ("LIVE / REST / DOWN"). */
  window.MOCK.STREAM = { tennis: "connecting", football: "connecting", baseball: "connecting" };
  function setStreamStatus(sport, status) {
    if (window.MOCK.STREAM[sport] === status) return;
    window.MOCK.STREAM[sport] = status;
    window.dispatchEvent(new CustomEvent("quanta-data-update", { detail: { sport, status } }));
  }

  /* ──────────────────────────────────────────────────────────
     Boot — connect both streams. Tennis lives at root,
     football is mounted at /football on the same origin.
     ────────────────────────────────────────────────────────── */
  function boot() {
    connectStream("tennis",   "/ws");
    connectStream("football", "/football/ws");
    connectStream("baseball", "/baseball/ws");
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", boot);
  } else {
    boot();
  }

  // Diagnostic handle for the console
  // Diagnostic handle (`window.QuantaLiveData`) intentionally removed.
  // Exposing `ingest` / `HISTORY` to the page lets a malicious browser
  // extension dump or forge snapshots from one line of JS. Re-enable
  // ad-hoc with a localStorage flag if you need it for dev.
})();
