/* app.jsx — shell: campaign/session sidebar, step rail, prep/run modes,
   theme application, and the Tweaks panel. Depends on all other window globals. */

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

const TWEAK_DEFAULTS = {
  "dark": false,
  "accent": "oxblood",
  "font": "storybook",
  "density": "cozy",
  "layout": "checklist",
  "runLayout": "focus",
  "flourish": true,
  "apiKey": "",
  "model": "claude-haiku-4-5-20251001",
  "openaiApiKey": "",
};

const ACCENT_CHIPS = [
  ["#9a352e"], ["#4a7349"], ["#b07d22"], ["#7e54c8"], ["#2c7c8c"]
];

const ACCENT_NAMES = { "#9a352e": "oxblood", "#4a7349": "forest", "#b07d22": "gold", "#7e54c8": "violet", "#2c7c8c": "teal" };
const NAME_TO_HEX = { oxblood: "#9a352e", forest: "#4a7349", gold: "#b07d22", violet: "#7e54c8", teal: "#2c7c8c" };

const PREP_NOTES_TAB = {
  id: "prepNotes",
  short: "Prep notes",
  icon: "feather",
  synthetic: true,
  name: "prep notes",
  tagline: "Freeform space for ideas, research, and anything that doesn't fit the eight steps. Visible in both prep and run modes."
};

function stepHasContent(items) {
  return Array.isArray(items) && items.some((it) =>
    (it.title && it.title.trim()) || (it.body && it.body.trim()) || (Array.isArray(it.aspects) && it.aspects.length)
  );
}

// ── Campaign / session sidebar ───────────────────────────────────────────────
function Sidebar({ camp, session, onSelectSession, onAddSession, onRenameCampaign, onRenameBlurb, onEditCampaign, onRenameSession, onDeleteSession, onReorderSessions, open, onClose, onCollapse, onCampaigns, onTweaks, onExport, onImport }) {
  // Unified pointer-based drag (mouse + touch) — see steps.jsx for rationale.
  // Mouse: press the row and drag. Touch: long-press a row, then drag.
  const [dragFrom, setDragFrom] = React.useState(null);
  const [dragOver, setDragOver] = React.useState(null);
  const [dragging, setDragging] = React.useState(false);
  const dragFromRef = React.useRef(null);
  const dragOverRef = React.useRef(null);
  const ulRef = React.useRef(null);
  const pending = React.useRef(null);
  const longPressTimer = React.useRef(null);
  const draggedRef = React.useRef(false); // suppress the click that follows a drag

  const sessions = camp.sessions;

  const hoverOver = (i) => { if (i == null) return; dragOverRef.current = i; setDragOver(i); };

  const beginDrag = (i) => {
    draggedRef.current = true;
    dragFromRef.current = i; dragOverRef.current = i;
    setDragFrom(i); setDragOver(i); setDragging(true);
    if (navigator.vibrate) navigator.vibrate(40);
  };

  const cancelDrag = () => {
    clearTimeout(longPressTimer.current);
    pending.current = null;
    dragFromRef.current = null; dragOverRef.current = null;
    setDragFrom(null); setDragOver(null); setDragging(false);
  };

  const commitDrop = () => {
    const from = dragFromRef.current;
    const to = dragOverRef.current;
    cancelDrag();
    if (from === null || to === null || from === to) return;
    const next = sessions.slice();
    const [moved] = next.splice(from, 1);
    next.splice(to, 0, moved);
    onReorderSessions(next.map((s, idx) => ({ ...s, number: idx + 1 })));
  };

  const findSessionAtY = (y) => {
    const children = ulRef.current ? Array.from(ulRef.current.children) : [];
    for (let ci = 0; ci < children.length; ci++) {
      const rect = children[ci].getBoundingClientRect();
      if (y >= rect.top && y <= rect.bottom) return ci;
    }
    if (children.length) {
      if (y < children[0].getBoundingClientRect().top) return 0;
      return children.length - 1;
    }
    return null;
  };

  const visualSessions = React.useMemo(() => {
    if (dragFrom === null || dragOver === null || dragFrom === dragOver) return sessions;
    const next = sessions.slice();
    const [moved] = next.splice(dragFrom, 1);
    next.splice(dragOver, 0, moved);
    return next;
  }, [sessions, dragFrom, dragOver]);

  React.useEffect(() => {
    if (!dragging) return;
    const onMove = (e) => { e.preventDefault(); hoverOver(findSessionAtY(e.clientY)); };
    const onUp = () => commitDrop();
    const onCancel = () => cancelDrag();
    const blockScroll = (e) => e.preventDefault();
    window.addEventListener('pointermove', onMove, { passive: false });
    window.addEventListener('pointerup', onUp);
    window.addEventListener('pointercancel', onCancel);
    document.addEventListener('touchmove', blockScroll, { passive: false });
    document.body.style.userSelect = 'none';
    return () => {
      window.removeEventListener('pointermove', onMove);
      window.removeEventListener('pointerup', onUp);
      window.removeEventListener('pointercancel', onCancel);
      document.removeEventListener('touchmove', blockScroll);
      document.body.style.userSelect = '';
    };
  }, [dragging]);

  const onRowPointerDown = (e, i) => {
    draggedRef.current = false;
    if (e.pointerType === 'mouse') {
      if (e.button !== 0) return;
      pending.current = { index: i, startX: e.clientX, startY: e.clientY, mouse: true };
      return;
    }
    pending.current = { index: i, startX: e.clientX, startY: e.clientY, mouse: false };
    longPressTimer.current = setTimeout(() => {
      if (!pending.current) return;
      beginDrag(pending.current.index);
      pending.current = null;
    }, 350);
  };

  const onRowPointerMove = (e) => {
    const p = pending.current;
    if (dragging || !p) return;
    const moved = Math.abs(e.clientX - p.startX) > 6 || Math.abs(e.clientY - p.startY) > 6;
    if (!moved) return;
    if (p.mouse) { beginDrag(p.index); pending.current = null; }
    else { clearTimeout(longPressTimer.current); pending.current = null; }
  };

  const onRowPointerUp = () => {
    clearTimeout(longPressTimer.current);
    pending.current = null;
  };
  return (
    <>
      <div className={`scrim-side${open ? " show" : ""}`} onClick={onClose} />
      <aside className={`side${open ? " open" : ""}`}>
        <div className="side-brand">
          <window.Icon name="flame" size={22} className="brand-flame" />
          <div className="side-brand-tt">
            <span className="brand-name">Emberwright</span>
          </div>
          <button type="button" className="side-collapse-btn" onClick={onCollapse} title="Collapse sidebar" aria-label="Collapse sidebar">
            <window.Icon name="back" size={16} />
          </button>
        </div>

        <window.Flourish w={180} />

        <div className="side-camp">
          <div className="side-camp-hd">
            <button type="button" className="side-camp-label-btn" onClick={() => { onClose(); onCampaigns(); }} title="Switch campaign">
              <span className="side-label">Campaign</span>
              <window.Icon name="scroll" size={11} className="side-camp-switch-icon" />
            </button>
            <window.IconButton name="pen" title="Edit campaign details" onClick={onEditCampaign} />
          </div>
          <window.EditableArea value={camp.name} minRows={1} className="camp-name" onChange={onRenameCampaign} />
          <window.EditableArea value={camp.blurb} minRows={1} className="camp-blurb" placeholder="A one-line description of the campaign…" onChange={onRenameBlurb} />
        </div>

        <div className="side-sessions">
          <div className="side-sessions-hd">
            <span className="side-label">Sessions</span>
            <window.IconButton name="plus" title="New session" onClick={onAddSession} />
          </div>
          <ul ref={ulRef}>
            {visualSessions.map((s, visIdx) =>
              <li key={s.id}
                className={`${s.id === session.id ? "active" : ""}${dragging && s.id === sessions[dragFrom]?.id ? " dragging-session" : ""}`}>
                <button type="button" className="session-pick"
                  onPointerDown={(e) => onRowPointerDown(e, visIdx)}
                  onPointerMove={onRowPointerMove}
                  onPointerUp={onRowPointerUp}
                  onClick={() => { if (draggedRef.current) { draggedRef.current = false; return; } onSelectSession(s.id); }}>
                  <span className="session-no">#{s.number}</span>
                  <span className="session-nm">{s.name}</span>
                </button>
                {camp.sessions.length > 1 &&
                  <window.ConfirmDelete onConfirm={() => onDeleteSession(s.id)} label="Delete session" />
                }
              </li>
            )}
          </ul>
        </div>

        <div className="side-foot">
          <button type="button" className="side-export-btn" title="Import" onClick={() => { onClose(); onImport(); }}>
            <window.Icon name="import" size={15} />
          </button>
          <button type="button" className="side-export-btn" title="Export" onClick={() => { onClose(); onExport(); }}>
            <window.Icon name="export" size={15} />
          </button>
          <div className="side-account">
            <window.AuthControls menuUp={true} />
            <button type="button" className="side-tweaks-btn" onClick={() => { onClose(); onTweaks(); }}>
              <window.Icon name="settings" size={15} /> <span className="settings-label">Settings</span>
            </button>
          </div>
        </div>
      </aside>
    </>
  );
}

// ── Campaign details modal ───────────────────────────────────────────────────
function autoResizeTextarea(el) {
  if (!el) return;
  el.style.height = "auto";
  el.style.height = el.scrollHeight + "px";
}

function CampaignModal({ camp, onChange, onClose }) {
  const blurbRef = useRef(null);
  const toneRef = useRef(null);

  useEffect(() => {
    autoResizeTextarea(blurbRef.current);
    autoResizeTextarea(toneRef.current);
  }, [camp.blurb, camp.tone]);

  return (
    <window.Modal title="Campaign details" onClose={onClose} width={540}>
      <div className="camp-form">
        <label className="cf-row">
          <span className="cf-label">Name</span>
          <input className="cf-input" value={camp.name} onChange={(e) => onChange({ name: e.target.value })} />
        </label>
        <label className="cf-row">
          <span className="cf-label">Description</span>
          <textarea ref={blurbRef} className="cf-area" value={camp.blurb || ""}
            placeholder="A one-line pitch for the campaign."
            onChange={(e) => { onChange({ blurb: e.target.value }); autoResizeTextarea(e.target); }} />
        </label>
        <label className="cf-row">
          <span className="cf-label">Tone &amp; themes</span>
          <textarea ref={toneRef} className="cf-area" value={camp.tone || ""}
            placeholder="e.g. gothic and waterlogged; bargains, forgotten names, bittersweet wins. The mood you want every scene to carry."
            onChange={(e) => { onChange({ tone: e.target.value }); autoResizeTextarea(e.target); }} />
          <span className="cf-hint">Carried into every AI suggestion and rewrite across the campaign.</span>
        </label>
      </div>
    </window.Modal>
  );
}

// ── Step rail (prep navigation) — simple tabs ────────────────────────────────
function StepRail({ tabs, data, activeStep, onPick }) {
  return (
    <nav className="rail">
      {tabs.map((s) => {
        const isActive = s.id === activeStep;
        const filled = !s.synthetic && stepHasContent(data[s.id]);
        return (
          <button type="button" key={s.id} className={`rail-tab${isActive ? " active" : ""}`}
            onClick={() => onPick(s.id)}>
            <window.Icon name={s.icon} size={17} className="rail-icon" />
            <span className="rail-name">{s.short}</span>
            {filled && <span className="rail-dot" title="Has content" />}
          </button>
        );
      })}
    </nav>
  );
}

// ── Step switcher (mobile prep navigation) — dropdown jump sheet ─────────────
function StepSwitcher({ tabs, data, activeStep, onPick }) {
  const [open, setOpen] = React.useState(false);
  const idx = Math.max(0, tabs.findIndex((s) => s.id === activeStep));
  const cur = tabs[idx] || tabs[0];
  const pick = (id) => { onPick(id); setOpen(false); };

  React.useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open]);

  return (
    <nav className="switcher">
      <div className="switcher-bar">
        <button type="button" className="sw-current" onClick={() => setOpen(o => !o)} aria-haspopup="true" aria-expanded={open}>
          <window.Icon name={cur.icon} size={18} className="sw-cur-icon" />
          <span className="sw-cur-name">{cur.short}</span>
          <window.Icon name="chevron" size={14} className={`sw-cur-chev${open ? " open" : ""}`} />
        </button>
      </div>

      {open &&
        <div className="sheet-scrim" onMouseDown={() => setOpen(false)}>
          <div className="sheet" onMouseDown={(e) => e.stopPropagation()} role="menu">
            <div className="sheet-grab" />
            <ul className="sheet-list">
              {tabs.map((s, i) => {
                const filled = !s.synthetic && stepHasContent(data[s.id]);
                return (
                  <li key={s.id}>
                    <button type="button" className={`sheet-item${s.id === activeStep ? " active" : ""}`} onClick={() => pick(s.id)} role="menuitem">
                      <window.Icon name={s.icon} size={18} className="sheet-icon" />
                      <span className="sheet-name">{s.short}</span>
                      {filled && <span className="sheet-check"><window.Icon name="check" size={14} sw={2.6} /></span>}
                    </button>
                  </li>
                );
              })}
            </ul>
          </div>
        </div>
      }
    </nav>
  );
}

// ── Notes panes ───────────────────────────────────────────────────────────────
function PrepNotesImportModal({ sessions, sessionId, onImport, onClose }) {
  const others = sessions.filter((s) => s.id !== sessionId);
  const [fromId, setFromId] = React.useState(others[others.length - 1]?.id || "");
  const [selected, setSelected] = React.useState(new Set());

  const fromSess = others.find((s) => s.id === fromId);
  const candidates = (fromSess?.prepNotes || []).filter(
    (it) => it.title?.trim() || it.body?.trim()
  );

  React.useEffect(() => setSelected(new Set()), [fromId]);

  const toggle = (id) => setSelected((prev) => {
    const next = new Set(prev);
    if (next.has(id)) next.delete(id); else next.add(id);
    return next;
  });

  const allSelected = candidates.length > 0 && candidates.every((it) => selected.has(it.id));
  const toggleAll = () => setSelected(allSelected ? new Set() : new Set(candidates.map((it) => it.id)));

  const doImport = () => {
    const toAdd = candidates
      .filter((it) => selected.has(it.id))
      .map((it) => ({ ...it, id: window.RPG.uid() }));
    onImport(toAdd);
    onClose();
  };

  return (
    <window.Modal title="Import prep notes" onClose={onClose} width={500}>
      <div className="import-modal">
        <div className="import-from">
          <label className="import-from-label">From session</label>
          <select className="import-select" value={fromId} onChange={(e) => setFromId(e.target.value)}>
            {others.map((s) => (
              <option key={s.id} value={s.id}>#{s.number} — {s.name}</option>
            ))}
          </select>
        </div>

        {candidates.length === 0 ? (
          <p className="import-empty">No prep notes in that session.</p>
        ) : (
          <>
            <div className="import-list-hd">
              <span className="import-list-hd-count">{candidates.length} section{candidates.length !== 1 ? "s" : ""}</span>
              <button type="button" className="import-selall" onClick={toggleAll}>
                {allSelected ? "Deselect all" : "Select all"}
              </button>
            </div>
            <div className="import-list">
              {candidates.map((it) => (
                <label key={it.id} className={`import-row${selected.has(it.id) ? " sel" : ""}`}>
                  <input type="checkbox" checked={selected.has(it.id)} onChange={() => toggle(it.id)} />
                  <span className="import-row-main">
                    <span className="import-row-name">{it.title || it.body || "(untitled)"}</span>
                  </span>
                </label>
              ))}
            </div>
          </>
        )}

        <div className="import-footer">
          <button type="button" className="btn btn-sm" onClick={onClose}>Cancel</button>
          <button type="button" className="btn btn-sm btn-accent"
            disabled={selected.size === 0} onClick={doImport}>
            Import {selected.size > 0 ? selected.size : ""} selected
          </button>
        </div>
      </div>
    </window.Modal>
  );
}

function PrepNotesPane({ session, onChange, ctx, sessions, sessionId }) {
  const [importOpen, setImportOpen] = React.useState(false);
  const canImport = (sessions || []).filter((s) => s.id !== sessionId).length > 0;

  return (
    <div className="step-pane">
      <div className="step-head">
        <div className="step-head-l">
          <span className="step-eyebrow">Prep &amp; run</span>
          <h2 className="step-title">prep notes</h2>
          <p className="step-tagline">Ideas, research threads, and anything that doesn't fit the eight steps. Visible in both prep and run modes.</p>
        </div>
        {canImport && (
          <div className="step-head-r">
            <button type="button" className="import-btn" onClick={() => setImportOpen(true)}>
              <window.Icon name="import" size={15} /> Import
            </button>
          </div>
        )}
      </div>
      {importOpen && (
        <PrepNotesImportModal
          sessions={sessions}
          sessionId={sessionId}
          onImport={(toAdd) => onChange((prev) => [...(prev || []), ...toAdd])}
          onClose={() => setImportOpen(false)} />
      )}
      <window.NotesSections
        sections={session.prepNotes}
        onChange={onChange}
        ctx={ctx}
        bodyPlaceholder="Inspiration, lore you're developing, alternate scene ideas, player backstory hooks, things to research…" />
    </div>
  );
}

function SessionNotesPane({ session, onChange }) {
  return (
    <div className="notes-pane notes-run">
      <window.EditableArea value={session.notes} minRows={4}
        placeholder="Jot what happened, dropped clues, dangling threads, what to open with next time…"
        onChange={onChange} className="run-notes" />
    </div>
  );
}

// ── Top bar ──────────────────────────────────────────────────────────────────
function TopBar({ session, mode, setMode, onRenameSession, onMenu, onTweaks }) {
  return (
    <header className="topbar">
      <button type="button" className="menu-btn" onClick={onMenu} aria-label="Menu">
        <window.Icon name="dots" size={20} />
      </button>
      <div className="topbar-title">
        <span className="topbar-eyebrow">Session {session.number}</span>
        <window.EditableLine value={session.name} big wrap className="session-title" onChange={onRenameSession} />
      </div>
      <div className="topbar-right">
        <div className="mode-toggle">
          <button type="button" className={mode === "prep" ? "on" : ""} onClick={() => setMode(m => m === "prep" ? "run" : "prep")}>
            <window.Icon name="pen" size={15} /><span className="mode-label">Prep</span>
          </button>
          <button type="button" className={mode === "run" ? "on" : ""} onClick={() => setMode(m => m === "prep" ? "run" : "prep")}>
            <window.Icon name="play" size={15} /><span className="mode-label">Run</span>
          </button>
        </div>
        <div className="topbar-account">
          <window.AuthControls />
          <window.IconButton name="settings" title="Tweaks" onClick={onTweaks} size={18} />
        </div>
      </div>
    </header>
  );
}

// ── Campaigns management screen ──────────────────────────────────────────────
function CampaignsScreen({ state, setState, onOpen, onClose, onImport }) {
  const [editing, setEditing] = useState(null);
  const [modalEditingId, setModalEditingId] = useState(null);
  const [confirmDelete, setConfirmDelete] = useState(null);

  const addCampaign = () => {
    const blank = window.RPG.seedCampaign();
    blank.name = "New Campaign";
    blank.blurb = "";
    blank.tone = "";
    blank.sessions = [window.RPG.blankSession(1)];
    blank.id = window.RPG.uid();
    setState((st) => ({ ...st, campaigns: [...st.campaigns, blank], activeCampaign: blank.id, activeSession: blank.sessions[0].id }));
    setEditing(blank.id);
  };

  const deleteCampaign = (id) => {
    const remaining = state.campaigns.filter((c) => c.id !== id);
    if (remaining.length === 0) return;
    setState((st) => ({
      ...st,
      campaigns: remaining,
      activeCampaign: st.activeCampaign === id ? remaining[0].id : st.activeCampaign,
      activeSession: st.activeCampaign === id ? remaining[0].sessions[0].id : st.activeSession,
    }));
    setConfirmDelete(null);
  };

  const patchCamp = (id, changes) =>
    setState((st) => ({ ...st, campaigns: st.campaigns.map((c) => c.id === id ? { ...c, ...changes } : c) }));

  const stepsDone = (camp) => {
    const steps = window.RPG.STEP_DEFS;
    const latest = camp.sessions[camp.sessions.length - 1];
    if (!latest) return 0;
    return steps.filter((s) => (latest.data[s.id] || []).some((it) => it.body || it.title)).length;
  };

  return (
    <div className="camp-screen">
      <div className="camp-screen-hd">
        <div>
          <h1 className="camp-screen-title">Campaigns</h1>
          <p className="camp-screen-sub">Choose a campaign to prep, or start a new one.</p>
        </div>
        <div style={{ display: "flex", gap: 10 }}>
          <button type="button" className="btn" onClick={onImport}>
            <window.Icon name="import" size={16} /> Import
          </button>
          <button type="button" className="btn btn-accent" onClick={addCampaign}>
            <window.Icon name="plus" size={16} sw={2} /> New campaign
          </button>
        </div>
      </div>

      <div className="camp-grid">
        {state.campaigns.map((c) => {
          const isActive = c.id === state.activeCampaign;
          const isEditing = editing === c.id;
          const done = stepsDone(c);
          return (
            <div key={c.id} className={`camp-card${isActive ? " current" : ""}`}>
              <div className="camp-card-hd">
                {isEditing ? (
                  <input className="camp-card-name-input" autoFocus value={c.name}
                    onChange={(e) => patchCamp(c.id, { name: e.target.value })}
                    onBlur={() => setEditing(null)}
                    onKeyDown={(e) => { if (e.key === "Enter") setEditing(null); }} />
                ) : (
                  <h2 className="camp-card-name" onClick={() => setEditing(c.id)}>{c.name || "Unnamed campaign"}</h2>
                )}
                {isActive && <span className="camp-badge">Active</span>}
              </div>
              <p className="camp-card-blurb">{c.blurb || <span className="run-muted">No description yet.</span>}</p>
              {c.tone && <p className="camp-card-tone"><window.Icon name="feather" size={13} /> {c.tone}</p>}
              <div className="camp-card-stats">
                <span><window.Icon name="scroll" size={14} /> {c.sessions.length} session{c.sessions.length !== 1 ? "s" : ""}</span>
                <span><window.Icon name="check" size={14} sw={2.4} /> {done}/8 last prep</span>
              </div>
              <div className="camp-card-actions">
                <button type="button" className="btn btn-accent btn-sm" onClick={() => onOpen(c.id)}>
                  <window.Icon name="play" size={14} /> Open
                </button>
                <button type="button" className="btn btn-sm" onClick={() => setModalEditingId(c.id)}>
                  <window.Icon name="pen" size={14} /> Edit
                </button>
                {state.campaigns.length > 1 && (
                  <button type="button" className="btn btn-sm btn-ghost-danger"
                    onClick={() => setConfirmDelete(c.id)}>
                    <window.Icon name="trash" size={14} /> Delete
                  </button>
                )}
              </div>
            </div>
          );
        })}
      </div>

      {confirmDelete && (
        <window.Modal title="Delete campaign?" onClose={() => setConfirmDelete(null)} width={400}>
          <div style={{ display: "flex", flexDirection: "column", gap: 18, padding: "4px 0" }}>
            <p style={{ fontSize: 15, color: "var(--ink-soft)" }}>This will permanently delete the campaign and all its sessions. This cannot be undone.</p>
            <div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
              <button type="button" className="btn btn-sm" onClick={() => setConfirmDelete(null)}>Cancel</button>
              <button type="button" className="btn btn-sm btn-accent" onClick={() => deleteCampaign(confirmDelete)}>Delete</button>
            </div>
          </div>
        </window.Modal>
      )}
      {modalEditingId && (() => {
        const mc = state.campaigns.find(c => c.id === modalEditingId);
        return mc ? <CampaignModal camp={mc} onChange={(changes) => patchCamp(modalEditingId, changes)} onClose={() => setModalEditingId(null)} /> : null;
      })()}
    </div>
  );
}

function App() {
  const [t, setTweak] = window.useTweaks(TWEAK_DEFAULTS);
  const [state, setState] = useState(null); // null until hydrated from IndexedDB
  const [mode, setMode] = useState("prep");
  const [view, setView] = useState("session");
  const [activeStep, setActiveStep] = useState("characters");
  const [sideOpen, setSideOpen] = useState(false);
  const [sideCollapsed, setSideCollapsed] = useState(false);
  const [highlightedCard, setHighlightedCard] = useState(null);
  const [campModal, setCampModal] = useState(false);
  const [exportOpen, setExportOpen] = useState(false);
  const [importOpen, setImportOpen] = useState(false);
  const boardRefs = useRef({});
  const isFirstLoad = useRef(true);

  // Hydrate from IndexedDB, then let the sync engine drive live updates.
  //  - replaceTree: full swap (initial reconcile / login / logout)
  //  - applyRow / removeRow: granular Realtime patches from other devices
  // All of these go through setState, so the persistence effect below mirrors
  // them to IDB and lets the sync engine reconcile — no echo, because the engine
  // updates its shadow before invoking these callbacks.
  useEffect(() => {
    let cancelled = false;
    window.RPGDB.loadState().then((s) => { if (!cancelled) setState(s); });
    if (window.RPGSync) window.RPGSync.onRemote({
      replaceTree: (tree) => { if (!cancelled) setState(tree); },
      applyRow: (table, row) => { if (!cancelled) setState((prev) => prev ? window.RPGSync.applyRowToTree(prev, table, row) : prev); },
      removeRow: (table, id) => { if (!cancelled) setState((prev) => prev ? window.RPGSync.removeRowFromTree(prev, table, id) : prev); },
    });
    return () => { cancelled = true; };
  }, []);

  // Auto-save: every change is mirrored to IndexedDB and handed to the sync
  // engine, which debounces and pushes only the rows that actually changed.
  useEffect(() => {
    if (!state) return;
    if (isFirstLoad.current) { isFirstLoad.current = false; return; }
    window.RPGDB.saveState(state);
    if (window.RPGSync) window.RPGSync.localChanged(state);
  }, [state]);

  const camp = state ? (state.campaigns.find((c) => c.id === state.activeCampaign) || state.campaigns[0]) : null;
  const session = camp ? (camp.sessions.find((s) => s.id === state.activeSession) || camp.sessions[0]) : null;
  const steps = window.RPG.STEP_DEFS;

  const cardIndex = useMemo(
    () => window.RPG.buildCardIndex(session ? session.data : {}),
    [session]
  );

  const themeVars = useMemo(() => {
    const accent = typeof t.accent === "object" ? t.accent[0] : NAME_TO_HEX[t.accent] || t.accent;
    return window.RPGTheme.buildTheme({ ...t, accent });
  }, [t]);

  useEffect(() => {
    document.body.style.background = themeVars["--bg"];
    document.body.style.colorScheme = themeVars.colorScheme;
  }, [themeVars]);

  useEffect(() => {
    const tb = document.querySelector(".topbar");
    if (!tb) return;
    const set = () => {
      const h = tb.offsetHeight + "px";
      document.documentElement.style.setProperty("--topbar-h", h);
      const app = tb.closest(".app");
      if (app) app.style.setProperty("--topbar-h", h);
    };
    set();
    let ro;
    if (typeof ResizeObserver !== "undefined") { ro = new ResizeObserver(set); ro.observe(tb); }
    window.addEventListener("resize", set);
    return () => { if (ro) ro.disconnect(); window.removeEventListener("resize", set); };
  }, [mode, t.density, t.font, session && session.name]);

  // updateCamp/updateSession accept either a plain object/value or an updater
  // function. The functional form derives from the freshest state inside setState,
  // which is what prevents stale-closure clobbering when edits fire faster than
  // React commits (lost/scrambled text, sections resetting to empty).
  const updateCamp = (changes) => setState((st) => ({
    ...st, campaigns: st.campaigns.map((c) =>
      c.id === camp.id ? { ...c, ...(typeof changes === "function" ? changes(c) : changes) } : c)
  }));
  const updateSession = (next) => updateCamp((c) => ({
    sessions: c.sessions.map((s) => {
      if (typeof next === "function") return s.id === session.id ? next(s) : s;
      return s.id === next.id ? next : s;
    })
  }));
  const setStepItems = (stepId, items) =>
    updateSession((sess) => ({
      ...sess,
      data: { ...sess.data, [stepId]: typeof items === "function" ? items(sess.data[stepId]) : items },
    }));

  const addSession = () => {
    const num = Math.max(0, ...camp.sessions.map((s) => s.number)) + 1;
    const ns = window.RPG.blankSession(num);
    updateCamp({ sessions: [...camp.sessions, ns] });
    setState((st) => ({ ...st, activeSession: ns.id }));
    setMode("prep"); setActiveStep("characters"); setSideOpen(false);
  };
  const deleteSession = (id) => {
    const remaining = camp.sessions.filter((s) => s.id !== id);
    const renumbered = remaining.map((s, i) => ({ ...s, number: i + 1 }));
    updateCamp({ sessions: renumbered });
    if (session.id === id) setState((st) => ({ ...st, activeSession: renumbered[0].id }));
  };
  const reorderSessions = (reordered) => updateCamp({ sessions: reordered });
  const selectSession = (id) => { setState((st) => ({ ...st, activeSession: id })); setSideOpen(false); };

  const resetDemo = async () => {
    if (!window.confirm("Reset all data back to the sample campaign? This erases your sessions.")) return;
    await window.RPGDB.clearState();
    if (window.RPGSync) await window.RPGSync.clearCloud();
    const fresh = await window.RPGDB.loadState();
    await window.RPGDB.saveState(fresh);
    setState(fresh); // auto-save effect pushes the fresh seed to the (now-cleared) cloud
    setMode("prep"); setActiveStep("characters");
  };

  const openTweaks = () => window.postMessage({ type: '__activate_edit_mode' }, '*');

  const ctx = session
    ? { campaign: camp.name, blurb: camp.blurb, tone: camp.tone, session: session.name, pcs: session.data.characters || [] }
    : {};
  const tabs = [...steps, PREP_NOTES_TAB];
  const isNotes = activeStep === "prepNotes";
  const onNotes = (v) => updateSession((sess) => ({
    ...sess, prepNotes: typeof v === "function" ? v(sess.prepNotes) : v,
  }));

  const toggleSidebar = () => {
    const side = document.querySelector(".side");
    const drawer = side && getComputedStyle(side).position === "fixed";
    if (drawer) setSideOpen((o) => !o); else setSideCollapsed((c) => !c);
  };

  useEffect(() => {
    if (t.layout !== "board") return;
    const io = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        if (!entry.isIntersecting) continue;
        const id = Object.keys(boardRefs.current).find((k) => boardRefs.current[k] === entry.target);
        if (id) setActiveStep(id);
        break;
      }
    }, { threshold: 0.3 });
    Object.values(boardRefs.current).forEach((el) => el && io.observe(el));
    return () => io.disconnect();
  }, [t.layout, session && session.id]);

  const pickStep = (id) => {
    setActiveStep(id);
    if (t.layout === "board") {
      const el = boardRefs.current[id];
      if (!el) return;
      // Find the real scroll container: it's the window normally, but the .app
      // itself in the device-frame preview (see .stage-on .app overflow-y in CSS).
      // offsetTop is unreliable across that boundary, so measure live rects.
      let sc = el.parentElement;
      while (sc) {
        const oy = getComputedStyle(sc).overflowY;
        if ((oy === "auto" || oy === "scroll") && sc.scrollHeight > sc.clientHeight + 1) break;
        sc = sc.parentElement;
      }
      const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const behavior = reduce ? "auto" : "smooth";
      if (sc) {
        const top = el.getBoundingClientRect().top - sc.getBoundingClientRect().top + sc.scrollTop - 80;
        sc.scrollTo({ top: Math.max(0, top), behavior });
      } else {
        window.scrollTo({ top: Math.max(0, el.getBoundingClientRect().top + window.scrollY - 80), behavior });
      }
    }
  };

  const navigateToCard = (title) => {
    const entry = cardIndex.get(title.toLowerCase().trim());
    if (!entry) return;
    pickStep(entry.stepId);
    setHighlightedCard({ stepId: entry.stepId, cardId: entry.cardId });
    setTimeout(() => {
      const el = document.getElementById(`card-${entry.cardId}`);
      if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
    }, 150);
    setTimeout(() => setHighlightedCard(null), 1800);
  };

  const activeDef = steps.find((s) => s.id === activeStep) || steps[0];
  const dev = t.previewSize || "desktop";

  // Loading gate — placed after all hooks so hook order stays stable.
  if (!state || !camp || !session) {
    return <div className="app" style={themeVars}><div className="boot" /></div>;
  }

  return (
    <div className={`stage${dev !== "desktop" ? " stage-on" : ""}`} data-dev={dev}>
      <div className={`viewport${dev !== "desktop" ? " vp-frame vp-" + dev : ""}`}>
        <div className={`app${mode === "run" ? " is-run" : ""}${sideCollapsed ? " side-collapsed" : ""}${view === "campaigns" ? " view-campaigns" : ""}`}
          style={themeVars} data-flourish={t.flourish ? "1" : "0"}>

          {view === "campaigns" ? (
            <div className="main">
              <header className="topbar">
                <button type="button" className="menu-btn" style={{ display: "grid" }} onClick={() => setView("session")} aria-label="Back">
                  <window.Icon name="back" size={20} />
                </button>
                <div className="topbar-title">
                  <span className="topbar-eyebrow">Emberwright</span>
                  <span className="session-title" style={{ fontFamily: "var(--font-display)", fontWeight: 600 }}>Campaigns</span>
                </div>
                <div className="topbar-right">
                  <window.AuthControls />
                  <window.IconButton name="settings" title="Tweaks" onClick={openTweaks} size={18} />
                </div>
              </header>
              <CampaignsScreen state={state} setState={setState}
                onOpen={(id) => {
                  setState((st) => ({ ...st, activeCampaign: id, activeSession: state.campaigns.find(c => c.id === id)?.sessions[0]?.id || st.activeSession }));
                  setView("session");
                }}
                onClose={() => setView("session")}
                onImport={() => setImportOpen(true)} />
            </div>
          ) : (
            <>
              {view !== "campaigns" &&
                <Sidebar
                  camp={camp} session={session} open={sideOpen} onClose={() => setSideOpen(false)}
                  onSelectSession={selectSession} onAddSession={addSession} onDeleteSession={deleteSession}
                  onRenameCampaign={(v) => updateCamp({ name: v })}
                  onRenameBlurb={(v) => updateCamp({ blurb: v })}
                  onEditCampaign={() => setCampModal(true)}
                  onCollapse={() => setSideCollapsed(true)}
                  onCampaigns={() => { setSideOpen(false); setView("campaigns"); }}
                  onReorderSessions={reorderSessions}
                  onRenameSession={(v) => updateSession({ ...session, name: v })}
                  onTweaks={openTweaks}
                  onExport={() => setExportOpen(true)}
                  onImport={() => setImportOpen(true)} />
              }
              <div className="main">
                <TopBar
                  session={session} mode={mode} setMode={setMode}
                  onRenameSession={(v) => updateSession({ ...session, name: v })}
                  onMenu={toggleSidebar}
                  onTweaks={openTweaks} />

                {mode === "prep" ?
                  <div className={`prep prep-${t.layout}`}>
                    <StepRail tabs={tabs} data={session.data} activeStep={activeStep} onPick={pickStep} />
                    <StepSwitcher tabs={tabs} data={session.data} activeStep={activeStep} onPick={pickStep} />
                    <div className="prep-content">
                      {t.layout === "checklist" ?
                        isNotes ?
                          <PrepNotesPane session={session} onChange={onNotes} ctx={ctx}
                            sessions={camp.sessions} sessionId={session.id} /> :
                          <window.StepEditor
                            key={activeDef.id} step={activeDef}
                            items={session.data[activeDef.id] || []}
                            setItems={(items) => setStepItems(activeDef.id, items)}
                            ctx={ctx} sessions={camp.sessions} sessionId={session.id}
                            onCardLinkClick={navigateToCard} cardIndex={cardIndex} highlightedCard={highlightedCard} />
                        :
                        <>
                          {steps.map((s) =>
                            <div key={s.id} className="board-section" ref={(el) => boardRefs.current[s.id] = el}>
                              <window.StepEditor
                                step={s} items={session.data[s.id] || []}
                                setItems={(items) => setStepItems(s.id, items)} ctx={ctx}
                                sessions={camp.sessions} sessionId={session.id}
                                onCardLinkClick={navigateToCard} cardIndex={cardIndex} highlightedCard={highlightedCard} />
                            </div>
                          )}
                          <div className="board-section" ref={(el) => boardRefs.current.prepNotes = el}>
                            <PrepNotesPane session={session} onChange={onNotes} ctx={ctx}
                              sessions={camp.sessions} sessionId={session.id} />
                          </div>
                        </>
                      }
                    </div>
                  </div> :
                  <window.RunMode session={session} setSession={updateSession} ctx={ctx} layout={t.runLayout} />
                }
              </div>
              {campModal && <CampaignModal camp={camp} onChange={updateCamp} onClose={() => setCampModal(false)} />}
              {exportOpen && <window.ExportModal camp={camp} session={session} tweaks={t} onClose={() => setExportOpen(false)} />}
            </>
          )}
          {importOpen && <window.MdImportModal camp={camp} setState={setState} onClose={() => setImportOpen(false)} />}
        </div>
      </div>

      <window.TweaksPanel title="Settings">
        <window.TweakSection label="Theme" />
        <window.TweakToggle label="Candlelight (dark)" value={t.dark} onChange={(v) => setTweak("dark", v)} />
        <window.TweakColor label="Accent" value={NAME_TO_HEX[t.accent] ? [NAME_TO_HEX[t.accent]] : t.accent}
          options={ACCENT_CHIPS}
          onChange={(v) => setTweak("accent", ACCENT_NAMES[v[0]] || v[0])} />
        <window.TweakSection label="Type" />
        <window.TweakSelect label="Lettering" value={t.font}
          options={[{ value: "storybook", label: "Storybook — Cinzel" }, { value: "tome", label: "Tome — IM Fell" }, { value: "quill", label: "Quill — Cormorant" }]}
          onChange={(v) => setTweak("font", v)} />
        <window.TweakSection label="Layout" />
        <window.TweakRadio label="Density" value={t.density} options={["compact", "cozy", "roomy"]}
          onChange={(v) => setTweak("density", v)} />
        <window.TweakRadio label="Run view" value={t.runLayout} options={[{ value: "focus", label: "Focus" }, { value: "board", label: "Board" }]}
          onChange={(v) => setTweak("runLayout", v)} />
        <window.TweakSection label="AI" />
        <window.TweakPassword label="Claude API key" value={t.apiKey} placeholder="sk-ant-…"
          onChange={(v) => setTweak("apiKey", v)} />
        {!t.apiKey && <div className="tweak-api-hint">
          Get a key at <a href="https://console.anthropic.com" target="_blank" rel="noopener noreferrer">console.anthropic.com</a>. Stored in your browser only.
        </div>}
        <window.TweakSelect label="Model" value={t.model}
          options={[
            { value: "claude-haiku-4-5-20251001", label: "Haiku — fast" },
            { value: "claude-sonnet-4-6", label: "Sonnet — balanced" },
            { value: "claude-opus-4-8", label: "Opus — most capable" },
          ]}
          onChange={(v) => setTweak("model", v)} />
        <window.TweakSection label="Image AI" />
        <window.TweakPassword label="OpenAI API key" value={t.openaiApiKey} placeholder="sk-…"
          onChange={(v) => setTweak("openaiApiKey", v)} />
        {!t.openaiApiKey && <div className="tweak-api-hint">
          Get a key at <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer">platform.openai.com/api-keys</a>. Stored in your browser only.
        </div>}
      </window.TweaksPanel>
    </div>
  );
}

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