/* QUANTA · Auth + Session
   - useAuth: per-tab session (sessionStorage) with login/logout + profile
   - useSessionWatcher: detects duplicate sessions across tabs via
     BroadcastChannel + kick-others action
   - Session log: append-only event log (LOGIN / LOGOUT / DUPLICATE / KICKED),
     stored in localStorage so all tabs see the same history
   - LoginScreen, DuplicateBanner, UserMenu, UserProfilePanel components

   This is a CLIENT-SIDE prototype of the auth flow. Wiring to a real server:
     – Replace useAuth with API calls (POST /login → JWT)
     – Replace BroadcastChannel with server-side session table + WS push
     – appendLog() should hit POST /audit-log instead of localStorage */

const { useState: useStateA, useEffect: useEffectA, useRef: useRefA, useMemo: useMemoA } = React;

const SESSION_KEY = "quanta-session";
const LOG_KEY = "quanta-session-log";
const PROFILE_KEY_PREFIX = "quanta-profile:";
const CHANNEL_NAME = "quanta-sessions";
const HEARTBEAT_MS = 4000;
const PEER_TIMEOUT_MS = 12000;

function shortId(s) { return (s || "").slice(0, 6); }

function getSessionLog() {
  try { return JSON.parse(localStorage.getItem(LOG_KEY) || "[]"); }
  catch { return []; }
}
function appendLog(entry) {
  const log = getSessionLog();
  log.push({ ts: Date.now(), ...entry });
  while (log.length > 200) log.shift();
  try { localStorage.setItem(LOG_KEY, JSON.stringify(log)); } catch {}
  // Cross-tab notify
  try { window.dispatchEvent(new CustomEvent("quanta-log-update")); } catch {}
}

function loadProfile(user) {
  try {
    const raw = localStorage.getItem(PROFILE_KEY_PREFIX + user);
    if (!raw) return { kellyDivisor: 3, alertThreshold: 0.03 };
    return { kellyDivisor: 3, alertThreshold: 0.03, ...JSON.parse(raw) };
  } catch { return { kellyDivisor: 3, alertThreshold: 0.03 }; }
}
function saveProfile(user, profile) {
  try { localStorage.setItem(PROFILE_KEY_PREFIX + user, JSON.stringify(profile)); } catch {}
}

// Helper crypto-quality local ID (replaces Math.random())
function cryptoLocalId() {
  if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
  const arr = new Uint8Array(12);
  if (typeof crypto !== "undefined" && crypto.getRandomValues) crypto.getRandomValues(arr);
  else for (let i = 0; i < arr.length; i++) arr[i] = (Math.random() * 256) | 0;
  return Array.from(arr, b => b.toString(16).padStart(2, "0")).join("");
}

function useAuth() {
  const [session, setSession] = useStateA(null);
  const [profile, setProfile] = useStateA(null);
  const [bootDone, setBootDone] = useStateA(false);

  /* Pull the server-side notif row right after the session restores
     so the dashboard's slider + Kelly buttons reflect what Telegram
     is actually using. Single source of truth: server's user_notif. */
  const _hydrateFromServer = async (username) => {
    try {
      const r = await fetch("/api/notif/me", { credentials: "include" });
      if (!r.ok) return loadProfile(username);
      const data = await r.json();
      const notif = (data && data.notif) || {};
      const base = loadProfile(username);
      return {
        ...base,
        kellyDivisor: notif.kelly_divisor ?? base.kellyDivisor,
        alertThreshold: (notif.threshold != null) ? Number(notif.threshold) : base.alertThreshold,
      };
    } catch {
      return loadProfile(username);
    }
  };

  // Au mount : essaie de restaurer la session via cookie
  useEffectA(() => {
    let cancelled = false;
    fetch("/api/auth/me", { credentials: "include" })
      .then(async (r) => {
        if (cancelled) return;
        if (r.ok) {
          const { user } = await r.json();
          const sess = { user: user.username, role: user.role,
                         sessionId: cryptoLocalId(), since: Date.now() };
          setSession(sess);
          const p = await _hydrateFromServer(user.username);
          if (!cancelled) setProfile(p);
          appendLog({ kind: "LOGIN_RESTORED", user: user.username, sessionId: sess.sessionId });
        }
      })
      .catch(() => { /* offline ou serveur down */ })
      .finally(() => { if (!cancelled) setBootDone(true); });
    return () => { cancelled = true; };
  }, []);

  const login = async (username, password) => {
    try {
      const r = await fetch("/api/auth/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify({ username, password }),
      });
      if (r.status === 429) {
        const j = await r.json().catch(() => ({}));
        return { ok: false, error: j.detail || "Trop de tentatives. Réessaie plus tard." };
      }
      if (!r.ok) {
        return { ok: false, error: "Identifiants invalides" };
      }
      const { user } = await r.json();
      const sess = { user: user.username, role: user.role,
                     sessionId: cryptoLocalId(), since: Date.now() };
      setSession(sess);
      const p = await _hydrateFromServer(user.username);
      setProfile(p);
      appendLog({ kind: "LOGIN", user: user.username, sessionId: sess.sessionId });
      return { ok: true };
    } catch (e) {
      return { ok: false, error: "Serveur injoignable" };
    }
  };

  const logout = async () => {
    if (session) appendLog({ kind: "LOGOUT", user: session.user, sessionId: session.sessionId });
    try { await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); } catch {}
    setSession(null);
    setProfile(null);
  };

  /* Debounced sync of shared fields → server's user_notif row.
     - kellyDivisor / alertThreshold both gate the Telegram dispatcher
       AND the in-app alerts panel, so they MUST be unified.
     - The slider can fire dozens of changes per drag; a 350ms debounce
       coalesces them into a single POST when the user pauses. */
  const _syncTimerRef = useRefA(null);
  const _syncPendingRef = useRefA({});
  const _flushSync = () => {
    const patch = _syncPendingRef.current;
    _syncPendingRef.current = {};
    if (!patch || Object.keys(patch).length === 0) return;
    const body = {};
    if ("kellyDivisor"   in patch) body.kelly_divisor = patch.kellyDivisor;
    if ("alertThreshold" in patch) body.threshold     = patch.alertThreshold;
    if (Object.keys(body).length === 0) return;
    fetch("/api/notif/me", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify(body),
    }).catch(() => { /* swallow — local state stays as the user set it */ });
  };
  const _queueSync = (patch) => {
    Object.assign(_syncPendingRef.current, patch);
    if (_syncTimerRef.current) clearTimeout(_syncTimerRef.current);
    _syncTimerRef.current = setTimeout(_flushSync, 350);
  };

  const updateProfile = (patch) => {
    if (!session) return;
    const next = { ...profile, ...patch };
    saveProfile(session.user, next);
    setProfile(next);
    // Fields that also live server-side → debounced POST. Other patch
    // keys (purely local prefs) skip the sync.
    const serverFields = {};
    if ("kellyDivisor"   in patch) serverFields.kellyDivisor   = patch.kellyDivisor;
    if ("alertThreshold" in patch) serverFields.alertThreshold = patch.alertThreshold;
    if (Object.keys(serverFields).length) _queueSync(serverFields);
  };

  return { session, profile, login, logout, updateProfile,
           isAuthed: !!session, bootDone };
}

function useSessionWatcher(session, onKicked) {
  const [duplicate, setDuplicate] = useStateA(null);
  const peersRef = useRefA(new Map());

  useEffectA(() => {
    if (!session) { setDuplicate(null); return; }
    let channel;
    try { channel = new BroadcastChannel(CHANNEL_NAME); }
    catch (e) { console.warn("[auth] BroadcastChannel unavailable", e); return; }

    const peers = peersRef.current;
    peers.clear();

    const checkDup = () => {
      const now = Date.now();
      for (const [sid, ts] of peers.entries()) {
        if (now - ts > PEER_TIMEOUT_MS) peers.delete(sid);
      }
      if (peers.size > 0) {
        const first = [...peers.keys()][0];
        setDuplicate(d => (d && d.otherSessionId === first) ? d : { otherSessionId: first, since: now });
      } else {
        setDuplicate(null);
      }
    };
    const announce = (type) => {
      channel.postMessage({
        type, user: session.user,
        sessionId: session.sessionId, ts: Date.now(),
      });
    };

    channel.onmessage = (ev) => {
      const msg = ev.data;
      if (!msg || msg.user !== session.user) return;
      if (msg.sessionId === session.sessionId) return;

      if (msg.type === "presence" || msg.type === "hello") {
        const wasNew = !peers.has(msg.sessionId);
        peers.set(msg.sessionId, msg.ts);
        if (wasNew) {
          appendLog({
            kind: "DUPLICATE_DETECTED",
            user: session.user, sessionId: session.sessionId,
            info: `peer=${shortId(msg.sessionId)}`,
          });
          announce("hello"); // tell the other tab we're here too
        }
        checkDup();
      } else if (msg.type === "leave") {
        peers.delete(msg.sessionId);
        checkDup();
      } else if (msg.type === "kick-all") {
        // Another tab issued a kick — if it's us being kicked, exit.
        appendLog({
          kind: "KICKED",
          user: session.user, sessionId: session.sessionId,
          info: `by=${shortId(msg.sessionId)}`,
        });
        onKicked && onKicked();
      }
    };

    announce("hello");
    const id = setInterval(() => announce("presence"), HEARTBEAT_MS);
    const cleanup = setInterval(checkDup, 5000);

    const onBeforeUnload = () => { try { announce("leave"); } catch {} };
    window.addEventListener("beforeunload", onBeforeUnload);

    return () => {
      try { announce("leave"); } catch {}
      window.removeEventListener("beforeunload", onBeforeUnload);
      clearInterval(id); clearInterval(cleanup);
      channel.close();
      peers.clear();
    };
  }, [session, onKicked]);

  const kickOthers = () => {
    if (!session) return;
    try {
      const ch = new BroadcastChannel(CHANNEL_NAME);
      ch.postMessage({
        type: "kick-all",
        user: session.user, sessionId: session.sessionId,
        ts: Date.now(),
      });
      ch.close();
    } catch {}
    appendLog({
      kind: "KICKED_OTHERS",
      user: session.user, sessionId: session.sessionId,
    });
    setDuplicate(null);
    peersRef.current.clear();
  };

  return { duplicate, kickOthers };
}

/* ──────────────────────────────────────────────────────────
   LOGIN SCREEN
   ────────────────────────────────────────────────────────── */
function QLoginScreen({ onLogin }) {
  const [user, setUser] = useStateA("");
  const [pwd, setPwd] = useStateA("");
  const [error, setError] = useStateA("");
  const [busy, setBusy] = useStateA(false);

  const submit = async (e) => {
    e && e.preventDefault();
    const u = user.trim();
    if (!u) { setError("Identifiant requis"); return; }
    if (!pwd) { setError("Mot de passe requis"); return; }
    setError("");
    setBusy(true);
    const res = await onLogin(u, pwd);
    if (!res || !res.ok) {
      setError((res && res.error) || "Échec de connexion");
      setBusy(false);
    }
    // sinon le parent change d'écran ; on ne reset pas busy
  };

  return (
    <div className="q-login">
      <div className="q-login-bg-strip">
        <span className="lock">CONNEXION SÉCURISÉE</span>
        <span className="muted">QUANTA · ODDS · LIVE</span>
        <span style={{marginLeft: "auto"}} className="muted">v4.0 · BUILD 2026.05</span>
      </div>
      <div className="q-login-body">
        <form className="q-login-card" onSubmit={submit}>
          <div className="q-login-logo">
            <img className="mark" src="/icon-192.png?v=2" alt="Quanta" />
            <div className="name">QUANTA</div>
            <div className="meta">SESSION · NEW</div>
          </div>
          <div className="q-login-h">Bienvenue.</div>
          <div className="q-login-sub">
            Authentifie-toi pour accéder aux cotes en direct.
            Tant que la session n'est pas active, aucune donnée n'est diffusée.
          </div>

          {error && <div className="q-login-error">{error}</div>}

          <div className="q-login-field">
            <label htmlFor="q-user">Identifiant</label>
            <input
              id="q-user" type="text" autoComplete="username" autoFocus
              value={user} onChange={(e) => setUser(e.target.value)}
              placeholder="ex: client-jean"
              disabled={busy}
            />
          </div>
          <div className="q-login-field">
            <label htmlFor="q-pwd">Mot de passe</label>
            <input
              id="q-pwd" type="password" autoComplete="current-password"
              value={pwd} onChange={(e) => setPwd(e.target.value)}
              placeholder="••••••••"
              disabled={busy}
            />
          </div>

          <button className="q-login-submit" type="submit" disabled={busy}>
            {busy ? "Connexion en cours…" : "Se connecter"}
          </button>
        </form>
      </div>
    </div>
  );
}

/* ──────────────────────────────────────────────────────────
   DUPLICATE BANNER
   ────────────────────────────────────────────────────────── */
function QDuplicateBanner({ duplicate, onKickOthers }) {
  if (!duplicate) return null;
  return (
    <div className="q-dup-banner">
      <div className="q-dup-icon">!</div>
      <div className="q-dup-text">
        <b>Session multiple détectée</b> — ton compte est ouvert sur un autre
        onglet ou appareil.
        <small>peer #{shortId(duplicate.otherSessionId)} · enregistré dans le journal</small>
      </div>
      <button className="q-dup-cta ghost" onClick={onKickOthers}>
        Fermer les autres sessions
      </button>
    </div>
  );
}

/* ──────────────────────────────────────────────────────────
   USER MENU (dropdown in header)
   ────────────────────────────────────────────────────────── */
function QUserMenu({ session, profile, onOpenProfile, onOpenLog, onOpenAdmin, onLogout }) {
  const [open, setOpen] = useStateA(false);
  const ref = useRefA(null);

  useEffectA(() => {
    if (!open) return;
    const onClick = (e) => {
      if (ref.current && !ref.current.contains(e.target)) setOpen(false);
    };
    document.addEventListener("mousedown", onClick);
    return () => document.removeEventListener("mousedown", onClick);
  }, [open]);

  const initial = (session.user[0] || "?").toUpperCase();
  return (
    <div className="q-user-wrap" ref={ref}>
      <button className="q-user-btn" onClick={() => setOpen(o => !o)}>
        <span className="q-user-avatar">{initial}</span>
        <span>{session.user}</span>
        <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="6 9 12 15 18 9"/></svg>
      </button>
      {open && (
        <div className="q-user-menu">
          <div className="q-user-menu-h">
            <div className="name">{session.user}</div>
            <div className="meta">session #{shortId(session.sessionId)} · kelly /{profile.kellyDivisor}</div>
          </div>
          <button className="q-user-menu-item" onClick={() => { setOpen(false); onOpenProfile(); }}>
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 0 0-16 0"/></svg>
            Profil & paramètres
          </button>
          <button className="q-user-menu-item" onClick={() => { setOpen(false); onOpenLog(); }}>
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="M9 12h6M9 16h6"/></svg>
            Journal des sessions
          </button>
          {session.role === "admin" && (
            <button className="q-user-menu-item" onClick={() => { setOpen(false); onOpenAdmin && onOpenAdmin(); }}>
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2l3 7h7l-5.5 4 2 7-6.5-4.5L5.5 20l2-7L2 9h7z"/></svg>
              Panneau admin
            </button>
          )}
          <button className="q-user-menu-item danger" onClick={() => { setOpen(false); onLogout(); }}>
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>
            Déconnexion
          </button>
        </div>
      )}
    </div>
  );
}

/* ──────────────────────────────────────────────────────────
   USER PROFILE PANEL (slide-in)
   ────────────────────────────────────────────────────────── */
function QProfilePanel({ open, onClose, session, profile, updateProfile, visibleBMs, toggleVisibleBM, bipeEnabled, setBipeEnabled }) {
  const closeRef = useRefA(null);
  useEffectA(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, onClose]);
  // Focus trap: transfer focus to the close button when the panel opens
  // so keyboard / screen-reader users land inside the dialog.
  useEffectA(() => {
    if (open && closeRef.current) closeRef.current.focus();
  }, [open]);

  if (!session || !profile) return null;
  const sinceMin = Math.round((Date.now() - session.since) / 60000);
  return (
    <>
      <div className={"q-alerts-backdrop" + (open ? " is-open" : "")} onClick={onClose}></div>
      <aside
        className={"q-alerts" + (open ? " is-open" : "")}
        role="dialog"
        aria-modal={open ? "true" : "false"}
        aria-hidden={!open}
        aria-label="Profil et paramètres"
      >
        <div className="q-alerts-head">
          <div className="q-alerts-h-title">Profil & paramètres</div>
          <button ref={closeRef} className="q-alerts-close" onClick={onClose} aria-label="Close">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
          </button>
        </div>

        <div style={{flex: 1, overflowY: "auto"}}>
          <div className="q-profile-section">
            <div className="q-profile-h">Compte</div>
            <div className="q-profile-row"><span className="k">Identifiant</span><span className="v">{session.user}</span></div>
            <div className="q-profile-row"><span className="k">Session ID</span><span className="v">#{shortId(session.sessionId)}</span></div>
            <div className="q-profile-row"><span className="k">Connecté depuis</span><span className="v">{sinceMin}m</span></div>
          </div>

          <div className="q-profile-section">
            <div className="q-profile-h">Diviseur Kelly</div>
            <div className="q-kelly-divisor">
              {[1, 2, 3, 4, 5].map(d => (
                <button
                  key={d}
                  className={profile.kellyDivisor === d ? "is-active" : ""}
                  onClick={() => updateProfile({ kellyDivisor: d })}
                >
                  /{d}
                </button>
              ))}
            </div>
            <div className="q-divisor-help">
              La mise suggérée vaut <b>f* / {profile.kellyDivisor}</b> où <b>f*</b> est la fraction Kelly théorique.
              {profile.kellyDivisor === 1 && " Full Kelly maximise la croissance mais avec une variance énorme."}
              {profile.kellyDivisor === 2 && " Demi-Kelly — agressif, croissance forte mais drawdowns conséquents."}
              {profile.kellyDivisor === 3 && " Tiers-Kelly — bon compromis croissance / variance (recommandé)."}
              {profile.kellyDivisor === 4 && " Quart-Kelly — conservateur, faible variance."}
              {profile.kellyDivisor === 5 && " Cinquième-Kelly — très conservateur, idéal en phase d'apprentissage."}
              <br/>S'applique <b>aussi aux notifications Telegram</b> — la mise dans le message push utilise ce diviseur.
            </div>
          </div>

          <div className="q-profile-section">
            <div className="q-profile-h">Seuil d'edge minimum</div>
            <input
              type="range" min="0" max="0.10" step="0.005"
              value={profile.alertThreshold ?? 0.03}
              onChange={(e) => updateProfile({ alertThreshold: parseFloat(e.target.value) })}
              className="q-slider"
              aria-label="Seuil minimum d'edge pour déclencher une alerte"
            />
            <div style={{fontFamily:"IBM Plex Mono, monospace", fontSize:13, color:"var(--pos)", fontWeight:700, marginTop:8}}>
              ≥ {((profile.alertThreshold ?? 0.03) * 100).toFixed(1)} %
            </div>
            <div className="q-divisor-help">
              Edge minimum pour déclencher :
              <ul style={{margin:"4px 0 0 16px", padding:0}}>
                <li>une <b>alerte</b> + bipe sur le site (panneau ALERTS)</li>
                <li>une <b>notification Telegram</b> push</li>
              </ul>
              0% = chaque edge positif déclenche. Le filtre de persistance 2s reste actif pour éviter les faux positifs entre les points.
            </div>
          </div>

          {typeof bipeEnabled === "boolean" && typeof setBipeEnabled === "function" && (
            <div className="q-profile-section">
              <div className="q-profile-h">Bipe sonore</div>
              <label className={"q-bipe-toggle" + (bipeEnabled ? " is-on" : "")}>
                <input
                  type="checkbox"
                  checked={bipeEnabled}
                  onChange={(e) => setBipeEnabled(e.target.checked)}
                />
                <span>{bipeEnabled ? "Bipe activé" : "Silencieux"}</span>
              </label>
              <div className="q-divisor-help">
                Joue un court bipe quand une opportunité ≥ seuil <b>tient
                plus de 2 secondes</b>. Filtre les faux positifs entre les
                points (le LAY Betfair oscille brièvement à la suspension /
                reprise) — seules les vraies values déclenchent un signal.
              </div>
            </div>
          )}

          {visibleBMs && toggleVisibleBM && (
            <div className="q-profile-section">
              <div className="q-profile-h">Bookmakers du dashboard</div>
              <DashboardBookmakers
                visibleBMs={visibleBMs}
                toggleVisibleBM={toggleVisibleBM}
              />
            </div>
          )}

          <div className="q-profile-section">
            <div className="q-profile-h">Notifications Telegram</div>
            <NotifSettings session={session} />
          </div>
        </div>
      </aside>
    </>
  );
}

/* ──────────────────────────────────────────────────────────
   PER-USER BOOKMAKER PREFERENCES

   Two distinct settings, two storage paths on purpose:

   * Telegram-notification filter: persisted server-side in
     `user_notif.bookmakers` (CSV). Survives device changes, shared
     across devices logged in as the same account. Edited via
     POST /api/notif/me.

   * Dashboard visibility: persisted in localStorage, keyed by
     username. Per-device by design — most operators sit at the same
     laptop and may want to hide books they never use without affecting
     their account-wide notification filter. A separate "Dashboard
     bookmakers" section in the profile panel surfaces the toggles.

   Both settings share the same constant ALL_BMS_LIST so the UI stays
   consistent — adding a new BM requires touching this single line.
   ────────────────────────────────────────────────────────── */
const ALL_BMS_LIST = ["Betclic", "Winamax", "Yonibet", "PMU", "Unibet"];
const ALL_SPORTS_LIST = [
  { value: "tennis",   label: "Tennis" },
  { value: "football", label: "Football" },
];

function _localKey(username) {
  return username ? `quanta-visible-bms-${username}` : null;
}

function useVisibleBMs(username) {
  const key = _localKey(username);
  const [visible, setVisible] = useStateA(() => {
    if (!key) return new Set(ALL_BMS_LIST);
    try {
      const raw = localStorage.getItem(key);
      if (!raw) return new Set(ALL_BMS_LIST);
      const arr = JSON.parse(raw);
      if (!Array.isArray(arr)) return new Set(ALL_BMS_LIST);
      const known = arr.filter(b => ALL_BMS_LIST.includes(b));
      // An empty selection is almost always a mistake — restore all BMs
      // rather than render an empty dashboard.
      return new Set(known.length ? known : ALL_BMS_LIST);
    } catch { return new Set(ALL_BMS_LIST); }
  });
  const toggle = (bm) => {
    setVisible(prev => {
      const next = new Set(prev);
      if (next.has(bm)) next.delete(bm); else next.add(bm);
      if (key) {
        try { localStorage.setItem(key, JSON.stringify([...next])); } catch {}
      }
      return next;
    });
  };
  return [visible, toggle];
}

/* DashboardBookmakers — toggle list rendered inside QProfilePanel.
   No network calls: state is owned by useVisibleBMs at the
   QuantaAppAuthed level and threaded down here for editing. */
function DashboardBookmakers({ visibleBMs, toggleVisibleBM }) {
  return (
    <>
      <div className="q-bm-toggles">
        {ALL_BMS_LIST.map(bm => {
          const on = visibleBMs.has(bm);
          return (
            <label key={bm} className={"q-bm-toggle" + (on ? " is-on" : "")}>
              <input
                type="checkbox"
                checked={on}
                onChange={() => toggleVisibleBM(bm)}
              />
              <span>{bm}</span>
            </label>
          );
        })}
      </div>
      <div className="q-divisor-help">
        Décoche les bookmakers que tu ne veux PAS voir dans les cartes / tableaux du dashboard.
        Préférence locale (par appareil) — n'affecte pas les notifications.
      </div>
    </>
  );
}

/* ──────────────────────────────────────────────────────────
   NOTIF SETTINGS — Telegram link UI inside the profile panel.
   Gracefully degrades when the /api/notif endpoints aren't available
   (the backend worker is shipping in parallel).
   ────────────────────────────────────────────────────────── */
function NotifSettings({ session }) {
  const EMPTY = {
    loading: true, available: true, linked: false,
    chat_id: null, threshold: null,
    sports: "", bookmakers: "",
  };
  const [state, setState] = useStateA(EMPTY);
  const [linking, setLinking] = useStateA(false);
  const [linkPayload, setLinkPayload] = useStateA(null); // { bot_url, token }

  const _unavailable = () =>
    setState({ ...EMPTY, loading: false, available: false });

  // Server wraps the row in `{notif: {...}}`. Be defensive: a future
  // un-wrapping or partial response shouldn't blank the UI.
  const _absorbNotif = (rawJson) => {
    const notif = (rawJson && rawJson.notif) || rawJson || {};
    setState({
      loading: false,
      available: true,
      linked: !!notif.telegram_chat_id,
      chat_id: notif.telegram_chat_id || null,
      threshold: notif.threshold ?? null,
      sports: notif.sports ?? "",
      bookmakers: notif.bookmakers ?? "",
    });
  };

  const refresh = async () => {
    try {
      const r = await fetch("/api/notif/me", { credentials: "include" });
      if (r.status === 404 || r.status === 501 || !r.ok) {
        _unavailable();
        return;
      }
      _absorbNotif(await r.json());
    } catch {
      _unavailable();
    }
  };

  useEffectA(() => { refresh(); }, [session && session.user]);

  const startLink = async () => {
    setLinking(true);
    try {
      const r = await fetch("/api/notif/telegram/link", { method: "POST", credentials: "include" });
      if (!r.ok) { setLinking(false); return; }
      const data = await r.json();
      setLinkPayload({ bot_url: data.bot_url, token: data.token });
    } catch { /* swallow */ }
    setLinking(false);
  };

  const unlink = async () => {
    try {
      await fetch("/api/notif/telegram/unlink", { method: "POST", credentials: "include" });
    } catch {}
    setLinkPayload(null);
    refresh();
  };

  /* Persist a partial settings patch (`{bookmakers: "..."}` or
     `{sports: "..."}`) via POST /api/notif/me.

     Optimistic UI: we set local state immediately so the checkbox flips
     without waiting on the round-trip, then reconcile with the row the
     server returns. A failed POST silently leaves the optimistic value
     in place — next `refresh()` will correct it. */
  const persist = async (patch) => {
    setState(prev => ({ ...prev, ...patch }));
    try {
      const r = await fetch("/api/notif/me", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify(patch),
      });
      if (r.ok) {
        _absorbNotif(await r.json());
      }
    } catch { /* keep optimistic state */ }
  };

  const _csvToSet = (s) =>
    new Set(String(s || "").split(",").map(x => x.trim()).filter(Boolean));
  const _setToCsv = (set) => [...set].join(",");

  const toggleBm = (bm) => {
    const next = _csvToSet(state.bookmakers);
    if (next.has(bm)) next.delete(bm); else next.add(bm);
    persist({ bookmakers: _setToCsv(next) });
  };
  const toggleSport = (sp) => {
    const next = _csvToSet(state.sports);
    if (next.has(sp)) next.delete(sp); else next.add(sp);
    persist({ sports: _setToCsv(next) });
  };

  if (state.loading) {
    return <div className="q-notif-state">Chargement…</div>;
  }
  if (!state.available) {
    return (
      <div className="q-notif-state">
        Telegram pas encore configuré côté serveur.
        <div style={{fontSize:10.5, color:"var(--ink-3)", marginTop:4}}>
          Cette section s'activera dès que les notifs seront déployées.
        </div>
      </div>
    );
  }

  const bmSet = _csvToSet(state.bookmakers);
  const spSet = _csvToSet(state.sports);

  /* When linked, surface the per-account filters (BMs + sports) so the
     user can curate which spike events the Telegram bot pushes to them.
     These are persisted server-side so they're shared across devices —
     a deliberate split from the dashboard-visibility toggle below, which
     is local-only. */
  const filtersBlock = (
    <>
      <div className="q-notif-h">Bookmakers (notifications)</div>
      <div className="q-bm-toggles">
        {ALL_BMS_LIST.map(bm => {
          const on = bmSet.has(bm);
          return (
            <label key={bm} className={"q-bm-toggle" + (on ? " is-on" : "")}>
              <input
                type="checkbox"
                checked={on}
                onChange={() => toggleBm(bm)}
              />
              <span>{bm}</span>
            </label>
          );
        })}
      </div>
      <div className="q-divisor-help">
        Seuls les value-bets sur les bookmakers cochés déclenchent une notification.
        <br/>Tout décocher = <b>tous les bookmakers</b> (équivalent à tout cocher).
      </div>
      <div className="q-notif-h" style={{marginTop: 14}}>Sports (notifications)</div>
      <div className="q-bm-toggles">
        {ALL_SPORTS_LIST.map(sp => {
          const on = spSet.has(sp.value);
          return (
            <label key={sp.value} className={"q-bm-toggle" + (on ? " is-on" : "")}>
              <input
                type="checkbox"
                checked={on}
                onChange={() => toggleSport(sp.value)}
              />
              <span>{sp.label}</span>
            </label>
          );
        })}
      </div>
      <div className="q-divisor-help">
        Décoche les sports que tu ne veux pas recevoir en notifications.
        <br/>Tout décocher = <b>tous les sports</b> (équivalent à tout cocher).
      </div>
    </>
  );

  if (state.linked) {
    return (
      <>
        <div className="q-notif-state linked">✓ Notifications Telegram actives</div>
        <div style={{fontSize:11, color:"var(--ink-3)", lineHeight:1.55}}>
          Tu reçois les value-bets ≥ seuil défini en push.
        </div>
        <div className="q-notif-actions">
          <button className="q-notif-btn ghost" type="button" onClick={unlink}>Délier Telegram</button>
        </div>
        {filtersBlock}
      </>
    );
  }
  // Not linked yet
  return (
    <>
      <div className="q-notif-state">Notifications Telegram non liées</div>
      <div style={{fontSize:11, color:"var(--ink-3)", lineHeight:1.55}}>
        Lie ton compte Telegram pour recevoir les value-bets en push, sans garder l'écran ouvert.
      </div>
      {linkPayload && linkPayload.bot_url ? (
        <>
          <a className="q-notif-link" href={linkPayload.bot_url} target="_blank" rel="noopener noreferrer">
            {linkPayload.bot_url}
          </a>
          {linkPayload.token && (
            <div style={{fontFamily:"IBM Plex Mono, monospace", fontSize:11, color:"var(--ink-3)", marginTop:6, wordBreak:"break-all"}}>
              Code : <b style={{color:"var(--ink)"}}>{linkPayload.token}</b>
            </div>
          )}
          <div className="q-notif-actions">
            <button className="q-notif-btn ghost" type="button" onClick={refresh}>J'ai cliqué</button>
          </div>
        </>
      ) : (
        <div className="q-notif-actions">
          <button className="q-notif-btn" type="button" onClick={startLink} disabled={linking}>
            {linking ? "…" : "🔗 Lier mon Telegram"}
          </button>
        </div>
      )}
      {/* Pre-pair the filters so a freshly linked friend doesn't have to
          dig back in — the row already exists in DB (lazy-materialised
          on first /api/notif/me hit) with the default 5 BMs + 2 sports. */}
      {filtersBlock}
    </>
  );
}

/* ──────────────────────────────────────────────────────────
   SESSION LOG PANEL (slide-in, read-only)
   ────────────────────────────────────────────────────────── */
function QSessionLogPanel({ open, onClose }) {
  const [entries, setEntries] = useStateA(() => getSessionLog());
  const closeRef = useRefA(null);
  useEffectA(() => {
    if (!open) return;
    const refresh = () => setEntries(getSessionLog());
    refresh();
    window.addEventListener("quanta-log-update", refresh);
    // Listen to storage events for cross-tab sync
    window.addEventListener("storage", refresh);
    const id = setInterval(refresh, 3000);
    return () => {
      window.removeEventListener("quanta-log-update", refresh);
      window.removeEventListener("storage", refresh);
      clearInterval(id);
    };
  }, [open]);
  useEffectA(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, onClose]);
  // Focus trap: send focus to the close button when the panel opens.
  useEffectA(() => {
    if (open && closeRef.current) closeRef.current.focus();
  }, [open]);

  const sorted = [...entries].reverse();
  return (
    <>
      <div className={"q-alerts-backdrop" + (open ? " is-open" : "")} onClick={onClose}></div>
      <aside
        className={"q-alerts" + (open ? " is-open" : "")}
        role="dialog"
        aria-modal={open ? "true" : "false"}
        aria-hidden={!open}
        aria-label="Journal des sessions"
      >
        <div className="q-alerts-head">
          <div className="q-alerts-h-title">Journal des sessions · {entries.length}</div>
          <button ref={closeRef} className="q-alerts-close" onClick={onClose} aria-label="Close">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
          </button>
        </div>
        <div className="q-alerts-tools">
          <span>SOURCE</span><b>localStorage (mock)</b>
          <span style={{color:"var(--ink-3)"}}>·</span>
          <span style={{marginLeft:"auto", color:"var(--ink-3)"}}>200 derniers évts</span>
        </div>
        <div style={{flex: 1, overflowY: "auto"}}>
          {sorted.length === 0 ? (
            <div className="q-log-empty">Aucun événement encore.<br/>Connecte-toi pour générer une entrée.</div>
          ) : sorted.map((ev, i) => {
            const d = new Date(ev.ts);
            const time = `${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}:${String(d.getSeconds()).padStart(2,"0")}`;
            const kindClass =
              ev.kind === "LOGIN" ? "login" :
              ev.kind === "LOGOUT" ? "logout" :
              ev.kind === "DUPLICATE_DETECTED" ? "dup" :
              ev.kind === "KICKED" || ev.kind === "KICKED_OTHERS" ? "kick" : "";
            return (
              <div key={i} className="q-log-entry">
                <div className="q-log-time">{time}</div>
                <div className="q-log-body">
                  <span className={"q-log-kind " + kindClass}>{ev.kind}</span>
                  <div className="q-log-detail">
                    <b>{ev.user}</b>
                    <code>#{shortId(ev.sessionId)}</code>
                    {ev.info && <span style={{color: "var(--ink-3)", marginLeft: 6}}>· {ev.info}</span>}
                  </div>
                </div>
              </div>
            );
          })}
        </div>
      </aside>
    </>
  );
}

/* ──────────────────────────────────────────────────────────
   BOOT SPLASH — shown while /api/auth/me is in flight
   ────────────────────────────────────────────────────────── */
function QBootSplash() {
  return (
    <div className="q" data-theme="dark" style={{display:"grid", placeItems:"center", minHeight:"100vh", background:"#0b0e13"}}>
      <div style={{fontFamily:"IBM Plex Mono, monospace", fontSize:11, color:"#6b7280", letterSpacing:".12em"}}>CONNECTING…</div>
    </div>
  );
}

/* ──────────────────────────────────────────────────────────
   INVITE / SETUP SCREEN
   The friend opens /?invite=<token>. We:
     1. Resolve the token via GET /api/auth/setup-info  (returns username)
     2. Show "Bienvenue <username>, choisis ton mot de passe" form
     3. POST /api/auth/setup → server consumes token, sets pwd, returns
        user + sets the session cookie → friend is logged in immediately.
   QuantaApp picks this screen instead of QLoginScreen when the query
   string carries `?invite=<token>` (see quanta-app.jsx).
   ────────────────────────────────────────────────────────── */
function QSetupScreen({ token, onLoggedIn }) {
  const [phase, setPhase] = useStateA("loading");
  const [username, setUsername] = useStateA("");
  const [expiresAt, setExpiresAt] = useStateA(null);
  const [pwd, setPwd] = useStateA("");
  const [pwd2, setPwd2] = useStateA("");
  const [error, setError] = useStateA("");

  // Resolve token on mount
  useEffectA(() => {
    let cancelled = false;
    fetch(`/api/auth/setup-info?token=${encodeURIComponent(token)}`,
          { credentials: "include" })
      .then(async (r) => {
        if (cancelled) return;
        if (!r.ok) {
          setError("Lien d'invitation invalide ou expiré.");
          setPhase("ready");
          return;
        }
        const data = await r.json();
        setUsername(data.username);
        setExpiresAt(data.expires_at);
        setPhase("ready");
      })
      .catch(() => {
        if (cancelled) return;
        setError("Serveur injoignable. Réessaie dans un instant.");
        setPhase("ready");
      });
    return () => { cancelled = true; };
  }, [token]);

  const submit = async (e) => {
    e && e.preventDefault();
    if (!pwd || pwd.length < 8) { setError("Mot de passe trop court (8 caractères minimum)."); return; }
    if (pwd !== pwd2) { setError("Les deux mots de passe ne correspondent pas."); return; }
    setError("");
    setPhase("submitting");
    try {
      const r = await fetch("/api/auth/setup", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify({ token, password: pwd }),
      });
      if (r.status === 429) {
        const j = await r.json().catch(() => ({}));
        setError(j.detail || "Trop de tentatives. Réessaie plus tard.");
        setPhase("ready");
        return;
      }
      if (!r.ok) {
        const j = await r.json().catch(() => ({}));
        setError(j.detail || "Lien d'invitation invalide ou expiré.");
        setPhase("ready");
        return;
      }
      const { user } = await r.json();
      setPhase("done");
      // Drop the ?invite=... from the URL so a refresh doesn't reuse a now-dead
      // token; parent re-evaluates auth state (cookie is set server-side).
      try {
        const url = new URL(location.href);
        url.searchParams.delete("invite");
        history.replaceState(null, "", url.toString());
      } catch {}
      onLoggedIn && onLoggedIn(user);
    } catch (e) {
      setError("Serveur injoignable.");
      setPhase("ready");
    }
  };

  if (phase === "loading") {
    return (
      <div className="q-login">
        <div className="q-login-bg-strip">
          <span className="lock">VERIFICATION DU LIEN…</span>
        </div>
      </div>
    );
  }

  if (!username) {
    return (
      <div className="q-login">
        <div className="q-login-bg-strip">
          <span className="lock">LIEN INVALIDE</span>
        </div>
        <div className="q-login-body">
          <div className="q-login-card">
            <div className="q-login-logo">
              <img className="mark" src="/icon-192.png?v=2" alt="Quanta" />
              <div className="name">QUANTA</div>
            </div>
            <div className="q-login-h">Lien expiré ou déjà utilisé.</div>
            <div className="q-login-sub">
              Demande à l'admin de générer une nouvelle invitation.
            </div>
            {error && <div className="q-login-error">{error}</div>}
            <button className="q-login-submit"
                    onClick={() => {
                      try {
                        const u = new URL(location.href);
                        u.searchParams.delete("invite");
                        location.href = u.toString();
                      } catch { location.href = "/"; }
                    }}>
              Aller à la connexion
            </button>
          </div>
        </div>
      </div>
    );
  }

  const expDate = expiresAt ? new Date(expiresAt * 1000) : null;
  return (
    <div className="q-login">
      <div className="q-login-bg-strip">
        <span className="lock">INVITATION QUANTA</span>
        {expDate && <span className="muted" style={{marginLeft:"auto"}}>
          EXPIRE {expDate.toLocaleDateString()} {expDate.toLocaleTimeString([], {hour:"2-digit", minute:"2-digit"})}
        </span>}
      </div>
      <div className="q-login-body">
        <form className="q-login-card" onSubmit={submit}>
          <div className="q-login-logo">
            <img className="mark" src="/icon-192.png?v=2" alt="Quanta" />
            <div className="name">QUANTA</div>
            <div className="meta">SETUP</div>
          </div>
          <div className="q-login-h">Bienvenue, {username}.</div>
          <div className="q-login-sub">
            Choisis ton mot de passe — c'est celui que tu utiliseras à chaque
            connexion. Minimum 8 caractères, idéalement long et inhabituel.
          </div>

          {error && <div className="q-login-error">{error}</div>}

          <div className="q-login-field">
            <label htmlFor="q-pwd1">Nouveau mot de passe</label>
            <input id="q-pwd1" type="password" autoComplete="new-password" autoFocus
                   value={pwd} onChange={(e) => setPwd(e.target.value)}
                   placeholder="••••••••"
                   disabled={phase === "submitting"} />
          </div>
          <div className="q-login-field">
            <label htmlFor="q-pwd2">Confirmer</label>
            <input id="q-pwd2" type="password" autoComplete="new-password"
                   value={pwd2} onChange={(e) => setPwd2(e.target.value)}
                   placeholder="••••••••"
                   disabled={phase === "submitting"} />
          </div>

          <button className="q-login-submit" type="submit" disabled={phase === "submitting"}>
            {phase === "submitting" ? "Activation en cours…" : "Activer mon compte"}
          </button>
        </form>
      </div>
    </div>
  );
}


Object.assign(window, {
  useAuth, useSessionWatcher, appendLog, getSessionLog, shortId,
  QLoginScreen, QSetupScreen,
  QDuplicateBanner, QUserMenu, QProfilePanel, QSessionLogPanel,
  QBootSplash, cryptoLocalId,
  useVisibleBMs, DashboardBookmakers, ALL_BMS_LIST, ALL_SPORTS_LIST,
});
