/* ===================================================================
   app.jsx — shell: lobby · top bar · DM/Player toggle · rail · tweaks
   =================================================================== */
const { useState, useEffect, useRef, useCallback } = React;

/* Contains a crashing child so one flaky widget (e.g. the audio bar's YouTube
   iframe) can never tear down the whole shared table for everyone mid-turn. */
class ErrorBoundary extends React.Component {
  constructor(props) { super(props); this.state = { failed: false }; }
  static getDerivedStateFromError() { return { failed: true }; }
  componentDidCatch() { /* swallowed on purpose — the rest of the table keeps running */ }
  render() { return this.state.failed ? (this.props.fallback ?? null) : this.props.children; }
}

const MONS_INIT = { m1: 13, m2: 11, m3: 8, m4: 0, m5: 15 };
const MON_DEX  = { m1: 2,  m2: 2,  m3: -1, m4: 0, m5: 1 };

/* ---------- Save / recall game state by code ---------- */
const SAVE_VERSION = 1;
const SAVE_PREFIX  = 'embers.save.';
// Uploaded portraits/maps are stored as data: URIs; persisting them blows past
// localStorage limits, so we drop them for now (picture saving comes later) and
// fall back to a sprite so the token still renders after a recall.
const stripImg = (e) => {
  if (!e || typeof e !== 'object') return e;
  if (typeof e.portrait === 'string' && e.portrait.startsWith('data:'))
    return { ...e, portrait: null, sprite: e.sprite || '🙂' };
  return e;
};
const genSaveCode = () => 'EMBER-' + Math.random().toString(36).slice(2, 6).toUpperCase();

/* ---------- DM encounter staging tray (left rail) ---------- */
function EncounterTray({ monsters, revealed, toggleReveal, getEnt, onAddChar, onFlip }) {
  return (
    <aside className="tray leather stitched">
      <div className="tray-hd"><span className="eyebrow">Encounter · staged</span></div>
      <div className="tray-note">Only you can see these until revealed.</div>
      <div className="tray-list">
        {monsters.map(m => {
          const e = getEnt(m.id); const shown = revealed.has(m.id);
          const ally = e.allegiance === 'ally';
          const notes = e.notes && e.notes.length ? e.notes : (m.note ? [m.note] : []);
          return (
            <div key={m.id} className={`tray-row ${shown?'shown':'hidden'} ${ally?'ally':''} ${e.boss?'boss':''}`}>
              <div className="tray-main">
                <span className="tray-sprite" style={{'--ring':e.color}}>{e.portrait ? <img className="sprite-img" src={e.portrait} alt="" /> : e.sprite}</span>
                <div className="tray-body">
                  <div className="tray-name">{e.name}
                    {e.boss ? <span className="side-pip boss">boss</span> : (e.custom && <span className={`side-pip ${ally?'ally':'enemy'}`}>{ally?'ally':'foe'}</span>)}
                  </div>
                  <div className="tray-meta">
                    <span className="reveal-pill">{ally ? 'visible' : (m.reveal||'manual')}</span>
                    {e.hpMax && <span>{e.hp}/{e.hpMax} hp</span>}
                  </div>
                </div>
                <div className="tray-actions">
                  {e.custom && <button className="flip-btn" title="Switch allegiance (ally ⇄ enemy)" onClick={()=>onFlip(m.id)}>⇄</button>}
                  <button className={`eye-btn ${shown?'on':''}`} title={shown?'Hide from party':'Reveal to party'}
                    onClick={()=>toggleReveal(m.id)}>{shown?'👁':'🙈'}</button>
                </div>
              </div>
              {notes.length>0 && (
                <ul className="tray-notes">
                  {notes.slice(0,3).map((n,i)=><li key={i}>{n}</li>)}
                </ul>
              )}
            </div>
          );
        })}
      </div>
      <button className="tray-add" onClick={onAddChar}>➕ Add enemy or ally…</button>
    </aside>
  );
}

/* ---------- Player's own quick card (left rail, player view) ---------- */
function PlayerCard({ getEnt, activeRef, setRightTab }) {
  const e = getEnt(activeRef);
  const hpPct = Math.max(0,(e.hp/e.hpMax)*100);
  return (
    <aside className="tray leather stitched">
      <div className="tray-hd"><span className="eyebrow">You are playing</span></div>
      <div className="pc-card">
        <span className="pc-sprite" style={{'--ring':e.color}}>{e.portrait ? <img className="sprite-img" src={e.portrait} alt="" /> : e.sprite}</span>
        <div className="pc-name display">{e.char}</div>
        <div className="pc-sub">Lvl {e.lvl} {e.cls}</div>
        <div className="pc-hp"><div className="hp-bar"><i style={{width:`${hpPct}%`,background:hpPct>50?'#3f7d4a':hpPct>25?'#c1933f':'#a23644'}}/></div>
          <span>{e.hp}/{e.hpMax} HP</span></div>
        <div className="pc-stats"><span>AC {e.ac}</span><span>Init {e.init}</span><span>{e.sheet?.speed}ft</span></div>
        <button className="btn ghost" style={{width:'100%'}} onClick={()=>setRightTab('sheet')}>Open character sheet</button>
      </div>
    </aside>
  );
}

/* ---------- House-rule alerts (left rail, under the player's character) ---------- */
function RuleAlertsPanel({ polls, queue, onOpen }) {
  const live = polls.filter(p => p.status === 'open');
  if (!live.length && !queue.length) return null;
  return (
    <aside className="tray leather stitched rule-alerts">
      <div className="tray-hd"><span className="eyebrow">House rules{live.length ? ` · ${live.length} live` : ''}</span></div>
      <div className="rule-list">
        {live.map(p => (
          <button key={p.id} className="rule-row live" onClick={onOpen} title="Open the Rules panel to vote">
            <span className="rule-dot" aria-hidden="true" />
            <span className="rule-text">{p.title}</span>
            <span className="rule-cta">Vote ›</span>
          </button>
        ))}
        {queue.map(s => (
          <button key={s.id} className="rule-row" onClick={onOpen} title="Open the Rules panel">
            <span className="rule-text">{s.text}</span>
            <span className="rule-up">▲ {s.up}</span>
          </button>
        ))}
      </div>
    </aside>
  );
}

/* ---------- Lobby / join (no sign-up, private room) ---------- */
function Lobby({ onEnter, takenRefs }) {
  const [tab, setTab] = useState('create');   // 'create' | 'pregen'
  const [name, setName] = useState('');
  const [roomCode, setRoomCode] = useState('');   // optional DM room/save code to join directly
  // Live lobby presence (how many DMs are running games right now). Fail-soft:
  // useLobbyStats is always defined (lobby-presence.jsx loads before app.jsx) and
  // self-heals to zeros when the presence backend is offline, so we call it
  // unconditionally per the rules of hooks.
  const lobby = window.useLobbyStats(5000);
  const [pick, setPick] = useState('p1');
  const [cName, setCName] = useState('');
  const [race, setRace] = useState('Human');
  const [cls, setCls]   = useState('Fighter');
  const [color, setColor] = useState(CHAR_COLORS[1]);
  const [portrait, setPortrait] = useState(null);
  const portraitInput = useRef(null);
  const open = [...PARTY.filter(p=>!p.isDM), ...PREGEN_HEROES];
  const canUpload = Ent.getCanUpload();   // image uploads are the supporter feature (server-verified)
  const [lockHint, setLockHint] = useState(false);
  const [uploadErr, setUploadErr] = useState('');
  const [uploading, setUploading] = useState(false);

  // Uploads go through the server (sniff + strip metadata + quota); it returns
  // a served URL string we store in place of the old inline data-URL.
  const onPortrait = async (e) => {
    const f = e.target.files && e.target.files[0]; e.target.value = '';
    if (!f || !f.type.startsWith('image/')) return;
    setUploadErr(''); setUploading(true);
    try {
      const url = await Ent.uploadImage(f, 'portrait');
      setPortrait(url);
    } catch (err) {
      setUploadErr(err && err.message ? err.message : 'Upload failed.');
    } finally { setUploading(false); }
  };
  const klass = CLASSES_5E.find(c=>c.name===cls) || CLASSES_5E[5];
  const previewSprite = portrait ? null : klass.sprite;

  const seatOpts = () => ({ code: roomCode.trim(), name: name.trim() || 'Guest' });
  const createSeat = () => {
    const finalName = cName.trim() || `${race} ${cls}`;
    onEnter('player', { custom:true, ...buildCharacter({ name: finalName, race, cls, portrait, color }), player: name.trim() || 'Guest' }, seatOpts());
  };

  return (
    <div className="lobby-overlay">
      <div className="lobby-card parchment stitched">
        <div className="lobby-crest"><span className="seal big">E</span></div>
        <div className="eyebrow" style={{textAlign:'center'}}>Private table · invite only</div>
        <h1 className="display lobby-title">Embers of the Deep Roads</h1>
        <div className="lobby-room">
          <span className="lock">🔒</span>
          <span className="seats-left">4 pregen heroes · unlimited custom slots</span>
        </div>

        {/* Ambient, timer-polled count: NOT a live region (would spam SRs every
            5s). Decorative dot is hidden; the text is read on normal navigation. */}
        <div className="lobby-live">
          <span className="ll-dot" aria-hidden="true" />
          <span className="ll-text">
            <b>{lobby.dmCount}</b> {lobby.dmCount === 1 ? 'DM is' : 'DMs are'} running games right now
            {lobby.waitingCount > 0 && <span className="ll-wait"> · {lobby.waitingCount} waiting</span>}
          </span>
        </div>

        <label className="lobby-label" htmlFor="player-name">Your name at the table</label>
        <input id="player-name" className="lobby-input" value={name} onChange={e=>setName(e.target.value)} placeholder="e.g. Alex" />

        <label className="lobby-label" htmlFor="room-code">DM room code <span style={{textTransform:'none',letterSpacing:0}}>(optional)</span></label>
        <input id="room-code" className="lobby-input lobby-code" value={roomCode}
          onChange={e=>setRoomCode(e.target.value)} placeholder="EMBER-XXXX" autoComplete="off"
          aria-describedby="room-code-hint" />
        <p id="room-code-hint" className="lobby-code-hint">Have a code from your DM? Enter it to join their table. No code? You’ll wait for a DM to invite you in.</p>

        <div className="lobby-modetabs">
          <button className={tab==='create'?'on':''} onClick={()=>setTab('create')}>Create a character</button>
          <button className={tab==='pregen'?'on':''} onClick={()=>setTab('pregen')}>Quick-pick a hero</button>
        </div>

        {tab==='create' ? (
          <div className="creator">
            <div className="creator-top">
              <button className={`portrait-drop ${canUpload?'':'locked'}`} style={{'--ring':color}}
                aria-label={canUpload?'Upload a character portrait':'Image uploads are a supporter feature'}
                title={canUpload?'Upload a character portrait':'Image uploads are a supporter feature'}
                onClick={()=>canUpload ? portraitInput.current?.click() : setLockHint(true)}>
                {portrait ? <img src={portrait} alt="" /> : <span className="pd-emoji">{previewSprite}</span>}
                <span className="pd-badge">{canUpload?'⤓':'🔒'}</span>
              </button>
              <input ref={portraitInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" style={{display:'none'}} onChange={onPortrait} />
              {uploading && <p className="save-note" role="status">Uploading portrait…</p>}
              {uploadErr && <p className="save-note err" role="alert">{uploadErr}</p>}
              <div className="creator-fields">
                <input className="lobby-input" aria-label="Character name" value={cName} onChange={e=>setCName(e.target.value)} placeholder="Character name" />
                <div className="select-row">
                  <label>Race
                    <select value={race} onChange={e=>setRace(e.target.value)}>
                      {RACES_5E.map(r=><option key={r.name} value={r.name}>{r.name}</option>)}
                    </select>
                  </label>
                  <label>Class
                    <select value={cls} onChange={e=>{setCls(e.target.value); const k=CLASSES_5E.find(c=>c.name===e.target.value); if(k&&!portrait) setColor(k.color);}}>
                      {CLASSES_5E.map(c=><option key={c.name} value={c.name}>{c.name}</option>)}
                    </select>
                  </label>
                </div>
              </div>
            </div>
            <div className="color-row">
              <span className="eyebrow">Token color</span>
              <div className="color-swatches">
                {CHAR_COLORS.map(c=><button key={c} aria-label={`Token color ${c}`} aria-pressed={color===c} className={`csw ${color===c?'on':''}`} style={{background:c}} onClick={()=>setColor(c)} />)}
              </div>
            </div>
            <p className="creator-hint">{(!canUpload && lockHint)
              ? <span className="hint-lock">🔒 Image uploads are a supporter feature. Pick a sprite &amp; colour for now — once an admin unlocks uploads on this device (💳 at the table), you can add a portrait from your character sheet.</span>
              : <>Upload a portrait and it becomes your map token &amp; sheet picture. Stats are pre-rolled for a level-5 {cls}.</>}</p>
          </div>
        ) : (
          <div className="lobby-heroes">
            {open.map(p=>(
              <button key={p.id} className={`hero-pick ${pick===p.id?'on':''}`} style={{'--ring':p.color}} onClick={()=>setPick(p.id)}>
                <span className="hp-sprite">{p.portrait ? <img className="hp-portrait" src={p.portrait} alt="" /> : p.sprite}</span>
                <span className="hp-name">{p.name}</span>
                <span className="hp-cls">{p.cls}</span>
              </button>
            ))}
          </div>
        )}

        <div className="lobby-actions">
          {tab==='create'
            ? <button className="btn" style={{flex:1}} onClick={createSeat}>{roomCode.trim() ? 'Create & join table' : 'Create & take a seat'}</button>
            : <button className="btn" style={{flex:1}} onClick={()=>onEnter('player', pick, seatOpts())}>{roomCode.trim() ? 'Join table' : 'Take a seat'}</button>}
          <button className="btn wine" onClick={()=>onEnter('dm', 'dm', { dmName: name.trim() || 'Dungeon Master' })}>Enter as DM</button>
        </div>
      </div>
    </div>
  );
}

/* ---------- Top bar ---------- */
function TopBar({ isDM, setIsDM, maps, currentMap, setMap, presence, kick, getEnt, onUpload, userStatus, setStatus, onOpenSave, onOpenSupport, canUpload, onLockedUpload, isAdmin, isSupporter, activeRef }) {
  const [mapOpen, setMapOpen] = useState(false);
  const [menuRef, setMenuRef] = useState(null);
  return (
    <header className="topbar leather">
      <div className="tb-left">
        <span className="seal">E</span>
        <div className="tb-title">
          <div className="display campaign">Embers of the Deep Roads</div>
          <button className="map-switch" onClick={()=>setMapOpen(o=>!o)}>
            {currentMap.name} <span className="caret">▾</span>
          </button>
          {mapOpen && (
            <div className="map-menu parchment stitched">
              {maps.map(m=>(
                <button key={m.id} className={`map-item ${m.id===currentMap.id?'on':''}`} onClick={()=>{setMap(m.id);setMapOpen(false);}}>
                  <div><div className="mi-name">{m.image?'🗺 ':''}{m.name}</div><div className="mi-sub">{m.sub}</div></div>
                  {m.id===currentMap.id ? <span className="mi-here">on table</span> : (m.image && <span className="mi-tag">image</span>)}
                </button>
              ))}
              {isDM && (canUpload
                ? <button className="map-add" onClick={()=>{onUpload&&onUpload();setMapOpen(false);}}>➕ Upload image map…</button>
                : <button className="map-add locked" onClick={()=>{onLockedUpload&&onLockedUpload();setMapOpen(false);}}>🔒 Upload image map (supporter)</button>)}
            </div>
          )}
        </div>
      </div>

      <div className="tb-presence">
        {presence.map(ref=>{ const e=getEnt(ref); const st=(userStatus||{})[ref]; return (
          <div key={ref} className={`pres ${st?'has-status status-'+st:''}`} style={{'--ring':e.color}} title={`${e.name} · ${e.ping}ms${st?` · ${st==='timeout'?'in timeout':'in waiting room'}`:''}`}>
            <span className="pres-sprite">{e.portrait ? <img className="sprite-img" src={e.portrait} alt="" /> : e.sprite}</span>
            {isSupporter && !isDM && ref===activeRef && <span className="pres-supporter" title="Supporter — image uploads unlocked" aria-label="Supporter">⭐</span>}
            <span className="pres-ping" style={{background:e.ping<35?'#3f7d4a':e.ping<50?'#c1933f':'#a23644'}} />
            {st && <span className="pres-status" aria-label={st==='timeout'?'In timeout':'In waiting room'} title={st==='timeout'?'In timeout':'In waiting room'}>{st==='timeout'?'🔇':'⏳'}</span>}
            {isDM && !e.isDM && (
              <div className="pres-manage">
                <button className="pres-menu-btn" aria-haspopup="menu" aria-expanded={menuRef===ref} aria-label={`Manage ${e.name}`} title={`Manage ${e.name}`} onClick={()=>setMenuRef(m=>m===ref?null:ref)}>⋯</button>
                {menuRef===ref && (
                  <div className="pres-menu parchment stitched" role="menu">
                    <div className="pm-head">{e.name}</div>
                    {st && <button role="menuitem" onClick={()=>{setStatus(ref,null);setMenuRef(null);}}>↩ Return to table</button>}
                    {st!=='waiting' && <button role="menuitem" onClick={()=>{setStatus(ref,'waiting');setMenuRef(null);}}>⏳ Waiting room</button>}
                    {st!=='timeout' && <button role="menuitem" onClick={()=>{setStatus(ref,'timeout');setMenuRef(null);}}>🔇 Send to timeout</button>}
                    <button role="menuitem" className="pm-danger" onClick={()=>{kick(ref);setMenuRef(null);}}>✕ Remove from table</button>
                  </div>
                )}
              </div>
            )}
          </div>
        );})}
        <span className="pres-lock" title="Room is locked — invite only">🔒</span>
      </div>

      <div className="tb-right">
        <button className={`tb-save tb-support ${isAdmin?'is-admin':''} ${canUpload?'is-paid':''}`} onClick={onOpenSupport}
          aria-label={isAdmin?'Image uploads & admin':'Unlock image uploads'} title={isAdmin?'Image uploads & admin':canUpload?'Image uploads unlocked':'Unlock image uploads'}>{isAdmin?'★':'💳'}</button>
        <button className="tb-save" onClick={onOpenSave} aria-label="Save or recall game" title="Save or recall game">💾</button>
        <div className="view-toggle" role="group" aria-label="View as">
          <button className={!isDM?'on':''} aria-pressed={!isDM} onClick={()=>setIsDM(false)}>Player</button>
          <button className={isDM?'on':''} aria-pressed={isDM} onClick={()=>setIsDM(true)}>DM</button>
        </div>
      </div>
    </header>
  );
}

/* ---------- Save / recall by code ---------- */
function SaveLoadModal({ lastCode, onSave, onLoad, onDelete, onClose }) {
  const [code, setCode] = useState('');
  const copy = () => { try { navigator.clipboard.writeText(lastCode); } catch {} };
  return (
    <div className="lobby-overlay" onClick={onClose}>
      <div className="lobby-card save-card parchment stitched" onClick={e=>e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="save-title">
        <button className="save-x" onClick={onClose} aria-label="Close">✕</button>
        <h2 id="save-title" className="display">Save &amp; recall</h2>

        <section className="save-block">
          <button className="btn wine" style={{width:'100%'}} onClick={onSave}>Save this game</button>
          {lastCode && (
            <div className="save-code-row">
              <span className="save-code-label">Recall code</span>
              <code className="save-code">{lastCode}</code>
              <button className="btn ghost save-copy" onClick={copy} aria-label="Copy code">Copy</button>
            </div>
          )}
        </section>

        <section className="save-block">
          <label className="lobby-label" htmlFor="save-recall-input">Recall a saved game</label>
          <input id="save-recall-input" className="lobby-input" value={code}
                 onChange={e=>setCode(e.target.value)} placeholder="EMBER-XXXX"
                 onKeyDown={e=>{ if(e.key==='Enter') onLoad(code); }} />
          <div className="save-actions">
            <button className="btn" onClick={()=>onLoad(code)}>Recall</button>
            <button className="btn ghost save-danger" onClick={()=>onDelete(code)}>Remove save</button>
          </div>
        </section>

        <p className="save-note">Map and character pictures aren’t stored yet — they’ll be added later. Remove a save once the game is complete.</p>
      </div>
    </div>
  );
}

/* ---------- Supporter (image uploads) + admin grants ---------- */
const isEmailish = (e) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(e || '').trim());
const clockTime = (ms) => { try { return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); } catch { return ''; } };

function SupporterModal({ tier, isAdmin, supporter, onRedeem, onAdminLogin, onAdminLogout, onBootstrap, onClose }) {
  // redeem (supporter) fields
  const [rName, setRName] = useState('');
  const [rEmail, setREmail] = useState('');
  const [code, setCode] = useState('');
  const [busy, setBusy] = useState(false);
  // admin: passkey + first-time owner bootstrap fields
  const [bootEmail, setBootEmail] = useState('');
  const [bootSecret, setBootSecret] = useState('');
  const [gName, setGName] = useState('');
  const [gEmail, setGEmail] = useState('');
  const [minted, setMinted] = useState([]);   // [{ code, name, email, expiresAt }]
  // The owner sign-in is hidden until the owner presses Ctrl+Alt+\ while this
  // dialog is open, OR uses the always-present "Owner sign-in" affordance below
  // (an accessible alternative, since the chord overlaps screen-reader combos).
  const [adminRevealed, setAdminRevealed] = useState(false);
  const signInRef = useRef(null);
  const dialogRef = useRef(null);
  const closeRef = useRef(null);
  const paid = tier === 'paid';
  const copy = (txt) => { try { navigator.clipboard.writeText(txt); } catch {} };

  useEffect(() => {
    const onKey = (e) => {
      if (e.ctrlKey && e.altKey && (e.key === '\\' || e.code === 'Backslash')) {
        e.preventDefault();
        setAdminRevealed(true);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);
  // Move focus to the passkey button once the hidden control appears.
  useEffect(() => { if (adminRevealed && !isAdmin) signInRef.current?.focus(); }, [adminRevealed, isAdmin]);

  // Modal focus management: initial focus, Escape-to-close, Tab focus trap, and
  // focus return to the invoking control on close (SC 2.1.2 / 2.4.3 / 2.4.7).
  useEffect(() => {
    const prev = document.activeElement;
    closeRef.current?.focus();
    const node = dialogRef.current;
    const onKey = (e) => {
      if (e.key === 'Escape') { e.preventDefault(); onClose(); return; }
      if (e.key !== 'Tab' || !node) return;
      const all = node.querySelectorAll(
        'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),summary,[tabindex]:not([tabindex="-1"])',
      );
      // Only visible, focusable controls (skips inputs inside a collapsed <details>).
      const list = Array.prototype.filter.call(all, (el) => el.offsetParent !== null);
      if (list.length === 0) return;
      const first = list[0], last = list[list.length - 1];
      if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
      else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
    };
    node?.addEventListener('keydown', onKey);
    return () => {
      node?.removeEventListener('keydown', onKey);
      if (prev && typeof prev.focus === 'function') prev.focus();
    };
  }, []);

  // Passkey sign-in: the OS handles the local prompt and any cross-device QR.
  const trySignIn = async () => { if (busy) return; setBusy(true); try { await onAdminLogin(); } finally { setBusy(false); } };
  const tryBootstrap = async () => {
    if (busy || !isEmailish(bootEmail) || !bootSecret) return;
    setBusy(true);
    try { if (await onBootstrap(bootEmail, bootSecret)) setBootSecret(''); }
    finally { setBusy(false); }
  };
  const tryRedeem = async () => {
    if (busy) return; setBusy(true);
    try { if (await onRedeem(code, rEmail, rName)) setCode(''); }
    finally { setBusy(false); }
  };
  const mint = async () => {
    if (busy || !isEmailish(gEmail)) return;
    setBusy(true);
    try {
      const res = await Ent.mintGrant(gEmail, gName);
      if (res.ok) {
        const entry = { code: res.code, name: gName.trim() || res.email, email: res.email, expiresAt: res.expiresAt ? res.expiresAt * 1000 : Date.now() + Ent.CODE_TTL_MIN * 60 * 1000 };
        setMinted(m => [entry, ...m].slice(0, 12));
      }
    } finally { setBusy(false); }
  };

  return (
    <div className="lobby-overlay" onClick={onClose}>
      <div ref={dialogRef} className="lobby-card support-card parchment stitched" onClick={e=>e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="support-title">
        <button ref={closeRef} className="save-x" onClick={onClose} aria-label="Close">✕</button>
        <h2 id="support-title" className="display">Image uploads</h2>

        <div className={`tier-pill ${paid?'paid':'free'}`}>
          {paid ? '★ Supporter — character & map image uploads are on' : 'Free table — everything except image uploads'}
        </div>

        {paid && supporter && (
          <div className="supporter-id">
            <span className="supporter-badge" aria-hidden="true">⭐</span>
            <span><b>{supporter.username}</b>{supporter.email ? <> · {supporter.email}</> : null}</span>
          </div>
        )}

        {!paid && (
          <section className="save-block">
            <div className="eyebrow">Have an unlock code?</div>
            <label className="lobby-label" htmlFor="redeem-name">Your name</label>
            <input id="redeem-name" className="lobby-input" value={rName} onChange={e=>setRName(e.target.value)} placeholder="e.g. Sam" autoComplete="name" />
            <label className="lobby-label" htmlFor="redeem-email">Your email (the one the code was sent to)</label>
            <input id="redeem-email" className="lobby-input" type="email" value={rEmail} onChange={e=>setREmail(e.target.value)} placeholder="sam@example.com" autoComplete="email" />
            <label className="lobby-label" htmlFor="redeem-input">Unlock code</label>
            <input id="redeem-input" className="lobby-input" value={code} onChange={e=>setCode(e.target.value)}
                   placeholder="EMBER-PASS-XXXX-XXX-XXXXXX" autoComplete="off"
                   onKeyDown={e=>{ if(e.key==='Enter') tryRedeem(); }} />
            <div className="save-actions">
              <button className="btn wine" onClick={tryRedeem} aria-busy={busy} aria-disabled={busy}>{busy ? 'Unlocking…' : 'Unlock uploads'}</button>
            </div>
            <p className="save-note">Codes expire {Ent.CODE_TTL_LABEL} after the admin creates them, and only work for the email they were made for.</p>
          </section>
        )}

        {!isAdmin && !adminRevealed && (
          <button className="linklike owner-reveal" onClick={()=>setAdminRevealed(true)}
                  aria-expanded={adminRevealed} aria-controls="admin-block">Owner sign-in</button>
        )}

        {(isAdmin || adminRevealed) && (
        <section id="admin-block" className="save-block admin-block">
          <div className="eyebrow">Admin</div>
          {isAdmin ? (
            <>
              <p className="save-note">Generate a {Ent.CODE_TTL_LABEL} code for a trusted player, then copy it and send it to them. They enter their name, that email, and the code on their own device.</p>
              <label className="lobby-label" htmlFor="gen-name">Their name</label>
              <input id="gen-name" className="lobby-input" value={gName} onChange={e=>setGName(e.target.value)} placeholder="e.g. Sam" autoComplete="off" />
              <label className="lobby-label" htmlFor="gen-email">Their email</label>
              <input id="gen-email" className="lobby-input" type="email" value={gEmail} onChange={e=>setGEmail(e.target.value)} placeholder="sam@example.com" autoComplete="off"
                     onKeyDown={e=>{ if(e.key==='Enter') mint(); }} />
              <div className="save-actions">
                <button className="btn" onClick={mint} aria-busy={busy} aria-disabled={busy || !isEmailish(gEmail)}>{busy ? 'Working…' : 'Generate code'}</button>
                <button className="btn ghost" onClick={onAdminLogout}>Sign out</button>
              </div>
              {minted.length>0 && (
                <ul className="code-list">
                  {minted.map(en=>(
                    <li key={en.code}>
                      <div className="code-meta">
                        <code className="save-code">{en.code}</code>
                        <span className="code-for">for <b>{en.name}</b> · {en.email} · expires {clockTime(en.expiresAt)}</span>
                      </div>
                      <button className="btn ghost save-copy" onClick={()=>copy(en.code)} aria-label={`Copy code for ${en.name}`}>Copy</button>
                    </li>
                  ))}
                </ul>
              )}
            </>
          ) : (
            <>
              <p className="save-note">Sign in with your passkey. On a computer you’ll be prompted for this device’s passkey; you can also choose “use a phone” and scan the QR with your phone’s secure chip.</p>
              <div className="save-actions">
                <button className="btn" ref={signInRef} onClick={trySignIn} aria-busy={busy} aria-disabled={busy}>{busy ? 'Waiting for passkey…' : 'Sign in with passkey'}</button>
              </div>
              <details className="admin-setup">
                <summary>First-time owner setup</summary>
                <p className="save-note">One-time: register this device’s (or your phone’s) passkey as the owner. Requires the bootstrap secret from the server config. After the first owner exists, this path closes automatically.</p>
                <label className="lobby-label" htmlFor="boot-email">Owner email</label>
                <input id="boot-email" className="lobby-input" type="email" value={bootEmail} onChange={e=>setBootEmail(e.target.value)} placeholder="owner@example.com" autoComplete="email" />
                <label className="lobby-label" htmlFor="boot-secret">Bootstrap secret</label>
                <input id="boot-secret" className="lobby-input" type="password" value={bootSecret} onChange={e=>setBootSecret(e.target.value)} placeholder="from .dev.vars / wrangler secret" autoComplete="off" />
                <div className="save-actions">
                  <button className="btn" onClick={tryBootstrap} aria-busy={busy} aria-disabled={busy || !isEmailish(bootEmail) || !bootSecret}>Register owner passkey</button>
                </div>
              </details>
            </>
          )}
        </section>
        )}

        <p className="save-note">Uploads are unlocked by a server-verified, single-use code bound to your email — entitlements are enforced on the server, not stored on this device.</p>
      </div>
    </div>
  );
}

/* ---------- Root app ---------- */
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "theme": "sky",
  "fogMode": "sight",
  "spriteStyle": "disc",
  "diceMode": "builder",
  "diceAnimate": true,
  "showGrid": true,
  "railSide": "right",
  "inkColor": "#7d2330",
  "ytStyle": "bar",
  "geoStyle": "ring"
}/*EDITMODE-END*/;

const ENEMY_RING = '#a23644';
const ALLY_RING  = '#3f7d4a';

/* ---------- Status overlays (player-side) ---------- */
function TimeoutScreen({ controllerRef }) {
  useEffect(() => {
    try { controllerRef.current?.playSearch?.('Rick Astley Together Forever', { label: 'Timeout', title: 'Together Forever — Rick Astley', toast: 'You have been sent to timeout.' }); } catch {}
    return () => { try { controllerRef.current?.stop?.(); } catch {} };
  }, []);
  return (
    <div className="timeout-screen" role="alertdialog" aria-label="You have been placed in timeout" aria-modal="true">
      <div className="jail-bars" aria-hidden="true">{Array.from({ length: 7 }).map((_, i) => <span key={i} />)}</div>
      <div className="timeout-card leather stitched">
        <span className="timeout-icon" aria-hidden="true">🔇</span>
        <h2>You're in timeout</h2>
        <p>The DM has paused your turn. Sit tight — you'll be back at the table soon.</p>
      </div>
    </div>
  );
}

/* The mid-game "you've been sent to the waiting room" overlay (a DM moved a
   seated player out). NOTE: this MUST NOT be named `WaitingRoom` — in the
   no-build Babel-standalone setup every script's top-level declarations leak
   into the global scope, and app.jsx loads after lobby-presence.jsx, so a
   top-level `WaitingRoom` here would clobber the presence `window.WaitingRoom`
   (the no-code lobby waiting room). Distinct name keeps the globals separate. */
function InGameWaitingRoom({ controllerRef }) {
  useEffect(() => {
    try { controllerRef.current?.playSearch?.('epic fantasy ambient adventure music', { label: 'Waiting room', title: 'Fantasy Waiting Music', toast: 'You are in the waiting room.' }); } catch {}
    return () => { try { controllerRef.current?.stop?.(); } catch {} };
  }, []);
  return (
    <div className="waiting-room" role="alertdialog" aria-label="You are in the waiting room" aria-modal="true">
      <div className="waiting-card leather stitched">
        <span className="waiting-icon" aria-hidden="true">⏳</span>
        <h2>Waiting Room</h2>
        <p>The adventure will resume shortly. Rest by the fire while the DM prepares the next scene.</p>
        <div className="waiting-embers" aria-hidden="true">{Array.from({ length: 5 }).map((_, i) => <span key={i} />)}</div>
      </div>
    </div>
  );
}

function App() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
  const [entered, setEntered] = useState(false);
  const [isDM, setIsDM] = useState(false);
  const [pendingSeat, setPendingSeat] = useState(null);   // { payload, name } — player waiting for a DM invite
  const [activeRef, setActiveRef] = useState('p1');

  const [tokensState, setTokensState] = useState(() => START_TOKENS.map(s => ({ ...s })));
  const [hpState, setHpState] = useState(() => {
    const o = {}; [...PARTY, ...MONSTERS].forEach(e => { if (e.hp!=null) o[e.id]=e.hp; }); return o;
  });
  const [revealed, setRevealed] = useState(() => new Set());
  const [round, setRound] = useState(2);
  const [turnIdx, setTurnIdx] = useState(0);
  const [log, setLog] = useState(() => CHAT.map((m,i)=>({ ...m, id: m.id })));
  const logId = useRef(100);
  const [polls, setPolls] = useState(POLLS);
  const [queue, setQueue] = useState(SUGGESTIONS_QUEUE);
  const [charRef, setCharRef] = useState('p1');
  const [rightTab, setRightTab] = useState('dice');
  const [presence, setPresence] = useState(PARTY.map(p=>p.id));
  const [toast, setToast] = useState(null);
  const [railL, setRailL] = useState(false);   // left panel collapsed
  const [railR, setRailR] = useState(false);   // right panel collapsed
  const [currentMapId, setCurrentMapId] = useState('map1');
  const [uploadedMaps, setUploadedMaps] = useState([]);
  const fileInputRef = useRef(null);
  const [customFoes, setCustomFoes] = useState([]);     // DM-added enemies & allies
  const [showComposer, setShowComposer] = useState(false);
  const [geofences, setGeofences] = useState([]);       // map trigger zones
  const [editingGeo, setEditingGeo] = useState(null);
  const ytRef = useRef({});                              // shared audio controller
  const [initRolls, setInitRolls] = useState({ p1:18, p2:22, p3:9, p4:25, m1:13, m2:11, m3:8, m5:15 });
  const [justRolled, setJustRolled] = useState(null);
  const rollSeq = useRef(0);
  const [customChars, setCustomChars] = useState({});
  const [charOverrides, setCharOverrides] = useState({});
  const [userStatus, setUserStatus] = useState({});     // ref → 'timeout' | 'waiting'
  const [conditions, setConditions] = useState({});     // ref → [{ id, name, glyph, color, kind }]
  const [castFx, setCastFx] = useState(null);           // brief element-cast flash { ref, glyph, color, key }
  const [saveOpen, setSaveOpen] = useState(false);      // save/recall-by-code modal
  const [lastCode, setLastCode] = useState(null);       // last generated save code
  const [tier, setTier] = useState(() => Ent.getTier());        // 'free' | 'paid' (this device)
  const [isAdmin, setIsAdmin] = useState(() => Ent.getAdmin()); // device recognised as admin
  const [supporter, setSupporter] = useState(() => Ent.getSupporter()); // { username, email } | null
  const [supportOpen, setSupportOpen] = useState(false);        // supporter / admin modal
  const canUpload = tier === 'paid' || isAdmin;                 // image uploads are the paid feature

  const allMaps = [...MAPS, ...uploadedMaps];
  const currentMap = allMaps.find(m=>m.id===currentMapId) || allMaps[0];
  const onTower = !!(currentMap && typeof currentMap.id === 'string' && currentMap.id.indexOf('bloom') === 0);
  const MONS = [...MONSTERS, ...customFoes].filter(m => m.scope !== 'bloom' || onTower);   // boss only in the tower

  const handleMapFile = async (e) => {
    const file = e.target.files && e.target.files[0]; e.target.value = '';
    if (!file) return;
    if (!file.type.startsWith('image/')) { showToast('Please choose an image file'); return; }
    try {
      const url = await Ent.uploadImage(file, 'map');
      const map = { id: 'up'+Date.now(), name: file.name.replace(/\.[^.]+$/, ''), sub: 'Uploaded map', cells:{w:24,h:16}, url, image: true };
      setUploadedMaps(m => [...m, map]); setCurrentMapId(map.id);
      showToast('Map uploaded — fog reset for the new battlefield ✓');
    } catch (err) {
      showToast(err && err.message ? err.message : 'Map upload failed');
    }
  };

  // theme
  useEffect(() => { document.documentElement.dataset.theme = t.theme || 'sky'; }, [t.theme]);

  // Hydrate entitlement state from the server once on startup (fail-safe to
  // anonymous/free). The cookie session, if any, decides admin/supporter here.
  useEffect(() => {
    let live = true;
    Ent.refresh().then(() => { if (live) { setTier(Ent.getTier()); setIsAdmin(Ent.getAdmin()); setSupporter(Ent.getSupporter()); } });
    return () => { live = false; };
  }, []);

  // While the local user is DM, keep the server presence room alive with a
  // heartbeat so the lobby DM count + waiting players can see it; close it on
  // exit. Best-effort and fail-soft — never blocks the table. If the room was
  // evicted (e.g. a long network gap), try to silently reclaim it; only warn the
  // DM once if even the reclaim fails, so waiting players aren't lost in silence.
  useEffect(() => {
    if (!entered || !isDM || !window.Presence) return;
    let warned = false;
    const tick = async () => {
      const r = await window.Presence.dmHeartbeat().catch(() => ({ ok: false }));
      if (r && r.ok) { warned = false; return; }
      if (r && r.reason === 'expired' && lastCode) {
        const re = await window.Presence.dmOpen(lastCode, 'Dungeon Master').catch(() => ({ ok: false }));
        if (re && re.ok) { warned = false; return; }
      }
      if (!warned) { warned = true; showToast('Lobby presence lost — players may not see your table right now.'); }
    };
    const id = setInterval(tick, 8000);
    return () => { clearInterval(id); window.Presence.dmClose().catch(() => {}); };
  }, [entered, isDM, lastCode]);

  // entity accessor merges live HP + rolled initiative (+ custom characters + user edits)
  const getEnt = useCallback((ref) => {
    const base = customChars[ref] || customFoes.find(f=>f.id===ref) || byId(ref);
    const ov = charOverrides[ref] || {};
    return { ...base, ...ov, hp: hpState[ref] ?? base.hp, init: initRolls[ref] ?? base.init ?? MONS_INIT[ref] };
  }, [hpState, initRolls, customChars, charOverrides, customFoes]);

  const editChar = (ref, patch) => setCharOverrides(o => ({ ...o, [ref]: { ...(o[ref]||{}), ...patch } }));
  const canEditChar = (ref) => isDM || ref === activeRef;

  const setHp = (ref, delta) => setHpState(s => {
    const base = customChars[ref] || customFoes.find(f=>f.id===ref) || byId(ref); const max = base.hpMax || 999;
    const cur = s[ref] ?? base.hp ?? 0;
    return { ...s, [ref]: Math.max(0, Math.min(max, cur + delta)) };
  });

  // auto monster reveal: proximity + scripted + allies are visible
  useEffect(() => {
    const all = [...MONSTERS, ...customFoes];
    const isFoe = (ref) => all.some(m => m.id === ref);
    setRevealed(prev => {
      const next = new Set(prev);
      all.forEach(m => {
        const tk = tokensState.find(x=>x.ref===m.id); if(!tk) return;
        if (m.reveal==='visible' || m.allegiance==='ally') { next.add(m.id); return; }
        if (m.reveal==='proximity') {
          const near = tokensState.filter(x=>!isFoe(x.ref))
            .some(p=>Math.hypot(p.x-tk.x,p.y-tk.y)<=3);
          if (near) next.add(m.id);
        } else if (m.reveal==='scripted' && round>=3) next.add(m.id);
      });
      return next;
    });
  }, [tokensState, round, customFoes]);

  const toggleReveal = (id) => setRevealed(s => { const n=new Set(s); n.has(id)?n.delete(id):n.add(id); return n; });

  // turn order — sorted by rolled initiative (DEX breaks ties)
  const bonusOf = (ref) => { const b = customChars[ref] || customFoes.find(f=>f.id===ref) || byId(ref); return b.sheet ? modOf(b.sheet.DEX) : (MON_DEX[ref] ?? 0); };
  const initOf  = (ref) => initRolls[ref] ?? (customChars[ref]||customFoes.find(f=>f.id===ref)||byId(ref)).init ?? MONS_INIT[ref] ?? 10;
  const order = (() => {
    const players = presence.filter(ref => ref!=='dm' && !MONS.find(m=>m.id===ref));
    const combatants = [
      ...players,
      ...MONS.filter(m=>!m.trap && (isDM || revealed.has(m.id))).map(m=>m.id),
    ];
    combatants.sort((a,b)=> (initOf(b)-initOf(a)) || (bonusOf(b)-bonusOf(a)));
    return combatants;
  })();
  const safeTurn = Math.min(turnIdx, Math.max(0, order.length-1));
  const endTurn = () => setTurnIdx(i => { const n=i+1; if(n>=order.length){ setRound(r=>r+1); return 0;} return n; });
  const prevTurn = () => setTurnIdx(i => Math.max(0, i-1));

  const addRoll = (res) => { logId.current++; setLog(l => [...l, { id:logId.current, kind:'roll', who:res.who, text:res.label, formula:res.formula, total:res.total, dice:res.dice }]); };

  // --- Initiative: a real d20 + DEX roll through the 5e roll engine ---
  const rollInitFor = (ref) => {
    const mod = bonusOf(ref);
    const res = rollFormula(`1d20${mod>=0?'+':''}${mod}`);
    setInitRolls(s => ({ ...s, [ref]: res.total }));
    addRoll({ ...res, label: 'Initiative', who: ref });
    setJustRolled(ref); setTimeout(()=>setJustRolled(null), 750);
    return res.total;
  };
  const rollAllInit = () => {
    setRound(1); setTurnIdx(0);
    // DM rolls every NPC on the field — built-in foes AND DM-staged enemies/allies.
    // Seated player characters always roll their own.
    const npcs = order.filter(ref => MONS.find(m=>m.id===ref));
    npcs.forEach((ref, i) => setTimeout(() => rollInitFor(ref), i*130));
    showToast(npcs.length ? 'Enemies & allies rolled initiative — players, roll your own!' : 'No NPCs on the field to roll for');
  };
  const sendMsg = (text) => { logId.current++; setLog(l => [...l, { id:logId.current, kind: isDM?'narrate':'say', who: isDM?'dm':activeRef, text }]); };
  const showToast = (msg) => { setToast(msg); setTimeout(()=>setToast(null), 2200); };

  // ---- Save / recall the whole table by a short code (delete when the game ends) ----
  const buildSnapshot = () => ({
    v: SAVE_VERSION, savedAt: Date.now(),
    tokensState: tokensState.map(({ fx, fy, ...rest }) => rest),
    hpState, revealed: [...revealed], round, turnIdx,
    log, polls, queue, currentMapId,
    customFoes: customFoes.map(stripImg),
    customChars: Object.fromEntries(Object.entries(customChars).map(([k, v]) => [k, stripImg(v)])),
    charOverrides, geofences, initRolls, userStatus, conditions, presence, charRef,
  });
  const applySnapshot = (s) => {
    if (!s || s.v !== SAVE_VERSION) return false;
    setTokensState(Array.isArray(s.tokensState) ? s.tokensState : []);
    setHpState(s.hpState || {});
    setRevealed(new Set(s.revealed || []));
    setRound(s.round ?? 1); setTurnIdx(s.turnIdx ?? 0);
    setLog(s.log || []); setPolls(s.polls || []); setQueue(s.queue || []);
    setCurrentMapId(MAPS.find(m => m.id === s.currentMapId) ? s.currentMapId : 'map1'); // uploaded maps aren't persisted yet
    setCustomFoes(s.customFoes || []);
    setCustomChars(s.customChars || {});
    setCharOverrides(s.charOverrides || {});
    setGeofences(s.geofences || []);
    setInitRolls(s.initRolls || {});
    setUserStatus(s.userStatus || {});
    setConditions(s.conditions || {});
    if (s.presence) setPresence(s.presence);
    if (s.charRef) setCharRef(s.charRef);
    return true;
  };
  const saveGame = () => {
    const code = genSaveCode();
    try {
      localStorage.setItem(SAVE_PREFIX + code, JSON.stringify(buildSnapshot()));
      setLastCode(code);
      showToast('Game saved — recall code ' + code);
    } catch (err) { showToast('Save failed — storage may be full'); }
  };
  const loadGame = (raw) => {
    const code = (raw || '').trim().toUpperCase();
    if (!code) { showToast('Enter a save code first'); return; }
    let stored = null; try { stored = localStorage.getItem(SAVE_PREFIX + code); } catch {}
    if (!stored) { showToast('No saved game found for ' + code); return; }
    let ok = false; try { ok = applySnapshot(JSON.parse(stored)); } catch { ok = false; }
    if (ok) { setSaveOpen(false); showToast('Recalled saved game ' + code); }
    else showToast('That save could not be read');
  };
  const deleteGame = (raw) => {
    const code = (raw || '').trim().toUpperCase();
    if (!code) { showToast('Enter a save code to remove'); return; }
    try { localStorage.removeItem(SAVE_PREFIX + code); } catch {}
    if (lastCode === code) setLastCode(null);
    showToast('Save ' + code + ' removed');
  };

  // ---- Free / paid (image uploads) + admin — server-verified ----
  // After any server action, mirror the fresh Ent snapshot into React state.
  const syncEnt = () => { setTier(Ent.getTier()); setIsAdmin(Ent.getAdmin()); setSupporter(Ent.getSupporter()); };
  const redeemCode = async (code, email, username) => {
    const res = await Ent.redeem(code, email, username);
    if (res.ok) {
      const who = Ent.getSupporter();
      syncEnt();
      showToast('Image uploads unlocked — welcome, ' + (who && who.username || 'supporter') + '! ✓');
      return true;
    }
    showToast(res.reason === 'expired' ? 'That code has expired — ask the admin for a fresh one'
      : res.reason === 'already_redeemed' ? 'That code was already used'
      : res.reason === 'email_mismatch' ? 'That code was issued for a different email'
      : 'That code or email doesn’t match');
    return false;
  };
  const adminLogin = async () => {
    const res = await Ent.adminLoginPasskey();
    if (res.ok) {
      syncEnt();
      showToast(Ent.getAdmin() ? 'Admin mode on ✓' : 'Signed in ✓');
      return true;
    }
    showToast(res.reason === 'cancelled' ? 'Passkey sign-in cancelled'
      : res.reason === 'unsupported' ? 'This device can’t use passkeys'
      : res.reason === 'unknown_credential' ? 'No passkey is registered for this table yet'
      : 'Passkey sign-in failed');
    return false;
  };
  const adminBootstrap = async (email, secret) => {
    const res = await Ent.registerPasskey({ email, displayName: String(email || '').split('@')[0], bootstrapSecret: secret });
    if (res.ok) { syncEnt(); showToast('Owner passkey registered — admin mode on ✓'); return true; }
    showToast(res.reason === 'cancelled' ? 'Passkey setup cancelled'
      : res.reason === 'bootstrap_required' ? 'Bootstrap secret rejected (or owner already set up)'
      : res.reason === 'unsupported' ? 'This device can’t create passkeys'
      : 'Passkey setup failed');
    return false;
  };
  const adminLogout = async () => { await Ent.logout(); syncEnt(); showToast('Signed out'); };
  const lockedUpload = () => { showToast('Image uploads are a supporter feature'); setSupportOpen(true); };
  const kick = (ref) => { setPresence(p=>p.filter(x=>x!==ref)); setTokensState(ts=>ts.filter(x=>x.ref!==ref)); setUserStatus(s=>{const n={...s};delete n[ref];return n;}); setConditions(c=>{const n={...c};delete n[ref];return n;}); showToast(`${getEnt(ref).name} was removed from the table`); };
  const setStatus = (ref, status) => {
    setUserStatus(s => { const n = { ...s }; if (status) n[ref] = status; else delete n[ref]; return n; });
    const nm = getEnt(ref).name;
    showToast(status === 'timeout' ? `${nm} was sent to timeout 🔇` : status === 'waiting' ? `${nm} moved to the waiting room ⏳` : `${nm} is back at the table`);
  };

  // --- DM: Elements / Power — unleash an element on a target ---
  const clearCondition = (ref, id) => setConditions(c => ({ ...c, [ref]: (c[ref] || []).filter(x => x.id !== id) }));
  const castElement = (el, ref) => {
    const tgt = getEnt(ref);
    const nm = tgt.char ? tgt.char.split(' ')[0] : tgt.name;
    let detail = '';
    if (el.kind === 'damage' || el.kind === 'heal') {
      const res = rollFormula(el.dice);
      const amt = res.total;
      setHp(ref, el.kind === 'damage' ? -amt : amt);
      addRoll({ ...res, label: `${el.name} (${el.kind})`, who: 'dm' });
      detail = el.kind === 'damage' ? `for ${amt} damage` : `for ${amt} healing`;
    }
    if (el.status) {
      setConditions(c => {
        const cur = c[ref] || [];
        if (cur.some(x => x.name === el.status)) return c;
        return { ...c, [ref]: [...cur, { id: 'cx' + (rollSeq.current++), name: el.status, glyph: el.glyph, color: el.color, kind: el.kind }] };
      });
      detail = detail ? `${detail} — ${el.status}` : `— ${el.status}`;
    }
    logId.current++;
    setLog(l => [...l, { id: logId.current, kind: 'narrate', who: 'dm', text: `${el.glyph} ${el.name} ${el.verb} ${nm} ${detail}.`.replace(/\s+/g, ' ').trim() }]);
    const key = ++rollSeq.current;
    setCastFx({ ref, glyph: el.glyph, color: el.color, key });
    setTimeout(() => setCastFx(f => (f && f.key === key ? null : f)), 950);
    showToast(`${el.glyph} ${el.name} → ${nm}`);
  };

  // --- House-rule announcement (fired when a rule is suggested or put to a vote) ---
  const [ruleAlert, setRuleAlert] = useState(null);
  const announceRule = (info) => {
    setRuleAlert({ ...info, key: Date.now() });
    showToast(info.kind === 'vote' ? 'A new rule vote is live ⚖' : 'A new house rule was suggested ⚖');
  };

  // --- DM: stage a custom enemy / ally ---
  const addCustomChar = (foe) => {
    setCustomFoes(fs => [...fs, foe]);
    setHpState(s => ({ ...s, [foe.id]: foe.hp }));
    setTokensState(ts => {
      const n = ts.filter(tk => String(tk.ref).startsWith('f')).length;
      return [...ts, { ref: foe.id, x: 11 + (n % 4), y: 6 + (Math.floor(n / 4) % 3) }];
    });
    if (foe.allegiance === 'ally' || foe.reveal === 'visible')
      setRevealed(s => { const z = new Set(s); z.add(foe.id); return z; });
    setShowComposer(false);
    showToast(`${foe.name} staged — ${foe.allegiance === 'ally' ? 'an ally stands ready' : 'a foe waits in the dark'}`);
  };
  const flipAllegiance = (id) => {
    setCustomFoes(fs => fs.map(f => {
      if (f.id !== id) return f;
      const toAlly = f.allegiance !== 'ally';
      return { ...f, allegiance: toAlly ? 'ally' : 'enemy', color: toAlly ? ALLY_RING : ENEMY_RING, reveal: toAlly ? 'visible' : 'manual' };
    }));
    const f = customFoes.find(x => x.id === id);
    if (f && f.allegiance !== 'ally') setRevealed(s => { const z = new Set(s); z.add(id); return z; }); // became ally → visible
    showToast(f && f.allegiance === 'ally' ? 'Turned to a foe — they draw steel' : 'Turned to an ally — they join your side');
  };

  // --- DM: map geofences (trigger zones) ---
  const createGeo = (cell) => {
    const g = { id: 'g' + Date.now(), x: cell.x, y: cell.y, r: 2, ytUrl: '', revealRef: null, narration: '', once: true, armed: false, triggered: false };
    setGeofences(gs => [...gs, g]); setEditingGeo(g.id);
  };
  const editGeo = (id) => setEditingGeo(id);
  const saveGeo = (g) => { setGeofences(gs => gs.map(x => x.id === g.id ? { ...g } : x)); setEditingGeo(null); showToast('Trigger zone armed — hidden from the party'); };
  const deleteGeo = (id) => { setGeofences(gs => gs.filter(x => x.id !== id)); setEditingGeo(null); showToast('Trigger zone removed'); };
  const closeGeo = () => { setGeofences(gs => gs.filter(g => g.armed || g.id !== editingGeo)); setEditingGeo(null); };
  const onGeoEnter = useCallback((geo) => {
    if (geo.ytUrl && ytRef.current && ytRef.current.play) ytRef.current.play(geo.ytUrl, { label: 'Triggered', toast: 'A trigger zone cued music ♪' });
    if (geo.revealRef) setRevealed(s => { const z = new Set(s); z.add(geo.revealRef); return z; });
    if (geo.narration) { logId.current++; setLog(l => [...l, { id: logId.current, kind: 'narrate', who: 'dm', text: geo.narration }]); }
    if (geo.once) setGeofences(gs => gs.map(g => g.id === geo.id ? { ...g, triggered: true, armed: false } : g));
  }, []);

  const tweaksForMap = { ...t, _setInk: (c)=>setTweak('inkColor', c) };

  // Actually seat a player at the local table (pregen string ref or a freshly
  // built custom character object). Pulled out of enter() so the waiting room
  // can resume the exact same seating once a DM's invite is accepted.
  const takeSeat = (payload) => {
    if (typeof payload === 'string') {           // pregen hero
      setActiveRef(payload); setCharRef(payload);
      setPresence(p => p.includes(payload) ? p : [...p, payload]);
      // illustrated pregens have no preset board token — drop one near the party start
      setTokensState(ts => ts.some(x => x.ref === payload) ? ts : [...ts, { ref: payload, x: 4 + (ts.length % 3), y: 13 }]);
    } else {                                     // freshly created character
      const id = 'c' + Date.now();
      const char = { id, ...payload, initial: (payload.char||'?')[0], ping: 30 };
      setCustomChars(c => ({ ...c, [id]: char }));
      setHpState(s => ({ ...s, [id]: char.hp }));
      setPresence(p => [...p, id]);
      // drop a token near the party start
      setTokensState(ts => [...ts, { ref:id, x: 4 + (ts.length%3), y: 13 }]);
      setActiveRef(id); setCharRef(id);
      showToast(`${char.char} joined the table`);
    }
  };

  // Lobby entry router. opts: { code?, name?, dmName? }.
  //  • DM  → open a server presence room under a room code (the shared save
  //          code) so players can find/join, then enter the table.
  //  • Player WITH a code → join the table directly.
  //  • Player WITHOUT a code → land in the waiting room until a DM invites them.
  const enter = (role, payload, opts = {}) => {
    if (role === 'dm') {
      const code = lastCode || genSaveCode();
      setLastCode(code);
      // Best-effort: the local table works regardless of the presence room.
      window.Presence && window.Presence.dmOpen(code, opts.dmName || 'Dungeon Master')
        .then(r => { if (!r.ok && r.reason === 'room_taken') showToast('That room code is already live — using it locally.'); })
        .catch(() => {});
      setEntered(true); setIsDM(true);
      return;
    }
    const code = String(opts.code || '').trim();
    if (code) { setEntered(true); setIsDM(false); takeSeat(payload); return; }
    // No code → wait to be invited.
    setPendingSeat({ payload, name: opts.name || 'Guest' });
  };

  // Player accepted a DM's invite from the waiting room → seat them now.
  const acceptInvite = (roomCode) => {
    const seat = pendingSeat; setPendingSeat(null);
    setEntered(true); setIsDM(false);
    if (roomCode) setLastCode(roomCode);
    if (seat) takeSeat(seat.payload);
  };

  const tabs = [
    { id:'dice', label:'Dice', icon:'⚄' },
    ...(isDM ? [{ id:'powers', label:'Powers', icon:'✦' }] : []),
    { id:'chat', label:'Chat', icon:'❝' },
    { id:'poll', label:'Rules', icon:'⚖' },
    { id:'sheet',label:'Sheet',icon:'📜' },
  ];
  // the Powers tab is DM-only — bounce players back to Dice if they were on it
  useEffect(() => { if (!isDM && rightTab === 'powers') setRightTab('dice'); }, [isDM, rightTab]);

  const takenRefs = presence.filter(r => r!=='dm');
  // Capture the window-global presence components once so a concurrent torn read
  // can't hand React a null component mid-render (no-build window-global pattern).
  const WaitingRoomView = window.WaitingRoom;
  const DmRosterPanel = window.DmWaitingPanel;
  if (!entered) {
    // A player who entered without a room code waits here to be invited.
    if (pendingSeat && WaitingRoomView) {
      return (<>
        <WaitingRoomView name={pendingSeat.name} onJoin={acceptInvite} onCancel={() => setPendingSeat(null)} />
        <TweakDock t={t} setTweak={setTweak} />
      </>);
    }
    return (<><Lobby onEnter={enter} takenRefs={takenRefs} /><TweakDock t={t} setTweak={setTweak} /></>);
  }

  const sheetRefs = isDM ? presence.filter(r => r!=='dm' && !MONSTERS.find(m=>m.id===r)) : [activeRef];

  const openRules = () => { setRightTab('poll'); setRailR(false); };

  return (
    <div className={`app layout-${t.railSide} ${railL?'lc':''} ${railR?'rc':''}`}>
      <TopBar isDM={isDM} setIsDM={setIsDM} maps={allMaps} currentMap={currentMap} setMap={setCurrentMapId}
        presence={presence} kick={kick} getEnt={getEnt} onUpload={()=>fileInputRef.current && fileInputRef.current.click()}
        userStatus={userStatus} setStatus={setStatus} onOpenSave={()=>setSaveOpen(true)}
        onOpenSupport={()=>setSupportOpen(true)} canUpload={canUpload} onLockedUpload={lockedUpload} isAdmin={isAdmin}
        isSupporter={tier==='paid'} activeRef={activeRef} />
      <input ref={fileInputRef} type="file" accept="image/*" style={{display:'none'}} onChange={handleMapFile} />

      {/* DM-only floating roster of players in the waiting room. */}
      {isDM && DmRosterPanel && <DmRosterPanel />}

      <div className="stage">
        {/* LEFT rail */}
        <div className={`rail-left ${railL?'collapsed':''}`}>
          {!railL && (isDM
            ? <EncounterTray monsters={MONS} revealed={revealed} toggleReveal={toggleReveal} getEnt={getEnt}
                onAddChar={()=>setShowComposer(true)} onFlip={flipAllegiance} />
            : <><PlayerCard getEnt={getEnt} activeRef={activeRef} setRightTab={setRightTab} />
                <RuleAlertsPanel polls={polls} queue={queue} onOpen={openRules} /></>)}
        </div>

        {/* MAP */}
        <main className="map-area">
          {/* Rail toggles live on the map edges so they sit outside the panels and never cover panel text */}
          <button className="rail-toggle toggle-l" aria-label={railL?'Expand left panel':'Collapse left panel'}
            aria-expanded={!railL} title={railL?'Expand panel':'Collapse panel'} onClick={()=>setRailL(v=>!v)}>{railL?'»':'«'}</button>
          <button className="rail-toggle toggle-r" aria-label={railR?'Expand right panel':'Collapse right panel'}
            aria-expanded={!railR} title={railR?'Expand panel':'Collapse panel'} onClick={()=>setRailR(v=>!v)}>{railR?'«':'»'}</button>
          <MapCanvas key={currentMapId} isDM={isDM} tweaks={tweaksForMap} round={round}
            tokensState={onTower ? tokensState : tokensState.filter(t=>t.ref!=='m6')}
            setTokensState={setTokensState} addRoll={addRoll} activeRef={activeRef}
            currentMap={currentMap} revealed={revealed} setRevealed={setRevealed} getEnt={getEnt}
            monsters={MONS} geofences={geofences} onCreateGeo={createGeo} onEditGeo={editGeo} onGeoEnter={onGeoEnter} />
        </main>

        {/* RIGHT rail */}
        <div className={`rail-right leather ${railR?'collapsed':''}`}>
          {!railR && <>
          <PartyPanel isDM={isDM} getEnt={getEnt} setHp={setHp} order={order} turnIdx={safeTurn} round={round}
            onEndTurn={endTurn} onPrev={prevTurn} activeRef={activeRef} visibleMonsters={revealed} monsters={MONS}
            onRollInit={rollAllInit} onRollOwn={rollInitFor} justRolled={justRolled} conditions={conditions} />
          <div className="rail-tabs">
            {tabs.map(tb=>(
              <button key={tb.id} className={`rt ${rightTab===tb.id?'on':''}`} aria-pressed={rightTab===tb.id} onClick={()=>setRightTab(tb.id)}>
                <span className="rt-icon" aria-hidden="true">{tb.icon}</span>{tb.label}
              </button>
            ))}
          </div>
          <div className="rail-content parchment">
            {rightTab==='dice'  && <DicePanel tweaks={t} addRoll={addRoll} activeRef={isDM?'dm':activeRef} />}
            {rightTab==='powers' && isDM && <ElementsPanel targets={order} getEnt={getEnt} onCast={castElement} conditions={conditions} onClearCond={clearCondition} castFx={castFx} />}
            {rightTab==='chat'  && <ChatPanel log={log} isDM={isDM} activeRef={activeRef} onSend={sendMsg} />}
            {rightTab==='poll'  && <PollPanel polls={polls} setPolls={setPolls} queue={queue} setQueue={setQueue} activeRef={isDM?'dm':activeRef} isDM={isDM} onAnnounce={announceRule} />}
            {rightTab==='sheet' && <SheetPanel getEnt={getEnt} setHp={setHp} addRoll={addRoll} charRef={charRef} setCharRef={setCharRef} isDM={isDM} syncToast={showToast} rollInitiative={rollInitFor} sheetRefs={sheetRefs} editChar={editChar} canEditChar={canEditChar} canUpload={canUpload} onLockedUpload={lockedUpload} />}
          </div>
          </>}
        </div>
      </div>

      {ruleAlert && (
        <div className="rule-alert" role="alert">
          <span className="ra-bell" aria-hidden="true">⚖</span>
          <div className="ra-body">
            <b>New {ruleAlert.kind==='vote' ? 'rule vote' : 'house-rule suggestion'}</b>
            <span className="ra-text">“{ruleAlert.text}” — {getEnt(ruleAlert.by).name}</span>
          </div>
          <button className="ra-go" onClick={()=>{ openRules(); setRuleAlert(null); }}>Open Rules ›</button>
          <button className="ra-x" aria-label="Dismiss announcement" onClick={()=>setRuleAlert(null)}>✕</button>
        </div>
      )}
      <div className="toast-region" role="status" aria-live="polite" aria-atomic="true">
        {toast && <div className="toast leather stitched">{toast}</div>}
      </div>
      <ErrorBoundary>
        <YouTubeBar isDM={isDM} style={t.ytStyle} controllerRef={ytRef} onToast={showToast} />
      </ErrorBoundary>
      {!isDM && userStatus[activeRef]==='timeout' && <TimeoutScreen controllerRef={ytRef} />}
      {!isDM && userStatus[activeRef]==='waiting' && <InGameWaitingRoom controllerRef={ytRef} />}
      {showComposer && <CharacterComposer onAdd={addCustomChar} onClose={()=>setShowComposer(false)} canUpload={canUpload} onLocked={lockedUpload} />}
      {editingGeo && (() => {
        const g = geofences.find(x=>x.id===editingGeo); if(!g) return null;
        return <GeofenceConfig geo={g} foes={[...customFoes, ...MONSTERS.filter(m=>!m.trap)]}
          onSave={saveGeo} onDelete={()=>deleteGeo(g.id)} onClose={closeGeo} />;
      })()}
      {saveOpen && <SaveLoadModal lastCode={lastCode} onSave={saveGame} onLoad={loadGame} onDelete={deleteGame} onClose={()=>setSaveOpen(false)} />}
      {supportOpen && <SupporterModal tier={tier} isAdmin={isAdmin} supporter={supporter} onRedeem={redeemCode} onAdminLogin={adminLogin} onAdminLogout={adminLogout} onBootstrap={adminBootstrap} onClose={()=>setSupportOpen(false)} />}
      <TweakDock t={t} setTweak={setTweak} />
    </div>
  );
}

/* ---------- Tweaks panel ---------- */
function TweakDock({ t, setTweak }) {
  return (
    <TweaksPanel>
      <TweakSection label="Look & feel" />
      <TweakRadio label="Theme" value={t.theme} options={['sky','arcane','ember']} onChange={v=>setTweak('theme',v)} />
      <TweakRadio label="Sprites" value={t.spriteStyle} options={['disc','pixel','mini']} onChange={v=>setTweak('spriteStyle',v)} />
      <TweakToggle label="Show grid" value={t.showGrid} onChange={v=>setTweak('showGrid',v)} />
      <TweakRadio label="Panels" value={t.railSide} options={['right','left']} onChange={v=>setTweak('railSide',v)} />
      <TweakSection label="Fog of war" />
      <TweakRadio label="Reveal" value={t.fogMode} options={['sight','manual','open']} onChange={v=>setTweak('fogMode',v)} />
      <TweakSection label="Dice" />
      <TweakRadio label="Style" value={t.diceMode} options={['quick','builder','sets']} onChange={v=>setTweak('diceMode',v)} />
      <TweakToggle label="Tumble animation" value={t.diceAnimate} onChange={v=>setTweak('diceAnimate',v)} />
      <TweakSection label="DM tools" />
      <TweakRadio label="Music bar" value={t.ytStyle} options={['bar','pill','tape']} onChange={v=>setTweak('ytStyle',v)} />
      <TweakRadio label="Trigger zone" value={t.geoStyle} options={['ring','rune','zone']} onChange={v=>setTweak('geoStyle',v)} />
    </TweaksPanel>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
