/* Shell — sidebar + topbar + brand switcher */
const { useState: useStateShell } = React;

const NAV = [
  { group: "Overview", items: [
    { id: "dashboard", label: "Dashboard", icon: "dashboard" },
    { id: "activity", label: "Activity feed", icon: "activity" },
  ]},
  { group: "Payments", items: [
    { id: "transactions", label: "Transactions", icon: "arrow_down_up", count: "2.1K" },
    { id: "deposits", label: "Deposits", icon: "arrow_down", count: "12" },
    { id: "withdrawals", label: "Withdrawals", icon: "arrow_up", badge: "4" },
    { id: "methods", label: "Payment methods", icon: "credit_card" },
  ]},
  { group: "Players", items: [
    { id: "players", label: "Players", icon: "users" },
  ]},
  { group: "Finance", items: [
    { id: "fees", label: "Fees & commissions", icon: "percent" },
    { id: "reports", label: "Reports", icon: "chart" },
  ]},
  { group: "Platform", items: [
    { id: "brands", label: "Brands", icon: "flag" },
    { id: "settings", label: "Settings", icon: "settings" },
    { id: "devphase", label: "Development Phase", icon: "activity" },
  ]},
];

const TOPNAV_FLAT = [
  { id: "dashboard", label: "Dashboard", icon: "dashboard" },
  { id: "activity", label: "Activity", icon: "activity" },
  { id: "transactions", label: "Transactions", icon: "arrow_down_up" },
  { id: "deposits", label: "Deposits", icon: "arrow_down" },
  { id: "withdrawals", label: "Withdrawals", icon: "arrow_up", badge: "4" },
  { id: "methods", label: "Methods", icon: "credit_card" },
  { id: "players", label: "Players", icon: "users" },
  { id: "fees", label: "Fees", icon: "percent" },
  { id: "reports", label: "Reports", icon: "chart" },
  { id: "devphase", label: "Dev Phase", icon: "activity" },
];

const LANGUAGES = [
  { code: "en", label: "English",    flag: "🇬🇧" },
  { code: "it", label: "Italiano",   flag: "🇮🇹" },
  { code: "de", label: "Deutsch",    flag: "🇩🇪" },
  { code: "fr", label: "Français",   flag: "🇫🇷" },
  { code: "es", label: "Español",    flag: "🇪🇸" },
  { code: "pt", label: "Português",  flag: "🇵🇹" },
  { code: "nl", label: "Nederlands", flag: "🇳🇱" },
  { code: "pl", label: "Polski",     flag: "🇵🇱" },
];

const Sidebar = ({ active, onNav, brand, onOpenBrandSwitch }) => {
  // Collapsed state — narrow sidebar that hides every label and surfaces
  // the icon only. Persisted across sessions per operator so the choice
  // sticks. Tooltip on each nav item still surfaces the label.
  // pbStore, not raw localStorage: see src/store.jsx for why every key is
  // registered. Old raw "1"/"0" values still read correctly (both truthy/falsy
  // through JSON.parse), so nobody's collapsed sidebar springs open on deploy.
  const [collapsed, setCollapsed] = useState(() => !!pbStore.get("pb-side-collapsed", false));
  useEffect(() => {
    pbStore.set("pb-side-collapsed", collapsed);
    // Add a body-level class so the layout grid in app.jsx can adjust the
    // sidebar column width when collapsed.
    document.body.classList.toggle("side-collapsed", collapsed);
  }, [collapsed]);
  return (
    <aside className={`side ${collapsed ? "side--collapsed" : ""}`}>
      <div className="side__brand">
        <div className="logo">PB</div>
        {!collapsed && (
          <div className="name">
            PayBO
            <small>Payment backoffice</small>
          </div>
        )}
        <button
          className="side__collapse-btn"
          onClick={() => setCollapsed(c => !c)}
          title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
          style={{
            marginLeft:"auto", padding:"4px 6px", border:"1px solid var(--side-border, rgba(255,255,255,.1))",
            background:"transparent", color:"var(--side-text, #fff)", borderRadius:6, cursor:"pointer",
            display:"inline-grid", placeItems:"center",
          }}>
          <Icon name={collapsed ? "chevron_right" : "chevron_left"} size={12}/>
        </button>
      </div>
      {!collapsed && (
      <div style={{position:"relative"}}>
        <button className="side__brand-switch" onClick={onOpenBrandSwitch}>
          <span className="brand-dot" style={{background: brand.color}}/>
          <span className="brand-info">
            <div className="b-name">{brand.name}</div>
            <div className="b-sub">{brand.currency} · {brand.market}</div>
          </span>
          <Icon name="chevron_down" size={13} style={{color:"var(--side-text-dim)"}}/>
        </button>
        {/* Hover help — explains the brand selector acts like an auto-login,
            not just a filter. Positioned outside the button so click events
            on the chevron still open the switcher. */}
        <span style={{position:"absolute", top:8, right:30, zIndex:2}}>
          <Tip>
            <strong>Brand selector — works like an auto-login.</strong><br/>
            Pick a brand and the whole back office is scoped to that tenant:
            transactions, players, payment methods, routes, reports — everything
            shows only that brand's data. Per-page brand filters are hidden /
            locked. Pick <strong>All brands</strong> to switch back to the
            aggregate network view where per-page filters become available
            again.
          </Tip>
        </span>
      </div>
      )}
      {collapsed && (
        <button
          className="side__brand-mini"
          onClick={onOpenBrandSwitch}
          title={`Brand: ${brand.name}`}
          style={{
            display:"grid", placeItems:"center", width:36, height:36, margin:"4px auto 8px",
            borderRadius:8, background:"transparent", border:"1px solid var(--side-border, rgba(255,255,255,.1))",
            cursor:"pointer",
          }}>
          <span style={{width:14, height:14, borderRadius:4, background:brand.color}}/>
        </button>
      )}

      <div style={{overflowY:"auto", flex:1, paddingBottom:8}}>
        {NAV.map(g => (
          <div className="side__group" key={g.group}>
            {!collapsed && <div className="side__group-label">{g.group}</div>}
            {g.items.map(item => (
              <a key={item.id}
                 className={`side__item ${active === item.id ? "active" : ""}`}
                 title={collapsed ? item.label : undefined}
                 onClick={() => onNav(item.id)}>
                <Icon name={item.icon} size={16} className="icon"/>
                {!collapsed && <span className="label">{item.label}</span>}
                {!collapsed && item.badge && <span className="badge">{item.badge}</span>}
                {!collapsed && item.count && !item.badge && <span className="count">{item.count}</span>}
              </a>
            ))}
          </div>
        ))}
      </div>

      <div className="side__footer">
        {!collapsed && (
          <div className="user">
            <div className="avatar" style={{background:"var(--g-200)", color:"var(--g-600)"}}>JM</div>
            <div style={{minWidth:0, flex:1}}>
              <div className="u-name">Jules Moreau</div>
              <div className="u-role">Platform admin</div>
            </div>
          </div>
        )}
        {collapsed && (
          <div style={{display:"grid", placeItems:"center", width:36, height:36, margin:"0 auto", borderRadius:999, background:"var(--g-200)", color:"var(--g-600)", fontWeight:700, fontSize:12}} title="Jules Moreau · Platform admin">JM</div>
        )}
      </div>
    </aside>
  );
};

const Topbar = ({ title, breadcrumb, brand, navMode, onOpenBrandSwitch, active, onNav, theme }) => {
  const isGP = theme === "iwakiri";
  return (
    <header className="topbar">
      {navMode === "top" && (
        <div className="topnav-brand" style={isGP?{gap:8}:{}}>
          {isGP ? (
            <>
              <div style={{width:28, height:28, borderRadius:"50%", background:"#fff", border:"2px solid var(--p-500)", display:"grid", placeItems:"center"}}>
                <span style={{color:"var(--p-500)", fontWeight:900, fontSize:14, fontStyle:"italic"}}>g</span>
              </div>
              <div style={{fontWeight:700, fontSize:16, color:"#212121", letterSpacing:"-0.01em"}}>iwakiri</div>
            </>
          ) : (
            <>
              <div className="logo">PB</div>
              <div style={{fontWeight:650, letterSpacing:"-0.01em"}}>PayBO</div>
            </>
          )}
        </div>
      )}

      {navMode !== "top" && (
        <div className="topbar__breadcrumb">
          {breadcrumb && breadcrumb.map((b, i) => (
            <React.Fragment key={i}>
              {i > 0 && <span className="sep">/</span>}
              <span className={i === breadcrumb.length - 1 ? "current" : ""}>{b}</span>
            </React.Fragment>
          ))}
        </div>
      )}

      {navMode === "top" && !isGP && (
        <div style={{position:"relative", display:"inline-flex", alignItems:"center"}}>
          <button className="side__brand-switch" style={{margin:0, maxWidth:240}} onClick={onOpenBrandSwitch}>
            <span className="brand-dot" style={{background: brand.color}}/>
            <span className="brand-info">
              <div className="b-name" style={{color:"var(--text-primary)"}}>{brand.name}</div>
              <div className="b-sub">{brand.currency} · {brand.market}</div>
            </span>
            <Icon name="chevron_down" size={13} style={{color:"var(--text-tertiary)"}}/>
          </button>
          <span style={{marginLeft:6}}>
            <Tip>
              <strong>Brand selector — works like an auto-login.</strong><br/>
              Pick a brand and the whole back office is scoped to that tenant:
              transactions, players, payment methods, routes, reports — everything
              shows only that brand's data. Per-page brand filters are hidden /
              locked. Pick <strong>All brands</strong> to switch back to the
              aggregate network view where per-page filters become available
              again.
            </Tip>
          </span>
        </div>
      )}

      {/* Right-side actions: search + notifications + help + language */}
      <div className="topbar__actions" style={{marginLeft:"auto"}}>
        {!isGP && (
          <div className="topbar__search">
            <Icon name="search" size={14} className="ic"/>
            <input className="input input--sm" placeholder="Search players, transactions, IDs…"/>
            <span className="kbd">⌘K</span>
          </div>
        )}
        <button className="btn btn--ghost btn--icon" title="Notifications"><Icon name="bell" size={15}/></button>
        {!isGP && <button className="btn btn--ghost btn--icon" title="Help"><Icon name="help" size={15}/></button>}
        <LanguageSwitcher/>
      </div>
    </header>
  );
};

const SubNav = ({ active, onNav }) => (
  <nav className="subnav">
    {TOPNAV_FLAT.map(it => (
      <div key={it.id}
           className={`subnav__item ${active === it.id ? "active" : ""}`}
           onClick={() => onNav(it.id)}>
        <Icon name={it.icon} size={14} className="icon"/>
        {it.label}
        {it.badge && <span className="badge" style={{background:"var(--err-500)", color:"#fff", fontSize:10, padding:"1px 5px", borderRadius:999, fontWeight:600}}>{it.badge}</span>}
      </div>
    ))}
  </nav>
);

const BrandSwitcher = ({ brands, all, active, onPick, onClose, onManage }) => {
  const allActive = active.isAll;
  return (
  <div style={{position:"fixed", inset:0, background:"rgba(15,20,32,.45)", backdropFilter:"blur(4px)", zIndex:100, display:"grid", placeItems:"center"}} onClick={onClose}>
    <div style={{width:480, background:"#fff", borderRadius:16, overflow:"hidden", boxShadow:"0 30px 60px -15px rgba(15,20,32,.35), 0 8px 24px -8px rgba(15,20,32,.18)"}} onClick={e=>e.stopPropagation()}>
      {/* Header */}
      <div style={{padding:"16px 18px 12px", display:"flex", alignItems:"center", gap:10, borderBottom:"1px solid var(--border-subtle)"}}>
        <div style={{width:30, height:30, borderRadius:9, background:"linear-gradient(135deg, var(--p-500), var(--p-700))", display:"grid", placeItems:"center", color:"#fff", boxShadow:"0 3px 8px -2px color-mix(in oklab, var(--p-500) 45%, transparent)"}}>
          <Icon name="flag" size={14}/>
        </div>
        <div style={{flex:1, minWidth:0}}>
          <div style={{fontWeight:700, fontSize:14.5, letterSpacing:"-0.01em", display:"inline-flex", alignItems:"center"}}>
            Switch brand
            {window.Tip && <Tip>Picking a brand here works like an <strong>auto-login</strong> to that tenant — every page (Dashboard, Transactions, Players, Methods, Routes, Reports) will only show that brand's data. Per-page brand filters become locked. Pick <strong>All brands</strong> to switch back to the aggregate network view where per-page filters are available again.</Tip>}
          </div>
          <div style={{fontSize:11.5, color:"var(--text-tertiary)", marginTop:1}}>Scope the app to one brand or view all aggregated</div>
        </div>
        <button className="btn btn--ghost btn--icon btn--sm" onClick={onClose}><Icon name="x" size={13}/></button>
      </div>

      {/* All brands hero row */}
      <div style={{padding:"12px 12px 8px"}}>
        <div
          onClick={()=> { onPick(all); onClose(); }}
          style={{
            position:"relative",
            display:"flex", alignItems:"center", gap:12,
            padding:"12px 14px",
            borderRadius:12,
            cursor:"pointer",
            background: allActive
              ? "linear-gradient(135deg, color-mix(in oklab, var(--p-500) 6%, #fff), color-mix(in oklab, var(--p-500) 12%, #fff))"
              : "linear-gradient(135deg, var(--n-25), var(--n-50))",
            border: allActive ? "1.5px solid var(--p-500)" : "1px solid var(--border-default)",
            boxShadow: allActive
              ? "0 4px 12px -2px color-mix(in oklab, var(--p-500) 25%, transparent)"
              : "0 1px 2px rgba(15,20,32,.04)",
            transition:"all .15s cubic-bezier(.4,0,.2,1)"
          }}
          onMouseEnter={e => { if (!allActive){ e.currentTarget.style.borderColor = "var(--border-strong)"; e.currentTarget.style.transform="translateY(-1px)"; }}}
          onMouseLeave={e => { if (!allActive){ e.currentTarget.style.borderColor = "var(--border-default)"; e.currentTarget.style.transform="translateY(0)"; }}}>
          <div style={{width:40, height:40, borderRadius:10, background:"linear-gradient(135deg,#2a3040,#0f1420)", display:"grid", placeItems:"center", color:"#fff", boxShadow:"0 2px 6px rgba(15,20,32,.2), inset 0 1px 0 rgba(255,255,255,.1)"}}>
            <Icon name="grid" size={17}/>
          </div>
          <div style={{flex:1, minWidth:0}}>
            <div style={{fontWeight:700, fontSize:14, letterSpacing:"-0.005em"}}>All brands <span style={{fontSize:10.5, fontWeight:600, padding:"1px 6px", borderRadius:999, background: allActive ? "var(--p-500)" : "var(--n-75)", color: allActive ? "#fff" : "var(--text-secondary)", marginLeft:6, verticalAlign:2, letterSpacing:".03em"}}>AGGREGATE</span></div>
            <div style={{fontSize:11.5, color:"var(--text-tertiary)", marginTop:2}}>See everything across {brands.length} brands combined</div>
          </div>
          {allActive && (
            <div style={{width:22, height:22, borderRadius:999, background:"var(--p-500)", display:"grid", placeItems:"center", color:"#fff", boxShadow:"0 2px 4px -1px color-mix(in oklab, var(--p-500) 40%, transparent)"}}>
              <Icon name="check" size={13}/>
            </div>
          )}
        </div>
      </div>

      {/* Divider with label */}
      <div style={{padding:"4px 18px 8px", display:"flex", alignItems:"center", gap:8}}>
        <div style={{flex:1, height:1, background:"var(--border-subtle)"}}/>
        <div style={{fontSize:10, fontWeight:700, letterSpacing:".08em", color:"var(--text-tertiary)", textTransform:"uppercase"}}>Individual brands</div>
        <div style={{flex:1, height:1, background:"var(--border-subtle)"}}/>
      </div>

      {/* Brand grid */}
      <div style={{padding:"0 12px 12px", maxHeight:360, overflow:"auto", display:"grid", gridTemplateColumns:"1fr 1fr", gap:8}}>
        {brands.map(b => {
          const isActive = !allActive && active.id === b.id;
          return (
            <div key={b.id}
                 onClick={()=> { onPick(b); onClose(); }}
                 style={{
                   position:"relative",
                   padding:"12px 12px 10px",
                   borderRadius:12,
                   cursor:"pointer",
                   background: isActive ? "linear-gradient(180deg, #fff, var(--p-50) 140%)" : "#fff",
                   border: isActive ? "1.5px solid var(--p-500)" : "1px solid var(--border-default)",
                   boxShadow: isActive
                     ? "0 4px 12px -2px color-mix(in oklab, var(--p-500) 25%, transparent), 0 1px 3px rgba(15,20,32,.06)"
                     : "0 1px 2px rgba(15,20,32,.04)",
                   transition:"all .15s cubic-bezier(.4,0,.2,1)"
                 }}
                 onMouseEnter={e => { if (!isActive){ e.currentTarget.style.borderColor = "var(--border-strong)"; e.currentTarget.style.transform="translateY(-1px)"; e.currentTarget.style.boxShadow="0 4px 10px -2px rgba(15,20,32,.1), 0 1px 2px rgba(15,20,32,.05)"; }}}
                 onMouseLeave={e => { if (!isActive){ e.currentTarget.style.borderColor = "var(--border-default)"; e.currentTarget.style.transform="translateY(0)"; e.currentTarget.style.boxShadow="0 1px 2px rgba(15,20,32,.04)"; }}}>
              {isActive && (
                <div style={{position:"absolute", top:10, right:10, width:18, height:18, borderRadius:999, background:"var(--p-500)", display:"grid", placeItems:"center", color:"#fff", boxShadow:"0 2px 4px -1px color-mix(in oklab, var(--p-500) 40%, transparent)"}}>
                  <Icon name="check" size={11}/>
                </div>
              )}
              <div style={{display:"flex", alignItems:"center", gap:10, marginBottom:8}}>
                <div style={{width:34, height:34, borderRadius:10, background: b.color, display:"grid", placeItems:"center", color:"#fff", fontWeight:800, fontSize:12, letterSpacing:".02em", boxShadow:"0 2px 4px rgba(15,20,32,.08), inset 0 1px 0 rgba(255,255,255,.15)"}}>{b.short}</div>
                <div style={{flex:1, minWidth:0}}>
                  <div style={{fontWeight:700, fontSize:13.5, color:"var(--text-primary)", letterSpacing:"-0.005em", whiteSpace:"nowrap", overflow:"hidden", textOverflow:"ellipsis"}}>{b.name}</div>
                </div>
              </div>
              <div style={{display:"flex", gap:4, flexWrap:"wrap"}}>
                <span style={{fontSize:10, fontWeight:600, padding:"2px 7px", borderRadius:999, background:"var(--n-75)", color:"var(--text-secondary)", letterSpacing:".02em"}}>{b.currency}</span>
                <span style={{fontSize:10, fontWeight:600, padding:"2px 7px", borderRadius:999, background:"var(--n-75)", color:"var(--text-secondary)", letterSpacing:".02em"}}>{b.market}</span>
              </div>
            </div>
          );
        })}
      </div>

      {/* Manage link */}
      <div style={{padding:"10px 16px 14px", borderTop:"1px solid var(--border-subtle)", display:"flex", alignItems:"center", justifyContent:"space-between", gap:8}}>
        <div style={{fontSize:11.5, color:"var(--text-tertiary)"}}>{brands.length} brands configured</div>
        <button
          onClick={()=> { onClose(); onManage && onManage(); }}
          style={{height:32, padding:"0 12px", borderRadius:8, border:"1px solid var(--border-default)", background:"#fff", color:"var(--text-primary)", fontWeight:600, fontSize:12.5, display:"inline-flex", alignItems:"center", gap:6, cursor:"pointer", transition:"all .12s"}}
          onMouseEnter={e=>{e.currentTarget.style.borderColor="var(--p-500)"; e.currentTarget.style.color="var(--p-600)";}}
          onMouseLeave={e=>{e.currentTarget.style.borderColor="var(--border-default)"; e.currentTarget.style.color="var(--text-primary)";}}>
          <Icon name="settings" size={12}/> Manage brands
        </button>
      </div>
    </div>
  </div>
  );
};

/* ---------- Language switcher ---------- */
const LANGS = [
  { code:"en", label:"English",   flag:"🇬🇧" },
  { code:"it", label:"Italiano",  flag:"🇮🇹" },
  { code:"es", label:"Español",   flag:"🇪🇸" },
  { code:"de", label:"Deutsch",   flag:"🇩🇪" },
  { code:"fr", label:"Français",  flag:"🇫🇷" },
  { code:"pt", label:"Português", flag:"🇵🇹" },
];
const LanguageSwitcher = () => {
  const [open, setOpen] = useStateShell(false);
  const [lang, setLang] = useStateShell(() => {
    const stored = pbStore.get("pb-lang", null);
    return LANGS.find(l => l.code === stored) || LANGS[0];
  });
  React.useEffect(()=> { pbStore.set("pb-lang", lang.code); }, [lang]);
  return (
    <div style={{position:"relative"}}>
      <button
        onClick={()=>setOpen(o=>!o)}
        style={{
          height:32, padding:"0 10px", borderRadius:8,
          border:"1px solid var(--border-default)", background:"#fff",
          display:"inline-flex", alignItems:"center", gap:8, cursor:"pointer",
          fontSize:12.5, fontWeight:600, color:"var(--text-primary)", transition:"all .12s"
        }}
        onMouseEnter={e=>{e.currentTarget.style.borderColor="var(--border-strong)";}}
        onMouseLeave={e=>{e.currentTarget.style.borderColor="var(--border-default)";}}>
        <span style={{fontSize:14, lineHeight:1}}>{lang.flag}</span>
        <span style={{textTransform:"uppercase", letterSpacing:".04em"}}>{lang.code}</span>
        <Icon name="chevron_down" size={11} style={{color:"var(--text-tertiary)"}}/>
      </button>
      {open && (
        <>
          <div onClick={()=>setOpen(false)} style={{position:"fixed", inset:0, zIndex:40}}/>
          <div style={{
            position:"absolute", right:0, top:36, zIndex:41,
            width:180, background:"#fff",
            border:"1px solid var(--border-default)",
            borderRadius:10, overflow:"hidden",
            boxShadow:"0 10px 24px -6px rgba(15,20,32,.18), 0 4px 10px -2px rgba(15,20,32,.08)"
          }}>
            {LANGS.map(l => (
              <div key={l.code}
                onClick={()=>{ setLang(l); setOpen(false); }}
                style={{
                  padding:"9px 12px", display:"flex", alignItems:"center", gap:10,
                  cursor:"pointer", fontSize:13,
                  background: l.code === lang.code ? "var(--p-50)" : "transparent",
                  color: l.code === lang.code ? "var(--p-700)" : "var(--text-primary)",
                  fontWeight: l.code === lang.code ? 600 : 500,
                }}
                onMouseEnter={e=>{ if(l.code!==lang.code) e.currentTarget.style.background="var(--n-25)"; }}
                onMouseLeave={e=>{ if(l.code!==lang.code) e.currentTarget.style.background="transparent"; }}>
                <span style={{fontSize:15, lineHeight:1}}>{l.flag}</span>
                <span style={{flex:1}}>{l.label}</span>
                {l.code === lang.code && <Icon name="check" size={12} style={{color:"var(--p-600)"}}/>}
              </div>
            ))}
          </div>
        </>
      )}
    </div>
  );
};

/* ---------------------------------------------------------------- session ---
   A BLOCKING sign-in gate, not a dismissible banner.

   Every screen in this build reads Supabase and every policy keys off
   auth.uid(), so signed out the whole platform is empty by construction. The
   first version of this was a banner above the page, and that was the wrong
   shape: it left 69 screens rendering their empty states underneath, each one
   looking like a system with no data in it.

   So the gate covers the app until there is a session. Three things it
   deliberately does NOT do:

     * It does not gate /dev/connection. That page is the diagnostics for the
       very thing stopping you signing in, and it holds no tenant data. A gate
       you cannot get behind to fix the reason it is up is a locked door with
       the key inside.
     * It does not say "sign in" when Supabase is not configured. Signing in is
       impossible then, and the fix is two globals in index.html — a different
       problem with a different answer.
     * It does not stop at the token. GoTrue will happily authenticate a user
       who has no row in `users`, and RLS then returns nothing from every table
       — which looks exactly like a working login onto an empty database. The
       gate resolves current_app_user() before it lets go.

   Additive: new components and their mount, nothing existing changed. */

const SB_GATE_OPEN_PATHS = ["/dev/connection"];

/* WHO MAY OPEN THE PLATFORM — and it is no longer a constant here.

   It was `SB_GATE_MAX_LEVEL = 0`: admit Super Admins, refuse everyone else.
   That was honest about being a placeholder and wrong for the architecture,
   which has a skin admin signing in at their own brand's host:

     iwakiri.com              the platform console. Super admins.
     admin.juegojoker.com     JuegoJoker's back office. Its own operators.
     juegojoker.com           the player site — a different application.

   One number cannot express that, because the answer depends on the HOST as
   well as the person. And the moment it depends on a comparison between two
   values that both arrive in the browser, it stops being a rule: `me.user_level`
   and the brand's id are both editable by whoever is being refused.

   So the whole decision moved into surface_access() (supabase/034), which reads
   the caller from the session and returns a verdict. The host goes up — the
   database cannot see a Host header, so someone has to tell it — but saying
   the wrong one cannot elevate anybody: claiming the console host still asks
   whether YOUR level is 0, and claiming another brand's host still asks whether
   YOU belong to it.

   This is still a DOOR, not a permission model. Everything behind it is scoped
   by row-level security to the caller's own subtree, so admitting more roles
   does not widen what any of them can see. The difference is that the door now
   consults something the person knocking cannot edit. */
const SB_GATE_SURFACE_LABEL = {
  console: "the platform console",
  admin: "a brand's back office",
  frontend: "the player site",
  api: "the frontend API",
};

/* The enrolment QR.

   GoTrue renders the code for us and returns it on the enrol response, so there
   is no QR encoder in this repo and no library to load — which also means
   nothing here has to be right about Reed-Solomon for someone to sign in.

   It arrives either as an SVG document or as a data: URI, depending on the
   GoTrue version, and both go into an <img src>. Deliberately NOT
   dangerouslySetInnerHTML: an <img> cannot execute script whatever the bytes
   turn out to be, and "it came from our own auth server" is an argument that
   stops being true the first time someone points this build at a different one.

   If neither form is present the otpauth:// URI is shown as text. Every
   authenticator app accepts a pasted key, so a missing picture is a smaller
   inconvenience than a blank panel with no way forward. */
const SbTotpQr = ({ qr, uri }) => {
  const src = !qr ? null
    : /^data:/i.test(qr) ? qr
    : /^\s*<svg/i.test(qr) ? "data:image/svg+xml;utf8," + encodeURIComponent(qr)
    : null;
  if (src) return <img className="sb-gate__qrimg" src={src} alt="Two-factor setup QR code" width="176" height="176" />;
  return (
    <div className="sb-gate__qrfallback">
      <div className="sb-gate__qrnote">No QR image was returned. Paste this into your authenticator app:</div>
      <code className="sb-gate__qruri">{uri || "(no setup URI)"}</code>
    </div>
  );
};

const SbAuthGate = () => {
  const [email, setEmail] = useStateShell("");
  const [pw, setPw] = useStateShell("");
  const [busy, setBusy] = useStateShell(false);
  const [err, setErr] = useStateShell(null);
  const [me, setMe] = useStateShell(undefined);   // undefined = not checked yet
  const [diag, setDiag] = useStateShell(null);
  const [tick, bump] = useStateShell(0);
  const [enrol, setEnrol] = useStateShell(null);  // {factorId, secret, uri} during enrolment
  const [code, setCode] = useStateShell("");
  const [door, setDoor] = useStateShell(undefined); // surface_access(), undefined = not asked
  const [factors, setFactors] = useStateShell(undefined); // GoTrue's factor list

  const sb = window.sb;

  /* Re-render on sign-in/sign-out, including from another tab: src/supabase.js
     dispatches `sb:session` whenever the stored session changes. */
  React.useEffect(() => {
    const fn = () => bump(n => n + 1);
    window.addEventListener("sb:session", fn);
    return () => window.removeEventListener("sb:session", fn);
  }, []);

  /* Resolve the caller whenever a session appears. A token alone is not access.

     This asks auth_status(), NOT me(). me() is current_app_user(), which returns
     nothing until the session is aal2 — so at aal1 it cannot tell "you have no
     operator row" apart from "you have not done your second factor yet". Both
     are an empty result, and they need opposite answers from this screen.
     auth_status() is the narrow, deliberately-aal1-readable window that
     distinguishes them (supabase/016). */
  React.useEffect(() => {
    if (!sb || !sb.live || !sb.signedIn) {
      setMe(undefined); setDoor(undefined); setFactors(undefined); return;
    }
    let alive = true;
    setMe(undefined); setDoor(undefined); setFactors(undefined);

    /* ASK GOTRUE WHAT FACTORS EXIST, and treat that as the authority on which
       panel to show.

       auth_status() reports the same thing from the database, and it is
       best-effort by construction: 016 wraps the auth.mfa_factors read in a
       try/catch and returns NULL if the function's owner cannot see that table.
       NULL is not `true`, so `factor_verified === true` was false, so the gate
       chose the ENROL panel for an account that already had a verified factor —
       and enrolment then 422'd on the name, from a screen with no way out.

       GoTrue is asked with the caller's own token and needs no database grant,
       so it answers where the database may not. This only selects which panel
       to render; the boundary is still app_mfa_satisfied(), server-side, which
       no answer here can change. */
    Promise.resolve(sb.listFactors()).then(r => {
      if (!alive) return;
      setFactors(r && r.ok ? r.data
        : { known: false, all: [], totp: [], verified: [], unverified: [],
            error: (r && r.error && r.error.message) || "Could not list factors." });
    });
    Promise.resolve(sb.authStatus()).then(r => {
      if (!alive) return;
      setMe(r && r.ok ? r.data : null);
      if (r && !r.ok) setErr(r.error);
    });
    /* Asked at the same moment and independently, not chained behind
       auth_status(). Both are aal1-readable and neither needs the other's
       answer, so serialising them would only make the gate slower to decide.

       WHEN THE CHECK ITSELF IS NOT THERE, DO NOT REFUSE EVERYONE.

       The first version of this closed the door on any failure, reasoning that
       an unknown verdict must not read as "allowed". That reasoning is right
       about WHICH BRAND somebody may open and wrong about whether the door
       opens at all, and the difference locked a super admin out of a database
       whose only fault was being one migration behind: surface_access() did not
       exist yet, the call failed, and the gate refused the one account that
       could have applied the migration. That is the 2FA dead end again — a
       screen that says no and offers nothing.

       So a MISSING check falls back to the rule that existed before it:
       super admin only. That is the most restrictive door in the schema, not
       the least, and RLS underneath is untouched — so the fallback cannot show
       anyone a row they could not already see. A check that RAN and said no is
       still honoured to the letter. */
    Promise.resolve(sb.surfaceAccess()).then(r => {
      if (!alive) return;
      if (r && r.ok) { setDoor(r.data); return; }
      const e = (r && r.error) || {};
      setDoor({
        allowed: null,                 // null = nobody asked, not "no"
        reason: e.absent ? "check_absent" : "unreachable",
        detail: e.message || "surface_access() could not be reached.",
        status: e.status || 0,
      });
    });
    return () => { alive = false; };
  }, [tick, sb && sb.signedIn]);

  if (!sb) return null;

  let path = "";
  try { path = window.location.pathname || ""; } catch (_e) { /* no-op */ }
  if (SB_GATE_OPEN_PATHS.some(p => path === p || path.indexOf(p + "/") === 0)) return null;

  const configured = sb.live;
  const signedIn = configured && sb.signedIn;
  const linked = signedIn && me && me.linked;
  /* The DATABASE's verdict, not a re-derivation of it. `door` is whatever
     surface_access() said; `undefined` means it has not answered yet, which is
     "wait", not "yes". */
  const doorKnown = door !== undefined;
  /* `allowed === null` means surface_access() never answered — it is not
     installed, or the call failed. Fall back to the rule that predates it:
     super admin only. See the comment on the fetch above for why a missing
     check must not read as a refusal. */
  const doorAbsent = doorKnown && door.allowed === null;
  const levelOk = linked && doorKnown &&
    (door.allowed === true || (doorAbsent && Number(me.user_level) === 0));
  /* The second factor. `me.mfa_ok` is the DATABASE's answer — app_mfa_satisfied()
     evaluated server-side on this very request — not a re-derivation from our
     own token. If the two ever disagreed, the database's is the one that
     decides what any screen can read, so it is the one the door uses. */
  const mfaOk = linked && me.mfa_ok === true;
  const permitted = linked && levelOk && !me.blocked && mfaOk;
  if (permitted) return null;                      // the only way past the gate

  /* Does a VERIFIED factor exist? GoTrue first, because it can answer when the
     database cannot; the database's answer is kept as a fallback for the case
     where GoTrue is the thing that is unreachable. Either saying yes is enough
     — neither can invent one. */
  const hasVerifiedFactor =
    (factors && factors.known && (factors.verified || []).length > 0) ||
    (linked && me.factor_verified === true);
  const staleFactors = (factors && factors.unverified) || [];

  const submit = async (e) => {
    e && e.preventDefault();
    if (busy) return;
    setBusy(true); setErr(null);
    const r = await sb.signIn(email.trim(), pw);
    setBusy(false);
    if (r && r.ok) { setPw(""); bump(n => n + 1); }
    else setErr((r && r.error) || { message: "Sign-in failed." });
  };

  /* Start enrolment. Clears any half-finished factor first: GoTrue refuses a
     second factor under the same friendly name, and an unverified one is not a
     second factor — it is an abandoned attempt. */
  const beginEnrol = async () => {
    if (busy) return;
    setBusy(true); setErr(null);

    /* Re-asked here rather than reusing the list fetched on mount: enrolment
       can be reached minutes later, and in another tab. */
    const existing = await sb.listFactors();
    if (existing && existing.ok && existing.data.known) {
      /* A VERIFIED FACTOR MEANS THIS PANEL IS THE WRONG ONE. Do not enrol a
         second one over the top — that is how somebody ends up with two
         secrets and no idea which their phone holds. Switch to the challenge
         and let them enter the code they already have. */
      if ((existing.data.verified || []).length > 0) {
        setBusy(false);
        setFactors(existing.data);
        setErr({ message: "This account already has a working second factor. Enter the six-digit code from your authenticator app." });
        return;
      }
      /* Unverified ones are abandoned attempts, not second factors. They are
         what GoTrue's name conflict is about, and clearing them is the fix. */
      for (const f of (existing.data.unverified || [])) await sb.unenrollFactor(f.id);
    }

    const r = await sb.enrollTotp();
    setBusy(false);
    if (r && r.ok) { setEnrol(r.data); setCode(""); }
    else setErr((r && r.error) || { message: "Could not start enrolment." });
  };

  /* Answer a challenge. Used by both the enrolment panel (first code, which is
     what marks the factor verified) and the sign-in challenge panel. On success
     GoTrue hands back a new session carrying aal2 and sb.verifyTotp swaps it
     in, which is what makes the database start answering. */
  const submitCode = async (e) => {
    e && e.preventDefault();
    if (busy) return;
    let factorId = enrol && enrol.factorId;
    if (!factorId) {
      const fs = await sb.listFactors();
      const d = (fs && fs.ok && fs.data) || null;
      /* Any TOTP factor, verified first. A code can only be entered against a
         factor that exists, and if GoTrue lists an unverified one that IS the
         one the phone was set up from — verifying it is exactly what finishes
         the enrolment somebody abandoned halfway. Taking only verified ones
         here is what left a half-finished account with no route forward. */
      factorId = (d && ((d.verified || [])[0] || (d.totp || [])[0]) || {}).id;
      if (!factorId) {
        setErr({ message: d && d.known === false
          ? "The auth server would not list this account's factors, so there is nothing to check the code against. Open the diagnostics page."
          : "There is no second factor on this account yet — set one up first." });
        return;
      }
    }
    /* THE LOCKOUT (069): five wrong codes lock verification for fifteen
       minutes, self-expiring. Checked before the attempt and recorded after a
       failure. Honest scope note: in THIS build GoTrue verifies the code, so
       the browser could bypass this check by calling GoTrue directly — the
       transport boundary is GoTrue's own rate limiting, and this layer is the
       product surface (the message, the count, the audit row). In isystem the
       backend verifies TOTP itself, so the same lock is hard there; the spec
       says so. */
    const lock = await sb.twofaLockStatus();
    if (lock && lock.ok && lock.data && lock.data.locked) {
      setErr({ message: `Too many wrong codes. Try again after ${new Date(lock.data.until).toLocaleTimeString()}.` });
      return;
    }
    setBusy(true); setErr(null);
    const r = await sb.verifyTotp(factorId, code);
    setBusy(false);
    if (r && r.ok) {
      /* Enrolment bookkeeping (069): the activation date, device and browser
         the Security panel shows (§7), captured at the only moment they are
         true. The session is aal2 as of the verify, so the RPC admits it.
         Best-effort: losing the log line must not turn a successful enrolment
         into an error the user retries. */
      if (enrol) {
        try {
          await sb.twofaRecordEnrolment(
            (navigator.platform || "").slice(0, 120),
            (navigator.userAgent || "").slice(0, 120));
        } catch (_e) { /* the panel shows an em-dash instead */ }
      }
      setCode(""); setEnrol(null); bump(n => n + 1);
    } else {
      /* Record the failure; the response carries the countdown, which is a
         better message than "Verification failed" five times running. */
      let msg = (r && r.error && r.error.message) || "Verification failed.";
      const rec = await sb.twofaRecordFailure();
      if (rec && rec.ok && rec.data) {
        msg = rec.data.locked
          ? `Wrong code — and that was the fifth. Locked until ${new Date(rec.data.until).toLocaleTimeString()}.`
          : `Wrong code. ${rec.data.remaining} attempt${rec.data.remaining === 1 ? "" : "s"} left before a 15-minute lock.`;
      }
      setErr({ message: msg });
    }
  };

  const runDiagnostics = async () => {
    setDiag("running");
    const r = await sb.diagnose();
    setDiag(r && r.ok ? r.data : { verdict: (r && r.error && r.error.message) || "Diagnostics failed." });
  };

  /* Which state this is decides everything below. They are genuinely different
     problems and a single "please sign in" would be wrong for most of them.

     Order matters: blocked and under-level come BEFORE the second-factor steps,
     because there is no point walking someone through enrolment for an account
     that will be refused at the next line anyway. */
  const state = !configured ? "unconfigured"
    : (signedIn && (me === undefined || door === undefined || factors === undefined)) ? "checking"
    : (signedIn && me !== null && !me.linked) ? "unlinked"
    : (linked && me.blocked) ? "blocked"
    : (linked && !levelOk) ? "door"
    : (linked && !mfaOk && enrol) ? "enrolling"
    /* GoTrue's answer, not the database's. `me.factor_verified` is NULL
       whenever the database cannot read auth.mfa_factors, and NULL fell
       through to "enrol" — offering setup to an account that already had a
       factor, on a screen whose only button then failed. */
    : (linked && !mfaOk && hasVerifiedFactor) ? "challenge"
    : (linked && !mfaOk) ? "enrol"
    : (signedIn && me === null) ? "unlinked"
    : (sb.session && sb.expired) ? "expired"
    : "signedout";

  const roleName = (lvl) => ({ 0: "Super Admin", 1: "Affiliate", 2: "Skin Admin",
    4: "Customer Care", 6: "Administration", 8: "Master", 9: "Regulator",
    10: "Agent", 15: "Promoter", 20: "Shop", 30: "Player" })[Number(lvl)] || `Level ${lvl}`;

  /* Six ways the door says no, and they are six different problems. A single
     "not enough access" was what the old gate showed for all of them, and it
     sent somebody to look at their role when the actual answer was that the
     seed had made them the wrong thing. Each reason gets a title, a sentence
     and — where there is one — the fix. */
  const surfaceLabel = (s) => SB_GATE_SURFACE_LABEL[s] || (s ? `the ${s} surface` : "this address");
  const doorTitle = {
    unknown_host: "This address is not configured",
    wrong_application: "Wrong application for this address",
    no_operator: "Signed in, but not an operator",
    blocked: "This account is blocked",
    level_too_low: "Not enough access",
    wrong_brand: "Wrong brand for this address",
    unreachable: "Could not check this address",
    check_absent: "This build's access check is not installed",
  };

  return (
    <div className="sb-gate" role="dialog" aria-modal="true" aria-labelledby="sb-gate-title">
      <div className="sb-gate__card">
        <div className="sb-gate__brand">
          <span className="sb-gate__mark"><Icon name="lock" size={16} /></span>
          <div>
            <div className="sb-gate__title" id="sb-gate-title">
              {state === "unconfigured" ? "Not connected"
                : state === "unlinked" ? "Signed in, but not an operator"
                : state === "blocked" ? "This account is blocked"
                : state === "door" ? (doorTitle[door && door.reason] || "Not enough access")
                : state === "enrol" ? "Set up your second factor"
                : state === "enrolling" ? "Scan this, then confirm"
                : state === "challenge" ? "Second factor"
                : state === "expired" ? "Session expired"
                : state === "checking" ? "Checking your account…"
                : "Sign in"}
            </div>
            <div className="sb-gate__sub">
              {state === "unconfigured"
                ? <>This build has no database configured, so there is nothing to sign in to.</>
                : state === "unlinked"
                  ? <>Your login worked. It is just not linked to an operator account.</>
                  : state === "blocked"
                    ? <>Signed in as <b>{me.username}</b>, but <code>users.blocked</code> is set on that account.</>
                    : state === "door"
                      ? (door.reason === "unknown_host"
                          ? <><code>{door.host || "this hostname"}</code> is not a domain of any brand, and is not the platform console either.</>
                          : door.reason === "wrong_application"
                            ? <><code>{door.host}</code> is {surfaceLabel(door.surface)}{door.skin_name ? <> for <b>{door.skin_name}</b></> : null}. This build is the back office.</>
                            : door.reason === "wrong_brand"
                              ? <>Signed in as <b>{me.username}</b> ({roleName(me.user_level)}), who belongs to a different brand than <b>{door.skin_name || door.host}</b>.</>
                              : door.reason === "check_absent"
                                ? <>Signed in as <b>{me.username}</b> ({roleName(me.user_level)}). <code>surface_access()</code> is not in this database, so who may open which address cannot be answered — and this build admits <b>Super Admin</b> only until it can.</>
                                : door.reason === "unreachable"
                                  ? <>The database could not be asked whether this address admits you, so this build admits <b>Super Admin</b> only until it can.</>
                                : <>Signed in as <b>{me.username}</b> ({roleName(me.user_level)}). {surfaceLabel(door.surface)[0].toUpperCase() + surfaceLabel(door.surface).slice(1)}{door.skin_name ? <> for <b>{door.skin_name}</b></> : null} does not admit that role.</>)
                      : state === "enrol"
                        ? <>Signed in as <b>{me.username}</b>. This account has no second factor yet, and the database will not answer a password-only session.</>
                        : state === "enrolling"
                          ? <>Add this to an authenticator app, then type the code it shows. The first correct code is what turns the factor on.</>
                          : state === "challenge"
                            ? <>Signed in as <b>{me.username}</b>. Enter the six-digit code from your authenticator app.</>
                            : state === "expired"
                              ? <>Your session ran out. Everything below is closed until you sign in again.</>
                              : <>Sign in to continue. Row-level security keys off your account, so every screen is closed until you do — nothing is broken.</>}
            </div>
          </div>
        </div>

        {state === "unconfigured" && (
          <div className="sb-gate__note">
            Set <code>window.SUPABASE_URL</code> and <code>window.SUPABASE_ANON_KEY</code> in
            <code> index.html</code>. Both are required — with either missing every request fails
            before it leaves the browser.
          </div>
        )}

        {state === "unlinked" && (
          <div className="sb-gate__note">
            The auth user signed in has no row in <code>users</code> with its
            <code> auth_user_id</code>. Every policy resolves the caller through that row, so the
            platform would open onto empty tables on every screen — which is why this stops here
            rather than letting you in to find out screen by screen.
            <div className="sb-gate__fix">
              Fix: run <code>supabase/002_seed_dev.sql</code> with <code>iwk.email</code> set to
              this address, or set <code>auth_user_id</code> on an existing operator.
            </div>
          </div>
        )}

        {state === "checking" && <div className="sb-gate__note">Resolving your operator account…</div>}

        {state === "door" && (
          <div className="sb-gate__note">
            {door.reason === "unknown_host" ? (
              <>
                Which brand a hostname belongs to is data, not configuration in this build:
                <code> resolve_host()</code> strips a surface prefix — <code>admin.</code>,
                <code> www.</code>, <code>fapi.</code> — and looks the rest up in
                <code> skin_domains</code>. Nothing matched, so there is no brand to open.
                <div className="sb-gate__fix">
                  Fix: add the brand's <b>base</b> domain on the Skins screen — <code>example.com</code>,
                  not <code>admin.example.com</code>. The prefixed hosts then resolve to it on
                  their own.
                </div>
              </>
            ) : door.reason === "wrong_application" ? (
              <>
                The same database serves the player site, the frontend API and the back office,
                and they are separate deployments. Rendering a back office here would put operator
                screens on a player's hostname, which is why this refuses instead.
                <div className="sb-gate__fix">
                  Operators sign in at <code>admin.{door.base_domain || door.host}</code>.
                </div>
              </>
            ) : door.reason === "wrong_brand" ? (
              <>
                Your account is senior enough — it belongs to another brand. Row-level security
                would have shown you your own subtree with this brand's name on the page, which
                is a more confusing thing to see than this message.
                <div className="sb-gate__fix">
                  Sign in at your own brand's address, or ask a Super Admin — they can open any
                  brand's back office.
                </div>
              </>
            ) : (door.reason === "check_absent" || door.reason === "unreachable") ? (
              <>
                Domain routing decides which brand each hostname belongs to and who may open it.
                Until <code>surface_access()</code> answers, neither question can be, so this
                build falls back to the rule that predates it: <b>Super Admin only</b>. That is the
                most restrictive door in the schema, and row-level security underneath is
                unchanged — nobody sees a row they could not already see.
                <div className="sb-gate__fix">
                  Fix: apply <code>supabase/RUN_ALL.sql</code>, which includes
                  <code> supabase/034_surface_access.sql</code>. Then reload.
                  {door.status ? <> The call returned <code>{door.status}</code>.</> : null}
                  {door.status === 403
                    ? <> A 403 means the function is there but <code>authenticated</code> has no
                        EXECUTE on it — re-applying grants it.</>
                    : null}
                </div>
                <div className="sb-gate__fix">{door.detail}</div>
              </>
            ) : (
              <>
                Who may open which address is one row per surface in <code>surface_apps</code>,
                read by <code>surface_access()</code> — not a constant in this file, and not a
                comparison made in your browser. It is a door, not a permission model: everything
                behind it is still scoped by row-level security to your own subtree.
              </>
            )}
          </div>
        )}

        {state === "blocked" && (
          <div className="sb-gate__note">
            A blocked operator is refused at the door rather than let in to find every action
            failing one at a time. Another Super Admin can clear it on the Users screen.
          </div>
        )}

        {state === "enrol" && (
          <>
            <div className="sb-gate__note">
              The real platform runs two-factor middleware on every back-office route. So does this
              one, except the check is in the database rather than in front of it: until this
              session has answered a second factor, <code>current_app_user()</code> returns no row
              and every table reads as empty. There is no setting that turns it off, and no
              screen that works without it.
              <div className="sb-gate__fix">
                You will need an authenticator app — Google Authenticator, 1Password, Aegis, Bitwarden
                or any other TOTP app. Nothing is sent by SMS.
              </div>
            </div>

            {/* Say what the button is about to do. A half-finished enrolment is
                what GoTrue's "a factor with that name already exists" is about,
                and this screen used to report that error with no way to act on
                it. It is cleared automatically now — but silently clearing
                something is only acceptable if the screen said it would. */}
            {staleFactors.length > 0 && (
              <div className="sb-gate__note">
                This account has {staleFactors.length === 1 ? "a half-finished enrolment" : `${staleFactors.length} half-finished enrolments`} —
                a QR code that was generated and never confirmed with a code. That is not a second
                factor and it is what blocks a new one, so it will be removed when you continue.
                {staleFactors.length === 1 && (
                  <div className="sb-gate__fix">
                    If you already scanned it, you do not need to start again: enter the six-digit
                    code it is showing and that finishes the enrolment.
                  </div>
                )}
              </div>
            )}

            {factors && factors.known === false && (
              <div className="sb-gate__note">
                The auth server did not return this account&rsquo;s factor list, so this screen
                cannot tell whether an enrolment was already started. Setting up will still work —
                the new factor is given a name nothing can already hold.
              </div>
            )}

            <button className="btn btn--primary sb-gate__submit" type="button"
              onClick={beginEnrol} disabled={busy}>
              {busy ? "Setting up…" : staleFactors.length > 0 ? "Remove and set up again" : "Set up two-factor"}
            </button>

            {/* The one case the old screen could not express: a scanned but
                unconfirmed factor. Entering its code finishes the job, and
                that is strictly better than throwing the secret away and
                making the operator scan a second one. */}
            {staleFactors.length === 1 && (
              <form className="sb-gate__form" onSubmit={submitCode}>
                <label className="sb-gate__label" htmlFor="sb-gate-stalecode">
                  Already scanned it? Enter the code instead
                </label>
                <input id="sb-gate-stalecode" className="input" inputMode="numeric"
                  autoComplete="one-time-code" placeholder="123456" maxLength={6} value={code}
                  onChange={e => setCode(e.target.value.replace(/\D/g, ""))} />
                <button className="btn btn--ghost sb-gate__submit" type="submit"
                  disabled={busy || code.length !== 6}>
                  {busy ? "Checking…" : "Finish enrolment"}
                </button>
              </form>
            )}
          </>
        )}

        {state === "enrolling" && enrol && (
          <>
            <div className="sb-gate__qr">
              <SbTotpQr qr={enrol.qr} uri={enrol.uri} />
            </div>
            <div className="sb-gate__note">
              If you cannot scan it, add the key by hand:
              <div className="sb-gate__secret"><code>{enrol.secret}</code></div>
              This is the only time it is shown. It is not written to this
              machine, and there is no way to display it again — starting over
              is the recovery path, not retrieval.
            </div>
            <form className="sb-gate__form" onSubmit={submitCode}>
              <label className="sb-gate__label" htmlFor="sb-gate-code">Code from the app</label>
              <input id="sb-gate-code" className="input" inputMode="numeric" autoComplete="one-time-code"
                placeholder="123456" maxLength={6} value={code} autoFocus
                onChange={e => setCode(e.target.value.replace(/\D/g, ""))} />
              <button className="btn btn--primary sb-gate__submit" type="submit"
                disabled={busy || code.length !== 6}>
                {busy ? "Confirming…" : "Confirm and finish"}
              </button>
            </form>
            <button className="btn btn--ghost btn--sm sb-gate__signout" type="button"
              onClick={() => { setEnrol(null); setCode(""); setErr(null); }}>
              Start over
            </button>
          </>
        )}

        {state === "challenge" && (
          <form className="sb-gate__form" onSubmit={submitCode}>
            <label className="sb-gate__label" htmlFor="sb-gate-code2">Six-digit code</label>
            <input id="sb-gate-code2" className="input" inputMode="numeric" autoComplete="one-time-code"
              placeholder="123456" maxLength={6} value={code} autoFocus
              onChange={e => setCode(e.target.value.replace(/\D/g, ""))} />
            <button className="btn btn--primary sb-gate__submit" type="submit"
              disabled={busy || code.length !== 6}>
              {busy ? "Checking…" : "Continue"}
            </button>
          </form>
        )}

        {(state === "signedout" || state === "expired") && (
          <form className="sb-gate__form" onSubmit={submit}>
            <label className="sb-gate__label" htmlFor="sb-gate-email">Email</label>
            <input id="sb-gate-email" className="input" type="email" autoComplete="username"
              placeholder="you@example.com" value={email} autoFocus
              onChange={e => setEmail(e.target.value)} />

            <label className="sb-gate__label" htmlFor="sb-gate-pw">Password</label>
            <input id="sb-gate-pw" className="input" type="password" autoComplete="current-password"
              placeholder="••••••••" value={pw} onChange={e => setPw(e.target.value)} />

            <button className="btn btn--primary sb-gate__submit" type="submit"
              disabled={busy || !email || !pw}>
              {busy ? "Signing in…" : "Sign in"}
            </button>
          </form>
        )}

        {err && (
          <div className="sb-gate__err" role="alert">
            <Icon name="alert" size={13} />
            <span>{err.message || String(err)}</span>
          </div>
        )}

        {(state === "unlinked" || state === "door" || state === "blocked"
          || state === "enrol" || state === "challenge") && (
          <button className="btn btn--ghost btn--sm sb-gate__signout" onClick={() => sb.signOut()}>
            Sign out and try another account
          </button>
        )}

        <div className="sb-gate__foot">
          <button className="sb-gate__link" type="button" onClick={runDiagnostics}>
            {diag ? "Re-run connection check" : "Why can't I sign in?"}
          </button>
          <a className="sb-gate__link" href="/dev/connection">Open diagnostics page</a>
        </div>

        {diag && diag !== "running" && (
          <pre className="sb-gate__diag">{diag.verdict || ""}
{(diag.checks || []).map(c => `${c.ok ? "ok  " : "FAIL"} ${c.name}${c.detail ? " — " + c.detail : ""}`).join("\n")}</pre>
        )}
        {diag === "running" && <pre className="sb-gate__diag">Running…</pre>}
      </div>
    </div>
  );
};

window.SbAuthGate = SbAuthGate;

/* ------------------------------------------------------ impersonation bar ---
   A fixed strip across the top of every screen while an impersonation is open.

   Not a toast and not a corner badge, deliberately. The whole hazard of
   impersonation is forgetting: every screen shows the target's data, correctly
   and without a hint that it is not yours, and the operator who forgets is
   the one who reads a balance off the wrong account and acts on it. So it is
   unmissable, it is on every screen, and it carries the way out.

   It re-asks after every write refusal too — `sb:impersonation` is dispatched
   by nothing yet, but the listener is here so a screen that catches the
   refusal can tell this bar to re-check rather than reload the page. */
const SbImpersonationBar = () => {
  const [state, setState] = useStateShell(null);
  const [busy, setBusy] = useStateShell(false);
  const [tick, bump] = useStateShell(0);
  const sb = window.sb;

  React.useEffect(() => {
    const fn = () => bump(n => n + 1);
    window.addEventListener("sb:session", fn);
    window.addEventListener("sb:impersonation", fn);
    return () => {
      window.removeEventListener("sb:session", fn);
      window.removeEventListener("sb:impersonation", fn);
    };
  }, []);

  React.useEffect(() => {
    if (!sb || !sb.live || !sb.signedIn) { setState(null); return; }
    let alive = true;
    Promise.resolve(sb.impersonationStatus()).then(r => {
      if (alive) setState(r && r.ok ? r.data : null);
    });
    return () => { alive = false; };
  }, [tick, sb && sb.signedIn]);

  /* The bar is `position: fixed`, so it overlays the top edge and the app needs
     the same amount of room back. Done with a body class rather than a wrapper
     element because the shell owns the layout and pushing it down from inside
     would mean editing a shared file's rules — which stays additive-only.
     Cleared on unmount, or a stopped impersonation leaves a 34px gap. */
  const showing = !!(state && state.impersonating);
  React.useEffect(() => {
    try { document.body.classList.toggle("has-imp-bar", showing); } catch (_e) { /* no-op */ }
    return () => { try { document.body.classList.remove("has-imp-bar"); } catch (_e) { /* no-op */ } };
  }, [showing]);

  if (!showing) return null;

  const stop = async () => {
    if (busy) return;
    setBusy(true);
    await sb.endImpersonation();
    setBusy(false);
    window.dispatchEvent(new CustomEvent("sb:impersonation"));
    /* Every screen behind this is showing the target's rows and holds them in
       its own state. Re-fetching them one by one is 69 places to get wrong;
       reloading is one. */
    try { window.location.reload(); } catch (_e) { /* no-op */ }
  };

  return (
    <div className="sb-imp" role="status">
      <Icon name="eye" size={14} />
      <span className="sb-imp__txt">
        Viewing as <b>{state.target_name}</b>{state.target_level != null
          ? <> ({roleLabelFor(state.target_level)})</> : null}. Everything on
        screen is theirs, and nothing can be changed from here.
      </span>
      <button className="sb-imp__stop" type="button" onClick={stop} disabled={busy}>
        {busy ? "Stopping…" : "Stop"}
      </button>
    </div>
  );
};

/* Its own name rather than reusing the gate's local `roleName`, because that
   one is scoped inside SbAuthGate and every top-level const in this build is a
   window global — two `roleName`s would be one, silently, decided by load
   order. */
const roleLabelFor = (lvl) => ({ 0: "Super Admin", 1: "Affiliate", 2: "Skin Admin",
  4: "Customer Care", 6: "Administration", 8: "Master", 9: "Regulator",
  10: "Agent", 15: "Promoter", 20: "Shop", 30: "Player" })[Number(lvl)] || `Level ${lvl}`;

window.SbImpersonationBar = SbImpersonationBar;
window.Sidebar = Sidebar;
window.Topbar = Topbar;
window.SubNav = SubNav;
window.BrandSwitcher = BrandSwitcher;
window.LanguageSwitcher = LanguageSwitcher;
