// VaultStudyView v2 — the "study workspace" for REAL vault analyses.
//
//   • Renders as a SHEET over the dimmed library (mounted by index.html in
//     .vsheet-layer; the sheet element is the scroll container, id via scrollId).
//   • Sticky left rail: the study's own WMM-17 section spine (## I. … headings),
//     scrollspy-highlighted, click-to-jump; reception pinned at the foot.
//   • Condensed sticky header: back · ref+title (appears on scroll) · prev/next
//     pericope from the real studies index · audio player · progress hairline.
//   • Keyboard: [ ] previous/next pericope · j k next/previous section.
//   • Player: browser SpeechSynthesis — scope "Passage" (standard translation
//     only) or "Analysis" (from the current section, auto-advancing). Blocks that
//     are mostly Greek/Hebrew/Syriac script are skipped rather than mangled.
//     Engine-agnostic by design: the same queue can later be fed by cached
//     ElevenLabs audio from the review API.
//
// Also exports: VaultSourceReader (cited-source sheet with "Back to the
// analysis") and VaultAnalysesStrip (drawer strip).

// ── inline markdown → React nodes ─────────────────────────────
// Renders the bracketed analytical apparatus ([Greek — gloss, parsing]) as a
// typographically subordinate voice: smaller sans, muted ink, with Greek
// lemmata picked out. The surrounding translation stays the serif reading voice.
function vmdApparatus(inner, keyBase) {
  // split into Greek runs vs the rest so lemmata can carry their own style
  const parts = [];
  const gre = /[Ͱ-Ͽἀ-῿][Ͱ-Ͽἀ-῿\s᾽᾿''ʼ·,.]*/g;
  let last = 0, m, i = 0;
  while ((m = gre.exec(inner)) !== null) {
    if (m.index > last) parts.push(inner.slice(last, m.index));
    parts.push(React.createElement('span', { key: keyBase + '-gk' + i, className: 'vmd-app-gk' }, m[0]));
    last = m.index + m[0].length; i++;
  }
  if (last < inner.length) parts.push(inner.slice(last));
  return React.createElement('span', { className: 'vmd-app', key: keyBase + '-app' },
    '[', parts, ']');
}

// Vault-internal roots the source API can serve — wikilinks into these become live.
const VSV_SOURCE_ROOTS = /^(tier1-sources|tier2-library|tier2-patristic|tier3-vernacular|productions)\//;

function vmdWikilink(tok, keyBase, opts) {
  // [[path#^anchor|Label]] · [[path|Label]] · [[path#^anchor]] · [[path]] · ![[embed]]
  const wm = tok.match(/^(!?)\[\[([^\[\]]+)\]\]$/);
  if (!wm) return tok;
  const inner = wm[2];
  const pipe = inner.indexOf('|');
  const target = (pipe >= 0 ? inner.slice(0, pipe) : inner).trim();
  const label = (pipe >= 0 ? inner.slice(pipe + 1) : '').trim();
  const hashAt = target.indexOf('#');
  const rawPath = (hashAt >= 0 ? target.slice(0, hashAt) : target).trim();
  const hash = hashAt >= 0 ? target.slice(hashAt + 1).trim() : '';
  const anchor = hash && hash.startsWith('^') ? hash : null;
  const text = label || rawPath.split('/').pop() || target;
  const clickable = !wm[1] && opts && opts.onOpenSource && VSV_SOURCE_ROOTS.test(rawPath);
  if (!clickable) {
    return React.createElement('span', { key: keyBase, className: 'vmd-wikitext' }, text);
  }
  const path = rawPath.endsWith('.md') ? rawPath : rawPath + '.md';
  return React.createElement('button', {
    key: keyBase, className: 'vmd-wikilink', title: rawPath + (anchor ? ' · at ' + anchor : ''),
    onClick: () => opts.onOpenSource({ path, anchor, label: text })
  }, text);
}

function vmdInline(text, keyBase, opts) {
  if (!text) return null;
  const out = [];
  // order matters: wikilinks before md-links before bare apparatus brackets.
  // The apparatus alternative allows ONE level of nested brackets ([who is],
  // [your name] inside a gloss) — without it the outer bracket fails to
  // tokenize and the whole explanation renders as translation voice.
  const re = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*\n]+\*)|(!?\[\[[^\[\]]+\]\])|(\[[^\]]+\]\([^)]+\))|(\[(?:[^\[\]]|\[[^\[\]]*\])+\])/g;
  let last = 0, m, i = 0;
  while ((m = re.exec(text)) !== null) {
    if (m.index > last) out.push(text.slice(last, m.index));
    const tok = m[0];
    if (tok.startsWith('`')) {
      out.push(React.createElement('code', { key: keyBase + '-c' + i }, tok.slice(1, -1)));
    } else if (tok.startsWith('**')) {
      // recurse: emphasis spans may carry wikilinks ("[[Paul|Paul]]" in a lede)
      out.push(React.createElement('strong', { key: keyBase + '-b' + i },
        vmdInline(tok.slice(2, -2), keyBase + '-b' + i, opts)));
    } else if (tok.startsWith('*')) {
      out.push(React.createElement('em', { key: keyBase + '-i' + i },
        vmdInline(tok.slice(1, -1), keyBase + '-i' + i, opts)));
    } else if (/^!?\[\[/.test(tok)) {
      out.push(vmdWikilink(tok, keyBase + '-w' + i, opts));
    } else if (/^\[[^\]]+\]\([^)]+\)$/.test(tok)) {
      const mm = tok.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
      out.push(React.createElement('span', {
        key: keyBase + '-l' + i, className: 'vmd-link', title: mm ? mm[2] : ''
      }, mm ? mm[1] : tok));
    } else {
      const inner = tok.slice(1, -1);
      // trust-tag chips: the study's own confidence taxonomy, rendered as
      // graded badges (owner policy: show everything, tagged by trust level)
      if (/^(VERIFIED(?:-SEMANTIC|-EXTRA-CORPUS)?|SYNTHESIS|CONTESTED|PROBABLE|POSSIBLE|DISPUTED|SYNTH)$/.test(inner)) {
        out.push(React.createElement('span', {
          key: keyBase + '-t' + i,
          className: 'vmd-tag t-' + inner.toLowerCase().replace('verified-', 'v')
        }, inner));
      } else {
        out.push(vmdApparatus(inner, keyBase + '-a' + i));
      }
    }
    last = m.index + tok.length; i++;
  }
  if (last < text.length) out.push(text.slice(last));
  return out;
}

// ── block-level markdown renderer ─────────────────────────────
function vmdBlocks(md, keyPrefix, opts) {
  const lines = (md || '').split('\n');
  const els = [];
  let i = 0, k = 0;
  const anchorOf = (line) => {
    const m = line.match(/\s\^([A-Za-z0-9_-]+)\s*$/);
    return m ? m[1] : null;
  };
  const strip = (line) => line.replace(/\s\^([A-Za-z0-9_-]+)\s*$/, '');

  while (i < lines.length) {
    const raw = lines[i];
    const line = raw.trimEnd();
    if (!line.trim()) { i++; continue; }

    if (line.trim().startsWith('```')) {
      const buf = []; i++;
      while (i < lines.length && !lines[i].trim().startsWith('```')) { buf.push(lines[i]); i++; }
      i++;
      els.push(React.createElement('pre', { key: 'k' + (k++), className: 'vmd-pre' }, buf.join('\n')));
      continue;
    }
    if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) {
      els.push(React.createElement('hr', { key: 'k' + (k++), className: 'vmd-hr' })); i++; continue;
    }
    const hm = line.match(/^(#{1,6})\s+(.*)$/);
    if (hm) {
      const lvl = Math.min(hm[1].length, 6);
      const anch = anchorOf(hm[2]);
      els.push(React.createElement('h' + Math.max(2, lvl), {
        key: 'k' + (k++), className: 'vmd-h vmd-h' + lvl,
        id: anch ? 'vanch-' + anch : undefined
      }, vmdInline(strip(hm[2]), 'h' + k, opts)));
      i++; continue;
    }
    if (line.startsWith('>')) {
      const buf = [];
      while (i < lines.length && lines[i].trimEnd().startsWith('>')) {
        buf.push(lines[i].trimEnd().replace(/^>\s?/, '')); i++;
      }
      const anch = buf.map(anchorOf).find(Boolean);
      els.push(React.createElement('blockquote', {
        key: 'k' + (k++), className: 'vmd-quote', id: anch ? 'vanch-' + anch : undefined
      }, buf.map((b, bi) => React.createElement('p', { key: bi }, vmdInline(strip(b) || ' ', 'q' + k + bi, opts)))));
      continue;
    }
    if (line.startsWith('|')) {
      const rows = [];
      while (i < lines.length && lines[i].trim().startsWith('|')) { rows.push(lines[i].trim()); i++; }
      const cells = r => r.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
      const body = rows.filter(r => !/^\|?[\s:|-]+\|?$/.test(r));
      const [head, ...rest] = body;
      els.push(React.createElement('div', { key: 'k' + (k++), className: 'vmd-tablewrap' },
        React.createElement('table', { className: 'vmd-table' },
          head && React.createElement('thead', null, React.createElement('tr', null,
            cells(head).map((c, ci) => React.createElement('th', { key: ci }, vmdInline(c, 't' + k + ci, opts))))),
          React.createElement('tbody', null, rest.map((r, ri) =>
            React.createElement('tr', { key: ri },
              cells(r).map((c, ci) => React.createElement('td', { key: ci }, vmdInline(c, 'td' + k + ri + ci, opts))))))
        )));
      continue;
    }
    if (/^\s*([-*]|\d+\.)\s+/.test(line)) {
      const items = [];
      const ordered = /^\s*\d+\./.test(line);
      while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i])) {
        let item = lines[i].replace(/^\s*([-*]|\d+\.)\s+/, '');
        i++;
        while (i < lines.length && /^\s{2,}\S/.test(lines[i]) && !/^\s*([-*]|\d+\.)\s+/.test(lines[i])) {
          item += ' ' + lines[i].trim(); i++;
        }
        items.push(item);
      }
      els.push(React.createElement(ordered ? 'ol' : 'ul', { key: 'k' + (k++), className: 'vmd-list' },
        items.map((it, ii) => {
          const anch = anchorOf(it);
          return React.createElement('li', { key: ii, id: anch ? 'vanch-' + anch : undefined },
            vmdInline(strip(it), 'li' + k + ii, opts));
        })));
      continue;
    }
    {
      const buf = [line];
      i++;
      while (i < lines.length) {
        const nx = lines[i].trimEnd();
        if (!nx.trim() || nx.startsWith('#') || nx.startsWith('>') || nx.startsWith('|') ||
            /^\s*([-*]|\d+\.)\s+/.test(nx) || /^\s*(---+)\s*$/.test(nx) || nx.trim().startsWith('```')) break;
        buf.push(nx); i++;
      }
      const joined = buf.join(' ');
      const anch = anchorOf(joined);
      let body = strip(joined);
      // "**8:7** …" verse paragraphs → a small sans verse marker before serif prose
      let vn = null;
      const vm = body.match(/^\*\*(\d+[:.]\d+(?:\s*[–\-]\s*[\d:]+)?[ab]?)\*\*\s*(.*)$/);
      if (vm) { vn = vm[1]; body = vm[2]; }
      // "(— …)" paragraphs are the translator's editorial asides
      const isAside = /^\(\s*[—–-]/.test(body);
      els.push(React.createElement('p', {
        key: 'k' + (k++),
        className: 'vmd-p' + (isAside ? ' vmd-aside' : ''),
        id: anch ? 'vanch-' + anch : undefined
      },
        vn && React.createElement('span', { className: 'vmd-vn' }, vn),
        vmdInline(body, 'p' + k, opts)));
    }
  }
  return els;
}

// ── split a WMM-17 study into intro + top-level sections ──────
function vsvSplit(md) {
  const lines = (md || '').split('\n');
  const intro = { buf: [] };
  const sections = [];
  let cur = intro;
  for (let i = 0; i < lines.length; i++) {
    const l = lines[i];
    // The document h1 repeats the hero — drop it.
    if (/^#\s+Analytical Translation:/i.test(l)) continue;
    const m = l.match(/^##\s+(?!#)(.*)$/);
    if (m) {
      const t = m[1].trim();
      // "## *Pericope title*" also repeats the hero — fold into intro.
      if (/^\*[^*]+\*$/.test(t)) { cur = intro; continue; }
      cur = { title: t, buf: [] };
      sections.push(cur);
      continue;
    }
    cur.buf.push(l);
  }
  return {
    intro: intro.buf.join('\n'),
    sections: sections.map((s, i) => {
      const clean = s.title.replace(/\s\^[\w-]+\s*$/, '').replace(/\*/g, '');
      const rm = clean.match(/^([IVXLC]+)\.\s*(.*)$/);
      return {
        id: 'vsec-' + i,
        roman: rm ? rm[1] : '·',
        label: (rm ? rm[2] : clean).replace(/\s*\[SYNTHESIS\]\s*/g, '').trim(),
        md: s.buf.join('\n')
      };
    })
  };
}

// ── speech text preparation ───────────────────────────────────
function vsvStripInline(t) {
  return t
    .replace(/\s\^[\w-]+\s*$/, '')
    .replace(/!?\[\[([^\[\]|]+)(?:\|([^\[\]]+))?\]\]/g, (m, p, l) => (l || p.split('#')[0].split('/').pop() || ''))
    .replace(/`([^`]+)`/g, '$1')
    .replace(/\*\*([^*]+)\*\*/g, '$1')
    .replace(/\*([^*\n]+)\*/g, '$1')
    .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
    .replace(/[#>|]/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
}
function vsvNonLatinHeavy(t) {
  const hits = (t.match(/[Ͱ-Ͽἀ-῿֐-׿܀-ݏሀ-፿]/g) || []).length;
  return t.length > 0 && hits / t.length > 0.25;
}
function vsvSpeechTexts(md) {
  const out = [];
  const lines = (md || '').split('\n');
  let para = [];
  let inFence = false;
  const flush = () => {
    if (!para.length) return;
    const t = vsvStripInline(para.join(' '));
    para = [];
    if (!t || t.length < 3) return;
    if (vsvNonLatinHeavy(t)) return;          // skip original-script blocks
    // sentence-chunk to ≤260 chars so SpeechSynthesis stays reliable
    const sentences = t.match(/[^.!?;·]+[.!?;·]+["')\]]*\s*|[^.!?;·]+$/g) || [t];
    let chunk = '';
    sentences.forEach(s => {
      if ((chunk + s).length > 260 && chunk) { out.push(chunk.trim()); chunk = ''; }
      chunk += s;
    });
    if (chunk.trim()) out.push(chunk.trim());
  };
  for (let i = 0; i < lines.length; i++) {
    const l = lines[i].trim();
    if (l.startsWith('```')) { inFence = !inFence; flush(); continue; }
    if (inFence) continue;
    if (!l) { flush(); continue; }
    if (l.startsWith('|')) { flush(); continue; }          // tables read badly — skip
    if (/^(---+|\*\*\*+)$/.test(l)) { flush(); continue; }
    if (/^#{1,6}\s/.test(l)) { flush(); const h = vsvStripInline(l); if (h && !vsvNonLatinHeavy(h)) out.push(h + '.'); continue; }
    para.push(l.replace(/^>\s?/, '').replace(/^([-*]|\d+\.)\s+/, ''));
  }
  flush();
  return out;
}

// ── trust tiers (owner policy 2026-07-04: show ALL rows, graded) ──
const TIER_META = {
  ol:        { chip: 'OL ✓',      label: 'Original-language confirmed — verbatim in the father’s own Greek/Latin, machine-matched and audited' },
  verified:  { chip: 'VERIFIED',  label: 'Verified at translation level — the quote exists in the source (ANF English)' },
  semantic:  { chip: 'V-SEM',     label: 'Verified-semantic — substance confirmed, wording not verbatim' },
  circular:  { chip: '◐ CIRC', label: 'Attested but circular — the Old-Latin bridge partly derives from this father' },
  flagged:   { chip: '⚠ REVIEW', label: 'Under scholar review — possible thematic over-claim' },
  contested: { chip: 'CONTESTED', label: 'Contested — the claim is disputed' },
  synthesis: { chip: 'SYNTH',     label: 'Editorial synthesis — interpretive parallel, never counted as a witness' },
  untagged:  { chip: '—',    label: 'Untagged — not yet classified' },
};
const TIER_ORDER = ['ol', 'verified', 'semantic', 'circular', 'flagged', 'contested', 'synthesis', 'untagged'];

function tlName(w) {
  return ((w.author_id || '?').replace(/-/g, ' ') + ' · ' +
    (w.work_slug || '').replace(/-/g, ' ') +
    (w.locator_normalized || w.locator ? ' ' + (w.locator_normalized || w.locator) : ''))
    .replace(/\b\w/g, c => c.toUpperCase());
}

// The FULL reception ledger for this pericope — every row at its trust level.
function TrustLedger({ led, reception, onOpenSource }) {
  const [showLow, setShowLow] = React.useState(false);
  const rows = (led && led.rows) || [];
  const recMeta = reception && reception.meta;

  // Honest empty states
  if (!rows.length) {
    const noLayer = recMeta && recMeta.status === 'no-reception-layer';
    return React.createElement('section', { className: 'vsv-reception compact', id: 'vsv-reception' },
      React.createElement('div', { className: 'vsv-ledger-head' },
        React.createElement('span', null, '❧ Reception ledger'),
        React.createElement('span', { className: 'sub' }, 'from the project trust ledger')
      ),
      React.createElement('p', { className: 'vsv-reception-note' },
        noLayer
          ? 'No early-church reception section has been written for this pericope yet — a gap in our records, not a claim about history.'
          : 'No ledger rows for this pericope.')
    );
  }

  const prime = rows.filter(r => r.tier !== 'synthesis' && r.tier !== 'untagged');
  const low = rows.filter(r => r.tier === 'synthesis' || r.tier === 'untagged');
  const tiersPresent = TIER_ORDER.filter(t => rows.some(r => r.tier === t));

  const row = (r, i) => React.createElement('div', { key: i, className: 'vsv-wit tl-' + r.tier },
    React.createElement('div', { className: 'vsv-wit-main' },
      React.createElement('div', { className: 'vsv-wit-name' },
        React.createElement('span', { className: 'tl-chip ' + r.tier, title: TIER_META[r.tier].label },
          TIER_META[r.tier].chip),
        tlName(r)),
      React.createElement('div', { className: 'vsv-wit-meta' },
        r.tag && r.tier !== 'verified' && r.tier !== 'synthesis' &&
          React.createElement('span', { className: 'vsv-band' }, 'tag ' + r.tag),
        r.date_band && React.createElement('span', { className: 'vsv-band' }, 'Era ' + r.date_band),
        (r.ol_score != null) && React.createElement('span', { className: 'vsv-score' }, 'OL ' + Number(r.ol_score).toFixed(2)),
        (r.ol_score == null && r.match_score != null) && React.createElement('span', { className: 'vsv-score' }, 'match ' + Number(r.match_score).toFixed(2)),
        r.tanhub_corroborated && React.createElement('span', { className: 'vsv-prec' }, 'corpus-corroborated'),
        r.precision && React.createElement('span', { className: 'vsv-prec' }, r.precision)
      )
    ),
    r.target_path && React.createElement('button', {
      className: 'vsv-wit-open',
      onClick: () => onOpenSource && onOpenSource({
        path: r.target_path, anchor: r.anchor,
        locator: r.locator_normalized, label: tlName(r)
      })
    }, 'Open the source ', window.I && window.I.arrowRight ? window.I.arrowRight(12) : '→')
  );

  return React.createElement('section', { className: 'vsv-reception compact', id: 'vsv-reception' },
    React.createElement('div', { className: 'vsv-ledger-head' },
      React.createElement('span', null, '❧ Reception ledger · ' + rows.length + ' rows'),
      React.createElement('span', { className: 'sub' }, 'all claims shown · graded by trust')
    ),
    React.createElement('p', { className: 'vsv-reception-note' },
      'Every reception claim this pericope carries, shown at its current trust level — nothing is hidden.'),
    React.createElement('div', { className: 'vsv-wits' }, prime.map(row)),
    low.length > 0 && React.createElement('button', {
      className: 'tl-lowtoggle', onClick: () => setShowLow(s => !s)
    }, (showLow ? 'Hide' : 'Show') + ' ' + low.length + ' editorial-synthesis / untagged rows (context, not witnesses)'),
    showLow && React.createElement('div', { className: 'vsv-wits tl-low' }, low.map(row)),
    React.createElement('div', { className: 'tl-legend' },
      tiersPresent.map(t => React.createElement('span', { key: t, className: 'tl-legend-item' },
        React.createElement('span', { className: 'tl-chip ' + t }, TIER_META[t].chip),
        ' ', TIER_META[t].label))
    )
  );
}

// ── witness list (legacy fallback, kept for older API) ────────
function VaultWitnesses({ reception, onOpenSource }) {
  if (!reception || !reception.meta) return null;
  const wits = reception.meta.witnesses || [];
  if (!wits.length) return null;
  const label = (w) =>
    (w.author_id || '?').replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) +
    ' · ' + (w.work_slug || '').replace(/-/g, ' ') + (w.locator ? ' ' + w.locator : '');
  return React.createElement('section', { className: 'vsv-reception compact', id: 'vsv-reception' },
    React.createElement('div', { className: 'vsv-ledger-head' },
      React.createElement('span', null, '❧ Reception ledger'),
      React.createElement('span', { className: 'sub' }, 'machine-verified')
    ),
    React.createElement('p', { className: 'vsv-reception-note' },
      'The audited subset of the reception claims above — each witness verified against the citations ledger and linked to the source at the cited location.'),
    React.createElement('div', { className: 'vsv-wits' },
      wits.map((w, i) => React.createElement('div', { key: i, className: 'vsv-wit' },
        React.createElement('div', { className: 'vsv-wit-main' },
          React.createElement('div', { className: 'vsv-wit-name' }, label(w)),
          React.createElement('div', { className: 'vsv-wit-meta' },
            React.createElement('span', { className: 'vsv-badge ' + String(w.verification || '').toLowerCase() },
              w.verification || '—'),
            w.date_band && React.createElement('span', { className: 'vsv-band' }, 'Era ' + w.date_band),
            (w.match_score != null) && React.createElement('span', { className: 'vsv-score' },
              'match ' + Number(w.match_score).toFixed(2)),
            w.precision && React.createElement('span', { className: 'vsv-prec' }, w.precision)
          )
        ),
        w.target_path && React.createElement('button', {
          className: 'vsv-wit-open',
          onClick: () => onOpenSource && onOpenSource({
            path: w.target_path, anchor: w.anchor, locator: w.locator, label: label(w)
          })
        }, 'Open the source ', window.I && window.I.arrowRight ? window.I.arrowRight(12) : '→')
      ))
    )
  );
}

// ── the study workspace ───────────────────────────────────────
function VaultStudyView({ studyId, onOpenSource, onClose, onNavigate, scrollId, onOpenBook, onOpenEdition, onOpenTestament }) {
  const [st, setSt] = React.useState({ loading: true });
  const [rec, setRec] = React.useState(null);
  const [led, setLed] = React.useState(null);
  const [sibs, setSibs] = React.useState({ prev: null, next: null });
  const [active, setActive] = React.useState(0);
  const [condensed, setCondensed] = React.useState(false);
  const [progress, setProgress] = React.useState(0);
  const [player, setPlayer] = React.useState({ on: false, paused: false, rate: 1, scope: 'analysis', sec: -1 });
  const speechRef = React.useRef({ q: [], i: 0 });
  const rateRef = React.useRef(1);
  const canSpeak = typeof window !== 'undefined' && 'speechSynthesis' in window;

  const container = () => document.getElementById(scrollId || 'vsheet-scroll');
  const parsed = React.useMemo(() => (st.data ? vsvSplit(st.data.markdown) : null), [st.data]);

  // ONE home for reception: if the study has an authored §V, the ledger renders
  // inside it; otherwise a synthetic final "❧ Reception" section carries it.
  // The rail navigates to it like any other section — no separate foot button.
  const railSections = React.useMemo(() => {
    if (!parsed) return [];
    const secs = parsed.sections.map(s => ({
      ...s, isReception: /patristic reception/i.test(s.label)
    }));
    if (!secs.some(s => s.isReception)) {
      secs.push({ id: 'vsec-reception', roman: '❧', label: 'Reception',
                  synthetic: true, isReception: true });
    }
    return secs;
  }, [parsed]);
  const meta = (st.data && st.data.meta) || {};
  const abbr = String(studyId || '').split('-')[0];
  // spelled-out reference from the study index (book_name/chapter/v1/v2),
  // falling back to whatever the frontmatter knows
  const idxE = sibs.self || {};
  const refBook = idxE.book_name ||
    (window.BOOK_NAMES && window.BOOK_NAMES[abbr]) || (meta.book || '').toUpperCase();
  const refCh = idxE.chapter || meta.chapter || '';
  const refVv = idxE.v1
    ? ':' + idxE.v1 + (idxE.v2 && idxE.v2 !== idxE.v1 ? '–' + idxE.v2 : '')
    : (meta.verses ? ':' + meta.verses : '');
  const refLabel = (refBook + ' ' + refCh + refVv).trim();
  const heroTitle = idxE.pericope_title || meta.pericope_title || refLabel;

  const stopSpeech = React.useCallback(() => {
    if (canSpeak) window.speechSynthesis.cancel();
    speechRef.current = { q: [], i: 0 };
    setPlayer(p => ({ ...p, on: false, paused: false, sec: -1 }));
  }, [canSpeak]);

  // load study + reception, reset scroll/player on pericope change
  React.useEffect(() => {
    let on = true;
    setSt({ loading: true }); setRec(null); setActive(0); setProgress(0);
    stopSpeech();
    window.AmosAPI.study(studyId)
      .then(d => { if (on) setSt({ loading: false, data: d }); })
      .catch(e => { if (on) setSt({ loading: false, error: String(e) }); });
    window.AmosAPI.receptionForStudy(studyId).then(r => { if (on) setRec(r); });
    setLed(null);
    if (window.AmosAPI.ledger) {
      window.AmosAPI.ledger(studyId).then(l => { if (on) setLed(l); }).catch(() => {});
    }
    const c = container(); if (c) c.scrollTop = 0;
    return () => { on = false; };
  }, [studyId]);

  // siblings (prev/next pericope) from the real index
  React.useEffect(() => {
    let on = true;
    window.AmosAPI.studies(abbr).then(list => {
      if (!on || !Array.isArray(list)) return;
      const i = list.findIndex(s => s.id === studyId);
      setSibs({
        prev: i > 0 ? list[i - 1] : null,
        next: (i >= 0 && i < list.length - 1) ? list[i + 1] : null,
        self: i >= 0 ? list[i] : null
      });
    }).catch(() => {});
    return () => { on = false; };
  }, [studyId, abbr]);

  // scroll: progress hairline + condensed header + scrollspy — one deterministic
  // handler (no rAF, no IntersectionObserver: both are suspended in occluded
  // tabs and flaky inside nested scroll containers).
  React.useEffect(() => {
    const c = container();
    if (!c || !parsed) return;
    const onScroll = () => {
      const max = c.scrollHeight - c.clientHeight;
      setProgress(max > 0 ? Math.min(100, (c.scrollTop / max) * 100) : 0);
      setCondensed(c.scrollTop > 170);
      if (railSections.length) {
        const cTop = c.getBoundingClientRect().top;
        let idx = 0;
        for (let i = 0; i < railSections.length; i++) {
          const el = document.getElementById(railSections[i].id);
          if (el && (el.getBoundingClientRect().top - cTop) <= 140) idx = i;
        }
        // fully scrolled -> the last section is active, even when a short
        // final section can't bring its top past the threshold
        if (max > 0 && c.scrollTop >= max - 4) idx = railSections.length - 1;
        setActive(idx);
      }
    };
    c.addEventListener('scroll', onScroll, { passive: true });
    onScroll();
    // Fallback position poll: some embedded/occluded browser contexts never
    // deliver scroll events on nested containers — 400 ms polling costs nothing
    // and guarantees the rail/progress track everywhere.
    let last = -1;
    const iv = setInterval(() => {
      if (c.scrollTop !== last) { last = c.scrollTop; onScroll(); }
    }, 400);
    return () => { c.removeEventListener('scroll', onScroll); clearInterval(iv); };
  }, [railSections, scrollId]);

  const jumpTo = React.useCallback((idOrIdx) => {
    const id = typeof idOrIdx === 'number'
      ? (railSections[idOrIdx] && railSections[idOrIdx].id)
      : idOrIdx;
    if (!id) return;
    // optimistic rail highlight — don't wait for the scroll tracker
    {
      const idx = railSections.findIndex(s => s.id === id);
      if (idx >= 0) setActive(idx);
    }
    const c = container(); const el = document.getElementById(id);
    if (!c || !el) return;
    const top = el.getBoundingClientRect().top - c.getBoundingClientRect().top + c.scrollTop - 64;
    const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    c.scrollTo({ top, behavior: reduce ? 'auto' : 'smooth' });
    // Some engines silently drop smooth scrolls — guarantee arrival.
    setTimeout(() => { if (Math.abs(c.scrollTop - top) > 600) c.scrollTop = top; }, 450);
  }, [railSections, scrollId]);

  // keyboard: [ ] pericopes · j k sections
  React.useEffect(() => {
    const h = (e) => {
      if (e.target && /INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return;
      if (e.key === '[') { if (sibs.prev && onNavigate) { e.preventDefault(); onNavigate(sibs.prev.id); } }
      else if (e.key === ']') { if (sibs.next && onNavigate) { e.preventDefault(); onNavigate(sibs.next.id); } }
      else if (e.key === 'j') { if (railSections.length) { e.preventDefault(); jumpTo(Math.min(active + 1, railSections.length - 1)); } }
      else if (e.key === 'k') { if (railSections.length) { e.preventDefault(); jumpTo(Math.max(active - 1, 0)); } }
    };
    window.addEventListener('keydown', h);
    return () => window.removeEventListener('keydown', h);
  }, [sibs, railSections, active, jumpTo, onNavigate]);

  // stop speech on unmount
  React.useEffect(() => () => { if (canSpeak) window.speechSynthesis.cancel(); }, [canSpeak]);

  // ── player ──
  const buildQueue = (scope, fromIdx) => {
    if (!parsed) return [];
    const secs = parsed.sections;
    const chunksFor = (sec, idx, announce) => {
      const out = [];
      if (announce) out.push({ sec: idx, text: 'Section ' + sec.roman + '. ' + sec.label + '.' });
      vsvSpeechTexts(sec.md).forEach(t => out.push({ sec: idx, text: t }));
      return out;
    };
    if (scope === 'passage') {
      let i = secs.findIndex(s => /standard translation|source passage/i.test(s.label));
      if (i < 0) i = 0;
      return secs[i] ? chunksFor(secs[i], i, false) : [];
    }
    const q = [];
    for (let i = Math.max(0, fromIdx); i < secs.length; i++) q.push(...chunksFor(secs[i], i, true));
    return q;
  };

  const speakNext = () => {
    const s = speechRef.current;
    if (s.i >= s.q.length) { setPlayer(p => ({ ...p, on: false, paused: false, sec: -1 })); return; }
    const item = s.q[s.i];
    const u = new SpeechSynthesisUtterance(item.text);
    u.rate = rateRef.current;
    u.lang = 'en-US';
    u.onend = () => { s.i++; speakNext(); };
    u.onerror = () => { s.i++; speakNext(); };
    setPlayer(p => {
      if (p.sec !== item.sec) {
        // follow the reading — bring the section into view on transitions
        const sec = parsed && parsed.sections[item.sec];
        if (sec) setTimeout(() => jumpTo(sec.id), 50);
      }
      return { ...p, sec: item.sec };
    });
    window.speechSynthesis.speak(u);
  };

  const togglePlay = () => {
    if (!canSpeak) return;
    if (player.on && !player.paused) { window.speechSynthesis.pause(); setPlayer(p => ({ ...p, paused: true })); return; }
    if (player.on && player.paused) { window.speechSynthesis.resume(); setPlayer(p => ({ ...p, paused: false })); return; }
    const q = buildQueue(player.scope, active);
    if (!q.length) return;
    window.speechSynthesis.cancel();
    speechRef.current = { q, i: 0 };
    setPlayer(p => ({ ...p, on: true, paused: false }));
    speakNext();
  };
  const setScope = (scope) => { stopSpeech(); setPlayer(p => ({ ...p, scope })); };
  const cycleRate = () => {
    const next = player.rate === 1 ? 1.25 : player.rate === 1.25 ? 1.5 : 1;
    rateRef.current = next;
    setPlayer(p => ({ ...p, rate: next }));
  };

  // ── render ──
  const witCount = rec && rec.meta ? (rec.meta.witness_count || (rec.meta.witnesses || []).length) : 0;
  const pnBtn = (sib, dir) => React.createElement('button', {
    className: 'vsv-pnbtn' + (sib ? '' : ' off'),
    disabled: !sib,
    title: sib ? ((dir < 0 ? 'Previous — ' : 'Next — ') + (sib.pericope_title || sib.id) +
      ' (' + (sib.abbr || '').toUpperCase() + ' ' + sib.chapter + ':' + sib.v1 + '–' + sib.v2 + ')  [' + (dir < 0 ? '[' : ']') + ']') : 'No ' + (dir < 0 ? 'previous' : 'next') + ' pericope',
    onClick: () => sib && onNavigate && onNavigate(sib.id)
  }, dir < 0 ? '‹' : '›');

  return React.createElement('div', { className: 'vsv-main' },
    // ── sticky header ──
    React.createElement('div', { className: 'vsv-topbar' },
      // closes the study sheet, returning to whatever page opened it
      // (library, book hub, plain edition, canon page) — so it says Back,
      // not Library; the chips beside it are the deterministic up-links
      React.createElement('button', { className: 'vsv-back', onClick: onClose, title: 'Close this analysis (Esc)' },
        '← Back'),
      onOpenTestament && refBook && React.createElement('button', {
        className: 'vsv-back', style: { marginLeft: 6 },
        onClick: () => onOpenTestament(),
        title: 'The New Testament — all 27 books on one screen'
      }, 'New Testament'),
      onOpenBook && refBook && React.createElement('button', {
        className: 'vsv-back', style: { marginLeft: 6 },
        onClick: () => onOpenBook(abbr),
        title: 'Open the ' + refBook + ' book page — themes, pericope map, early-church highlights'
      }, refBook),
      React.createElement('div', { className: 'vsv-tb-title' + (condensed ? ' show' : '') },
        React.createElement('b', null, refLabel),
        (heroTitle !== refLabel ? ' · ' + heroTitle : '')),
      React.createElement('div', { className: 'vsv-tools' },
        pnBtn(sibs.prev, -1), pnBtn(sibs.next, +1),
        React.createElement('span', { className: 'vsv-tooldiv' }),
        canSpeak && React.createElement('div', { className: 'vsv-scope', role: 'group', 'aria-label': 'Listening scope' },
          React.createElement('button', {
            className: player.scope === 'passage' ? 'on' : '',
            onClick: () => setScope('passage'), title: 'Listen to the passage (standard translation) only'
          }, 'Passage'),
          React.createElement('button', {
            className: player.scope === 'analysis' ? 'on' : '',
            onClick: () => setScope('analysis'), title: 'Listen to the analysis from the current section onward'
          }, 'Analysis')
        ),
        canSpeak && React.createElement('button', {
          className: 'vsv-rate', onClick: cycleRate, title: 'Playback speed (applies from the next sentence)'
        }, player.rate + '×'),
        React.createElement('button', {
          className: 'vsv-play' + (player.on && !player.paused ? ' playing' : ''),
          onClick: togglePlay,
          disabled: !canSpeak,
          title: !canSpeak ? 'No speech engine available in this browser'
            : player.on && !player.paused ? 'Pause' : 'Listen'
        }, player.on && !player.paused ? '❚❚' : '▶')
      ),
      React.createElement('div', { className: 'vsv-progress', style: { width: progress + '%' } })
    ),

    st.loading && React.createElement('div', { className: 'vsv-loading' }, 'Loading the analysis…'),
    st.error && React.createElement('div', { className: 'vsv-error' },
      'Could not load this analysis. Is the review API running? ', String(st.error)),

    st.data && React.createElement(React.Fragment, null,
      // ── hero ──
      React.createElement('header', { className: 'vsv-hero' },
        React.createElement('div', { className: 'vsv-hero-eyebrow' },
          'THE AMOS PROJECT · ANALYTICAL TRANSLATION'),
        React.createElement('h1', { className: 'vsv-hero-title' }, heroTitle),
        heroTitle !== refLabel && React.createElement('div', { className: 'vsv-hero-sub' },
          refLabel, meta.section ? ' · ' + meta.section : ''),
        React.createElement('div', { className: 'vsv-pills' },
          React.createElement('span', { className: 'vsv-pill' }, meta.mode || 'Mode B'),
          meta.base_text && React.createElement('span', { className: 'vsv-pill base' }, String(meta.base_text).slice(0, 60)),
          React.createElement('span', { className: 'vsv-pill status' }, (meta.status || 'draft').toUpperCase()),
          rec && rec.meta && React.createElement('span', { className: 'vsv-pill rec' }, witCount + ' verified witnesses')
        )
      ),

      // ── rail + article ──
      React.createElement('div', { className: 'vsv-layout' },
        railSections.length > 1 && React.createElement('nav', { className: 'vsv-rail', 'aria-label': 'Study sections' },
          React.createElement('div', { className: 'vsv-rail-head' }, 'Sections'),
          railSections.map((s, i) => React.createElement('button', {
            key: s.id,
            className: 'vsv-rail-item' + (i === active ? ' active' : '') + (player.on && player.sec === i ? ' playing' : ''),
            onClick: () => jumpTo(s.id),
            'aria-current': i === active ? 'true' : undefined
          },
            React.createElement('span', { className: 'roman' }, s.roman),
            React.createElement('span', { className: 'lbl' }, s.label,
              s.isReception && led && led.count > 0 &&
                React.createElement('span', { className: 'rail-count' }, led.count))
          )),
          React.createElement('div', { className: 'vsv-rail-foot' },
            React.createElement('div', { className: 'vsv-rail-keys' }, '[ ] pericope · j k section')
          )
        ),

        React.createElement('div', { className: 'vsv-article' },
          parsed && parsed.intro.trim() && React.createElement('div', { className: 'vsv-body vsv-intro' },
            vmdBlocks(parsed.intro, 'vin', { onOpenSource })),
          railSections.map((s, i) =>
            React.createElement('section', { key: s.id, id: s.id, className: 'vsv-section' },
              React.createElement('h2', { className: 'vsv-sec-head' },
                React.createElement('span', { className: 'vsv-sec-roman' }, s.roman),
                React.createElement('span', null, s.label),
                // the retranslation continues book-wide: door into the plain edition
                onOpenEdition && refBook && /analytical retranslation/i.test(s.label) &&
                  React.createElement('button', {
                    className: 'vsv-sec-edlink',
                    style: { marginLeft: 'auto', border: 'none', background: 'none',
                             color: 'var(--accent, #a33b2e)', cursor: 'pointer',
                             font: 'inherit', fontSize: '13px', whiteSpace: 'nowrap' },
                    onClick: () => onOpenEdition(abbr, studyId),
                    title: 'Read the whole of ' + refBook + ' in this translation, starting here'
                  }, 'Read the full retranslation of ' + refBook + ' →')
              ),
              !s.synthetic && React.createElement('div', { className: 'vsv-body' }, vmdBlocks(s.md, 'vs' + i, { onOpenSource })),
              // ONE home: the audited ledger renders inside the reception section
              s.isReception && React.createElement(TrustLedger, { led, reception: rec, onOpenSource })
            )
          ),
          (!parsed || parsed.sections.length <= 1) && st.data &&
            React.createElement('div', { className: 'vsv-body' }, vmdBlocks(st.data.markdown, 'vall', { onOpenSource })),


          // ── prev / next pericope cards ──
          (sibs.prev || sibs.next) && React.createElement('div', { className: 'vsv-pn' },
            sibs.prev ? React.createElement('button', {
              className: 'vsv-pncard', onClick: () => onNavigate && onNavigate(sibs.prev.id)
            },
              React.createElement('span', { className: 'pn-eyebrow' }, '← Previous pericope'),
              React.createElement('span', { className: 'pn-title' }, sibs.prev.pericope_title || sibs.prev.id),
              React.createElement('span', { className: 'pn-ref' },
                (sibs.prev.abbr || '').toUpperCase() + ' ' + sibs.prev.chapter + ':' + sibs.prev.v1 + '–' + sibs.prev.v2)
            ) : React.createElement('span', null),
            sibs.next ? React.createElement('button', {
              className: 'vsv-pncard next', onClick: () => onNavigate && onNavigate(sibs.next.id)
            },
              React.createElement('span', { className: 'pn-eyebrow' }, 'Next pericope →'),
              React.createElement('span', { className: 'pn-title' }, sibs.next.pericope_title || sibs.next.id),
              React.createElement('span', { className: 'pn-ref' },
                (sibs.next.abbr || '').toUpperCase() + ' ' + sibs.next.chapter + ':' + sibs.next.v1 + '–' + sibs.next.v2)
            ) : React.createElement('span', null)
          )
        )
      )
    )
  );
}

// ── the cited-source reader (its own sheet, on top of the study) ──
function VaultSourceReader({ source, onBack, scrollId, onOpenSource }) {
  const [st, setSt] = React.useState({ loading: true });

  React.useEffect(() => {
    let on = true;
    setSt({ loading: true });
    window.AmosAPI.source(source.path)
      .then(d => { if (on) setSt({ loading: false, data: d }); })
      .catch(e => { if (on) setSt({ loading: false, error: String(e) }); });
    return () => { on = false; };
  }, [source.path]);

  React.useEffect(() => {
    if (!st.data) return;
    const id = source.anchor ? 'vanch-' + String(source.anchor).replace(/^\^/, '') : null;
    const el = id && document.getElementById(id);
    const c = document.getElementById(scrollId || 'vsrc-scroll');
    if (el) {
      setTimeout(() => {
        el.scrollIntoView({ block: 'center' });
        el.classList.add('vanch-hit');
      }, 60);
    } else if (c) {
      c.scrollTop = 0;
    }
  }, [st.data, source.anchor, scrollId]);

  const meta = (st.data && st.data.meta) || {};
  // Title precedence: frontmatter title → the document's own H1 → filename.
  // Vault convention names author cards INFO.md; the human title lives in the
  // content, so the filename is the last resort, never the first choice.
  let bodyMd = st.data && st.data.markdown;
  let docTitle = meta.title;
  if (st.data && !docTitle) {
    const h1 = bodyMd.match(/^#\s+(.+?)\s*$/m);
    if (h1) {
      docTitle = h1[1];
      bodyMd = bodyMd.replace(h1[0], '');   // don't repeat it under the hero
    }
  }
  return React.createElement('div', { className: 'vsv-main vsr-main' },
    React.createElement('div', { className: 'vsr-returnbar' },
      React.createElement('button', { className: 'vsr-return', onClick: onBack },
        '← Back to the analysis'),
      React.createElement('div', { className: 'vsr-cite' },
        source.label || source.path, source.locator ? ' · at ' + source.locator : '')
    ),
    st.loading && React.createElement('div', { className: 'vsv-loading' }, 'Opening the source document…'),
    st.error && React.createElement('div', { className: 'vsv-error' }, String(st.error)),
    st.data && React.createElement(React.Fragment, null,
      React.createElement('header', { className: 'vsr-hero' },
        React.createElement('div', { className: 'vsv-hero-eyebrow' }, 'SOURCE DOCUMENT'),
        React.createElement('h1', { className: 'vsr-title' }, docTitle || source.path.split('/').pop()),
        React.createElement('div', { className: 'vsr-path' }, source.path)
      ),
      React.createElement('article', { className: 'vsv-body vsr-body' }, vmdBlocks(bodyMd, 'vsr', { onOpenSource }))
    )
  );
}

// ── drawer strip: real analyses for the open pericope ─────────
function VaultAnalysesStrip({ book, chapter, onOpenVaultStudy }) {
  const [list, setList] = React.useState(null);
  const [alive, setAlive] = React.useState(window.AmosAPI && window.AmosAPI.isAlive());

  React.useEffect(() => {
    if (!window.AmosAPI) return;
    window.AmosAPI.onStatus(setAlive);
  }, []);
  React.useEffect(() => {
    let on = true;
    setList(null);
    if (!alive || !book || chapter == null) return;
    window.AmosAPI.studies(String(book).toLowerCase(), Number(chapter))
      .then(l => { if (on) setList(l); })
      .catch(() => { if (on) setList([]); });
    return () => { on = false; };
  }, [alive, book, chapter]);

  if (!alive || !list || !list.length) return null;
  return React.createElement('div', { className: 'vas-strip' },
    React.createElement('div', { className: 'vas-head' },
      React.createElement('span', { className: 'vas-dot' }, '◆'),
      'Analyses for this chapter — ', String(list.length)),
    React.createElement('div', { className: 'vas-list' },
      list.map(s => React.createElement('button', {
        key: s.id, className: 'vas-item',
        onClick: () => onOpenVaultStudy && onOpenVaultStudy(s.id)
      },
        React.createElement('span', { className: 'vas-ref' },
          (s.abbr || s.book || '').toUpperCase() + ' ' + s.chapter + ':' + s.v1 + '–' + s.v2),
        React.createElement('span', { className: 'vas-title' }, s.pericope_title || s.id),
        React.createElement('span', { className: 'vas-open' }, 'Open →')
      ))
    )
  );
}

Object.assign(window, { VaultStudyView, VaultSourceReader, VaultAnalysesStrip });
