/* QUANTA · App — interactive prototype
   Wires the list ↔ detail routing, sport switch, light/dark theme, mobile menu.
   Pure React state — no real WS, mock data from window.MOCK. */

const { useState, useEffect, useRef, useMemo } = React;

/* ──────────────────────────────────────────────────────────
   HELPERS
   ────────────────────────────────────────────────────────── */
function shortName(s) {
  // "Alcaraz, C." -> "ALCA"; "Arsenal" -> "ARS"; "Man City" -> "MAC"
  const part = (s || "").split(",")[0].trim();
  const words = part.split(/\s+/);
  if (words.length > 1) {
    return words.map(w => w[0]).join("").toUpperCase().slice(0, 4);
  }
  return part.replace(/[^A-Za-z]/g, "").slice(0, 4).toUpperCase();
}
function pct(n, digits = 1) {
  return (n > 0 ? "+" : "") + (n * 100).toFixed(digits) + "%";
}
function heatClass(e) {
  if (e == null) return "";
  if (e >= 0.05) return " h-pos-3";
  if (e >= 0.025) return " h-pos-2";
  if (e > 0) return " h-pos-1";
  if (e <= -0.03) return " h-neg-2";
  if (e < 0) return " h-neg-1";
  return "";
}
function edgeHeat(e) {
  // for cmp table edge cell
  if (e == null) return "";
  if (e >= 0.05) return " h-pos-3";
  if (e >= 0.025) return " h-pos-2";
  if (e > 0) return " h-pos-1";
  if (e <= -0.03) return " h-neg-2";
  if (e < 0) return " h-neg-1";
  return "";
}

/* Null-safe formatters
   ─────────────────────
   Betfair LAY can be missing (the upstream BF stream hasn't matched the
   fixture). Components must render "—" in that case — never a synthesised
   substitute, which would silently hide the absence of a reference. */
function fmtOdds(v) { return v != null ? v.toFixed(2) : "—"; }
function fmtImpliedPct(v) { return v != null ? (100 / v).toFixed(1) + "%" : "—"; }

/* 3-way (football) helpers
   ─────────────────────────
   QUANTA's design is 2-way (p1/p2). We extend it so football fixtures
   also surface the draw cell. These helpers centralise the three
   places that need to know: "is this match 3-way?", "which side has the
   best edge across {p1, p2, draw}?", and "what label should I display
   for that side?". */
function isThreeWay(match) {
  return match && match.sport === "football";
}
function bestSideOf(b, threeWay) {
  let side = "p1", best = b.e1 != null ? b.e1 : -Infinity;
  if (b.e2 != null && b.e2 > best) { side = "p2"; best = b.e2; }
  if (threeWay && b.eDraw != null && b.eDraw > best) { side = "draw"; best = b.eDraw; }
  return side;
}
/* Same as bestSideOf, but returns null when the best edge is < 0 — used
   to gate the green "value" highlight: a side should only be greened when
   the edge is actually non-negative against Betfair LAY, not just "least
   bad" among negative options. */
function bestPositiveSideOf(b, threeWay) {
  const side = bestSideOf(b, threeWay);
  const e = side === "draw" ? b.eDraw : (side === "p1" ? b.e1 : b.e2);
  return (e != null && e >= 0) ? side : null;
}
function pickLabelFor(match, side) {
  if (side === "draw") return "Match nul";
  return side === "p1" ? match.p1 : match.p2;
}
function oddsForSide(b, side) {
  if (side === "draw") return b.draw;
  return side === "p1" ? b.p1 : b.p2;
}
function edgeForSide(b, side) {
  if (side === "draw") return b.eDraw;
  return side === "p1" ? b.e1 : b.e2;
}

function fmtAge(seconds) {
  // Returns {text, level} where level is 'fresh' | 'mid' | 'stale'
  if (seconds == null) return { text: "—", level: "stale" };
  if (seconds < 1) return { text: "now", level: "fresh" };
  if (seconds < 5) return { text: `${seconds.toFixed(1)}s`, level: "fresh" };
  if (seconds < 30) return { text: `${Math.round(seconds)}s`, level: "mid" };
  if (seconds < 90) return { text: `${Math.round(seconds)}s`, level: "stale" };
  return { text: `${Math.round(seconds/60)}m`, level: "stale" };
}

function fmtLiquidity(n) {
  if (n == null) return "—";
  if (n >= 1000) return `£${(n/1000).toFixed(1)}k`;
  return `£${n}`;
}

/* Market-level "total matched" — typically £k → £M range. Compact
   notation (`£1.2M`, `£45k`) so it fits in the cards/toolbar without
   blowing up the line height. Returns null when the BF stream hasn't
   reported a total yet (first few ticks after subscribe). */
function fmtTotalMatched(n) {
  if (n == null) return null;
  if (n >= 1_000_000) return `£${(n / 1_000_000).toFixed(2)}M`;
  if (n >= 1_000)     return `£${(n / 1_000).toFixed(1)}k`;
  return `£${Math.round(n)}`;
}

/* BfDepth — small badge shown next to the Betfair LAY odds.
   Combines two metrics into a single compact span:
     * at-best liquidity (how much is tradeable RIGHT NOW at this LAY)
     * volume already matched AT this exact LAY price (not the runner
       total — that mixes volume across many historical prices)
   Format: "£12k / £3.4k" with the slash distinguishing the two.
   The `title` attribute spells out which is which on hover.
   Falls back to the runner-total `matched` (less precise) when the
   per-price value isn't available — keeps the badge populated during
   the first WS ticks before EX_TRADED has been pushed. */
function BfDepth({ lay, matched, layMatched }) {
  const layStr = lay != null ? fmtLiquidity(lay) : "—";
  const atPrice = (layMatched != null) ? layMatched : null;
  const fallback = (atPrice == null && matched != null) ? matched : null;
  const display = atPrice != null ? atPrice : fallback;
  const totStr = fmtTotalMatched(display);
  if (totStr == null) {
    return <span className="q-liq" title="Liquidité disponible au meilleur prix LAY">{layStr}</span>;
  }
  const tooltip = atPrice != null
    ? "Liquidité au meilleur LAY : " + layStr +
      "\nDéjà matché à cette cote : " + totStr
    : "Liquidité au meilleur LAY : " + layStr +
      "\nTotal matché sur la sélection (runner) : " + totStr +
      "\n(EX_TRADED pas encore reçu — fallback runner total)";
  return (
    <span className="q-liq" title={tooltip}>
      {layStr}<span className="q-matched"> / {totStr}</span>
    </span>
  );
}

function FreshBadge({ lastSeen, tick }) {
  // tick is a number that re-renders the badge on each clock tick
  const age = lastSeen ? (Date.now() / 1000 - lastSeen) : null;
  const f = fmtAge(age);
  return <span className={"q-fresh " + f.level}>{f.text}</span>;
}

/* HelpTip — small inline help bubble for technical jargon.
   Tap the (?) to toggle a one-line definition. */
function HelpTip({ children, label }) {
  const [open, setOpen] = useState(false);
  // Best-effort aria-label: only concatenate text-like children so we
  // don't leak `[object Object]` into the accessible name.
  const ariaTerm = (typeof children === "string" || typeof children === "number")
    ? `Aide: ${children}` : "Aide";
  return (
    <span className="q-help">
      {children}
      <button
        className="q-help-btn"
        onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
        aria-label={ariaTerm}
        type="button"
      >?</button>
      {open && <span className="q-help-tip">{label}</span>}
    </span>
  );
}

/* QOnboarding — 4-step modal explaining the dashboard on first login.
   Skipping or completing persists `quanta-onboarded-{username}=1` in
   localStorage so the user never sees it again. */
function QOnboarding({ username, onClose }) {
  const [step, setStep] = useState(0);
  const steps = [
    { title: "Bienvenue sur QUANTA", body: "5 bookmakers comparés en temps réel à Betfair LAY pour repérer les value-bets live tennis + football.", icon: "⚡" },
    { title: "C'est quoi un edge ?", body: "Quand un bookmaker affiche une cote plus généreuse que Betfair LAY (la référence sharp), tu as un avantage. Au-dessus de +3 %, ça vaut le coup.", icon: "📊" },
    { title: "Combien miser ?", body: "Le critère de Kelly suggère la mise optimale. On affiche Kelly /3 par défaut (conservateur). Tu peux changer le diviseur dans ton profil.", icon: "🎯" },
    { title: "Reçois les alertes", body: "Active les notifs Telegram dans ton profil pour recevoir les value-bets en push, sans garder l'écran ouvert.", icon: "🔔" },
  ];
  const s = steps[step];
  return (
    <div className="q-onboarding-backdrop">
      <div className="q-onboarding" role="dialog" aria-modal="true" aria-labelledby="q-onb-title">
        <div className="q-onboarding-icon">{s.icon}</div>
        <div id="q-onb-title" className="q-onboarding-title">{s.title}</div>
        <div className="q-onboarding-body">{s.body}</div>
        <div className="q-onboarding-dots">
          {steps.map((_, i) => <span key={i} className={"dot" + (i === step ? " on" : "")} />)}
        </div>
        <div className="q-onboarding-actions">
          {step > 0
            ? <button className="q-btn" type="button" onClick={() => setStep(step - 1)}>← Précédent</button>
            : <span />
          }
          {step < steps.length - 1
            ? <button className="q-btn is-primary" type="button" onClick={() => setStep(step + 1)}>Suivant →</button>
            : <button className="q-btn is-primary" type="button" onClick={onClose}>OK, j'ai compris</button>
          }
        </div>
        <button className="q-onboarding-skip" type="button" onClick={onClose} aria-label="Passer">Passer</button>
      </div>
    </div>
  );
}

function Star({ on, onClick }) {
  return (
    <button
      className={"q-star" + (on ? " is-on" : "")}
      onClick={(e) => { e.stopPropagation(); onClick(); }}
      title={on ? "Retirer des favoris" : "Ajouter aux favoris"}
      aria-label="Toggle favorite"
    >
      <svg viewBox="0 0 24 24" fill={on ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
        <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
      </svg>
    </button>
  );
}

function useFavorites() {
  const [favs, setFavs] = useState(() => {
    try { return new Set(JSON.parse(localStorage.getItem("quanta-favs") || "[]")); }
    catch { return new Set(); }
  });
  const toggle = (key) => {
    setFavs(prev => {
      const next = new Set(prev);
      if (next.has(key)) next.delete(key); else next.add(key);
      try { localStorage.setItem("quanta-favs", JSON.stringify([...next])); } catch {}
      return next;
    });
  };
  return { favs, toggle, isFav: (k) => favs.has(k) };
}

/* Canonical BM column order. Single source of truth for every grid +
   the comparison table — adding a new bookmaker means touching this
   list (and the equivalent on the server side). */
const BM_DISPLAY_ORDER = ["Betclic", "Winamax", "Yonibet", "PMU", "Unibet"];

/* Filter the canonical order against the user's per-device "visible
   bookmakers" set. When `visibleBMs` is undefined (no auth context,
   or an older code path that doesn't pass it through), keep every
   BM — that's the legacy behaviour and a safe default. */
function applyVisibleBMs(visibleBMs) {
  if (!visibleBMs || typeof visibleBMs.has !== "function") return BM_DISPLAY_ORDER;
  return BM_DISPLAY_ORDER.filter(name => visibleBMs.has(name));
}

/* Stale threshold — past this many seconds since last_seen, we mark the
   bookmaker row as STALE (grayed + line-through + STALE tag). */
const STALE_SECONDS = 30;
function isStale(lastSeen) {
  if (!lastSeen) return false;
  return (Date.now() / 1000 - lastSeen) > STALE_SECONDS;
}

/* BM deep-link wrapper. Click stops propagation so the card click handler
   doesn't fire and navigate to detail at the same time. Always opens in
   a new tab. */
function BmLink({ url, name, children }) {
  if (!url) return <span>{children}</span>;
  return (
    <a
      href={url} target="_blank" rel="noopener noreferrer"
      className="q-bm-link"
      onClick={(e) => e.stopPropagation()}
      title={`Ouvrir ${name} dans un nouvel onglet`}
    >
      {children}
      <svg className="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
        <path d="M7 17 17 7M9 7h8v8"/>
      </svg>
    </a>
  );
}

/* ──────────────────────────────────────────────────────────
   CHROME — top strip + header
   ────────────────────────────────────────────────────────── */
function QClock() {
  const [t, setT] = useState(() => new Date());
  useEffect(() => {
    const id = setInterval(() => setT(new Date()), 1000);
    return () => clearInterval(id);
  }, []);
  const pad = (n) => String(n).padStart(2, "0");
  return (
    <span className="q-strip-clock">
      EU {pad(t.getHours())}:{pad(t.getMinutes())}:{pad(t.getSeconds())} CEST
    </span>
  );
}

function QTopStrip({ sport }) {
  const counts = {
    tennis: window.MOCK.TENNIS_MATCHES.length,
    football: window.MOCK.FOOT_MATCHES.length,
    baseball: (window.MOCK.BASEBALL_MATCHES || []).length,
  };
  const total = counts.tennis + counts.football + counts.baseball;
  // Read fresh on every render — parent re-renders every 1s via tick
  const status = (window.MOCK.STREAM && window.MOCK.STREAM[sport]) || "connecting";
  const stats = (window.MOCK.getStats && window.MOCK.getStats(sport)) || { lastTickAgeS: null };
  const lastTick = stats.lastTickAgeS;
  const statusLabel =
    status === "live" ? "LIVE" :
    status === "rest" ? "REST FALLBACK" :
    status === "down" ? "DOWN" : "CONNECTING";
  const cls =
    status === "live" ? "live" :
    status === "rest" ? "muted" :
    status === "down" ? "err" : "muted";
  const lastTickLabel = lastTick == null ? "—" : `${lastTick.toFixed(1)}s`;
  return (
    <div className="q-strip">
      <div className="q-strip-l">
        <span className="live">LIVE</span>
        <span className="muted">{total} MATCHES · {counts.tennis} TENNIS · {counts.football} FOOTBALL · {counts.baseball} BASEBALL</span>
        <QClock />
      </div>
      <div className="q-strip-r">
        <span className={cls}>STREAM</span>
        <span className="muted stream-detail">{statusLabel}</span>
        <span className="stream-detail">{lastTickLabel}</span>
      </div>
    </div>
  );
}

function QStreamBanner({ sport }) {
  const status = (window.MOCK.STREAM && window.MOCK.STREAM[sport]) || "connecting";
  if (status === "live") return null;
  const msg = {
    connecting: { icon: "…", text: "Connexion en cours…", cls: "" },
    rest:       { icon: "!", text: "Stream temps réel indisponible — fallback REST toutes les 5s.", cls: "warn" },
    down:       { icon: "!", text: "Connexion perdue — tentative de reconnexion…", cls: "err" },
  }[status] || { icon: "…", text: "État inconnu", cls: "" };
  return (
    <div className={"q-stream-banner " + msg.cls}>
      <div className="q-stream-banner-icon">{msg.icon}</div>
      <div className="q-stream-banner-text">{msg.text}</div>
    </div>
  );
}

function QHeader({ sport, onSport, theme, onTheme, alertCount, onAlertsOpen, search, onSearch, session, profile, onOpenProfile, onOpenLog, onOpenAdmin, onLogout }) {
  return (
    <div className="q-head">
      <div className="q-head-l">
        <div className="q-brand">
          <img className="q-brand-mark" src="/icon-192.png?v=2" alt="Quanta" />
          <div className="q-brand-name">QUANTA<span className="q-brand-suffix">ODDS · LIVE</span></div>
        </div>
        <div className="q-tabs">
          <button
            className={"q-tab" + (sport === "tennis" ? " is-active" : "")}
            onClick={() => onSport("tennis")}
          >
            TENNIS <span className="count">{String(window.MOCK.TENNIS_MATCHES.length).padStart(2, "0")}</span>
          </button>
          <button
            className={"q-tab" + (sport === "football" ? " is-active" : "")}
            onClick={() => onSport("football")}
          >
            FOOTBALL <span className="count">{String(window.MOCK.FOOT_MATCHES.length).padStart(2, "0")}</span>
          </button>
          <button
            className={"q-tab" + (sport === "baseball" ? " is-active" : "")}
            onClick={() => onSport("baseball")}
          >
            BASEBALL <span className="count">{String((window.MOCK.BASEBALL_MATCHES || []).length).padStart(2, "0")}</span>
          </button>
        </div>
      </div>
      <div className="q-head-r">
        <div className="q-cmd">
          <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
          <input
            type="search"
            value={search || ""}
            onChange={(e) => onSearch && onSearch(e.target.value)}
            placeholder="Équipe · joueur · tournoi…"
            aria-label="Rechercher"
            spellCheck={false}
            autoComplete="off"
          />
        </div>
        <button className="q-btn is-primary" onClick={onAlertsOpen}>
          <span className={alertCount > 0 ? "q-btn-badge" : ""}>ALERTS · {alertCount}</span>
        </button>
        <button
          className="q-btn q-btn-icon"
          onClick={() => onTheme(theme === "dark" ? "light" : "dark")}
          title={theme === "dark" ? "Mode clair" : "Mode sombre"}
          aria-label="Toggle theme"
        >
          {theme === "dark" ? (
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>
          ) : (
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
          )}
        </button>
        {session && (
          <QUserMenu
            session={session} profile={profile}
            onOpenProfile={onOpenProfile} onOpenLog={onOpenLog}
            onOpenAdmin={onOpenAdmin}
            onLogout={onLogout}
          />
        )}
      </div>
    </div>
  );
}

/* ──────────────────────────────────────────────────────────
   STATS RIBBON (computed from current sport)
   ────────────────────────────────────────────────────────── */
function QStatsRibbon({ matches, sport, visibleBMs }) {
  const stats = useMemo(() => {
    // Filter to matches that have at least one VISIBLE bookmaker.
    // Otherwise hiding Yonibet (which has many unique fixtures) would
    // leave the ribbon counting matches the user can't actually see.
    const allowed = (visibleBMs && typeof visibleBMs.has === "function") ? visibleBMs : null;
    const isVisible = (m) => {
      if (!allowed) return true;
      if (!m || !m.bm) return false;
      for (const name of Object.keys(m.bm)) {
        if (allowed.has(name)) return true;
      }
      return false;
    };
    const visible = matches.filter(isVisible);
    // When computing edges we restrict to visible bookmakers too — a
    // hidden BM's edge shouldn't move the average the user sees.
    const edges = visible.flatMap(m =>
      Object.entries(m.bm)
        .filter(([name]) => !allowed || allowed.has(name))
        .flatMap(([, b]) => [b.e1, b.e2])
        .filter(e => e != null)
    );
    const best = visible.reduce((a, m) => Math.max(a, m.bestEdge), 0);
    const avg = edges.length ? edges.reduce((a, b) => a + b, 0) / edges.length : 0;
    return {
      best, avg, count: visible.length,
      bms: new Set(
        visible.flatMap(m => Object.keys(m.bm).filter(n => !allowed || allowed.has(n)))
      ).size,
    };
  }, [matches, visibleBMs]);
  // Read live tick stats fresh on every render — the parent re-renders
  // every 1s via its `tick` interval, which keeps "Last tick" counting up
  // smoothly without an extra timer.
  const live = (window.MOCK.getStats && window.MOCK.getStats(sport)) || { ticksPerMin: 0, lastTickAgeS: null };
  const lastTickText = live.lastTickAgeS != null ? live.lastTickAgeS.toFixed(1) : "—";
  return (
    <div className="q-stats">
      <div className="q-stat is-accent">
        <div className="lbl">
          <HelpTip label="Edge = (cote bookmaker / cote BF LAY) − 1. Positif = value bet.">
            Best edge live
          </HelpTip>
        </div>
        <div className="val mono">{pct(stats.best)}</div>
      </div>
      <div className="q-stat">
        <div className="lbl">Avg edge live</div>
        <div className="val mono">{pct(stats.avg)}</div>
      </div>
      <div className="q-stat">
        <div className="lbl">Live matches</div>
        <div className="val mono">{stats.count}</div>
      </div>
      <div className="q-stat">
        <div className="lbl">Bookmakers</div>
        <div className="val mono">{stats.bms} <span className="delta flat">+BF</span></div>
      </div>
      <div className="q-stat">
        <div className="lbl">Ticks / min</div>
        <div className="val mono">{live.ticksPerMin}</div>
      </div>
      <div className="q-stat">
        <div className="lbl">Last tick</div>
        <div className="val mono">{lastTickText}<span style={{fontSize:14,color:"var(--ink-3)"}}>s</span></div>
      </div>
    </div>
  );
}

/* ──────────────────────────────────────────────────────────
   SCORE FORMATTING
   ────────────────────────────────────────────────────────── */
function ScoreInline({ match }) {
  if (match.sport === "tennis") {
    return (
      <span className="score-cell">
        {match.score.p1.map((v,i) => <span key={"a"+i} className={i === match.score.p1.length-1 ? "cur" : ""}>{v} </span>)}
        <span style={{color:"var(--ink-3)"}}>/ </span>
        {match.score.p2.map((v,i) => <span key={"b"+i} className={i === match.score.p2.length-1 ? "cur" : ""}>{v} </span>)}
      </span>
    );
  }
  return (
    <span className="score-cell">
      <span className="cur">{match.scoreLine}</span>
      <span style={{color: "var(--ink-3)", marginLeft: 6}}>{match.minute}</span>
    </span>
  );
}

/* ──────────────────────────────────────────────────────────
   TOP OPPORTUNITIES PANEL
   ────────────────────────────────────────────────────────── */
/* QTopsPanel — Top 3 opportunities with hysteresis.

   Without hysteresis, a match whose edge oscillates around the 3% line
   would appear/disappear on every snapshot — and because the Top panel
   sits at the top of the page, the rest of the layout would shake
   underneath it (especially on phone, where everything is stacked).

   Rules:
     * ENTER: an opportunity must hold edge ≥ THRESHOLD for ENTER_MS
       (2s) before joining the visible Top. Filters point-by-point
       blips and stale-tick artefacts.
     * STICK: once visible, it stays for STICK_MS (5s) after the edge
       last touched THRESHOLD. Even if it dips to +1.2% mid-window, the
       card keeps its slot — the user sees a number falling, not a card
       vanishing under their finger.

   State persists across renders via a ref so it survives the parent's
   re-render-every-1s tick without thrashing. */
const TOP_THRESHOLD = 0.03;
const TOP_ENTER_MS  = 2000;
const TOP_STICK_MS  = 5000;

function QTopsPanel({ matches, onOpen, favorites, flashKey, tick, visibleBMs }) {
  const stateRef = useRef(new Map());  // matchKey → {firstAbove, lastAboveAt, shown}

  // useMemo recomputes whenever `matches` reference changes OR the parent
  // tick bumps (1s interval). The tick dep matters: between WS pushes
  // `matches` may keep the same reference, but the STICK timer still
  // needs to age — without `tick`, an opportunity that's only sticky
  // (no longer above threshold) would freeze in the visible list.
  const top = useMemo(() => {
    const now = Date.now();
    const live = new Set();
    // Filter to matches whose best edge belongs to a VISIBLE bookmaker.
    // If the only BM hitting >3% on this match is one the user hid,
    // surfacing it in "Top opportunités" is misleading — they can't
    // actually act on it without re-enabling the BM.
    const allowed = (visibleBMs && typeof visibleBMs.has === "function") ? visibleBMs : null;
    const candidates = !allowed ? matches : matches.filter(m => {
      if (!m || !m.bm) return false;
      // Must have at least one visible BM with data
      let hasVisible = false;
      for (const name of Object.keys(m.bm)) {
        if (allowed.has(name)) { hasVisible = true; break; }
      }
      if (!hasVisible) return false;
      // AND the bestEdge must come from a visible BM
      return !m.bestEdgeBm || allowed.has(m.bestEdgeBm);
    });
    // Tick every match's hysteresis state once.
    for (const m of candidates) {
      live.add(m.key);
      let entry = stateRef.current.get(m.key);
      if (!entry) {
        entry = { firstAbove: null, lastAboveAt: null, shown: false };
        stateRef.current.set(m.key, entry);
      }
      if (m.bestEdge >= TOP_THRESHOLD) {
        if (entry.firstAbove == null) entry.firstAbove = now;
        entry.lastAboveAt = now;
      } else {
        entry.firstAbove = null;  // reset the enter timer; STICK timer keeps decaying
      }
    }
    // Drop entries that aren't in the current snapshot anymore (match ended).
    for (const k of [...stateRef.current.keys()]) {
      if (!live.has(k)) stateRef.current.delete(k);
    }
    // Decide visibility per match — only walk visible candidates so a
    // BM-hidden match drops out of Top immediately instead of lingering
    // until its STICK window expires.
    const visible = [];
    for (const m of candidates) {
      const entry = stateRef.current.get(m.key);
      if (!entry) continue;
      if (entry.shown) {
        if (entry.lastAboveAt != null && (now - entry.lastAboveAt) < TOP_STICK_MS) {
          visible.push(m);
        } else {
          entry.shown = false;  // exhausted the stick window — drop it
        }
      } else if (entry.firstAbove != null && (now - entry.firstAbove) >= TOP_ENTER_MS) {
        entry.shown = true;
        visible.push(m);
      }
    }
    return visible.sort((a, b) => b.bestEdge - a.bestEdge).slice(0, 3);
  }, [matches, tick, visibleBMs]);

  return (
    <div className="q-panel">
      <div className="q-panel-h">
        <div className="q-panel-title">
          <HelpTip label="Le meilleur edge live = la cote du bookmaker la plus généreuse vs la cote Betfair LAY (référence sharp). > +3 % = value bet.">
            Top opportunités
          </HelpTip>
          <span className="muted">· edge ≥ 3%</span>
        </div>
        <div className="q-panel-meta">SORT EDGE ↓</div>
      </div>
      {top.length === 0 ? (
        <div style={{padding: "40px 24px", textAlign: "center", color: "var(--ink-3)", fontFamily: "IBM Plex Mono, monospace", fontSize: 12, background: "var(--bg)"}}>
          Aucune opportunité ≥ 3 % actuellement
        </div>
      ) : (
      <div className="q-tops">
        {top.map((m, i) => {
          const pickName = pickLabelFor(m, m.bestEdgeOn);
          const bmEntry = m.bm[m.bestEdgeBm];
          const odd = bmEntry ? oddsForSide(bmEntry, m.bestEdgeOn) : null;
          const bfRef = m.bf ? oddsForSide(m.bf, m.bestEdgeOn) : null;
          const isFav = favorites && favorites.isFav(m.key);
          const isFlashing = flashKey === m.key;
          return (
            <div
              key={m.key}
              className={"q-top" + (isFlashing ? " is-flashing" : "")}
              onClick={() => onOpen(m.key)}
            >
              <div className="q-top-head">
                <span className="q-top-rank">#{String(i+1).padStart(2,"0")} · {m.tournament}</span>
                <div style={{display: "flex", alignItems: "center", gap: 4}}>
                  {favorites && <Star on={isFav} onClick={() => favorites.toggle(m.key)} />}
                  <span className="q-top-edge">{pct(m.bestEdge)}</span>
                </div>
              </div>
              <div className="q-top-players">{m.p1}<span className="vs">vs</span>{m.p2}</div>
              <div className="q-top-meta">
                {m.sport === "tennis"
                  ? `${m.score.p1.join(" ")} / ${m.score.p2.join(" ")} · ${m.surface.toUpperCase()}`
                  : `${m.scoreLine} · ${m.minute}`}
              </div>
              <div className="q-top-rec">
                <div>
                  <div className="lbl">PICK · {m.bestEdgeBm.toUpperCase()}</div>
                  <div className="pick">{pickName}</div>
                  <div style={{fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".04em", marginTop: 2}}>
                    vs BF LAY <b style={{color: "var(--warn)"}}>{bfRef != null ? bfRef.toFixed(2) : "—"}</b>
                  </div>
                </div>
                <div className="odd">{odd != null ? odd.toFixed(2) : "—"}</div>
              </div>
            </div>
          );
        })}
      </div>
      )}
    </div>
  );
}

/* ──────────────────────────────────────────────────────────
   GRID (desktop table) + CARDS (mobile)
   Each BM cell shows BOTH P1 and P2 odds so the user can see
   which player is being compared — fixes the previous ambiguity.
   ────────────────────────────────────────────────────────── */
function BmCell({ match, bmName }) {
  const b = match.bm[bmName];
  if (!b) return <td style={{color:"var(--ink-3)"}}>—</td>;
  const threeWay = isThreeWay(match);
  const greenSide = bestPositiveSideOf(b, threeWay);   // null when no edge >= 0
  const greenE = greenSide ? edgeForSide(b, greenSide) : null;
  const isBestBm = greenSide != null && match.bestEdgeBm === bmName;
  return (
    <td className={"q-cell" + heatClass(greenE) + (isBestBm ? " is-best" : "")}>
      <div className="stack">
        <div className={"side" + (greenSide === "p1" ? " is-best" : "")}>
          <span className="who">{shortName(match.p1)}</span>
          <span>
            <span className="num">{b.p1 != null ? b.p1.toFixed(2) : "—"}</span>
            {greenSide === "p1" ? <span className="edge">{pct(b.e1)}</span> : null}
          </span>
        </div>
        {threeWay && (
          <div className={"side" + (greenSide === "draw" ? " is-best" : "")}>
            <span className="who">X</span>
            <span>
              <span className="num">{b.draw != null ? b.draw.toFixed(2) : "—"}</span>
              {greenSide === "draw" ? <span className="edge">{pct(b.eDraw)}</span> : null}
            </span>
          </div>
        )}
        <div className={"side" + (greenSide === "p2" ? " is-best" : "")}>
          <span className="who">{shortName(match.p2)}</span>
          <span>
            <span className="num">{b.p2 != null ? b.p2.toFixed(2) : "—"}</span>
            {greenSide === "p2" ? <span className="edge">{pct(b.e2)}</span> : null}
          </span>
        </div>
      </div>
    </td>
  );
}

function BfCell({ match }) {
  const threeWay = isThreeWay(match);
  return (
    <td className="q-cell q-cell-bf">
      <div className="stack">
        <div className="side">
          <span className="who">{shortName(match.p1)}</span>
          <span>
            <span className="num">{fmtOdds(match.bf.p1)}</span>
            <BfDepth lay={match.bf.p1_lay_size} matched={match.bf.p1_matched} layMatched={match.bf.p1_lay_matched} />
          </span>
        </div>
        {threeWay && (
          <div className="side">
            <span className="who">X</span>
            <span>
              <span className="num">{fmtOdds(match.bf.draw)}</span>
              <BfDepth lay={match.bf.draw_lay_size} matched={match.bf.draw_matched} layMatched={match.bf.draw_lay_matched} />
            </span>
          </div>
        )}
        <div className="side">
          <span className="who">{shortName(match.p2)}</span>
          <span>
            <span className="num">{fmtOdds(match.bf.p2)}</span>
            <BfDepth lay={match.bf.p2_lay_size} matched={match.bf.p2_matched} layMatched={match.bf.p2_lay_matched} />
          </span>
        </div>
      </div>
    </td>
  );
}

function QGridDesktop({ matches, onOpen, favorites, flashKey, visibleBMs }) {
  const bmOrder = applyVisibleBMs(visibleBMs);
  return (
    <div className="q-grid-scroll">
      <table className="q-grid">
        <thead>
          <tr>
            <th style={{width: "22%"}}>MATCH</th>
            <th style={{width: 110, textAlign:"left"}}>SCORE</th>
            <th className="q-th-bf">BF LAY<span className="sub">BASELINE</span></th>
            {bmOrder.map(name => <th key={name}>{name.toUpperCase()}</th>)}
            <th style={{width: 150}}>BEST EDGE</th>
          </tr>
        </thead>
        <tbody>
          {matches.map(m => {
            const pickName = pickLabelFor(m, m.bestEdgeOn);
            const isFav = favorites && favorites.isFav(m.key);
            const isFlashing = flashKey === m.key;
            return (
              <tr
                key={m.key}
                className={(isFav ? "is-fav" : "") + (isFlashing ? " is-flashing" : "")}
                onClick={() => onOpen(m.key)}
              >
                <td>
                  <div className="pl">
                    {favorites && <Star on={isFav} onClick={() => favorites.toggle(m.key)} />}
                    <span className="symbol">{shortName(m.p1)}/{shortName(m.p2)}</span>
                    <div>
                      <div className="pl-name">{m.p1}<span className="pl-vs">vs</span>{m.p2}</div>
                      <div className="pl-tourn">{m.tournament}{m.sport === "tennis" ? ` · ${m.surface.toUpperCase()}` : ""}</div>
                    </div>
                  </div>
                </td>
                <td><ScoreInline match={m} /></td>
                <BfCell match={m} />
                {bmOrder.map(name => <BmCell key={name} match={m} bmName={name} />)}
                <td>
                  <div style={{display:"flex", flexDirection:"column", alignItems:"flex-end", gap: 3}}>
                    <span className="q-row-best">
                      {pct(m.bestEdge)}
                      <span className="bm">{m.bestEdgeBm.toUpperCase().slice(0,4)}</span>
                    </span>
                    <span style={{fontSize: 10.5, color: "var(--ink-3)", fontFamily: "IBM Plex Mono, monospace", letterSpacing: ".04em"}}>
                      ON · <b style={{color:"var(--ink)"}}>{m.bestEdgeOn === "draw" ? "NUL" : shortName(pickName)}</b>
                    </span>
                  </div>
                </td>
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}

function QGridMobile({ matches, onOpen, favorites, flashKey, visibleBMs }) {
  const bmOrder = applyVisibleBMs(visibleBMs);
  return (
    <div className="q-cards">
      {matches.map(m => {
        const pickName = pickLabelFor(m, m.bestEdgeOn);
        const isFav = favorites && favorites.isFav(m.key);
        const isFlashing = flashKey === m.key;
        return (
          <div
            key={m.key}
            className={"q-card" + (isFav ? " is-fav" : "") + (isFlashing ? " is-flashing" : "")}
            onClick={() => onOpen(m.key)}
          >
            <div className="q-card-head">
              <div>
                <div className="q-card-players">
                  {m.p1}<span className="vs">vs</span>{m.p2}
                </div>
                <div className="q-card-meta">{m.tournament}{m.sport === "tennis" ? ` · ${m.surface.toUpperCase()}` : ""}</div>
              </div>
              <div style={{display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4}}>
                {favorites && <Star on={isFav} onClick={() => favorites.toggle(m.key)} />}
                <span className="q-card-best">{pct(m.bestEdge)}</span>
              </div>
            </div>
            <div className="q-card-score">
              {m.sport === "tennis" ? (
                <>
                  {m.score.p1.map((v,i) => <span key={"a"+i} className={i === m.score.p1.length-1 ? "cur" : ""}>{v} </span>)}
                  <span style={{color:"var(--ink-3)"}}>/ </span>
                  {m.score.p2.map((v,i) => <span key={"b"+i} className={i === m.score.p2.length-1 ? "cur" : ""}>{v} </span>)}
                  <span style={{color:"var(--ink-3)", marginLeft: 8}}>· PICK <b style={{color:"var(--pos)"}}>{m.bestEdgeOn === "draw" ? "NUL" : shortName(pickName)}</b></span>
                </>
              ) : (
                <>
                  <span className="cur">{m.scoreLine}</span>
                  <span style={{marginLeft: 6}}>{m.minute}</span>
                  <span style={{color:"var(--ink-3)", marginLeft: 8}}>· PICK <b style={{color:"var(--pos)"}}>{m.bestEdgeOn === "draw" ? "NUL" : shortName(pickName)}</b></span>
                </>
              )}
            </div>
            <div className="q-card-bms">
              <div className="q-card-bm is-bf">
                <span className="name">BF LAY<span className="bf-tag">BASELINE</span></span>
                <span>
                  <span style={{color: "var(--ink-3)", fontSize: 10, marginRight: 4, letterSpacing: ".04em"}}>{shortName(m.p1)}</span>
                  <span className="odd" style={{color: "var(--ink)", fontWeight: 700}}>{fmtOdds(m.bf.p1)}</span>
                  {isThreeWay(m) && (
                    <>
                      <span style={{color: "var(--ink-3)", margin: "0 6px"}}>·</span>
                      <span style={{color: "var(--ink-3)", fontSize: 10, marginRight: 4, letterSpacing: ".04em"}}>X</span>
                      <span className="odd" style={{color: "var(--ink)", fontWeight: 700}}>{fmtOdds(m.bf.draw)}</span>
                    </>
                  )}
                  <span style={{color: "var(--ink-3)", margin: "0 6px"}}>·</span>
                  <span style={{color: "var(--ink-3)", fontSize: 10, marginRight: 4, letterSpacing: ".04em"}}>{shortName(m.p2)}</span>
                  <span className="odd" style={{color: "var(--ink)", fontWeight: 700}}>{fmtOdds(m.bf.p2)}</span>
                </span>
              </div>
              {bmOrder.map(name => {
                const b = m.bm[name];
                if (!b) return null;
                const threeWay = isThreeWay(m);
                // Same gating as the desktop cards: green/ink highlight only
                // when the best edge is actually >= 0 against Betfair LAY.
                const greenSide = bestPositiveSideOf(b, threeWay);
                const e = greenSide ? edgeForSide(b, greenSide) : null;
                const isBest = greenSide != null && m.bestEdgeBm === name;
                return (
                  <div key={name} className={"q-card-bm" + (isBest ? " is-best" : "")}>
                    <span className="name">{name.slice(0,4).toUpperCase()}</span>
                    <span>
                      <span style={{color: greenSide === "p1" ? "var(--ink)" : "var(--ink-3)"}} className="odd">{b.p1 != null ? b.p1.toFixed(2) : "—"}</span>
                      {threeWay && (
                        <>
                          <span style={{color: "var(--ink-3)", margin: "0 4px"}}>·</span>
                          <span style={{color: greenSide === "draw" ? "var(--ink)" : "var(--ink-3)"}} className="odd">{b.draw != null ? b.draw.toFixed(2) : "—"}</span>
                        </>
                      )}
                      <span style={{color: "var(--ink-3)", margin: "0 4px"}}>·</span>
                      <span style={{color: greenSide === "p2" ? "var(--ink)" : "var(--ink-3)"}} className="odd">{b.p2 != null ? b.p2.toFixed(2) : "—"}</span>
                      <span className={"edge " + (e != null && e >= 0 ? "" : "neg")}>{e != null ? pct(e) : "—"}</span>
                    </span>
                  </div>
                );
              })}
            </div>
          </div>
        );
      })}
    </div>
  );
}

function QGridCardsDesktop({ matches, onOpen, favorites, flashKey, tick, visibleBMs }) {
  const bmOrder = applyVisibleBMs(visibleBMs);
  return (
    <div className="q-mcards">
      {matches.map(m => {
        const threeWay = isThreeWay(m);
        const pickName = pickLabelFor(m, m.bestEdgeOn);
        const pickShort = m.bestEdgeOn === "draw" ? "NUL" : shortName(pickName);
        const isFav = favorites && favorites.isFav(m.key);
        const isFlashing = flashKey === m.key;
        // 3-way football grids need a 6th column for the draw. Inline style
        // keeps the change scoped to the affected card without forking the
        // base .q-mcard-grid stylesheet.
        const gridStyle = threeWay
          ? { gridTemplateColumns: "16px 56px 1fr 1fr 1fr 70px" }
          : undefined;
        return (
          <div
            key={m.key}
            className={"q-mcard" + (isFav ? " is-fav" : "") + (isFlashing ? " is-flashing" : "")}
            onClick={() => onOpen(m.key)}
          >
            <div className="q-mcard-head">
              <div className="q-mcard-id">
                <div className="q-mcard-symbol">{shortName(m.p1)}/{shortName(m.p2)} · {m.tournament.toUpperCase()}</div>
                <div className="q-mcard-players">{m.p1}<span className="vs">vs</span>{m.p2}</div>
                <div className="q-mcard-meta">
                  {m.sport === "tennis" ? (
                    <>
                      <span className="score">{m.score.p1.join(" ")} / {m.score.p2.join(" ")}</span>
                      <span>· {m.surface.toUpperCase()}</span>
                    </>
                  ) : (
                    <>
                      <span className="score">{m.scoreLine}</span>
                      <span>· {m.minute}</span>
                    </>
                  )}
                </div>
              </div>
              <div style={{display:"flex", flexDirection:"column", alignItems:"flex-end", gap: 4}}>
                {favorites && <Star on={isFav} onClick={() => favorites.toggle(m.key)} />}
                <div className="q-mcard-best">{pct(m.bestEdge)}</div>
                <div className="q-mcard-best-on">ON · <b>{pickShort}</b></div>
              </div>
            </div>

            <div className="q-mcard-grid" style={gridStyle}>
              <div className="q-mcard-thr" style={gridStyle}>
                <div></div>
                <div>BM</div>
                <div>{shortName(m.p1)}</div>
                {threeWay && <div>X</div>}
                <div>{shortName(m.p2)}</div>
                <div>EDGE</div>
              </div>

              {/* BF total-matched meta strip — separate from the LAY row
                  on purpose. The price row already carries per-side
                  liquidity; this slot carries the market-wide depth so
                  the user can sanity-check "£12k matched" vs "£200". */}
              <div className="q-mcard-bf-meta">
                <span className="lbl">BETFAIR LIVE</span>
                <span className="val">
                  {fmtTotalMatched(m.bf.total_matched) || <span className="muted">—</span>}
                  <span className="suffix"> matched</span>
                </span>
              </div>

              {/* BF LAY baseline row with liquidity + per-runner matched */}
              <div className="q-mcard-row is-bf" style={gridStyle}>
                <div><span className="q-mcard-dot" style={{background:"var(--warn)"}}></span></div>
                <div>
                  <BmLink url={m.bf.url} name="Betfair">BF LAY</BmLink>
                  <FreshBadge lastSeen={m.bf.last_seen} tick={tick} />
                </div>
                <div>
                  <span className="num">{fmtOdds(m.bf.p1)}</span>
                  <BfDepth lay={m.bf.p1_lay_size} matched={m.bf.p1_matched} layMatched={m.bf.p1_lay_matched} />
                </div>
                {threeWay && (
                  <div>
                    <span className="num">{fmtOdds(m.bf.draw)}</span>
                    <BfDepth lay={m.bf.draw_lay_size} matched={m.bf.draw_matched} layMatched={m.bf.draw_lay_matched} />
                  </div>
                )}
                <div>
                  <span className="num">{fmtOdds(m.bf.p2)}</span>
                  <BfDepth lay={m.bf.p2_lay_size} matched={m.bf.p2_matched} layMatched={m.bf.p2_lay_matched} />
                </div>
                <div><span style={{color:"var(--ink-3)", fontSize: 10}}>—</span></div>
              </div>

              {/* Bookmaker rows */}
              {bmOrder.map(name => {
                const b = m.bm[name]; if (!b) return null;
                // greenSide = the cote that should be highlighted, ONLY if its
                // edge is non-negative. Returns null when this BM has no value
                // anywhere — those rows stay neutral (no green tint).
                const greenSide = bestPositiveSideOf(b, threeWay);
                const e = greenSide ? edgeForSide(b, greenSide) : null;
                const isBest = greenSide != null && m.bestEdgeBm === name;
                const stale = isStale(b.last_seen);
                const color = (window.MOCK.BMS[name] || {}).color;
                return (
                  <div key={name} className={"q-mcard-row" + (isBest && !stale ? " is-best" : "") + (stale ? " is-stale" : "")} style={gridStyle}>
                    <div><span className="q-mcard-dot" style={{background:color}}></span></div>
                    <div>
                      <BmLink url={b.url} name={name}>{name.slice(0, 4).toUpperCase()}</BmLink>
                      {stale ? (
                        <span className="q-stale-tag" title={`Pas de tick depuis >${STALE_SECONDS}s`}>STALE</span>
                      ) : (
                        <FreshBadge lastSeen={b.last_seen} tick={tick} />
                      )}
                    </div>
                    <div>
                      <span className={"num" + (greenSide === "p1" && !stale ? " best" : " dim")}>
                        {b.p1 != null ? b.p1.toFixed(2) : "—"}
                      </span>
                    </div>
                    {threeWay && (
                      <div>
                        <span className={"num" + (greenSide === "draw" && !stale ? " best" : " dim")}>
                          {b.draw != null ? b.draw.toFixed(2) : "—"}
                        </span>
                      </div>
                    )}
                    <div>
                      <span className={"num" + (greenSide === "p2" && !stale ? " best" : " dim")}>
                        {b.p2 != null ? b.p2.toFixed(2) : "—"}
                      </span>
                    </div>
                    <div>
                      <span className={"edge " + (e != null && e >= 0 && !stale ? "pos" : "neg")}>{stale || e == null ? "—" : pct(e)}</span>
                    </div>
                  </div>
                );
              })}

              {/* Polymarket reference row — only when prediction-market data
                  is actually present. Football has no Polymarket; some tennis
                  fixtures aren't matched either. Skip the row instead of
                  crashing on null fields. */}
              {m.pm && m.pm.p1Buy != null && m.pm.p2Buy != null && (
                <div className="q-mcard-row is-pm">
                  <div><span className="q-mcard-dot" style={{background:"var(--ink)"}}></span></div>
                  <div>
                    <BmLink url={m.pm.url} name="Polymarket">PM</BmLink>
                    <span style={{fontSize:9, color:"var(--ink-3)", letterSpacing:".08em", marginLeft:5, fontWeight:600}}>REF</span>
                  </div>
                  <div>
                    <span className="num">{m.pm.p1Buy.toFixed(2)}</span>
                    {m.pm.p1Sell != null && <span className="pm-sell">/{m.pm.p1Sell.toFixed(2)}</span>}
                  </div>
                  <div>
                    <span className="num">{m.pm.p2Buy.toFixed(2)}</span>
                    {m.pm.p2Sell != null && <span className="pm-sell">/{m.pm.p2Sell.toFixed(2)}</span>}
                  </div>
                  <div><span style={{color:"var(--ink-3)", fontSize: 10}}>—</span></div>
                </div>
              )}
            </div>
          </div>
        );
      })}
    </div>
  );
}

function QGridPanel({ matches, onOpen, filter, onFilter, view, onView, favorites, flashKey, tick, search, visibleBMs }) {
  // Pre-filter: a match where NO visible bookmaker has odds is just BF +
  // Polymarket — a useless card. Drop them before the chip + search
  // filters so the "ALL · N" count reflects what the user actually sees.
  const visibleMatches = useMemo(() => {
    if (!visibleBMs || typeof visibleBMs.has !== "function") return matches;
    return matches.filter(m => {
      const bm = m && m.bm;
      if (!bm) return false;
      for (const name of Object.keys(bm)) {
        if (visibleBMs.has(name)) return true;
      }
      return false;
    });
  }, [matches, visibleBMs]);

  const filtered = useMemo(() => {
    let list = visibleMatches;
    if (filter === "edge3") list = list.filter(m => m.bestEdge >= 0.03);
    else if (filter === "favs") list = list.filter(m => favorites.isFav(m.key));
    const q = (search || "").trim().toLowerCase();
    if (q) {
      list = list.filter(m => {
        const haystack = (
          (m.p1 || "") + " " +
          (m.p2 || "") + " " +
          (m.tournament || "")
        ).toLowerCase();
        return haystack.includes(q);
      });
    }
    return list;
  }, [visibleMatches, filter, favorites.favs, search]);
  const favCount = visibleMatches.filter(m => favorites.isFav(m.key)).length;
  return (
    <div className="q-grid-wrap">
      <div className="q-grid-toolbar">
        <div className="q-chips">
          <button className={"q-chip" + (filter === "all" ? " is-active" : "")} onClick={() => onFilter("all")}>ALL · {visibleMatches.length}</button>
          <button className={"q-chip" + (filter === "edge3" ? " is-active" : "")} onClick={() => onFilter("edge3")}>EDGE ≥ 3%</button>
          <button className={"q-chip" + (filter === "favs" ? " is-active" : "")} onClick={() => onFilter("favs")}>★ FAVORIS · {favCount}</button>
          <button className="q-chip">SUSPENDED · 0</button>
        </div>
        <div className="q-toolbar-r">
          <span>SORT <b>EDGE ↓</b></span>
          <span>·</span>
          <div className="q-view-toggle">
            <button className={view === "table" ? "is-active" : ""} onClick={() => onView("table")}>
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
              TABLE
            </button>
            <button className={view === "cards" ? "is-active" : ""} onClick={() => onView("cards")}>
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
              CARDS
            </button>
          </div>
        </div>
      </div>
      {filtered.length === 0 ? (
        <div style={{padding: "60px 24px", textAlign: "center", color: "var(--ink-3)", fontFamily: "IBM Plex Mono, monospace", fontSize: 12}}>
          {filter === "favs" ? "Aucun match favori. Étoile une carte pour l'ajouter." : "Aucun match"}
        </div>
      ) : view === "cards" ? (
        <QGridCardsDesktop matches={filtered} onOpen={onOpen} favorites={favorites} flashKey={flashKey} tick={tick} visibleBMs={visibleBMs} />
      ) : (
        <>
          <QGridDesktop matches={filtered} onOpen={onOpen} favorites={favorites} flashKey={flashKey} visibleBMs={visibleBMs} />
          <QGridMobile matches={filtered} onOpen={onOpen} favorites={favorites} flashKey={flashKey} visibleBMs={visibleBMs} />
        </>
      )}
    </div>
  );
}

/* ──────────────────────────────────────────────────────────
   DETAIL VIEW
   ────────────────────────────────────────────────────────── */
function QSparkline({ data, color, height = 70 }) {
  if (!data || !data.length) return null;
  const w = 360, h = height;
  const min = Math.min(...data), max = Math.max(...data);
  const range = (max - min) || 1;
  const pts = data.map((v, i) => {
    const x = (i / (data.length - 1)) * w;
    const y = h - ((v - min) / range) * h * 0.85 - h * 0.075;
    return `${x.toFixed(1)},${y.toFixed(1)}`;
  });
  return (
    <svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none">
      <polyline points={`0,${h} ` + pts.join(" ") + ` ${w},${h}`} fill={color} opacity="0.10" stroke="none"/>
      <polyline points={pts.join(" ")} fill="none" stroke={color} strokeWidth="1.6" vectorEffect="non-scaling-stroke"/>
    </svg>
  );
}

function QDetailHead({ match, onBack }) {
  return (
    <>
      <div className="q-d-byline">
        <button type="button" className="q-d-back" onClick={onBack} aria-label="Retour">
          <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="m15 18-6-6 6-6"/></svg>
          BACK
        </button>
        <span>QUANTA &gt; {match.sport === "tennis" ? "TENNIS" : "FOOTBALL"} &gt; {match.tournament.toUpperCase()} &gt; {shortName(match.p1)}/{shortName(match.p2)}</span>
        <span style={{color:"var(--ink-3)"}}>· id {match.key}</span>
        <span className="live">LIVE</span>
      </div>
      <div className="q-d-head">
        <div className="q-d-id">
          <div className="q-d-symbol">
            {match.sport === "tennis"
              ? `${match.tournament.replace(/\s+/g, "-").toUpperCase()} · ${match.surface.toUpperCase()} · BO5`
              : `${match.tournament.replace(/\s+/g, "-").toUpperCase()} · 90′`}
          </div>
          <div className="q-d-title">{match.p1.split(",")[0]}<span className="vs">vs</span>{match.p2.split(",")[0]}</div>
          <div className="q-d-sub">
            {match.sport === "tennis"
              ? `${match.p1.toUpperCase()} · ${match.p2.toUpperCase()}`
              : `${match.tournament} matchday · ${match.minute} played`}
          </div>
        </div>
        <div className="q-d-board">
          {match.sport === "tennis" ? (
            <>
              <div className="q-d-board-head">SCOREBOARD · SET {match.score.p1.length}</div>
              <div className="q-d-board-row">
                <span className={"q-d-board-serve" + (match.score.serving === "p1" ? " is-on" : "")}></span>
                <span className="q-d-board-name">{match.p1.split(",")[0]}</span>
                <div className="q-d-board-sets">
                  {match.score.p1.map((v,i) => (
                    <div key={i} className={"q-d-board-set" + (i === match.score.p1.length-1 ? " is-current" : "")}>{v}</div>
                  ))}
                </div>
              </div>
              <div className="q-d-board-row">
                <span className={"q-d-board-serve" + (match.score.serving === "p2" ? " is-on" : "")}></span>
                <span className="q-d-board-name">{match.p2.split(",")[0]}</span>
                <div className="q-d-board-sets">
                  {match.score.p2.map((v,i) => (
                    <div key={i} className={"q-d-board-set" + (i === match.score.p2.length-1 ? " is-current" : "")}>{v}</div>
                  ))}
                </div>
              </div>
            </>
          ) : (
            <>
              <div className="q-d-board-head">SCOREBOARD · {match.minute}</div>
              <div className="q-d-foot-board">
                <div className="q-d-foot-team">
                  <div className="name">{match.p1}</div>
                  <div className="meta">HOME</div>
                </div>
                <div>
                  <div className="q-d-foot-score">
                    <span>{match.scoreLine.split("-")[0].trim()}</span>
                    <span className="sep">–</span>
                    <span>{match.scoreLine.split("-")[1].trim()}</span>
                  </div>
                  <div style={{textAlign:"center"}}>
                    <span className="q-d-foot-minute">{match.minute}</span>
                  </div>
                </div>
                <div className="q-d-foot-team">
                  <div className="name">{match.p2}</div>
                  <div className="meta">AWAY</div>
                </div>
              </div>
            </>
          )}
        </div>
      </div>
    </>
  );
}

function QComparisonTable({ match, visibleBMs }) {
  // Order BMs by the canonical display order (so the table doesn't reshuffle
  // depending on dict-key insertion order), then filter against the user's
  // dashboard-visibility set when one is provided.
  const allowed = (visibleBMs && typeof visibleBMs.has === "function")
    ? new Set([...visibleBMs])
    : null;
  const bmNames = BM_DISPLAY_ORDER
    .filter(name => match.bm[name])
    .filter(name => allowed ? allowed.has(name) : true);
  const p1Name = match.p1.split(",")[0];
  const p2Name = match.p2.split(",")[0];
  const threeWay = isThreeWay(match);
  // Helper: render a single odds cell with strikethrough when stale.
  const oddsCell = (v, stale) => (
    <td style={stale ? {textDecoration:"line-through", color:"var(--ink-3)"} : {}}>
      {v != null ? v.toFixed(2) : "—"}
    </td>
  );
  return (
    <div className="q-cmp">
      <div className="q-cmp-toolbar">
        <div className="q-cmp-title">Comparaison des cotes · {match.sport === "tennis" ? "BO5" : "1X2"}</div>
        <div className="q-cmp-meta">
          EDGE vs <b>BETFAIR LAY</b> · HEATMAP <b>ON</b> · {bmNames.length} BOOKMAKERS
          {match.pm ? " + 2 RÉFÉRENCES" : " + 1 RÉFÉRENCE"}
          {fmtTotalMatched(match.bf.total_matched) && (
            <> · BF MATCHED <b>{fmtTotalMatched(match.bf.total_matched)}</b></>
          )}
        </div>
      </div>
      <div className="q-cmp-scroll">
        <table className="q-cmp-table">
          <thead>
            <tr>
              <th>SOURCE</th>
              <th><span className="player">{p1Name}</span><span className="label">{threeWay ? "DOMICILE" : "P1 ODDS"}</span></th>
              {threeWay && <th><span className="player">X</span><span className="label">MATCH NUL</span></th>}
              <th><span className="player">{p2Name}</span><span className="label">{threeWay ? "EXTÉRIEUR" : "P2 ODDS"}</span></th>
              <th>IMPLIED {threeWay ? "1" : "P1"}</th>
              {threeWay && <th>IMPLIED X</th>}
              <th>IMPLIED {threeWay ? "2" : "P2"}</th>
              <th>EDGE {threeWay ? "1" : "P1"}</th>
              {threeWay && <th>EDGE X</th>}
              <th>EDGE {threeWay ? "2" : "P2"}</th>
            </tr>
          </thead>
          <tbody>
            <tr className="q-cmp-row-bf">
              <td>
                <span className="bm-tag">BASELINE</span>
                <BmLink url={match.bf.url} name="Betfair">Betfair LAY</BmLink>
              </td>
              <td style={{fontWeight:700}}>
                {fmtOdds(match.bf.p1)} <BfDepth lay={match.bf.p1_lay_size} matched={match.bf.p1_matched} layMatched={match.bf.p1_lay_matched} />
              </td>
              {threeWay && (
                <td style={{fontWeight:700}}>
                  {fmtOdds(match.bf.draw)} <BfDepth lay={match.bf.draw_lay_size} matched={match.bf.draw_matched} layMatched={match.bf.draw_lay_matched} />
                </td>
              )}
              <td style={{fontWeight:700}}>
                {fmtOdds(match.bf.p2)} <BfDepth lay={match.bf.p2_lay_size} matched={match.bf.p2_matched} layMatched={match.bf.p2_lay_matched} />
              </td>
              <td>{fmtImpliedPct(match.bf.p1)}</td>
              {threeWay && <td>{fmtImpliedPct(match.bf.draw)}</td>}
              <td>{fmtImpliedPct(match.bf.p2)}</td>
              <td style={{color:"var(--ink-3)"}}>—</td>
              {threeWay && <td style={{color:"var(--ink-3)"}}>—</td>}
              <td style={{color:"var(--ink-3)"}}>—</td>
            </tr>
            {/* Polymarket row only when prediction-market quotes are
                actually present. Football has none; tennis sometimes has
                none. Skipping is cleaner than rendering "0.00 / 0.00". */}
            {match.pm && match.pm.p1Buy != null && match.pm.p2Buy != null && (
              <tr className="q-cmp-row-pm">
                <td>
                  <span className="bm-tag">REF</span>
                  <BmLink url={match.pm.url} name="Polymarket">Polymarket</BmLink>
                </td>
                <td>
                  <div className="q-pm-pair">
                    <div className="pair-row"><span className="side">BUY</span><span>{match.pm.p1Buy.toFixed(2)}</span></div>
                    {match.pm.p1Sell != null && <div className="pair-row"><span className="side">SELL</span><span style={{color:"var(--ink-3)"}}>{match.pm.p1Sell.toFixed(2)}</span></div>}
                  </div>
                </td>
                {threeWay && <td style={{color:"var(--ink-3)"}}>—</td>}
                <td>
                  <div className="q-pm-pair">
                    <div className="pair-row"><span className="side">BUY</span><span>{match.pm.p2Buy.toFixed(2)}</span></div>
                    {match.pm.p2Sell != null && <div className="pair-row"><span className="side">SELL</span><span style={{color:"var(--ink-3)"}}>{match.pm.p2Sell.toFixed(2)}</span></div>}
                  </div>
                </td>
                <td>{(100/match.pm.p1Buy).toFixed(1)}%</td>
                {threeWay && <td style={{color:"var(--ink-3)"}}>—</td>}
                <td>{(100/match.pm.p2Buy).toFixed(1)}%</td>
                <td style={{color:"var(--ink-3)"}}>—</td>
                {threeWay && <td style={{color:"var(--ink-3)"}}>—</td>}
                <td style={{color:"var(--ink-3)"}}>—</td>
              </tr>
            )}
            {bmNames.map(name => {
              const b = match.bm[name];
              const stale = isStale(b.last_seen);
              return (
                <tr key={name} className={stale ? "is-stale" : ""}>
                  <td>
                    <span className="q-bm-mark" style={{background:(window.MOCK.BMS[name]||{}).color}}></span>
                    <BmLink url={b.url} name={name}>{name}</BmLink>
                    {stale
                      ? <span className="q-stale-tag" title={`Pas de tick depuis >${STALE_SECONDS}s`}>STALE</span>
                      : <FreshBadge lastSeen={b.last_seen} />
                    }
                  </td>
                  {oddsCell(b.p1, stale)}
                  {threeWay && oddsCell(b.draw, stale)}
                  {oddsCell(b.p2, stale)}
                  <td>{b.p1 != null ? (100/b.p1).toFixed(1) + "%" : "—"}</td>
                  {threeWay && <td>{b.draw != null ? (100/b.draw).toFixed(1) + "%" : "—"}</td>}
                  <td>{b.p2 != null ? (100/b.p2).toFixed(1) + "%" : "—"}</td>
                  <td className={"q-cmp-edge " + (b.e1>0?"pos":"neg") + (stale ? "" : edgeHeat(b.e1))}>{stale || b.e1 == null ? "—" : pct(b.e1)}</td>
                  {threeWay && <td className={"q-cmp-edge " + (b.eDraw>0?"pos":"neg") + (stale ? "" : edgeHeat(b.eDraw))}>{stale || b.eDraw == null ? "—" : pct(b.eDraw)}</td>}
                  <td className={"q-cmp-edge " + (b.e2>0?"pos":"neg") + (stale ? "" : edgeHeat(b.e2))}>{stale || b.e2 == null ? "—" : pct(b.e2)}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    </div>
  );
}

function QDetailFooter({ match }) {
  // Real 24-tick history from match.history.p1/p2.Betfair (maintained by
  // live-data.jsx). Falls back to a single-value placeholder when the
  // stream hasn't produced any history yet for this fixture.
  const p1Series = (match.history && match.history.p1 && match.history.p1.Betfair) || [];
  const p2Series = (match.history && match.history.p2 && match.history.p2.Betfair) || [];
  const hasP1 = p1Series.length > 0;
  const hasP2 = p2Series.length > 0;
  const p1Cur = match.bf.p1, p2Cur = match.bf.p2;
  const sumImplied = (match.bf.p1 != null && match.bf.p2 != null)
    ? (100/match.bf.p1 + 100/match.bf.p2).toFixed(1)
    : null;
  const safeMin = (arr) => hasArr(arr) ? Math.min(...arr).toFixed(2) : "—";
  const safeMax = (arr) => hasArr(arr) ? Math.max(...arr).toFixed(2) : "—";
  function hasArr(a) { return Array.isArray(a) && a.length > 0; }
  return (
    <div className="q-foot">
      <div className="q-foot-block">
        <div className="q-foot-h">Mouvement {match.p1.split(",")[0]} · last 24 ticks</div>
        <div className="q-sparkline-meta">
          <span>OPEN <b>{hasP1 ? p1Series[0].toFixed(2) : "—"}</b></span>
          <span>HI <b>{safeMax(p1Series)}</b></span>
          <span>LO <b>{safeMin(p1Series)}</b></span>
          <span>NOW <b>{p1Cur != null ? p1Cur.toFixed(2) : "—"}</b></span>
        </div>
        <div className="q-sparkline">{hasP1 ? <QSparkline data={p1Series} color="var(--pos)" /> : <div style={{height:70, display:"grid", placeItems:"center", color:"var(--ink-3)", fontFamily:"IBM Plex Mono, monospace", fontSize:11}}>en attente…</div>}</div>
      </div>
      <div className="q-foot-block">
        <div className="q-foot-h">Mouvement {match.p2.split(",")[0]} · last 24 ticks</div>
        <div className="q-sparkline-meta">
          <span>OPEN <b>{hasP2 ? p2Series[0].toFixed(2) : "—"}</b></span>
          <span>HI <b>{safeMax(p2Series)}</b></span>
          <span>LO <b>{safeMin(p2Series)}</b></span>
          <span>NOW <b>{p2Cur != null ? p2Cur.toFixed(2) : "—"}</b></span>
        </div>
        <div className="q-sparkline">{hasP2 ? <QSparkline data={p2Series} color="var(--neg)" /> : <div style={{height:70, display:"grid", placeItems:"center", color:"var(--ink-3)", fontFamily:"IBM Plex Mono, monospace", fontSize:11}}>en attente…</div>}</div>
      </div>
      <div className="q-foot-block">
        <div className="q-foot-h">Market snapshot</div>
        <div className="q-foot-spark">
          <div className="q-foot-row"><span className="k">Sum implied</span><span className="v">{sumImplied != null ? sumImplied + "%" : "—"}</span></div>
          <div className="q-foot-row"><span className="k">Overround</span><span className="v">{sumImplied != null ? (sumImplied - 100).toFixed(1) + "%" : "—"}</span></div>
          <div className="q-foot-row"><span className="k">Best edge</span><span className="v" style={{color:"var(--pos)"}}>{pct(match.bestEdge)}</span></div>
          <div className="q-foot-row"><span className="k">On player</span><span className="v">{match.bestEdgeOn === "p1" ? match.p1.split(",")[0] : match.p2.split(",")[0]}</span></div>
          <div className="q-foot-row"><span className="k">From bookmaker</span><span className="v">{match.bestEdgeBm}</span></div>
          <div className="q-foot-row"><span className="k">Last tick</span><span className="v">{(() => { const s = window.MOCK.getStats && window.MOCK.getStats(match.sport); return s && s.lastTickAgeS != null ? s.lastTickAgeS.toFixed(1) + "s ago" : "—"; })()}</span></div>
        </div>
      </div>
    </div>
  );
}

function QDetailView({ match, onBack, favorites, tick, profile, visibleBMs }) {
  return (
    <>
      <QDetailHead match={match} onBack={onBack} />
      <div className="q-d-action">
        <QKellyCard match={match} divisor={profile?.kellyDivisor || 3} />
        <QEventLog match={match} tick={tick} />
      </div>
      <QChartPanel match={match} />
      <QComparisonTable match={match} visibleBMs={visibleBMs} />
    </>
  );
}

/* ──────────────────────────────────────────────────────────
   LIST VIEW
   ────────────────────────────────────────────────────────── */
function QListView({ matches, sport, onOpen, favorites, flashKey, tick, search, visibleBMs }) {
  const [filter, setFilter] = useState("all");
  const [view, setView] = useState(() => {
    try { return localStorage.getItem("quanta-view") || "cards"; }
    catch { return "cards"; }
  });
  useEffect(() => {
    try { localStorage.setItem("quanta-view", view); } catch {}
  }, [view]);
  return (
    <>
      <QStatsRibbon matches={matches} sport={sport} visibleBMs={visibleBMs} />
      <QTopsPanel matches={matches} onOpen={onOpen} favorites={favorites} flashKey={flashKey} tick={tick} visibleBMs={visibleBMs} />
      <QGridPanel
        matches={matches} onOpen={onOpen}
        filter={filter} onFilter={setFilter}
        view={view} onView={setView}
        favorites={favorites} flashKey={flashKey} tick={tick}
        search={search}
        visibleBMs={visibleBMs}
      />
    </>
  );
}

/* ──────────────────────────────────────────────────────────
   APP
   ────────────────────────────────────────────────────────── */
function QuantaApp() {
  injectQuantaCSS();

  // Auth lives at the outer component so the LoginScreen can render
  // without instantiating any of the dashboard hooks. We keep theme at
  // this level too so the login screen respects dark/light.
  const auth = useAuth();
  const [theme, setTheme] = useState(() => {
    try { return localStorage.getItem("quanta-theme") || "dark"; }
    catch { return "dark"; }
  });
  useEffect(() => {
    try {
      localStorage.setItem("quanta-theme", theme);
      // Keep the <html> data-theme attribute in sync so the pre-paint
      // script's value matches the React tree (prevents a flash on toggle).
      document.documentElement.dataset.theme = theme;
      document.documentElement.style.background =
        theme === "dark" ? "#0b0e13" : "#ffffff";
    } catch {}
  }, [theme]);

  if (!auth.bootDone) {
    return <QBootSplash />;
  }

  // Invite flow: `?invite=<token>` short-circuits both the login screen and
  // the existing auth state. The friend opens the URL from WhatsApp/Telegram,
  // sets their own password via QSetupScreen, and the POST response sets a
  // session cookie that replaces any previous one. We re-evaluate auth on
  // success by hitting the /api/auth/me endpoint via the existing useAuth boot,
  // which means the simplest "did it work" signal is a hard reload of the
  // root URL once `?invite` has been stripped.
  let inviteToken = "";
  try {
    inviteToken = new URLSearchParams(location.search).get("invite") || "";
  } catch {}
  if (inviteToken) {
    return (
      <div className="q" data-theme={theme}>
        <QSetupScreen
          token={inviteToken}
          onLoggedIn={() => {
            // Reload from root so useAuth re-boots and reads the fresh cookie.
            // `?invite=` is already stripped from the URL by QSetupScreen.
            location.href = "/";
          }}
        />
      </div>
    );
  }

  if (!auth.isAuthed) {
    return (
      <div className="q" data-theme={theme}>
        <QLoginScreen onLogin={auth.login} />
      </div>
    );
  }
  return <QuantaAppAuthed auth={auth} theme={theme} setTheme={setTheme} />;
}

function QuantaAppAuthed({ auth, theme, setTheme }) {
  const { session, profile, logout, updateProfile } = auth;
  const { duplicate, kickOthers } = useSessionWatcher(session, () => logout());

  const [sport, setSport] = useState("tennis");
  const [openKey, setOpenKey] = useState(null);
  const favorites = useFavorites();
  const [profilePanel, setProfilePanel] = useState(false);
  const [logPanel, setLogPanel] = useState(false);
  const [adminPanel, setAdminPanel] = useState(false);

  // Dashboard-visibility BM filter (per-device, per-username).
  // Lives at this scope because every grid + comparison table needs to
  // read it. Edits flow through QProfilePanel.
  // The hook lives in quanta-auth.jsx and is exposed on window.
  const [visibleBMs, toggleVisibleBM] = window.useVisibleBMs(session && session.user);

  // Onboarding: show the 4-step modal on first login for this username.
  // Persists via localStorage so a returning user never sees it twice.
  const onboardKey = session ? `quanta-onboarded-${session.user}` : null;
  const [showOnboarding, setShowOnboarding] = useState(() => {
    if (!onboardKey) return false;
    try { return !localStorage.getItem(onboardKey); } catch { return false; }
  });
  const dismissOnboarding = () => {
    if (onboardKey) {
      try { localStorage.setItem(onboardKey, "1"); } catch {}
    }
    setShowOnboarding(false);
  };

  // Tick — re-renders every 1s so freshness badges stay live.
  // Also bumps on `quanta-data-update` (fired by live-data.jsx on WS push)
  // so new edges and odds appear instantly instead of waiting for the 1s tick.
  const [tick, setTick] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setTick(t => t + 1), 1000);
    const onData = () => setTick(t => t + 1);
    window.addEventListener("quanta-data-update", onData);
    return () => {
      clearInterval(id);
      window.removeEventListener("quanta-data-update", onData);
    };
  }, []);

  // Session loss — live-data.jsx dispatches "quanta-auth-lost" on WS close
  // 1008 or REST 401. Force a local logout so the login screen appears.
  useEffect(() => {
    const onAuthLost = () => {
      auth.logout();
    };
    window.addEventListener("quanta-auth-lost", onAuthLost);
    return () => window.removeEventListener("quanta-auth-lost", onAuthLost);
  }, [auth]);

  // Alerts queue + flash key. Uses profile.alertThreshold.
  const [alerts, setAlerts] = useState([]);
  const [flashKey, setFlashKey] = useState(null);
  const [alertsOpen, setAlertsOpen] = useState(false);

  // Bipe toggle (per-device, localStorage). Defaults to ON because the
  // 2s persistence check already filters out the ephemeral false
  // positives between tennis points — the bipes that survive are real.
  const [bipeEnabled, setBipeEnabled] = useState(() => {
    try { return localStorage.getItem("quanta-bipe") !== "0"; }
    catch { return true; }
  });
  useEffect(() => {
    try { localStorage.setItem("quanta-bipe", bipeEnabled ? "1" : "0"); } catch {}
  }, [bipeEnabled]);
  const bipeEnabledRef = useRef(bipeEnabled);
  useEffect(() => { bipeEnabledRef.current = bipeEnabled; }, [bipeEnabled]);

  // Audio bipe — generated via Web Audio API so we don't ship a sound
  // file. Two short tones (880Hz → 1320Hz) so it's audible without
  // being abrasive. The AudioContext is created lazily on first bipe
  // because most browsers block AC creation until the user has
  // interacted with the page at least once (login click counts).
  const audioCtxRef = useRef(null);
  const playBipe = () => {
    if (!bipeEnabledRef.current) return;
    try {
      const AC = window.AudioContext || window.webkitAudioContext;
      if (!AC) return;
      let ctx = audioCtxRef.current;
      if (!ctx || ctx.state === "closed") {
        ctx = new AC();
        audioCtxRef.current = ctx;
      }
      if (ctx.state === "suspended") { ctx.resume().catch(() => {}); }
      const now = ctx.currentTime;
      const blip = (freq, start, dur) => {
        const o = ctx.createOscillator();
        const g = ctx.createGain();
        o.type = "sine";
        o.frequency.value = freq;
        // Short attack + decay so it sounds like a "blip" not a buzz.
        g.gain.setValueAtTime(0.0001, start);
        g.gain.exponentialRampToValueAtTime(0.20, start + 0.01);
        g.gain.exponentialRampToValueAtTime(0.0001, start + dur);
        o.connect(g).connect(ctx.destination);
        o.start(start);
        o.stop(start + dur + 0.02);
      };
      blip(880,  now,         0.12);
      blip(1320, now + 0.14,  0.12);
    } catch { /* audio unavailable — swallow */ }
  };

  // Pending spike events that haven't passed the 2s persistence check.
  // Held in a ref so the effect doesn't tear them down on every render.
  // `firedRef` dedupes already-alerted ids across the lifetime of this
  // tab — the alerts list itself caps at 25 entries so we can't rely on
  // it for dedup of fresh events.
  const pendingRef = useRef(new Map());      // alertId → setTimeout handle
  const firedRef   = useRef(new Set());      // alertId already alerted

  /* Real alerts pipeline with persistence filter.

     The server fires `edge_spike` events on the transition <3% → ≥3%.
     Between tennis points the BF LAY price can briefly oscillate
     (suspension / re-open), which historically triggered alerts that
     vanished a second later — the "bipe entre les points" annoyance.

     Fix: hold each event for 2 seconds, then re-check the LIVE match
     state. If the BM still shows an edge ≥ threshold on the same side,
     it's a real opportunity → fire alert + flash card + audio bipe.
     If it has retreated, drop silently. */
  useEffect(() => {
    const PERSISTENCE_MS = 2000;
    const onDataUpdate = () => {
      const bySport = (window.MOCK && window.MOCK._lastSnapshotBySport) || {};
      const threshold = profile?.alertThreshold || 0.03;
      const allEvents = [];
      for (const [sportKey, snap] of Object.entries(bySport)) {
        const evs = (snap && snap.recent_events) || [];
        for (const ev of evs) allEvents.push({ ev, sportKey });
      }
      if (!allEvents.length) return;
      const findMatch = (key) =>
        window.MOCK.ALL_MATCHES.find(m => m.key === key);

      for (const { ev } of allEvents) {
        if (ev.type !== "edge_spike") continue;
        if (typeof ev.edge === "number" && ev.edge < threshold) continue;

        const matchKey = ev.match_key;
        const alertId = `${matchKey}-${ev.ts}-${ev.bm}-${ev.side}`;
        if (firedRef.current.has(alertId)) continue;
        if (pendingRef.current.has(alertId)) continue;

        const handle = setTimeout(() => {
          pendingRef.current.delete(alertId);
          // Persistence check: is the opportunity STILL valid right now?
          const live = findMatch(matchKey);
          if (!live || !live.bm || !live.bm[ev.bm]) return;
          const liveEdge = edgeForSide(live.bm[ev.bm], ev.side);
          if (liveEdge == null || liveEdge < threshold) return;
          // Survived the 2s window → fire.
          firedRef.current.add(alertId);
          // Trim firedRef so it doesn't grow unbounded across hours.
          if (firedRef.current.size > 500) {
            const keep = [...firedRef.current].slice(-250);
            firedRef.current = new Set(keep);
          }
          const m = live;
          const bmEntry = m.bm[ev.bm];
          const oddsResolved = (ev.odds != null && ev.odds > 0)
            ? ev.odds
            : (bmEntry && ev.side ? (oddsForSide(bmEntry, ev.side) || 0) : 0);
          const alertObj = {
            id: alertId,
            ts: ev.ts || (Date.now() / 1000),
            matchKey,
            sport: ev.sport || m.sport,
            tournament: m.tournament || matchKey,
            p1: ev.home || m.p1,
            p2: ev.away || m.p2,
            bm: ev.bm,
            side: ev.side,
            pick: ev.side === "p1" ? (ev.home || m.p1)
                : ev.side === "p2" ? (ev.away || m.p2)
                : "Match nul",
            odds: oddsResolved,
            edge: liveEdge,           // use the CURRENT edge, not the firing one
          };
          setAlerts(prev => {
            if (prev.some(a => a.id === alertObj.id)) return prev;
            return [alertObj, ...prev].slice(0, 25);
          });
          setFlashKey(matchKey);
          playBipe();
          setTimeout(() => setFlashKey(k => k === matchKey ? null : k), 3200);
        }, PERSISTENCE_MS);
        pendingRef.current.set(alertId, handle);
      }
    };
    window.addEventListener("quanta-data-update", onDataUpdate);
    return () => {
      window.removeEventListener("quanta-data-update", onDataUpdate);
      // Clear pending timers when the effect re-runs (e.g. threshold change).
      for (const h of pendingRef.current.values()) clearTimeout(h);
      pendingRef.current.clear();
    };
  }, [profile?.alertThreshold]);

  // Hash-based routing
  useEffect(() => {
    const apply = () => {
      const h = location.hash.replace(/^#\/?/, "");
      const parts = h.split("/").filter(Boolean);
      if (parts[0] === "tennis" || parts[0] === "football" || parts[0] === "baseball") setSport(parts[0]);
      if (parts[1] === "match" && parts[2]) setOpenKey(decodeURIComponent(parts[2]));
      else setOpenKey(null);
    };
    apply();
    window.addEventListener("hashchange", apply);
    return () => window.removeEventListener("hashchange", apply);
  }, []);

  const navigateMatch = (key) => { location.hash = `#/${sport}/match/${encodeURIComponent(key)}`; };
  const navigateBack = () => { location.hash = `#/${sport}`; };
  const navigateSport = (newSport) => { location.hash = `#/${newSport}`; };

  // Search query lives at this level so the input in QHeader stays mounted
  // when the user navigates into a match detail (the input value persists
  // across views). The actual filtering is applied inside QGridPanel.
  const [searchQuery, setSearchQuery] = useState("");

  const matches =
    sport === "tennis"   ? window.MOCK.TENNIS_MATCHES :
    sport === "baseball" ? (window.MOCK.BASEBALL_MATCHES || []) :
                           window.MOCK.FOOT_MATCHES;
  const openMatch = openKey ? matches.find(m => m.key === openKey) : null;

  const alertBadge = alerts.length;

  return (
    <div className="q" data-theme={theme}>
      <QTopStrip sport={sport} />
      <QHeader
        sport={sport} onSport={navigateSport}
        theme={theme} onTheme={setTheme}
        alertCount={alertBadge}
        onAlertsOpen={() => setAlertsOpen(true)}
        search={searchQuery} onSearch={setSearchQuery}
        session={session} profile={profile}
        onOpenProfile={() => setProfilePanel(true)}
        onOpenLog={() => setLogPanel(true)}
        onOpenAdmin={() => setAdminPanel(true)}
        onLogout={logout}
      />
      {/* Dedicated mobile/tablet search row — the header .q-cmd is hidden
          below 1024px because the header gets cramped (sport tabs + user
          menu + theme toggle already eat the width). We surface the same
          search state on a slim row right under the header instead, only
          when we're on the list view (a detail page has no list to filter). */}
      {!openMatch && (
        <div className="q-mobile-search">
          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
          <input
            type="search"
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            placeholder="Équipe · joueur · tournoi…"
            aria-label="Rechercher"
            spellCheck={false}
            autoComplete="off"
          />
        </div>
      )}
      <QDuplicateBanner duplicate={duplicate} onKickOthers={kickOthers} />
      <QStreamBanner sport={sport} />
      {openMatch
        ? <QDetailView match={openMatch} onBack={navigateBack} favorites={favorites} tick={tick} profile={profile} visibleBMs={visibleBMs} />
        : <QListView
            matches={matches} sport={sport} onOpen={navigateMatch}
            favorites={favorites} flashKey={flashKey} tick={tick}
            search={searchQuery}
            visibleBMs={visibleBMs}
          />
      }
      <QAlertsPanel
        open={alertsOpen}
        onClose={() => setAlertsOpen(false)}
        alerts={alerts} tick={tick}
        onOpenMatch={(key) => {
          setAlertsOpen(false);
          const m = window.MOCK.ALL_MATCHES.find(x => x.key === key);
          if (m) location.hash = `#/${m.sport}/match/${encodeURIComponent(key)}`;
        }}
      />
      <QProfilePanel
        open={profilePanel} onClose={() => setProfilePanel(false)}
        session={session} profile={profile} updateProfile={updateProfile}
        visibleBMs={visibleBMs} toggleVisibleBM={toggleVisibleBM}
        bipeEnabled={bipeEnabled} setBipeEnabled={setBipeEnabled}
      />
      <QSessionLogPanel
        open={logPanel} onClose={() => setLogPanel(false)}
      />
      {session && session.role === "admin" && (
        <QAdminPanel
          open={adminPanel} onClose={() => setAdminPanel(false)}
        />
      )}
      {showOnboarding && session && (
        <QOnboarding username={session.user} onClose={dismissOnboarding} />
      )}
    </div>
  );
}

window.QuantaApp = QuantaApp;
