/* export.jsx — export a session or campaign in two formats:
   • HTML — a self-contained, interactive standalone copy of Run mode (jump nav,
     collapsible sections, reveal/played checkboxes, image lightbox). Works offline.
   • Markdown — formatted text that imports cleanly into Google Docs.
   Reuses window.RUN_ORDER / RUN_META (runmode.jsx), window.ICON_PATHS (ui.jsx),
   window.RPG.STEP_DEFS (data.js), and window.RPGTheme.buildTheme (theme.js). */

// Google Fonts used by the app — copied from index.html so the export is faithful
// when opened standalone (Docs drops these on import, which is fine).
const EXPORT_FONTS_HREF =
  "https://fonts.googleapis.com/css2?family=Cinzel:wght@400;500;600;700&family=EB+Garamond:ital,wght@0,400;0,500;0,600;1,400&family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400&family=Crimson+Pro:ital,wght@0,400;0,500;0,600;1,400&family=IM+Fell+English:ital@0;1&family=IM+Fell+English+SC&family=JetBrains+Mono:wght@400;500&display=swap";

// Escape user content before it goes into the HTML string. This is the one
// security-relevant point — user text must never be emitted raw.
function esc(s) {
  return String(s == null ? "" : s)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

// ── Standalone Run-page renderers ─────────────────────────────────────────────
// These build the same DOM as runmode.jsx (RunMode) as an HTML string, reusing
// the live RUN_ORDER / RUN_META / ICON_PATHS so the export can't drift from the
// app. Interactivity is layered on by RUN_JS (vanilla, no framework).

function svgIcon(name, size, sw, cls) {
  const p = (window.ICON_PATHS || {})[name] || "";
  return '<svg ' + (cls ? 'class="' + cls + '" ' : "") +
    'width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" ' +
    'stroke="currentColor" stroke-width="' + (sw || 1.7) + '" ' +
    'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + p + "</svg>";
}

// Mirror of runmode.jsx RichText: "- " lines become styled bullets.
function runRichText(text) {
  if (!text) return "";
  const lines = String(text).split("\n");
  const hasBullets = lines.some((l) => l.indexOf("- ") === 0);
  if (!hasBullets) return "<span>" + esc(text) + "</span>";
  return "<span>" + lines.map((line) => {
    if (line.indexOf("- ") === 0)
      return '<span class="run-bullet"><span class="run-bullet-dot">•</span><span>' + esc(line.slice(2)) + "</span></span>";
    if (line) return '<span class="run-plain-line">' + esc(line) + "</span>";
    return "";
  }).join("") + "</span>";
}

function runImgThumb(src) {
  return src ? '<div class="run-item-img"><img src="' + esc(src) + '" alt="" /></div>' : "";
}

function runRefList(items, render) {
  if (!items || !items.length) return '<p class="run-muted">Nothing here yet.</p>';
  return '<ul class="run-ref">' + items.map(render).join("") + "</ul>";
}

// Body for one section id — mirrors runmode.jsx bodyFor().
function runBodyHtml(id, session) {
  const d = session.data || {};
  switch (id) {
    case "secrets": {
      const secrets = d.secrets || [];
      if (!secrets.length) return '<p class="run-muted">No secrets prepped.</p>';
      return '<ul class="run-secrets">' + secrets.map((s) =>
        '<li class="' + (s.revealed ? "revealed" : "") + '">' +
          '<button type="button" class="reveal-box" data-reveal="revealed" title="Click when revealed to players">' +
          svgIcon("check", 13, 2.6) + "</button>" +
          "<span>" + runRichText(s.body) + "</span></li>").join("") + "</ul>";
    }
    case "scenes": {
      const scenes = d.scenes || [];
      if (!scenes.length) return '<p class="run-muted">No scenes outlined.</p>';
      return '<ul class="run-scenes">' + scenes.map((s) =>
        '<li class="' + (s.done ? "done" : "") + '">' +
          '<button type="button" class="reveal-box" data-reveal="done" title="Mark played">' +
          svgIcon("check", 13, 2.6) + "</button>" +
          "<div><strong>" + esc(s.title || "Untitled scene") + "</strong>" +
          (s.body ? "<em>" + runRichText(s.body) + "</em>" : "") + "</div></li>").join("") + "</ul>";
    }
    case "npcs": return runRefList(d.npcs, (n) =>
      "<li>" + runImgThumb(n.image) + "<strong>" + esc(n.title) + "</strong>" +
      (n.sub ? '<span class="run-tag">' + esc(n.sub) + "</span>" : "") +
      (n.body ? "<em>" + runRichText(n.body) + "</em>" : "") + "</li>");
    case "locations": return runRefList(d.locations, (l) =>
      "<li>" + runImgThumb(l.image) + "<strong>" + esc(l.title) + "</strong>" +
      (Array.isArray(l.aspects) && l.aspects.length
        ? '<span class="run-aspects">' + l.aspects.map((a) => "<i>" + esc(a) + "</i>").join("") + "</span>" : "") +
      (l.body ? "<em>" + runRichText(l.body) + "</em>" : "") + "</li>");
    case "monsters": return runRefList(d.monsters, (m) =>
      "<li>" + runImgThumb(m.image) + "<strong>" + esc(m.title) +
      (m.sub ? '<span class="run-x">×' + esc(m.sub) + "</span>" : "") + "</strong>" +
      (m.body ? "<em>" + runRichText(m.body) + "</em>" : "") + "</li>");
    case "items": return runRefList(d.items, (it) =>
      "<li>" + runImgThumb(it.image) + "<strong>" + esc(it.title) + "</strong>" +
      (it.body ? "<em>" + runRichText(it.body) + "</em>" : "") + "</li>");
    case "characters": return runRefList(d.characters, (c) =>
      "<li>" + runImgThumb(c.image) + "<strong>" + esc(c.title) + "</strong>" +
      (c.sub ? '<span class="run-tag">' + esc(c.sub) + "</span>" : "") +
      (c.body ? "<em>" + runRichText(c.body) + "</em>" : "") + "</li>");
    case "notes": return '<textarea class="run-notes" placeholder="Jot what happened, dropped clues, dangling threads for next time…">' +
      esc(session.notes || "") + "</textarea>";
    default: return "";
  }
}

function runCount(id, d) {
  if (id === "secrets") { const s = d.secrets || []; return s.filter((x) => x.revealed).length + "/" + s.length; }
  if (id === "scenes") { const s = d.scenes || []; return s.filter((x) => x.done).length + "/" + s.length; }
  if (id === "notes") return null;
  return String((d[id] || []).length);
}

// One session → a full .run-wrap (jump nav + start + sections), matching RunMode.
function runSessionHtml(session, layout, active) {
  const RUN_ORDER = window.RUN_ORDER, RUN_META = window.RUN_META;
  const d = session.data || {};
  const start = (d.start && d.start[0]) || {};

  const nav = '<nav class="run-jump"><div class="run-jump-inner">' +
    ["start"].concat(RUN_ORDER).map((id) =>
      '<button type="button" data-jump="' + id + '">' + svgIcon(RUN_META[id].icon, 14) + " " + esc(RUN_META[id].label) + "</button>"
    ).join("") + "</div></nav>";

  const startBlock = '<section class="run-start" data-section="start" style="padding:20px">' +
    '<span class="run-start-eyebrow">' + svgIcon("flag", 14) + " Strong start</span>" +
    '<p class="run-start-text" style="font-size:16px">' +
    (start.body ? runRichText(start.body) : '<span class="run-muted">No strong start written.</span>') +
    "</p></section>";

  let inner;
  if (layout === "board") {
    inner = '<div class="run">' + startBlock + '<div class="run-grid">' +
      RUN_ORDER.map((id) => {
        const meta = RUN_META[id], count = runCount(id, d);
        const wide = id === "secrets" || id === "notes";
        return '<section class="run-panel' + (meta.accent ? " run-accent" : "") + (wide ? " run-wide" : "") +
          '" data-section="' + id + '"><header class="run-panel-hd">' + svgIcon(meta.icon, 17) +
          "<h3>" + esc(meta.title) + "</h3>" +
          (count != null ? '<span class="run-count">' + esc(count) + "</span>" : "") + "</header><div>" +
          runBodyHtml(id, session) + "</div></section>";
      }).join("") + "</div></div>";
  } else {
    inner = '<div class="run run-focus">' + startBlock +
      RUN_ORDER.map((id) => {
        const meta = RUN_META[id], count = runCount(id, d);
        const open = id === "secrets" || id === "scenes";
        return '<section class="focus-sec' + (open ? " open" : "") + (meta.accent ? " accent" : "") +
          '" data-section="' + id + '"><button type="button" class="focus-sec-hd">' +
          svgIcon(meta.icon, 18, 1.7, "focus-sec-icon") + "<h3>" + esc(meta.title) + "</h3>" +
          (count != null ? '<span class="run-count">' + esc(count) + "</span>" : "") +
          svgIcon("chevron", 16, 1.7, "focus-chev") + '</button><div class="focus-sec-body">' +
          runBodyHtml(id, session) + "</div></section>";
      }).join("") + "</div>";
  }

  return '<div class="run-wrap ew-session-view' + (active ? " active" : "") +
    '" data-session="' + esc(session.id) + '">' + nav + inner + "</div>";
}

// Serialize the theme var object into a :root rule (colorScheme → color-scheme).
function themeToCss(themeVars) {
  const rules = [];
  for (const [k, v] of Object.entries(themeVars)) {
    if (k === "colorScheme") rules.push("color-scheme: " + v + ";");
    else if (k[0] === "-") rules.push(k + ": " + v + ";");
  }
  return ":root {\n  " + rules.join("\n  ") + "\n}";
}

// Run-mode CSS, inlined verbatim from index.html (the .run-* / .focus-* / lightbox
// rules) plus a small base + standalone topbar, so the page looks identical to
// Run mode without the app shell. Selectors are global (no .app wrapper needed).
const EXPORT_CSS = `
:root { --topbar-h: 64px; }
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
  font-family: var(--font-body, 'EB Garamond', serif);
  font-size: var(--fs-base, 17px);
  line-height: var(--lh, 1.55);
  color: var(--ink, #2e2616);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  background:
    var(--vignette),
    radial-gradient(circle at 18% 12%, var(--paper-2), transparent 40%),
    repeating-linear-gradient(122deg, rgba(120,90,40,0.018) 0 2px, transparent 2px 5px),
    var(--bg);
  background-attachment: fixed;
  min-height: 100vh;
}
::selection { background: var(--accent-glow); }
button { font-family: inherit; cursor: pointer; background: none; border: 0; padding: 0; color: inherit; -webkit-appearance: none; appearance: none; }
textarea { font-family: inherit; }
h1, h2, h3, h4 { margin: 0; font-weight: 600; }

/* standalone topbar */
.ew-top { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 16px; padding: 12px clamp(18px, 3vw, 40px); min-height: var(--topbar-h); background: color-mix(in srgb, var(--bg), transparent 14%); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); border-bottom: 1px solid var(--line); }
.ew-top-title { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.ew-top-eyebrow { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-faint); }
.ew-session-title { font-family: var(--font-display); font-size: 20px; font-weight: 600; color: var(--ink); letter-spacing: var(--ls-display); }
.ew-session-select { font-family: var(--font-display); font-size: 19px; font-weight: 600; color: var(--ink); background: var(--paper); border: 1px solid var(--line); border-radius: 9px; padding: 4px 10px; cursor: pointer; max-width: 72vw; }

/* run mode (verbatim from index.html) */
.run-wrap { min-width: 0; }
.run-jump { position: sticky; top: var(--topbar-h, 72px); z-index: 15; background: color-mix(in srgb, var(--bg), transparent 14%); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); border-bottom: 1px solid var(--line); }
.run-jump-inner { max-width: 1180px; margin: 0 auto; display: flex; flex-wrap: wrap; gap: 7px; padding: 11px clamp(18px, 3vw, 44px) 12px; }
.run-jump button { display: inline-flex; align-items: center; gap: 6px; padding: 6px 13px; border-radius: 99px; border: 1px solid var(--line); background: var(--paper); color: var(--ink-soft); font-family: var(--font-display); font-size: 13px; letter-spacing: 0.01em; transition: all 0.15s; }
.run-jump button:hover { color: var(--accent-deep); border-color: var(--accent-line); background: var(--accent-soft); transform: translateY(-1px); }
.run-jump button svg { color: var(--accent); }
.run { padding: clamp(18px, 3vw, 36px) clamp(18px, 3vw, 44px) 80px; max-width: 1180px; margin: 0 auto; }
.run-start { background: linear-gradient(155deg, var(--paper-2), var(--paper)); border: 1px solid var(--accent-line); border-radius: 18px; padding: clamp(22px, 3.5vw, 40px); margin-bottom: 22px; position: relative; box-shadow: var(--shadow); overflow: hidden; }
.run-start::after { content: ""; position: absolute; top: -40%; right: -10%; width: 320px; height: 320px; background: radial-gradient(circle, var(--accent-glow), transparent 65%); opacity: 0.4; pointer-events: none; }
.run-start-eyebrow { display: inline-flex; align-items: center; gap: 7px; font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--accent); margin-bottom: 12px; }
.run-start-text { margin: 0; font-family: var(--font-display); font-size: clamp(20px, 2.6vw, 30px); line-height: 1.45; color: var(--ink); font-weight: 400; text-wrap: pretty; position: relative; }
.run-grid { columns: 2; column-gap: 20px; }
.run-panel { break-inside: avoid; display: inline-block; width: 100%; margin-bottom: 20px; background: var(--paper); border: 1px solid var(--line); border-radius: 14px; padding: 4px 18px 16px; box-shadow: var(--shadow-sm); }
.run-panel-hd { display: flex; align-items: center; gap: 10px; padding: 14px 0 10px; border-bottom: 1px solid var(--line-soft); margin-bottom: 12px; color: var(--accent); }
.run-panel-hd h3 { flex: 1; font-family: var(--font-display); font-size: 16px; font-weight: 600; letter-spacing: 0.03em; color: var(--ink); }
.run-count { font-family: var(--font-mono); font-size: 12px; color: var(--ink-faint); }
.run-muted { font-size: 14px; font-style: italic; color: var(--ink-faint); margin: 4px 0; }
.run-secrets, .run-scenes, .run-ref { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.run-secrets li, .run-scenes li { display: flex; align-items: flex-start; gap: 11px; padding: 9px 0; border-bottom: 1px solid var(--line-soft); }
.run-secrets li:last-child, .run-scenes li:last-child { border-bottom: 0; }
.run-wide .run-secrets { columns: 2; column-gap: 26px; }
.run-wide .run-secrets li { break-inside: avoid; }
.reveal-box { flex: none; width: 22px; height: 22px; margin-top: 1px; border-radius: 6px; border: 0; display: grid; place-items: center; background: var(--paper-sunk); box-shadow: inset 0 0 0 1.5px var(--line-strong); color: transparent; transition: all 0.16s; }
.reveal-box:hover { box-shadow: inset 0 0 0 1.5px var(--accent); }
.revealed .reveal-box, .done .reveal-box { background: var(--accent); box-shadow: none; color: var(--on-accent); }
.run-secrets li span { font-size: 15px; color: var(--ink-soft); transition: color 0.16s; }
.run-secrets li.revealed span { color: var(--ink-faint); text-decoration: line-through; }
.run-scenes li > div { display: flex; flex-direction: column; gap: 2px; }
.run-scenes strong { font-family: var(--font-display); font-size: 15.5px; color: var(--ink); }
.run-scenes em { font-size: 13.5px; color: var(--ink-soft); font-style: normal; }
.run-scenes li.done strong { text-decoration: line-through; text-decoration-color: var(--accent-line); color: var(--ink-faint); }
.run-ref li { padding: 11px 0; border-bottom: 1px solid var(--line-soft); display: flex; flex-direction: column; gap: 3px; }
.run-ref li:last-child { border-bottom: 0; }
.run-ref strong { font-family: var(--font-display); font-size: 16px; color: var(--ink); display: flex; align-items: baseline; gap: 7px; }
.run-x { font-family: var(--font-mono); font-size: 12px; color: var(--accent); }
.run-bullet { display: flex; align-items: baseline; gap: 6px; margin: 2px 0; }
.run-bullet-dot { color: var(--accent); font-size: 1em; flex: none; }
.run-plain-line { display: block; }
.run-tag { font-size: 13px; font-style: normal; color: var(--accent-deep); }
.run-ref em { font-size: 14px; color: var(--ink-soft); font-style: normal; }
.run-aspects { display: flex; flex-wrap: wrap; gap: 5px; margin: 1px 0; }
.run-aspects i { display: inline-block; white-space: nowrap; font-size: 12px; font-style: normal; color: var(--accent-deep); background: var(--accent-soft); padding: 2px 10px; border-radius: 99px; box-shadow: inset 0 0 0 1px var(--accent-line); }
.run-notes { width: 100%; box-sizing: border-box; border: 1px solid var(--line); background: var(--paper-sunk); border-radius: 9px; padding: 10px 12px; font-size: 15px; color: var(--ink); min-height: 70px; resize: vertical; outline: none; }
.run-item-img { width: 100%; height: 150px; overflow: hidden; border-radius: 8px; cursor: zoom-in; margin-bottom: 4px; }
.run-item-img img { width: 100%; height: 100%; object-fit: cover; display: block; transition: opacity 0.15s; }
.run-item-img:hover img { opacity: 0.88; }
.run.run-focus { max-width: 820px; }
.focus-sec { border: 1px solid var(--line); border-radius: 14px; background: var(--paper); margin-bottom: 12px; overflow: hidden; transition: border-color 0.18s; }
.focus-sec.accent { border-color: var(--accent-line); box-shadow: inset 0 0 0 1px var(--accent-line); }
.focus-sec-hd { width: 100%; display: flex; align-items: center; gap: 12px; padding: 15px 18px; background: none; border: 0; text-align: left; color: var(--ink); transition: background 0.15s; }
.focus-sec-hd:hover { background: var(--paper-2); }
.focus-sec.open .focus-sec-hd { border-bottom: 1px solid var(--line-soft); }
.focus-sec-icon { color: var(--accent); flex: none; }
.focus-sec-hd h3 { flex: 1; font-family: var(--font-display); font-size: 17px; font-weight: 600; letter-spacing: 0.02em; }
.focus-chev { color: var(--ink-faint); transition: transform 0.2s; }
.focus-sec.open .focus-chev { transform: rotate(90deg); }
.focus-sec-body { padding: 4px 18px 16px; }
.focus-sec:not(.open) .focus-sec-body { display: none; }
.run-focus .run-secrets li:first-child, .run-focus .run-scenes li:first-child, .run-focus .run-ref li:first-child { padding-top: 4px; }

/* image lightbox */
.img-lightbox { position: fixed; inset: 0; z-index: 400; background: rgba(10,6,2,0.92); display: flex; align-items: center; justify-content: center; cursor: zoom-out; }
.img-lightbox-img { max-width: min(90vw, 960px); max-height: 90vh; object-fit: contain; border-radius: 8px; box-shadow: 0 24px 80px rgba(0,0,0,0.6); cursor: default; display: block; }

/* standalone session switcher + footer */
.ew-session-view { display: none; }
.ew-session-view.active { display: block; }
.ew-foot { max-width: 1180px; margin: 0 auto; padding: 0 clamp(18px, 3vw, 44px) 60px; font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint); }

@media (max-width: 980px) {
  .run-grid { columns: 1; }
  .run-wide .run-secrets { columns: 1; }
  .run-jump-inner { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
  .run-jump-inner::-webkit-scrollbar { display: none; }
  .run-jump-inner button { flex: none; }
}
@media (max-width: 560px) {
  .run-item-img { height: 220px; border-radius: 10px; }
}
`;

// Vanilla interactivity for the standalone page (no framework): jump-nav scroll,
// collapsible focus sections, reveal/played checkboxes with live counts, image
// lightbox, and a session switcher. All toggles are in-memory (reset on reload).
const RUN_JS = `
(function () {
  var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  var sel = document.getElementById('ew-session-select');
  function showSession(id) {
    var views = document.querySelectorAll('.ew-session-view');
    for (var i = 0; i < views.length; i++) views[i].classList.toggle('active', views[i].getAttribute('data-session') === id);
    window.scrollTo(0, 0);
  }
  if (sel) sel.addEventListener('change', function () { showSession(sel.value); });

  function offsetFor(wrap) {
    var top = document.querySelector('.ew-top');
    var nav = wrap.querySelector('.run-jump');
    return (top ? top.offsetHeight : 0) + (nav ? nav.offsetHeight : 0) + 12;
  }
  function updateCount(section) {
    if (!section) return;
    var cnt = section.querySelector('.run-count');
    if (!cnt) return;
    var list = section.querySelector('.run-secrets') || section.querySelector('.run-scenes');
    if (!list) return;
    var marked = list.querySelectorAll('li.revealed, li.done').length;
    cnt.textContent = marked + '/' + list.querySelectorAll('li').length;
  }
  function openLightbox(src) {
    if (!src) return;
    var ov = document.createElement('div');
    ov.className = 'img-lightbox';
    var im = document.createElement('img');
    im.className = 'img-lightbox-img';
    im.src = src;
    im.addEventListener('click', function (ev) { ev.stopPropagation(); });
    ov.appendChild(im);
    function close() { ov.remove(); document.removeEventListener('keydown', onKey); }
    function onKey(ev) { if (ev.key === 'Escape') close(); }
    ov.addEventListener('click', close);
    document.addEventListener('keydown', onKey);
    document.body.appendChild(ov);
  }

  document.addEventListener('click', function (e) {
    var jb = e.target.closest('[data-jump]');
    if (jb) {
      var wrap = jb.closest('.run-wrap');
      var target = wrap.querySelector('[data-section="' + jb.getAttribute('data-jump') + '"]');
      if (!target) return;
      if (target.classList.contains('focus-sec')) target.classList.add('open');
      var y = target.getBoundingClientRect().top + window.scrollY - offsetFor(wrap);
      window.scrollTo({ top: Math.max(0, y), behavior: reduce ? 'auto' : 'smooth' });
      return;
    }
    var hd = e.target.closest('.focus-sec-hd');
    if (hd) { hd.parentElement.classList.toggle('open'); return; }
    var rb = e.target.closest('[data-reveal]');
    if (rb) {
      var li = rb.closest('li');
      li.classList.toggle(rb.getAttribute('data-reveal'));
      updateCount(rb.closest('[data-section]'));
      return;
    }
    var img = e.target.closest('.run-item-img');
    if (img) { var i = img.querySelector('img'); if (i) openLightbox(i.getAttribute('src')); }
  });
})();
`;

function sessionLabel(s) {
  return (s.number != null ? "Session " + s.number + " — " : "") + (s.name || "Untitled");
}

// Pure builder — returns a complete, self-contained interactive Run page.
function buildExportHtml({ campaign, sessions, tweaks }) {
  const t = tweaks || {};
  const themeVars = window.RPGTheme.buildTheme(t);
  const layout = t.runLayout === "board" ? "board" : "focus";
  const list = (sessions || []).filter(Boolean);
  const title = esc(campaign.name || "Campaign");

  // Topbar: a session switcher when more than one session, else a static title.
  const titleEl = list.length > 1
    ? '<select id="ew-session-select" class="ew-session-select" aria-label="Session">' +
        list.map((s, i) => '<option value="' + esc(s.id) + '"' + (i === 0 ? " selected" : "") + ">" +
          esc(sessionLabel(s)) + "</option>").join("") + "</select>"
    : '<span class="ew-session-title">' + esc(list[0] ? sessionLabel(list[0]) : "") + "</span>";

  const top = '<header class="ew-top"><div class="ew-top-title">' +
    '<span class="ew-top-eyebrow">' + title + "</span>" + titleEl + "</div></header>";

  const views = list.map((s, i) => runSessionHtml(s, layout, i === 0)).join("");
  const stamp = new Date().toLocaleDateString();

  return [
    "<!DOCTYPE html>",
    '<html lang="en">',
    "<head>",
    '<meta charset="utf-8" />',
    '<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />',
    "<title>" + title + " — Emberwright</title>",
    '<link rel="preconnect" href="https://fonts.googleapis.com" />',
    '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />',
    '<link href="' + EXPORT_FONTS_HREF + '" rel="stylesheet" />',
    "<style>" + themeToCss(themeVars) + EXPORT_CSS + "</style>",
    "</head>",
    "<body>",
    top,
    views,
    '<div class="ew-foot">Exported from Emberwright · ' + esc(stamp) + "</div>",
    "<script>" + RUN_JS + "<\/script>",
    "</body>",
    "</html>",
  ].join("\n");
}

// ── Markdown export (formatted text for Google Docs) ──────────────────────────
// Body text → markdown lines: "- "/"* " lines stay bullets, others stay as-is.
// Blank lines preserved as block separators; leading/trailing blanks trimmed.
function mdBodyLines(text) {
  const out = [];
  for (const raw of String(text || "").split(/\r?\n/)) {
    const line = raw.trim();
    if (!line) { out.push(""); continue; }
    const m = line.match(/^[-*]\s+(.*)$/);
    out.push(m ? "- " + m[1] : line);
  }
  while (out.length && out[0] === "") out.shift();
  while (out.length && out[out.length - 1] === "") out.pop();
  return out;
}

// One card → a markdown block. Generic over STEP_DEFS fields.
function mdCard(step, item) {
  const hasTitle = step.fields.some((f) => f.key === "title");
  if (hasTitle) {
    const head = [];
    let titleLine = item.title ? "**" + String(item.title).trim() + "**" : "";
    if (item.sub) titleLine += (titleLine ? " — " : "") + "_" + String(item.sub).trim() + "_";
    if (titleLine) head.push(titleLine);
    if (Array.isArray(item.aspects) && item.aspects.length) {
      head.push("_" + item.aspects.join(" · ") + "_");
    }
    const blocks = [];
    if (head.length) blocks.push(head.join("\n"));
    const body = mdBodyLines(item.body);
    if (body.length) blocks.push(body.join("\n"));
    return blocks.join("\n\n");
  }
  // No title: a list of single-body cards (e.g. secrets) → bullets;
  // a single-field step (Strong Start) → plain paragraph(s).
  const body = mdBodyLines(item.body);
  if (!body.length) return "";
  const reveal = step.revealable && item.revealed ? " _(revealed)_" : "";
  if (step.kind === "list") return "- " + body.join("\n  ") + reveal;
  return body.join("\n");
}

function buildExportMarkdown({ campaign, sessions }) {
  const steps = window.RPG.STEP_DEFS;
  const parts = ["# " + (campaign.name || "Campaign")];
  if (campaign.blurb) parts.push("_" + campaign.blurb.trim() + "_");
  if (campaign.tone) parts.push("**Tone & themes:** " + campaign.tone.trim());

  for (const session of sessions || []) {
    const num = session.number != null ? "Session " + session.number + " — " : "";
    parts.push("## " + num + (session.name || ""));
    if (session.date) parts.push("_" + session.date + "_");
    const d = session.data || {};
    for (const step of steps) {
      const items = d[step.id];
      if (!Array.isArray(items) || !items.length) continue;
      const hasTitle = step.fields.some((f) => f.key === "title");
      const cards = items.map((it) => mdCard(step, it)).filter(Boolean);
      if (!cards.length) continue;
      parts.push("### " + step.name.charAt(0).toUpperCase() + step.name.slice(1));
      // Title cards & paragraphs read better with a blank line between them;
      // bullet-only steps (secrets) stay as one tight list.
      const sep = hasTitle || step.kind === "single" ? "\n\n" : "\n";
      parts.push(cards.join(sep));
    }
  }

  parts.push("---");
  parts.push("_Exported from Emberwright · " + new Date().toLocaleDateString() + "_");
  return parts.join("\n\n") + "\n";
}

function slugify(s) {
  return String(s || "export")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 80) || "export";
}

function downloadFile(filename, content, mime) {
  const blob = new Blob([content], { type: (mime || "text/plain") + ";charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  setTimeout(() => URL.revokeObjectURL(url), 0);
}

function downloadHtml(filename, html) {
  downloadFile(filename, html, "text/html");
}

// ── Export dialog ─────────────────────────────────────────────────────────────
function Radio({ name, value, current, onPick, title, hint }) {
  return (
    <label className="cf-row" style={{ flexDirection: "row", alignItems: "flex-start", gap: 10 }}>
      <input type="radio" name={name} checked={current === value}
        onChange={() => onPick(value)} style={{ marginTop: 4 }} />
      <span>
        <strong>{title}</strong>
        <span className="cf-hint" style={{ display: "block" }}>{hint}</span>
      </span>
    </label>
  );
}

function ExportModal({ camp, session, tweaks, onClose }) {
  const [scope, setScope] = React.useState("session");
  const [format, setFormat] = React.useState("html");

  const doExport = () => {
    const sessions = scope === "campaign" ? (camp.sessions || []) : [session];
    const base = scope === "campaign"
      ? slugify(camp.name)
      : slugify(camp.name) + "-" + slugify(session.name);
    if (format === "markdown") {
      const md = buildExportMarkdown({ campaign: camp, sessions });
      downloadFile(base + ".md", md, "text/markdown");
    } else {
      const html = buildExportHtml({ campaign: camp, sessions, tweaks });
      downloadHtml(base + ".html", html);
    }
    onClose();
  };

  const formatHint = format === "markdown"
    ? "Downloads a formatted .md text file. Upload it to Google Drive and choose “Open with Google Docs” — headings, bold, and lists convert to real Doc styles. (Images are omitted.)"
    : "Downloads a self-contained, interactive Run page — the same layout as Run mode, with the jump nav, collapsible sections, reveal/played checkboxes, and image lightbox. Works offline; open it in any browser.";

  return (
    <window.Modal title="Export" onClose={onClose} width={460}>
      <div className="camp-form">
        <span className="cf-label">Include</span>
        <Radio name="ew-export-scope" value="session" current={scope} onPick={setScope}
          title="This session" hint={"“" + session.name + "” only."} />
        <Radio name="ew-export-scope" value="campaign" current={scope} onPick={setScope}
          title="Whole campaign" hint={"All " + (camp.sessions || []).length + " sessions of “" + camp.name + "”."} />

        <span className="cf-label" style={{ marginTop: 6 }}>Format</span>
        <Radio name="ew-export-format" value="html" current={format} onPick={setFormat}
          title="Standalone Run page (HTML)" hint="Interactive, offline copy of Run mode." />
        <Radio name="ew-export-format" value="markdown" current={format} onPick={setFormat}
          title="Google Doc (Markdown)" hint="Formatted text that imports cleanly into Google Docs." />

        <span className="cf-hint">{formatHint}</span>
        <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
          <button type="button" className="btn btn-ghost btn-sm" onClick={onClose}>Cancel</button>
          <button type="button" className="btn btn-accent btn-sm" onClick={doExport}>
            <window.Icon name="export" size={15} />Export
          </button>
        </div>
      </div>
    </window.Modal>
  );
}

window.buildExportHtml = buildExportHtml;
window.buildExportMarkdown = buildExportMarkdown;
window.downloadHtml = downloadHtml;
window.downloadFile = downloadFile;
window.ExportModal = ExportModal;
