/* ProvSQL Studio: entry script.
   Wires the shared chrome (mode switcher, example buttons, query form) plus
   both mode-specific sidebars: where-mode shows source-relation tables with
   hover-highlight, circuit-mode shows the provenance DAG (lazy-loaded
   circuit.js). Cross-mode navigation preserves the textarea content via
   sessionStorage and offers a per-row "→ Circuit" jump in where mode. */

(function () {
  const mode = document.body.classList.contains('mode-circuit') ? 'circuit' : 'where';

  // Metadata caches (schema panel, eval-strip mapping picker, eval-strip
  // custom-semiring optgroup) lazy-load once and would otherwise stay
  // stale for the lifetime of the page. We mark them dirty after every
  // successful /api/exec so the next time each panel opens, it
  // re-fetches; the toolbar refresh button forces an invalidation
  // explicitly (e.g. after schema changes outside the Studio session).
  // Each consumer is responsible for clearing its own flag once reload
  // succeeds.
  const Metadata = {
    schemaDirty:   true,
    mappingsDirty: true,
    customsDirty:  true,
    invalidateAll() {
      this.schemaDirty   = true;
      this.mappingsDirty = true;
      this.customsDirty  = true;
    },
  };
  window.ProvsqlStudio = window.ProvsqlStudio || {};
  window.ProvsqlStudio.metadata = Metadata;

  // Carry the textarea + a per-mode preload UUID across navigation.
  // The active-tab highlight is driven by CSS off <body class="mode-X">
  // so it doesn't flash when JS lags behind initial render.
  //
  // Two carry channels :
  //   ps.sql      always written : preserves the user's draft across the
  //               switch so they don't lose what they had typed.
  //   ps.sql.ran  written only if the textarea content matches the
  //               most-recently-executed SQL (ps.lastRunSql, set by
  //               runQuery). Drives the auto-replay decision in the new
  //               mode : we re-run on switch only if the query had
  //               actually been executed in the original mode. A draft
  //               sitting in the textarea (page reload, history nav,
  //               in-flight edit) must NOT auto-execute on switch
  //               because of side-effecting queries like add_provenance.
  document.querySelectorAll('.ps-modeswitch__btn').forEach(btn => {
    btn.addEventListener('click', () => {
      carryQueryForSwitch();
    });
  });
  // Restore the carried-over query if there is one. carriedRan controls
  // auto-replay; it's set only when the carried query had actually been
  // run in the previous mode.
  const carried = sessionStorage.getItem('ps.sql');
  const carriedFromSwitch = carried != null;
  const carriedRan = sessionStorage.getItem('ps.sql.ran') === '1';
  if (carriedFromSwitch) {
    document.getElementById('request').value = carried;
    sessionStorage.removeItem('ps.sql');
    sessionStorage.removeItem('ps.sql.ran');
  }
  function carryQueryForSwitch() {
    const sql = document.getElementById('request').value;
    sessionStorage.setItem('ps.sql', sql);
    const lastRun = sessionStorage.getItem('ps.lastRunSql');
    if (sql && lastRun === sql) {
      sessionStorage.setItem('ps.sql.ran', '1');
    } else {
      sessionStorage.removeItem('ps.sql.ran');
    }
  }
  // Expose so the where→circuit jump and the database-switch handler can
  // reuse the same carry rule.
  window.ProvsqlStudio.carryQueryForSwitch = carryQueryForSwitch;
  // If the previous page asked us to preload a circuit (via "→ Circuit"
  // button on a where-mode result row), pull the UUID out now so circuit-mode
  // setup can fire it after the result table renders.
  const preloadCircuitUuid = sessionStorage.getItem('ps.preloadCircuit');
  sessionStorage.removeItem('ps.preloadCircuit');

  // GUC toggles. In where mode, where_provenance is forced on (the wrap
  // calls where_provenance(...) and would otherwise return all-empty); the
  // toggle is locked. In circuit mode, both are user-controlled.
  setupGucToggles();
  // Connection chip in the top nav: pull the live current_user /
  // current_database() once at page load, then poll every 5s so the
  // dot turns terracotta if the server stops responding (e.g. PG was
  // restarted, network blip) and back to green when it recovers. The
  // matching server-side log filter (cli.py) drops these polls from
  // the access log to keep the console quiet.
  fetchConnInfo();
  setInterval(fetchConnInfo, 5000);
  setupConfigPanel();
  setupSchemaPanel();

  // ⌘ / Ctrl+Enter submits the query form. Alt+↑/Alt+↓ steps through the
  // saved query history without opening the dropdown.
  document.getElementById('request').addEventListener('keydown', (e) => {
    if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
      e.preventDefault();
      document.querySelector('form.wp-form').requestSubmit();
      return;
    }
    if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
      e.preventDefault();
      stepHistory(e.key === 'ArrowUp' ? +1 : -1);
    }
  });
  setupHistoryDropdown();

  // Clear-query button in the editor gutter : wipes the textarea so the
  // user can start over without selecting + deleting the previous text.
  // The current text is pushed to history first (pushHistory dedupes
  // consecutive entries, so an already-saved query won't double up) so
  // an accidental clear is one Alt+↑ away from recovery.
  document.getElementById('clear-btn')?.addEventListener('click', () => {
    const ta = document.getElementById('request');
    if (!ta) return;
    pushHistory(ta.value);
    ta.value = '';
    ta.setSelectionRange(0, 0);
    ta.focus();
    ta.dispatchEvent(new Event('input', { bubbles: true }));
  });

  // Cancel button (sibling of the Send button, hidden by default; runQuery
  // unhides it for the duration of an in-flight POST /api/exec). Firing
  // POST /api/cancel/<id> in parallel reaches the server on a different
  // worker thread and triggers pg_cancel_backend on the running pid; the
  // original /api/exec then comes back with a 57014 the renderer surfaces
  // as the standard error banner.
  document.getElementById('cancel-btn')?.addEventListener('click', async () => {
    const btn = document.getElementById('cancel-btn');
    const id  = btn.dataset.requestId;
    if (!id) return;
    btn.disabled = true;
    try {
      await fetch(`/api/cancel/${encodeURIComponent(id)}`, { method: 'POST' });
    } catch (e) {
      // Swallow: the in-flight /api/exec will still come back, with or
      // without our cancel landing. Re-enable the button so a follow-up
      // click is possible if the first didn't make it.
      btn.disabled = false;
    }
  });

  // Expose the env that the global runQuery reads (function declarations
  // are hoisted, so the named functions below are safe to reference here
  // even though they appear later in the IIFE). This MUST happen before
  // setupWhereMode / setupCircuitMode runs, because both can auto-replay
  // a carry-over query via runQuery, and if __provsqlStudio is still
  // undefined at that point the fallback default `{mode: 'where', ...}`
  // kicks in : the where-mode wrap then fires on /circuit pages and
  // explodes on aggregation circuits with "Wrong type of gate".
  window.__provsqlStudio = {
    mode, refreshRelations, escapeHtml, escapeAttr, formatCell,
    isRightAlignedType, matchesProvType, pushHistory,
  };

  if (mode === 'where') setupWhereMode();
  else                  setupCircuitMode();

  // The setup call has to come AFTER the SQL_KEYWORDS const declaration
  // below: function declarations are hoisted but `const` is not, so calling
  // setupSqlSyntaxHighlight() any earlier would hit the temporal dead zone
  // when refresh() invokes highlightSql() and reads SQL_KEYWORDS.

  /* ──────── SQL syntax highlight (textarea + <pre> overlay) ──────── */

  // Lightweight tokenizer: keyword / function / string / number / comment /
  // operator / identifier / whitespace. Single regex with named alternates so
  // the relative order is preserved (comments before strings before
  // identifiers). Brand-coloured via the hl-* classes in app.css.
  const SQL_KEYWORDS = new Set(([
    'select','from','where','and','or','not','in','is','null','any','some',
    'join','inner','outer','left','right','full','cross','natural','on','using',
    'group','by','order','having','distinct','all',
    'union','intersect','except','as','with','recursive',
    'insert','into','values','update','set','delete','returning',
    'create','table','view','index','schema','sequence','extension','function',
    'drop','alter','add','column','rename','to',
    'primary','key','foreign','references','unique','default','constraint','check',
    'case','when','then','else','end',
    'limit','offset','fetch','first','rows','row','only',
    'true','false','asc','desc','nulls',
    'between','like','ilike','similar','overlaps',
    'exists','cast','collate','escape',
    'begin','commit','rollback','savepoint','transaction','isolation','level',
    'grant','revoke','to','from','public','role','user',
    'if','exists','temp','temporary','unlogged','materialized',
    'analyze','explain','vacuum','copy','do','language',
    'array','within','filter','over','partition','window','range','unbounded','preceding','following','current','interval',
  ]).map(s => s.toLowerCase()));

  function escHtml(s) {
    return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  }

  function highlightSql(text) {
    // Token alternates, in priority order:
    //   1 = line comment     -- ...
    //   2 = block comment    /* ... */
    //   3 = single-quoted string (with escaped '')
    //   4 = dollar-quoted string $tag$ ... $tag$
    //   5 = double-quoted identifier "..."
    //   6 = number
    //   7 = identifier
    //   8 = operator
    //   9 = whitespace (passthrough)
    const re = /(--[^\n]*)|(\/\*[\s\S]*?\*\/)|('(?:[^']|'')*'?)|(\$([A-Za-z_][A-Za-z0-9_]*)?\$[\s\S]*?\$\5\$)|("(?:[^"]|"")*"?)|(\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|([A-Za-z_][A-Za-z0-9_]*)|([+\-*\/<>=!,;().|&%])|(\s+)/g;
    let out = '';
    let lastIdx = 0;
    let m;
    while ((m = re.exec(text)) !== null) {
      // Anything between matches is unrecognized; emit as-is (escaped).
      if (m.index > lastIdx) out += escHtml(text.slice(lastIdx, m.index));
      lastIdx = re.lastIndex;
      if      (m[1]) out += `<span class="hl-com">${escHtml(m[1])}</span>`;
      else if (m[2]) out += `<span class="hl-com">${escHtml(m[2])}</span>`;
      else if (m[3]) out += `<span class="hl-str">${escHtml(m[3])}</span>`;
      else if (m[4]) out += `<span class="hl-str">${escHtml(m[4])}</span>`;
      else if (m[6]) out += `<span class="hl-str">${escHtml(m[6])}</span>`;
      else if (m[7]) out += `<span class="hl-num">${escHtml(m[7])}</span>`;
      else if (m[8]) {
        const w = m[8];
        if (SQL_KEYWORDS.has(w.toLowerCase())) {
          out += `<span class="hl-kw">${escHtml(w)}</span>`;
        } else {
          // Function-call heuristic: identifier immediately followed by `(`.
          const after = text.charAt(re.lastIndex);
          if (after === '(') {
            out += `<span class="hl-fn">${escHtml(w)}</span>`;
          } else {
            out += escHtml(w);
          }
        }
      }
      else if (m[9]) out += `<span class="hl-op">${escHtml(m[9])}</span>`;
      else if (m[10]) out += m[10];   // whitespace passthrough
    }
    if (lastIdx < text.length) out += escHtml(text.slice(lastIdx));
    // Trailing newline keeps the <pre>'s last-line height aligned with the
    // textarea (which always reserves space for a trailing newline).
    return out + '\n';
  }

  function setupSqlSyntaxHighlight() {
    const ta = document.getElementById('request');
    const hlPre = document.getElementById('request-hl');
    if (!ta || !hlPre) return;
    const code = hlPre.querySelector('code');

    // Restore the user's preferred textarea height across reloads and
    // mode switches. ta.style.height is the same inline style the browser
    // writes on vertical drag, so round-tripping through it avoids the
    // box-sizing drift you'd get from offsetHeight/clientHeight.
    try {
      const saved = localStorage.getItem('ps.editorHeight');
      if (saved && /^\d+(\.\d+)?px$/.test(saved)) {
        const px = parseFloat(saved);
        if (px >= 40 && px <= 4000) ta.style.height = saved;
      }
    } catch (e) { /* localStorage disabled / quota: skip */ }

    function refresh() { code.innerHTML = highlightSql(ta.value); }
    function syncScroll() {
      hlPre.scrollTop  = ta.scrollTop;
      hlPre.scrollLeft = ta.scrollLeft;
    }
    ta.addEventListener('input', refresh);
    ta.addEventListener('scroll', syncScroll);
    // Re-sync after textarea resize: pre is positioned absolutely so its
    // box follows automatically, but scroll position can drift. Same
    // observer persists the user-set height; the initial firing during
    // restore writes back the value we just read, which is harmless.
    new ResizeObserver(() => {
      syncScroll();
      const h = ta.style.height;
      if (h) {
        try { localStorage.setItem('ps.editorHeight', h); }
        catch (e) { /* skip */ }
      }
    }).observe(ta);
    refresh();
  }

  setupSqlSyntaxHighlight();

  /* ──────── Where mode ──────── */

  async function setupWhereMode() {
    await refreshRelations();
    const body = document.getElementById('result-body');
    body.addEventListener('mouseover', (e) => onResultHover(e, true));
    body.addEventListener('mouseout',  (e) => onResultHover(e, false));
    body.addEventListener('click', (e) => {
      const btn = e.target.closest('[data-jump-circuit]');
      if (!btn) return;
      // Same carry rule as the mode-switch tab : only auto-replay in the
      // new mode if the textarea still matches the last-run SQL. The
      // preloadCircuit UUID is always carried so the DAG renders
      // regardless of whether we re-run the query.
      window.ProvsqlStudio.carryQueryForSwitch();
      sessionStorage.setItem('ps.preloadCircuit', btn.dataset.jumpCircuit);
      window.location.href = '/circuit';
    });
    // Quick-nav chips at the top of the sidebar: scroll the sidebar pane
    // so the target header lands at the top. We do this explicitly rather
    // than via scrollIntoView, because scrollIntoView picks the nearest
    // scrollable ancestor and may end up scrolling the page (sticky nav
    // hides the header) instead of the sidebar's own overflow pane.
    document.getElementById('sidebar-body').addEventListener('click', (e) => {
      const btn = e.target.closest('.wp-rel-nav__btn');
      if (!btn) return;
      const target = document.getElementById(btn.dataset.target);
      const sidebar = document.getElementById('sidebar');
      if (!target || !sidebar) return;
      const offset = target.getBoundingClientRect().top
                   - sidebar.getBoundingClientRect().top
                   + sidebar.scrollTop
                   - 10;  // small breathing gap above the header
      sidebar.scrollTo({ top: Math.max(0, offset), behavior: 'smooth' });
    });
    // Auto-replay only when the carried query had actually been executed
    // in the original mode (carriedRan). Plain reloads, history nav, and
    // unrun drafts must NOT auto-execute, because re-running a
    // side-effecting query (typically add_provenance) on switch is
    // dangerous.
    if (carriedRan && document.getElementById('request').value.trim()) {
      runQuery({ preventDefault() {} });
    }
  }

  // Last fetched /api/relations response, kept so the "Input gates only"
  // toggle can re-render without an extra round-trip.
  let _lastRelations = [];
  const INPUT_ONLY_KEY = 'ps.where.inputOnly';
  function inputOnlyEnabled() {
    // Default ON : the typical use case is inspecting the source tables
    // of a query, which are by construction input-gated.
    const v = sessionStorage.getItem(INPUT_ONLY_KEY);
    return v === null ? true : v === '1';
  }
  function setInputOnly(on) {
    sessionStorage.setItem(INPUT_ONLY_KEY, on ? '1' : '0');
  }

  async function refreshRelations() {
    let relations;
    try {
      const resp = await fetch('/api/relations');
      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
      relations = await resp.json();
    } catch (e) {
      document.getElementById('sidebar-body').innerHTML =
        `<p style="color:var(--terracotta-500)">Failed to load relations: ${escapeHtml(e.message)}</p>`;
      return;
    }
    _lastRelations = relations;
    renderRelations(relations);
  }

  function renderRelations(relations) {
    const body = document.getElementById('sidebar-body');
    if (!relations.length) {
      body.innerHTML = '<p style="opacity:.7">No provenance-tagged relations. Try <code>SELECT add_provenance(\'mytable\')</code>.</p>';
      return;
    }
    // Apply the "input gates only" filter : a relation passes when its
    // first row's provsql token is an input gate. Empty relations
    // (first_gate_type == null) are dropped under the filter, since
    // there's no input row to point at; rendering them would be noise.
    const inputOnly = inputOnlyEnabled();
    const totalCount = relations.length;
    const filtered = inputOnly
      ? relations.filter(r => r.first_gate_type === 'input')
      : relations;
    const hiddenCount = totalCount - filtered.length;
    const toggleHtml = `
      <div class="wp-rel-filter">
        <label class="wp-rel-filter__label" title="Hide relations whose provsql tokens are derived gates rather than input leaves">
          <input type="checkbox" id="opt-input-only"${inputOnly ? ' checked' : ''}>
          Input gates only${hiddenCount > 0 ? ` <span class="wp-rel-filter__hint">(${hiddenCount} hidden)</span>` : ''}
        </label>
      </div>`;
    if (!filtered.length) {
      body.innerHTML = toggleHtml +
        '<p style="opacity:.7">All provenance-tracked relations carry derived gates. Untick the filter to show them.</p>';
      bindInputOnlyToggle();
      return;
    }
    relations = filtered;
    // Quick-nav chips at the top: one per relation, click scrolls the
    // matching section into view inside the sidebar's own scroll pane.
    const navHtml = relations.length > 1
      ? `<nav class="wp-rel-nav">${
          relations.map(rel =>
            `<button type="button" class="wp-rel-nav__btn" data-target="${escapeAttr(headerId(rel.regclass))}">${escapeHtml(rel.regclass)}</button>`
          ).join('')
        }</nav>`
      : '';
    body.innerHTML = toggleHtml + navHtml + relations.map(rel => {
      // Skip the rewriter-added `provsql` UUID column when displaying; its
      // value is already exposed as the row id (used for the hover-highlight).
      // where_provenance numbers cells by user-column position (1-indexed,
      // ignoring provsql), which matches the original i+1 since provsql sits
      // at the end of the column list.
      const visible = rel.columns
        .map((c, i) => ({ c, i }))
        .filter(({ c }) => c.name !== 'provsql');
      // When list_relations capped the SELECT, surface the cap inline
      // ("100 of ~50000 tuples") so the user knows the sidebar isn't a
      // full mirror of the table. The "~" reflects pg_class.reltuples
      // being a planner estimate, not an exact count.
      const tuples = (() => {
        const shown = rel.rows.length;
        if (rel.truncated) {
          const total = rel.estimated_rows;
          if (total != null && total > shown) {
            return `${shown} of ~${total} tuples`;
          }
          return `${shown}+ tuples (capped)`;
        }
        return `${shown} tuples`;
      })();
      return `
      <section class="wp-relation" id="${escapeAttr(sectionId(rel.regclass))}">
        <header class="wp-relation__hdr" id="${escapeAttr(headerId(rel.regclass))}">
          <h3 class="wp-relation__name">${escapeHtml(rel.regclass)}</h3>
          <span class="wp-relation__meta">${tuples} · ${visible.length} cols</span>
        </header>
        <div class="wp-table-wrap">
          <table class="wp-table" id="t-${escapeAttr(rel.regclass)}">
            <thead><tr>${visible.map(({ c }) => {
              const cls = isRightAlignedType(c.type_name) ? ' class="is-right"' : '';
              return `<th${cls}>${escapeHtml(c.name)}</th>`;
            }).join('')}</tr></thead>
            <tbody>
              ${rel.rows.map(r => `
                <tr>
                  ${visible.map(({ c, i }) => {
                    const id = `${rel.regclass}:${r.uuid}:${i+1}`;
                    const cls = isRightAlignedType(c.type_name) ? ' is-right' : '';
                    return `<td id="${escapeAttr(id)}" class="${cls.trim()}">${formatCell(r.values[i], c.type_name)}</td>`;
                  }).join('')}
                </tr>
              `).join('')}
            </tbody>
          </table>
        </div>
      </section>`;
    }).join('');
    bindInputOnlyToggle();
  }

  // Wire the "Input gates only" checkbox emitted by renderRelations. The
  // toggle persists in sessionStorage and re-renders from the cached
  // /api/relations response : no extra round-trip just to flip the filter.
  function bindInputOnlyToggle() {
    const cb = document.getElementById('opt-input-only');
    if (!cb) return;
    cb.addEventListener('change', () => {
      setInputOnly(cb.checked);
      renderRelations(_lastRelations);
    });
  }

  // Stable, CSS-safe id for a relation's section (avoids periods, quotes, ...).
  function sectionId(regclass) {
    return 'rel-' + String(regclass).replace(/[^A-Za-z0-9_]/g, '_');
  }
  // Companion id for the relation's header element (table name + meta).
  function headerId(regclass) {
    return 'hdr-' + String(regclass).replace(/[^A-Za-z0-9_]/g, '_');
  }

  function onResultHover(e, on) {
    const cell = e.target.closest('.wp-result__cell');
    if (!cell) return;
    cell.classList.toggle('is-hover', on);
    let firstSource = null;
    (cell.dataset.sources || '').split(';').filter(Boolean).forEach(id => {
      const el = document.getElementById(id);
      if (el) {
        el.classList.toggle('is-source', on);
        if (on && !firstSource) firstSource = el;
      }
    });
    // Bring the first highlighted source into view if it's outside the
    // sidebar's scroll viewport. block:'nearest' avoids unnecessary scroll
    // when the cell is already visible.
    if (on && firstSource) {
      firstSource.scrollIntoView({ block: 'nearest', inline: 'nearest' });
    }
  }

  /* ──────── Circuit mode ──────── */

  /* ──────── query history ──────── */

  const HISTORY_KEY = 'ps.history';
  const HISTORY_CAP = 50;
  let _historyCursor = -1;  // -1 = current draft; 0..N-1 = nth-most-recent saved entry
  let _historyDriving = false;  // suppress the cursor reset when WE set ta.value

  function loadHistory() {
    try {
      const raw = localStorage.getItem(HISTORY_KEY);
      const arr = raw ? JSON.parse(raw) : [];
      return Array.isArray(arr) ? arr : [];
    } catch {
      return [];
    }
  }
  function saveHistory(arr) {
    try {
      localStorage.setItem(HISTORY_KEY, JSON.stringify(arr.slice(0, HISTORY_CAP)));
    } catch {}
  }
  function pushHistory(sql) {
    const trimmed = String(sql || '').trim();
    if (!trimmed) return;
    const arr = loadHistory();
    if (arr.length && arr[0] === trimmed) return;  // skip exact-duplicate consecutive entries
    arr.unshift(trimmed);
    saveHistory(arr);
  }

  function stepHistory(direction) {
    const arr = loadHistory();
    if (!arr.length) return;
    const ta = document.getElementById('request');
    if (_historyCursor === -1) _draft = ta.value;
    const valueAt = (i) => i === -1 ? (_draft || '') : arr[i];
    const current = ta.value;
    // Skip entries identical to what's already in the textarea so Alt+↑/↓
    // always produces a visible change (history can contain consecutive
    // duplicates separated by other queries, and the draft can match arr[0]).
    let next = _historyCursor + direction;
    while (next >= -1 && next < arr.length && valueAt(next) === current) {
      next += direction;
    }
    if (next < -1 || next >= arr.length) return;
    _historyCursor = next;
    _historyDriving = true;
    ta.value = valueAt(next);
    ta.dispatchEvent(new Event('input'));  // refresh syntax highlight
    _historyDriving = false;
  }
  let _draft = '';

  // Reset history-cursor when the USER edits the textarea (so the next
  // Alt+↑ starts from the most-recent entry again). When we set ta.value
  // ourselves from stepHistory, the synthetic input event must NOT reset
  // the cursor : that's what _historyDriving guards against.
  document.getElementById('request').addEventListener('input', () => {
    if (!_historyDriving) _historyCursor = -1;
  });

  function setupHistoryDropdown() {
    const btn  = document.getElementById('history-btn');
    const menu = document.getElementById('history-menu');
    if (!btn || !menu) return;

    function renderMenu() {
      const arr = loadHistory();
      if (!arr.length) {
        menu.innerHTML = '<li class="wp-history__empty">No saved queries yet.</li>';
        return;
      }
      const oneLine = (s) => s.replace(/\s+/g, ' ').trim();
      menu.innerHTML = arr.map((sql, i) =>
        `<li role="option" data-i="${i}" title="${escapeAttr(sql)}">${escapeHtml(oneLine(sql).slice(0, 200))}</li>`
      ).join('') + '<li class="wp-history__clear" data-clear="1">Clear history</li>';
    }
    function open() {
      renderMenu();
      menu.hidden = false;
      btn.setAttribute('aria-expanded', 'true');
    }
    function close() {
      menu.hidden = true;
      btn.setAttribute('aria-expanded', 'false');
    }
    btn.addEventListener('click', (e) => {
      e.stopPropagation();
      if (menu.hidden) open(); else close();
    });
    document.addEventListener('click', (e) => {
      if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) close();
    });
    menu.addEventListener('click', (e) => {
      const li = e.target.closest('li');
      if (!li) return;
      if (li.dataset.clear) {
        saveHistory([]);
        renderMenu();
        return;
      }
      const arr = loadHistory();
      const i = Number(li.dataset.i);
      const sql = arr[i];
      if (sql == null) return;
      const ta = document.getElementById('request');
      ta.value = sql;
      ta.dispatchEvent(new Event('input'));
      _historyCursor = i;
      close();
      ta.focus();
    });
  }

  let _currentConn = null;

  async function fetchConnInfo() {
    const el  = document.getElementById('conn-info');
    // The trigger is the wrapping <button id="conn-dot"> (plug icon +
    // status dot). We set the tooltip on the button so hovering the
    // icon also surfaces it; the inner span carries only the colour
    // class (.is-offline / connected).
    const trigger = document.getElementById('conn-dot');
    const dot = document.querySelector('.wp-nav__dot');
    if (!el) return;
    let reason = '';
    try {
      const resp = await fetch('/api/conn');
      if (!resp.ok) {
        // /api/conn responds with 503 + JSON {error, reason} when the
        // pool can't reach PG. Pull the reason out for the dot tooltip.
        try {
          const body = await resp.json();
          reason = body.reason || body.error || `HTTP ${resp.status}`;
        } catch (_) {
          reason = `HTTP ${resp.status}`;
        }
        throw new Error(reason);
      }
      const c = await resp.json();
      _currentConn = c;
      // Surface the numeric server version on the global so circuit.js
      // can gate version-specific dropdown options (currently the
      // `temporal` compiled semiring, which depends on tstzmultirange
      // and is therefore PG14+ only). Re-syncs the eval strip if it has
      // already been initialised, since /api/conn lands asynchronously.
      window.ProvsqlStudio.serverVersion = Number(c.server_version) || 0;
      if (window.ProvsqlStudio.syncCompiledSemiringAvailability) {
        window.ProvsqlStudio.syncCompiledSemiringAvailability();
      }
      // Footer version chip: ProvSQL extension version (NULL when the
      // extension is not installed on the connected database) and
      // Studio package version. Discreet — runs once per /api/conn poll
      // and is a no-op if the spans aren't present (e.g. tests).
      const extEl = document.getElementById('version-ext');
      const studioEl = document.getElementById('version-studio');
      if (extEl) extEl.textContent = c.extension_version ? `ProvSQL ${c.extension_version}` : '';
      if (studioEl) studioEl.textContent = c.studio_version ? `Studio ${c.studio_version}` : '';
      el.textContent = `${c.user}@${c.database}`;
      if (c.host) el.title = `host: ${c.host}`;
      if (dot) dot.classList.remove('is-offline');
      if (trigger) {
        // Surface only the server endpoint. The DB name is already
        // shown right next to the dot in the switcher chip, so we
        // don't repeat it; user@host:port is what the chip can't say.
        const where = c.host
          ? (c.port ? `${c.host}:${c.port}` : c.host)
          : 'local socket';
        trigger.title = `connected to ${c.user}@${where} (click to change)`;
      }
      // No DSN was given on the CLI and no PG* env hinted at a DB, so
      // the server fell back to the postgres maintenance DB. Show a
      // dismissible banner pointing the user at the switcher; once
      // they pick a real DB the flag clears server-side.
      _renderDbAutoHint(!!c.db_is_auto);
      // Render search_path with `provsql` shown as a locked chip so the
      // user can tell at a glance which segment is enforced by Studio.
      // The compose helper on the server already pinned provsql to the
      // end; we just style that segment.
      const sp = document.getElementById('searchpath-val');
      if (sp) {
        const path = c.search_path || '';
        const parts = path.split(',').map(s => s.trim()).filter(Boolean);
        sp.innerHTML = parts.map(p => {
          if (p === 'provsql' || p === '"provsql"') {
            return `<span class="wp-card__sp-locked" title="ProvSQL Studio appends “provsql” to your search_path so its helper functions are reachable as a fallback for unqualified names."><i class="fas fa-lock" aria-hidden="true"></i> provsql</span>`;
          }
          return escapeHtml(p);
        }).join(', ');
      }
    } catch (e) {
      // Don't clobber _currentConn or the displayed identity on a
      // transient blip: the chip keeps showing the last-known db name
      // (so the dropdown still works); only the dot turns red and the
      // tooltip explains.
      if (dot) dot.classList.add('is-offline');
      if (trigger) {
        trigger.title = `Cannot reach the database: ${reason || e.message || 'cannot connect to PostgreSQL'}`;
      }
    }
    setupDbSwitcher();
  }

  function setupDbSwitcher() {
    const btn  = document.getElementById('conn-info');
    const menu = document.getElementById('dbmenu');
    if (!btn || !menu || btn.dataset.bound === '1') return;
    btn.dataset.bound = '1';

    async function openMenu() {
      btn.setAttribute('aria-expanded', 'true');
      menu.hidden = false;
      menu.innerHTML = '<li style="opacity:.6">loading…</li>';
      try {
        const resp = await fetch('/api/databases');
        if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
        const dbs = await resp.json();
        const cur = _currentConn ? _currentConn.database : null;
        menu.innerHTML = dbs.map(name =>
          `<li role="option" data-db="${escapeAttr(name)}" `
          + `class="${name === cur ? 'is-current' : ''}">${escapeHtml(name)}</li>`
        ).join('') || '<li style="opacity:.6">(no accessible databases)</li>';
      } catch (e) {
        menu.innerHTML = `<li style="color:var(--terracotta-500)">Failed: ${escapeHtml(e.message)}</li>`;
      }
    }
    function closeMenu() {
      btn.setAttribute('aria-expanded', 'false');
      menu.hidden = true;
    }
    btn.addEventListener('click', (e) => {
      e.stopPropagation();
      if (menu.hidden) openMenu(); else closeMenu();
    });
    document.addEventListener('click', (e) => {
      if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) closeMenu();
    });
    menu.addEventListener('click', async (e) => {
      const li = e.target.closest('li[data-db]');
      if (!li) return;
      const target = li.dataset.db;
      if (_currentConn && target === _currentConn.database) {
        closeMenu();
        return;
      }
      menu.innerHTML = '<li style="opacity:.6">switching…</li>';
      let resp;
      try {
        resp = await fetch('/api/conn', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ database: target }),
        });
      } catch (err) {
        closeMenu();
        return;
      }
      if (!resp.ok) {
        closeMenu();
        return;
      }
      // Reloading is the cleanest way to reset every cached relation list,
      // result table, circuit cache, etc., to the new database's contents.
      // The previous query is meaningless against the new database (table
      // names rarely match), so we wipe the textarea : push the current
      // text to history first so the user can recover it via Alt+↑.
      const ta = document.getElementById('request');
      if (ta) pushHistory(ta.value);
      sessionStorage.removeItem('ps.sql');
      sessionStorage.removeItem('ps.sql.ran');
      sessionStorage.removeItem('ps.lastRunSql');
      if (ta) ta.value = '';
      window.location.reload();
    });
  }

  /* ──────── Config popover (provsql.active + provsql.verbose_level) ──────── */

  function setupConfigPanel() {
    const btn    = document.getElementById('config-btn');
    const panel  = document.getElementById('config-panel');
    const active = document.getElementById('cfg-active');
    const verb   = document.getElementById('cfg-verbose');
    const status = document.getElementById('cfg-status');
    if (!btn || !panel || !active || !verb) return;

    let loaded = false;

    const verbOut = document.getElementById('cfg-verbose-out');
    const depth   = document.getElementById('cfg-depth');
    const depthOut = document.getElementById('cfg-depth-out');
    const sidebarRows = document.getElementById('cfg-sidebar-rows');
    const resultRows = document.getElementById('cfg-result-rows');
    const circuitNodes = document.getElementById('cfg-circuit-nodes');
    const timeout = document.getElementById('cfg-timeout');
    const sp      = document.getElementById('cfg-search-path');
    const tsp     = document.getElementById('cfg-tool-search-path');
    const simplify = document.getElementById('cfg-simplify-on-load');
    const mcSeed   = document.getElementById('cfg-monte-carlo-seed');
    const rvSamples = document.getElementById('cfg-rv-mc-samples');

    async function loadConfig() {
      try {
        const resp = await fetch('/api/config');
        if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
        const cfg = await resp.json();
        const eff = cfg.effective || {};
        active.checked = (eff['provsql.active'] || 'on') !== 'off';
        verb.value     = eff['provsql.verbose_level'] || '0';
        if (verbOut) verbOut.textContent = verb.value;
        if (simplify) {
          simplify.checked = (eff['provsql.simplify_on_load'] || 'on') !== 'off';
        }
        if (mcSeed && eff['provsql.monte_carlo_seed'] != null) {
          mcSeed.value = String(eff['provsql.monte_carlo_seed']);
        }
        if (rvSamples && eff['provsql.rv_mc_samples'] != null) {
          rvSamples.value = String(eff['provsql.rv_mc_samples']);
        }
        const opts = cfg.options || {};
        if (depth && opts.max_circuit_depth != null) {
          depth.value = String(opts.max_circuit_depth);
          if (depthOut) depthOut.textContent = depth.value;
        }
        if (sidebarRows && opts.max_sidebar_rows != null) {
          sidebarRows.value = String(opts.max_sidebar_rows);
        }
        if (resultRows && opts.max_result_rows != null) {
          resultRows.value = String(opts.max_result_rows);
        }
        if (circuitNodes && opts.max_circuit_nodes != null) {
          circuitNodes.value = String(opts.max_circuit_nodes);
        }
        if (timeout && opts.statement_timeout_seconds != null) {
          timeout.value = String(opts.statement_timeout_seconds);
        }
        if (sp && opts.search_path != null) {
          sp.value = opts.search_path;
        }
        if (tsp && opts.tool_search_path != null) {
          tsp.value = opts.tool_search_path;
        }
        loaded = true;
        showStatus('');
      } catch (e) {
        showStatus(`Failed to load: ${e.message}`, true);
      }
    }
    function showStatus(msg, isErr) {
      if (!msg) {
        status.hidden = true;
        status.classList.remove('is-error');
        return;
      }
      status.textContent = msg;
      status.classList.toggle('is-error', !!isErr);
      status.hidden = false;
    }
    async function setGuc(name, value) {
      try {
        const resp = await fetch('/api/config', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ key: name, value: String(value) }),
        });
        if (!resp.ok) {
          const err = await resp.json().catch(() => ({}));
          throw new Error(err.error || `HTTP ${resp.status}`);
        }
        showStatus(`Saved: ${name} = ${value}`);
        // Auto-clear the status after a couple of seconds.
        clearTimeout(setGuc._t);
        setGuc._t = setTimeout(() => showStatus(''), 2000);
      } catch (e) {
        showStatus(e.message, true);
      }
    }

    function open() {
      panel.hidden = false;
      btn.setAttribute('aria-expanded', 'true');
      if (!loaded) loadConfig();
    }
    function close() {
      panel.hidden = true;
      btn.setAttribute('aria-expanded', 'false');
    }
    btn.addEventListener('click', (e) => {
      e.stopPropagation();
      if (panel.hidden) open(); else close();
    });
    document.addEventListener('click', (e) => {
      if (!panel.hidden && !panel.contains(e.target) && e.target !== btn) close();
    });
    active.addEventListener('change', () => {
      setGuc('provsql.active', active.checked ? 'on' : 'off');
    });
    if (simplify) {
      simplify.addEventListener('change', () => {
        setGuc('provsql.simplify_on_load', simplify.checked ? 'on' : 'off');
      });
    }
    if (mcSeed) {
      // -1 means "non-deterministic"; clamp absurd negatives but allow
      // any non-negative literal seed (including 0).
      mcSeed.addEventListener('change', () => {
        const raw = parseInt(mcSeed.value, 10);
        const n = Number.isFinite(raw) ? Math.max(-1, raw) : -1;
        mcSeed.value = String(n);
        setGuc('provsql.monte_carlo_seed', n);
      });
    }
    if (rvSamples) {
      // 0 is meaningful (disables the MC fallback); clamp negatives.
      rvSamples.addEventListener('change', () => {
        const raw = parseInt(rvSamples.value, 10);
        const n = Number.isFinite(raw) ? Math.max(0, raw) : 10000;
        rvSamples.value = String(n);
        setGuc('provsql.rv_mc_samples', n);
      });
    }
    // Live-update the value display as the slider drags; only POST on
    // release (`change`) so we don't hammer /api/config every step.
    verb.addEventListener('input', () => {
      if (verbOut) verbOut.textContent = verb.value;
    });
    verb.addEventListener('change', () => {
      const n = Math.max(0, Math.min(100, parseInt(verb.value || '0', 10) || 0));
      verb.value = String(n);
      if (verbOut) verbOut.textContent = verb.value;
      setGuc('provsql.verbose_level', n);
    });

    if (depth) {
      depth.addEventListener('input', () => {
        if (depthOut) depthOut.textContent = depth.value;
      });
      depth.addEventListener('change', () => {
        const n = Math.max(1, Math.min(50, parseInt(depth.value || '8', 10) || 8));
        depth.value = String(n);
        if (depthOut) depthOut.textContent = depth.value;
        setGuc('max_circuit_depth', n);
      });
    }
    if (sidebarRows) {
      sidebarRows.addEventListener('change', async () => {
        const n = Math.max(1, Math.min(5000, parseInt(sidebarRows.value || '100', 10) || 100));
        sidebarRows.value = String(n);
        await setGuc('max_sidebar_rows', n);
        // Refresh the where-mode sidebar so the new cap takes effect
        // immediately rather than on the next mode entry.
        if (document.body.classList.contains('mode-where')) {
          refreshRelations();
        }
      });
    }
    if (resultRows) {
      resultRows.addEventListener('change', () => {
        const n = Math.max(1, Math.min(100000, parseInt(resultRows.value || '1000', 10) || 1000));
        resultRows.value = String(n);
        setGuc('max_result_rows', n);
      });
    }
    if (circuitNodes) {
      circuitNodes.addEventListener('change', () => {
        const n = Math.max(10, Math.min(10000, parseInt(circuitNodes.value || '200', 10) || 200));
        circuitNodes.value = String(n);
        setGuc('max_circuit_nodes', n);
      });
    }
    if (timeout) {
      timeout.addEventListener('change', () => {
        const n = Math.max(1, Math.min(3600, parseInt(timeout.value || '30', 10) || 30));
        timeout.value = String(n);
        setGuc('statement_timeout_seconds', n);
      });
    }
    if (sp) {
      // Persist on blur and on Enter so the user's typing isn't saved
      // mid-edit. The trailing `, provsql` is enforced server-side; the
      // user only types the leading schemas they care about.
      const commit = () => {
        const v = (sp.value || '').trim();
        sp.value = v;
        setGuc('search_path', v);
        // Refresh the search_path readout under the query box so it
        // reflects the new value immediately rather than on the next
        // 5s connection-info poll.
        fetchConnInfo();
      };
      sp.addEventListener('blur', commit);
      sp.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') { e.preventDefault(); sp.blur(); }
      });
    }
    if (tsp) {
      // provsql.tool_search_path: same blur-and-Enter commit pattern as
      // search_path. Empty string falls back to the server's PATH.
      const commitTsp = () => {
        const v = (tsp.value || '').trim();
        tsp.value = v;
        setGuc('tool_search_path', v);
      };
      tsp.addEventListener('blur', commitTsp);
      tsp.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') { e.preventDefault(); tsp.blur(); }
      });
    }

    // Probability-display decimals : pure UI setting (no server roundtrip),
    // persisted in localStorage so the choice survives reloads. The eval
    // strip's float branch reads getProbDecimals() at render time.
    const probDec = document.getElementById('cfg-prob-decimals');
    if (probDec) {
      probDec.value = String(getProbDecimals());
      probDec.addEventListener('change', () => {
        const n = Math.max(0, Math.min(15, parseInt(probDec.value || '4', 10)));
        probDec.value = String(n);
        try { localStorage.setItem('ps.probDecimals', String(n)); } catch {}
      });
    }
  }

  // Default rounding for probability results. Configurable via the Config
  // panel's "Probability decimals" field; falls back to 4 when unset.
  function getProbDecimals() {
    const raw = (() => { try { return localStorage.getItem('ps.probDecimals'); } catch { return null; } })();
    const n = parseInt(raw || '', 10);
    return Number.isFinite(n) && n >= 0 && n <= 15 ? n : 4;
  }
  window.ProvsqlStudio.getProbDecimals = getProbDecimals;

  // Schema browser: top-nav button opening a popover that lists every
  // SELECT-able relation grouped by schema, with a search box and click
  // to insert "schema.relation" at the cursor in the textarea. The
  // /api/schema fetch is lazy (first open) and the result is cached for
  // the page session: if the schema actually changes (CREATE TABLE etc.),
  // a page reload re-fetches.
  function setupSchemaPanel() {
    const btn    = document.getElementById('schema-btn');
    const panel  = document.getElementById('schema-panel');
    const body   = document.getElementById('schema-body');
    const search = document.getElementById('schema-search');
    if (!btn || !panel || !body || !search) return;

    let loaded = false;
    let entries = [];

    async function load() {
      body.innerHTML = '<p class="wp-schema__empty">Loading…</p>';
      try {
        const resp = await fetch('/api/schema');
        if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
        entries = await resp.json();
        loaded = true;
        // Successful reload : clear the dirty flag so subsequent opens
        // reuse the cache until the next exec.
        if (window.ProvsqlStudio?.metadata) {
          window.ProvsqlStudio.metadata.schemaDirty = false;
        }
        render();
      } catch (e) {
        body.innerHTML =
          `<p class="wp-schema__empty">Failed to load schema: ${escapeHtml(e.message || String(e))}</p>`;
      }
    }

    function render() {
      const q = (search.value || '').trim().toLowerCase();
      const filtered = q
        ? entries.filter(r =>
            r.schema.toLowerCase().includes(q) ||
            r.table.toLowerCase().includes(q)  ||
            // Skip the bookkeeping `provsql` column so search results
            // match the visible column list : typing "provsql" should not
            // match every provenance-tracked relation through this hidden
            // column (use the PROV pill for that).
            r.columns.some(c => c.name !== 'provsql' && c.name.toLowerCase().includes(q))
          )
        : entries;
      if (filtered.length === 0) {
        body.innerHTML = '<p class="wp-schema__empty">No matches.</p>';
        return;
      }
      const bySchema = new Map();
      for (const r of filtered) {
        if (!bySchema.has(r.schema)) bySchema.set(r.schema, []);
        bySchema.get(r.schema).push(r);
      }
      // Within each schema, surface provenance-tracked tables first
      // (the user's primary working surface), then plain tables/views,
      // then mapping tables (rarely useful to inspect on their own;
      // they are auto-discovered by the eval strip). Alphabetical
      // within each rank, preserved by JS Array.sort's stability.
      const relRank = r => r.is_mapping ? 2 : (r.has_provenance ? 0 : 1);
      for (const rels of bySchema.values()) {
        rels.sort((a, b) => relRank(a) - relRank(b));
      }
      let html = '';
      for (const [schemaName, rels] of bySchema) {
        html += '<div class="wp-schema__group">';
        html += `<h5 class="wp-schema__schema">${escapeHtml(schemaName)}</h5>`;
        for (const r of rels) {
          const qname  = `${r.schema}.${r.table}`;
          // `bare_resolves` is the Python side's authoritative answer to
          // "would `SELECT ... FROM <bare>` find this exact relation
          // under the current search_path?". Use the bare name only when
          // the answer is yes; otherwise qualify so the click prefill
          // actually executes against the relation the user clicked.
          const insert = r.bare_resolves ? r.table : qname;
          // Hide the bookkeeping `provsql` uuid column from the user-visible
          // column list : its presence is what the PROV pill already signals.
          const visibleCols = r.columns.filter(c => c.name !== 'provsql');
          // Provenance-tracked tables (not mappings, not views) can have
          // a provenance mapping created on any of their columns. Render
          // each column name as a clickable span so the user can prefill
          // the corresponding `create_provenance_mapping(...)` call.
          const canMap = r.has_provenance && !r.is_mapping && r.kind === 'table';
          // ProvSQL-extended column types carry circuit references rather
          // than plain scalars, so query operators on them are intercepted by
          // the planner hook. Flag them with a small terracotta pill so the
          // user can spot them in the schema panel; matchesProvType handles
          // both the unqualified form (provsql on search_path) and the
          // qualified one.
          const colPill = c => {
            if (matchesProvType(c.type, 'random_variable')) {
              return `<span class="wp-schema__col-rv" title="random_variable: query operators on this column lift into provenance gates at planning time">rv</span>`;
            }
            if (matchesProvType(c.type, 'agg_token')) {
              return `<span class="wp-schema__col-agg" title="agg_token: each value carries a circuit UUID; click cells to inspect the underlying gate">agg</span>`;
            }
            return '';
          };
          const cols   = visibleCols.map(c => {
            const pill = colPill(c);
            // create_provenance_mapping needs a column whose value tags an
            // input gate. random_variable / agg_token columns don't carry an
            // extractable tag value (each row is a circuit gate or composite,
            // not a scalar), so the click affordance would prefill a
            // meaningless call. Render the column name as plain text in that
            // case.
            if (canMap && !pill) {
              return `<span class="wp-schema__col" data-action="create-mapping"`
                + ` data-qname="${escapeAttr(qname)}"`
                + ` data-table="${escapeAttr(r.table)}"`
                + ` data-col="${escapeAttr(c.name)}"`
                + ` title="Click to create a provenance mapping on ${escapeAttr(c.name)}"`
                + `>${escapeHtml(c.name)}</span>`;
            }
            return `${escapeHtml(c.name)}${pill}`;
          }).join(', ');
          // Mapping is the more specific classification: a mapping view
          // typically also carries an implicit provsql column (the planner
          // re-injects it for any view that selects from a provenance-tracked
          // table), but tagging it as both is noisy. Show only "mapping".
          const showProv = r.has_provenance && !r.is_mapping;
          const provCls   = showProv ? ' wp-schema__rel--prov' : '';
          // The PROV badge is split into PROV-TID / PROV-BID / PROV-OPAQUE
          // when the per-relation metadata is known (1.6.0+). Older
          // schemas leave `prov_kind` null and we fall back to a bare
          // "prov". The qualified form is discreet on purpose : it
          // matters most for probabilistic query evaluation (TID =
          // independent leaves, BID = block-correlated) but is
          // meaningful in other settings too. OPAQUE warns the user
          // that the safe-query rewriter will refuse on the table.
          let provLabel = 'prov';
          let provTip = 'Provenance-tracked (provsql uuid column)';
          let provKindCls = '';
          if (r.prov_kind === 'tid') {
            provLabel = 'prov-tid';
            provTip   = 'Provenance-tracked, independent leaves (TID): '
                      + 'one independent random variable per row, '
                      + 'standard probabilistic semantics.';
            provKindCls = ' wp-schema__rel-prov--tid';
          } else if (r.prov_kind === 'bid') {
            provLabel = 'prov-bid';
            provTip   = 'Provenance-tracked, block-correlated (BID): '
                      + 'rows sharing the same block key are mutually '
                      + 'exclusive (set via repair_key).';
            provKindCls = ' wp-schema__rel-prov--bid';
          } else if (r.prov_kind === 'opaque') {
            // OPAQUE keeps the bare "prov" label : the muted-tone
            // pill is enough to flag "kind not certified" and
            // "prov-opaque" reads as redundant against the tooltip.
            // Mirrors the convention used by the result-table pill.
            // Wording covers both flavours of opaque : tables marked
            // opaque via user-supplied provsql values (set_table_info
            // or the provenance_guard trigger), and views whose body
            // the planner-hook classifier cannot certify TID / BID
            // (e.g. multi-source join, sublink).
            provTip   = 'Provenance-tracked, opaque kind: the relation '
                      + 'either carries user-supplied or shared provsql '
                      + 'tokens, or its body has structure the classifier '
                      + 'cannot certify TID / BID (multi-source join, '
                      + 'sublink). The safe-query rewriter refuses to '
                      + 'fire on it.';
            provKindCls = ' wp-schema__rel-prov--opaque';
          }
          const provBadge = showProv
            ? `<span class="wp-schema__rel-prov${provKindCls}" `
              + `title="${escapeAttr(provTip)}">${provLabel}</span>`
            : '';
          const mapBadge = r.is_mapping
            ? `<span class="wp-schema__rel-map" title="Provenance mapping (value + provenance uuid columns)">mapping</span>`
            : '';
          const titleSuffix =
              (showProv ? ' · provenance-tracked' : '')
            + (r.is_mapping ? ' · provenance mapping' : '');
          // Column count for the tooltip mirrors the comma-separated
          // list rendered above: `visibleCols` is the post-filter view
          // (hiding the bookkeeping `provsql` column when present), so
          // its length is authoritative.  Naive `r.columns.length - 1`
          // would undercount views by one : views' planner-injected
          // `provsql` column never lands in pg_attribute, so it isn't
          // in `r.columns` to begin with, and the subtraction would
          // remove a non-existent entry.
          const visibleCount = visibleCols.length;
          // add/remove_provenance only target plain tables (the underlying
          // ALTER TABLE rejects views/matviews); mappings already serve a
          // separate purpose so we don't offer the toggle on them.
          const canAddRemove = r.kind === 'table' && !r.is_mapping;
          let actions = '';
          if (canAddRemove) {
            if (r.has_provenance) {
              actions =
                `<button type="button" class="wp-schema__rel-action" `
                + `data-action="remove-prov" data-qname="${escapeAttr(qname)}" `
                + `title="Insert SELECT remove_provenance('${escapeAttr(qname)}');">`
                + `<i class="fas fa-minus"></i> prov</button>`;
            } else {
              actions =
                `<button type="button" class="wp-schema__rel-action" `
                + `data-action="add-prov" data-qname="${escapeAttr(qname)}" `
                + `title="Insert SELECT add_provenance('${escapeAttr(qname)}');">`
                + `<i class="fas fa-plus"></i> prov</button>`;
            }
          }
          // Outer is a div with role=button so the inner action buttons can
          // be real <button> elements (nested buttons aren't valid HTML).
          html +=
            `<div class="wp-schema__rel${provCls}" role="button" tabindex="0"`
            + ` data-qname="${escapeAttr(insert)}"`
            + ` title="${escapeAttr(qname)}: ${visibleCount} column${visibleCount === 1 ? '' : 's'}${titleSuffix}">`
            + `<span class="wp-schema__rel-name">${escapeHtml(r.table)}</span>`
            + `<span class="wp-schema__rel-kind">${escapeHtml(r.kind)}</span>`
            + provBadge
            + mapBadge
            + (actions ? `<span class="wp-schema__rel-actions">${actions}</span>` : '');
          if (cols) {
            // `cols` is already escaped per-column inside the map above
            // (it's a mix of HTML spans and escaped text), so don't double-escape.
            html += `<span class="wp-schema__cols">${cols}</span>`;
          }
          html += `</div>`;
        }
        html += '</div>';
      }
      body.innerHTML = html;
    }

    function insertAtCursor(text) {
      const ta = document.getElementById('request');
      if (!ta) return;
      const start = ta.selectionStart != null ? ta.selectionStart : ta.value.length;
      const end   = ta.selectionEnd   != null ? ta.selectionEnd   : ta.value.length;
      ta.value = ta.value.slice(0, start) + text + ta.value.slice(end);
      const newPos = start + text.length;
      ta.setSelectionRange(newPos, newPos);
      ta.focus();
      // Notify the syntax-highlight overlay (it listens for `input`).
      ta.dispatchEvent(new Event('input', { bubbles: true }));
    }

    // Used by the per-relation action buttons (add/remove_provenance):
    // they generate a complete standalone query, so blow the previous
    // textarea content away rather than concatenate.
    function replaceQuery(text) {
      const ta = document.getElementById('request');
      if (!ta) return;
      ta.value = text;
      ta.setSelectionRange(text.length, text.length);
      ta.focus();
      ta.dispatchEvent(new Event('input', { bubbles: true }));
    }

    body.addEventListener('click', (e) => {
      // Action elements (add/remove_provenance row buttons,
      // create-mapping column spans) replace the textarea with a complete
      // SELECT call so the user can review and run it directly. Match
      // these first so they don't fall through to the row-level qname
      // insert.
      const action = e.target.closest('[data-action]');
      if (action) {
        const a = action.dataset.action;
        const q = action.dataset.qname;
        let sql;
        if (a === 'add-prov' && q) {
          sql = `SELECT add_provenance('${q}');`;
        } else if (a === 'remove-prov' && q) {
          sql = `SELECT remove_provenance('${q}');`;
        } else if (a === 'create-mapping' && q && action.dataset.col) {
          const col   = action.dataset.col;
          const table = action.dataset.table || q;
          // Default mapping name `<table>_<col>_mapping` is a sensible
          // starting point; the user can tweak it in the textarea before
          // hitting Send.
          const mname = `${table}_${col}_mapping`;
          sql = `SELECT create_provenance_mapping('${mname}', '${q}', '${col}');`;
        }
        if (sql) {
          replaceQuery(sql);
          close();
        }
        return;
      }
      const rel = e.target.closest('.wp-schema__rel');
      if (!rel || !rel.dataset.qname) return;
      // Clicking a relation row replaces the textarea with a ready-to-run
      // SELECT * FROM <relation> ; in practice that is what the user
      // wants nine times out of ten, so saving the keystrokes wins over
      // the bare-name insert.
      replaceQuery(`SELECT * FROM ${rel.dataset.qname};`);
      close();
    });
    // The relation row is now a div role=button (so the inner action
    // <button>s nest validly). Wire Enter/Space so keyboard users can
    // still activate the row insert.
    body.addEventListener('keydown', (e) => {
      if (e.key !== 'Enter' && e.key !== ' ') return;
      const rel = e.target.closest('.wp-schema__rel');
      if (!rel || rel !== e.target || !rel.dataset.qname) return;
      e.preventDefault();
      replaceQuery(`SELECT * FROM ${rel.dataset.qname};`);
      close();
    });
    search.addEventListener('input', () => { if (loaded) render(); });
    search.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') close();
    });

    function open() {
      panel.hidden = false;
      btn.setAttribute('aria-expanded', 'true');
      // Re-fetch on open if either we never loaded, or an exec since the
      // last load may have changed the schema (the dirty flag is set by
      // runQuery and by the toolbar refresh button).
      const dirty = window.ProvsqlStudio?.metadata?.schemaDirty;
      if (!loaded || dirty) load();
      setTimeout(() => search.focus(), 0);
    }
    function close() {
      panel.hidden = true;
      btn.setAttribute('aria-expanded', 'false');
    }
    btn.addEventListener('click', (e) => {
      e.stopPropagation();
      if (panel.hidden) open(); else close();
    });
    document.addEventListener('click', (e) => {
      if (!panel.hidden && !panel.contains(e.target) && e.target !== btn) close();
    });
    document.addEventListener('keydown', (e) => {
      if (e.key === 'Escape' && !panel.hidden) close();
    });

    // Toolbar refresh button. Forces a reload of the three metadata
    // caches; schema reloads in place if its panel is open, the eval-
    // strip caches reload lazily on next dropdown open via their dirty
    // flags. Also briefly spins the icon so the user sees feedback even
    // when the panel is closed.
    const refreshBtn = document.getElementById('metadata-refresh-btn');
    if (refreshBtn) {
      refreshBtn.addEventListener('click', () => {
        if (window.ProvsqlStudio?.metadata) {
          window.ProvsqlStudio.metadata.invalidateAll();
        }
        const icon = refreshBtn.querySelector('i');
        if (icon) {
          icon.classList.add('fa-spin');
          setTimeout(() => icon.classList.remove('fa-spin'), 600);
        }
        if (!panel.hidden) load();
      });
    }
  }

  function setupGucToggles() {
    const fs = document.getElementById('prov-scheme-fieldset');
    const up = document.getElementById('opt-update-prov');
    if (!fs || !up) return;
    const radios = fs.querySelectorAll('input[name="prov-scheme"]');

    // Persisted across mode switches. The stored value is the user's
    // last *circuit-mode* pick; Where UI mode locks the selector to
    // `where` but does not overwrite the stored value, so a
    // circuit→where→circuit round-trip preserves the user's pick.
    // `boolean`/`semiring`/`where`; default `semiring`.
    const savedMode = sessionStorage.getItem('ps.opt.provScheme') || 'semiring';
    const savedUpdate = sessionStorage.getItem('ps.opt.updateProv') === '1';

    const setMode = (m) => {
      radios.forEach((r) => { r.checked = (r.value === m); });
    };

    if (mode === 'where') {
      setMode('where');
      fs.classList.add('is-locked');
      fs.title = 'Where UI mode requires where-provenance (the cell-highlight wrap depends on it). '
               + 'Switch to Circuit mode to pick Boolean or Semiring.';
      radios.forEach((r) => { r.disabled = true; });
    } else {
      setMode(savedMode === 'where' || savedMode === 'boolean' ? savedMode : 'semiring');
    }
    up.checked = savedUpdate;

    fs.addEventListener('change', () => {
      if (mode === 'where') return;
      const picked = fs.querySelector('input[name="prov-scheme"]:checked');
      if (picked) sessionStorage.setItem('ps.opt.provScheme', picked.value);
    });
    up.addEventListener('change', () => {
      sessionStorage.setItem('ps.opt.updateProv', up.checked ? '1' : '0');
    });
  }

  function setupCircuitMode() {
    document.getElementById('sidebar-title').textContent = 'Provenance Circuit';
    document.getElementById('sidebar-body').innerHTML = circuitSidebarHtml();
    document.getElementById('result-legend').innerHTML =
      '<span class="wp-legend-swatch" style="background:var(--purple-500)"></span> Click a UUID / agg_token cell in the result to inspect its circuit.';

    // Click handler on result-body for UUID/agg_token cells. We rely on the
    // cell having data-circuit-uuid when it's clickable; set during render.
    // data-row-prov (also set during render) is forwarded so the eval
    // strip's "Condition on" auto-preset reflects the row the user just
    // clicked, including when the target was the row's random_variable
    // cell (whose scene root is the RV itself, not the row's prov).
    document.getElementById('result-body').addEventListener('click', (e) => {
      const cell = e.target.closest('.wp-result__cell.is-clickable');
      if (!cell || !cell.dataset.circuitUuid) return;
      loadCircuit(cell.dataset.circuitUuid, { rowProv: cell.dataset.rowProv || '' });
    });

    // If a query was carried over (mode switch / preload), run it so the
    // user has clickable cells immediately; otherwise wait for them to type.
    const carry = preloadCircuitUuid;
    // Coming from where mode's per-row "→ Circuit" jump: the carried UUID
    // was minted by a where-provenance wrap, so the same query must run
    // with where_provenance on here for the resulting circuit to contain
    // the project/eq gates the user is trying to inspect. Force the
    // selector to the `where` flavour (the user can switch it back for
    // follow-up runs); the radio flip is programmatic so it does not
    // fire `change` and does not get persisted to sessionStorage.
    if (carry) {
      const r = document.querySelector('input[name="prov-scheme"][value="where"]');
      if (r) r.checked = true;
    }
    // Load circuit.js so its init() can wire the toolbar buttons (zoom,
    // show-uuids). loadCircuit() also calls this, but only
    // when a circuit is being rendered : without the unconditional load
    // here, a circuit-mode page that just runs a query (no carry, no
    // immediate circuit fetch) would leave the toolbar buttons unbound.
    //
    // Microtask deferral: `ensureCircuitLib` closes over
    // `_circuitLibPromise`, which is declared with `let` further down in
    // this IIFE. setupCircuitMode runs synchronously during IIFE eval,
    // so calling it directly here would hit a TDZ. Queueing as a
    // microtask defers the call until after the IIFE returns, by which
    // time the binding is initialised. The function is idempotent (it
    // caches the promise), so the later loadCircuit() callers piggyback.
    queueMicrotask(ensureCircuitLib);

    // Auto-replay only when the carried query had actually been executed
    // (carriedRan), or when we arrived via a where→circuit jump (carry):
    // the jump implies a successful query run in where mode whose row
    // the user clicked. On plain reload / unrun draft, do NOT re-execute
    // (side-effects like add_provenance must not fire on their own).
    const shouldReplay =
      (carriedRan || carry) && document.getElementById('request').value.trim();
    if (shouldReplay) {
      runQuery({ preventDefault() {} }).then(() => {
        if (carry) loadCircuit(carry);
      });
    } else if (carry) {
      // No query but a preload UUID: render the circuit directly.
      loadCircuit(carry);
    }
  }

  async function loadCircuit(uuid, opts) {
    await ensureCircuitLib();
    window.ProvsqlCircuit.showLoading();
    const depthArg = opts && Number.isFinite(opts.depth) ? opts.depth : null;
    const url = `/api/circuit/${encodeURIComponent(uuid)}`
              + (depthArg != null ? `?depth=${depthArg}` : '');
    let resp;
    try {
      resp = await fetch(url);
    } catch (e) {
      window.ProvsqlCircuit.showError(`Network error: ${e.message}`);
      return;
    }
    if (!resp.ok) {
      const err = await resp.json().catch(() => ({}));
      // 413: structured "circuit too large" payload. Render the
      // actionable banner with a "Render at depth N-1" retry button
      // that re-fires loadCircuit at a lower depth.
      if (resp.status === 413 && err && err.error === 'circuit too large') {
        // Pass rootUuid so the eval strip can still target the
        // unrendered circuit: evaluation only needs the token, not a
        // displayed DAG.
        window.ProvsqlCircuit.showTooLarge(
          err,
          (lowerDepth) => loadCircuit(uuid, { depth: lowerDepth }),
          { rootUuid: uuid },
        );
        return;
      }
      window.ProvsqlCircuit.showError(err.error || `HTTP ${resp.status}`);
      return;
    }
    const scene = await resp.json();
    window.ProvsqlCircuit.renderCircuit(scene, {
      rowProv: (opts && opts.rowProv) || '',
    });
  }

  let _circuitLibPromise = null;
  function ensureCircuitLib() {
    if (window.ProvsqlCircuit) return Promise.resolve(window.ProvsqlCircuit);
    if (_circuitLibPromise) return _circuitLibPromise;
    _circuitLibPromise = new Promise((resolve, reject) => {
      const s = document.createElement('script');
      s.src = '/static/circuit.js';
      s.onload  = () => { try { window.ProvsqlCircuit.init(); } catch (e) {} resolve(window.ProvsqlCircuit); };
      s.onerror = () => reject(new Error('failed to load circuit.js'));
      document.body.appendChild(s);
    });
    return _circuitLibPromise;
  }

  function circuitSidebarHtml() {
    return `
      <div class="cv-toolbar" role="toolbar">
        <button class="cv-tool" id="tool-zoom-out" title="Zoom out"><i class="fas fa-search-minus"></i></button>
        <button class="cv-tool" id="tool-zoom-fit" title="Fit to screen"><i class="fas fa-expand"></i></button>
        <button class="cv-tool" id="tool-zoom-in" title="Zoom in"><i class="fas fa-search-plus"></i></button>
        <span class="cv-tool__sep"></span>
        <button class="cv-tool" id="tool-reset-layout" title="Reset node positions (undo any drag-to-move)"><i class="fas fa-undo"></i></button>
        <button class="cv-tool cv-tool--toggle" id="tool-show-uuids" aria-pressed="false" title="Show UUIDs"><i class="fas fa-fingerprint"></i></button>
        <button class="cv-tool cv-tool--toggle" id="tool-fullscreen" aria-pressed="false" title="Fullscreen circuit (Esc to exit)"><i class="fas fa-expand-arrows-alt"></i></button>
        <span id="circuit-title" hidden>Provenance Circuit</span>
        <span class="cv-toolbar__info" id="circuit-sub">Click a UUID cell to render.</span>
      </div>
      <div class="cv-canvas" id="canvas">
        <div class="cv-banner" id="cv-banner" hidden></div>
        <svg id="circuit" preserveAspectRatio="xMidYMid meet">
          <defs>
            <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
              <path d="M0,0 L10,5 L0,10 z" fill="var(--purple-700)"></path>
            </marker>
            <marker id="arrow-active" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
              <path d="M0,0 L10,5 L0,10 z" fill="var(--terracotta-500)"></path>
            </marker>
          </defs>
          <g id="circuit-edges"></g>
          <g id="circuit-nodes"></g>
        </svg>
        <aside class="cv-inspector" id="inspector">
          <header class="cv-inspector__hdr">
            <h3 class="cv-inspector__title" id="inspector-title">Node</h3>
            <a class="wp-help"
               href="https://provsql.org/docs/user/studio.html#studio-circuit-inspector"
               target="_blank" rel="noopener"
               title="Inspector: gate-specific metadata. Input/update gates expose probability with click-to-edit."
               aria-label="Help: inspector panel"><i class="fas fa-question-circle"></i></a>
            <button class="cv-inspector__close" id="inspector-close"><i class="fas fa-times"></i></button>
          </header>
          <div class="cv-inspector__body" id="inspector-body"></div>
        </aside>
      </div>
      <footer class="cv-eval" id="eval-strip">
        <div class="cv-eval__hdr">
          <h4 class="cv-eval__label">Evaluate<a class="wp-help"
             href="https://provsql.org/docs/user/studio.html#studio-circuit-eval-strip"
             target="_blank" rel="noopener"
             title="Run a semiring (compiled, custom, or Other: probability / PROV-XML) against the pinned node, else the root."
             aria-label="Help: evaluation strip"><i class="fas fa-question-circle"></i></a></h4>
          <span class="cv-eval__target" id="eval-target" title="Token the evaluation will be run against (selected node, otherwise the root)"></span>
        </div>
        <div class="cv-eval__form">
          <select class="cv-eval__semiring" id="eval-semiring">
            <!-- Compiled-semiring sub-optgroups (Boolean & symbolic,
                 Lineage, Numeric, Interval-valued) are injected at init
                 from circuit.js's compiled-semiring registry, ahead of
                 the "Custom Semirings" / "Other" groups below. -->
            <optgroup label="Custom Semirings" id="eval-custom-group" hidden>
              <!-- populated lazily from /api/custom_semirings -->
            </optgroup>
            <optgroup label="Distribution">
              <option value="distribution-profile">Distribution profile</option>
              <option value="moment">Moment</option>
              <option value="sample">Sample</option>
            </optgroup>
            <optgroup label="Other">
              <option value="probability">Probability</option>
              <option value="prov-xml">PROV-XML export</option>
            </optgroup>
          </select>
          <select class="cv-eval__mapping" id="eval-mapping" hidden>
            <option value="">(no mappings found)</option>
          </select>
          <span class="cv-eval__hint" id="eval-mapping-hint" hidden></span>
          <select class="cv-eval__method" id="eval-method" hidden>
            <optgroup label="Exact">
              <option value="">(default)</option>
              <option value="independent">independent</option>
              <option value="possible-worlds">possible-worlds</option>
              <option value="tree-decomposition">tree-decomposition</option>
              <option value="compilation">compilation</option>
            </optgroup>
            <optgroup label="Approximate">
              <option value="monte-carlo">monte-carlo</option>
              <option value="weightmc">weightmc</option>
            </optgroup>
          </select>
          <input type="number" class="cv-eval__args" id="eval-args-mc" hidden
                 min="1" step="1" placeholder="samples" value="10000"
                 autocomplete="off" title="Monte-Carlo sample count">
          <select class="cv-eval__args" id="eval-args-compiler" hidden
                  title="Knowledge compiler ProvSQL will invoke">
            <option value="d4">d4</option>
            <option value="c2d">c2d</option>
            <option value="minic2d">minic2d</option>
            <option value="dsharp">dsharp</option>
          </select>
          <input type="text" class="cv-eval__args" id="eval-args-wmc" hidden
                 value="0.8;0.2" placeholder="epsilon;delta"
                 autocomplete="off" spellcheck="false"
                 title="WeightMC parameters: epsilon;delta (defaults: 0.8;0.2)">
          <input type="number" class="cv-eval__args" id="eval-args-bins" hidden
                 min="1" step="1" placeholder="bins" value="30"
                 autocomplete="off" title="Histogram bin count for the distribution profile">
          <input type="number" class="cv-eval__args" id="eval-args-moment-k" hidden
                 min="0" step="1" placeholder="k" value="2"
                 autocomplete="off" title="Moment order k (k=1 gives the mean, k=2 with central=on gives the variance)">
          <select class="cv-eval__args" id="eval-args-moment-central" hidden
                  title="Raw moment E[X^k] vs central moment E[(X − E[X])^k]">
            <option value="false">raw</option>
            <option value="true">central</option>
          </select>
          <input type="number" class="cv-eval__args" id="eval-args-sample-n" hidden
                 min="1" step="1" placeholder="target n" value="100"
                 autocomplete="off"
                 title="Target number of samples.  Without conditioning, every draw is kept, so this is just the draw count.  With conditioning, rv_sample uses rejection sampling: draws from the unconditional distribution are accepted only when the event holds, capped at provsql.rv_mc_samples trials.  If the event is rare, you may get fewer accepts than requested.">
          <span class="cv-eval__args-group" id="eval-args-condition-group" hidden>
            <span class="cv-eval__args-label">Conditioned by:</span>
            <span class="cv-eval__cond-badge" id="eval-args-condition-badge" hidden
                  title="The Condition input was auto-filled with the row's provenance gate, the canonical conditioning event for expected(rv, provenance()).  Edit it to override.">
              <i class="fas fa-link"></i> row prov
            </span>
            <input type="text" class="cv-eval__args" id="eval-args-condition"
                   placeholder="condition on (UUID)" autocomplete="off"
                   spellcheck="false" size="20"
                   title="Optional conditioning gate UUID: the result becomes the conditional distribution X | event.  Auto-filled with the row's provenance when you click into a row's circuit; clear it for the unconditional distribution.">
          </span>
        </div>
        <div class="cv-eval__action-row">
          <button class="cv-eval__run wp-btn wp-btn--mini" id="eval-run" type="button">
            <i class="fas fa-play"></i> Run
          </button>
          <span class="cv-eval__btnpair">
            <button type="button" class="cv-eval__clear" id="eval-clear" title="Clear result" aria-label="Clear result" hidden>
              <i class="fas fa-eraser"></i>
            </button>
            <button type="button" class="cv-eval__copy" id="eval-copy" title="Copy result to clipboard" aria-label="Copy result" hidden>
              <i class="fas fa-clipboard"></i>
            </button>
          </span>
          <span class="cv-eval__result" id="eval-result"></span>
          <span class="cv-eval__bound"  id="eval-bound"></span>
          <span class="cv-eval__time"   id="eval-time"></span>
        </div>
      </footer>
    `;
  }

  /* ──────── shared helpers ──────── */

  function escapeHtml(s) {
    return String(s == null ? '' : s)
      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  }
  function escapeAttr(s) {
    return escapeHtml(s).replace(/"/g, '&quot;');
  }

  function formatCell(v, typeName) {
    // UUID-typed columns render as a short/full pair so the circuit
    // panel's "Show UUIDs" toggle can flip between abbreviated and full
    // display via a body-level CSS class : no re-render needed. The
    // outer span carries the full value as a title so hover always
    // reveals the original even when collapsed.
    const lowerType = (typeName || '').toLowerCase();
    if ((lowerType === 'uuid' || lowerType === 'random_variable') && v != null && v !== '') {
      const s = String(v);
      // Match circuit.js's shortUuid so both views render UUIDs the same
      // way: 4 hex chars + ellipsis is enough for cursory same/different
      // identification; the full value is a "Show UUIDs" click away.
      const shortStr = s.length > 4 ? s.slice(0, 4) + '…' : s;
      return `<span class="wp-uuid" title="${escapeAttr(s)}">`
           + `<span class="wp-uuid__short">${escapeHtml(shortStr)}</span>`
           + `<span class="wp-uuid__full">${escapeHtml(s)}</span>`
           + `</span>`;
    }
    // Multi-line text values (e.g. provsql.view_circuit's ASCII tree
    // dump, or any TEXT column that contains newlines) need pre-wrap +
    // monospace so newlines and indentation survive : the table cell's
    // default whitespace handling collapses them otherwise.
    if (typeof v === 'string' && v.indexOf('\n') !== -1) {
      return `<pre class="wp-cell-pre">${escapeHtml(v)}</pre>`;
    }
    return escapeHtml(v == null ? '' : v);
  }

  // PostgreSQL pg_type names that conventionally render right-aligned:
  // numerics (int / numeric / float / money) plus date/time/interval. Also
  // agg_token, whose visible glyph is a number ("3 (*)") even though its
  // underlying storage is a UUID.
  const RIGHT_ALIGNED_TYPES = new Set([
    'int2', 'int4', 'int8', 'smallint', 'integer', 'bigint',
    'numeric', 'decimal',
    'float4', 'float8', 'real',
    'money',
    'agg_token',
    'random_variable',
    'date', 'time', 'timetz', 'timestamp', 'timestamptz', 'interval',
    'uuid',
  ]);
  function isRightAlignedType(typeName) {
    return RIGHT_ALIGNED_TYPES.has((typeName || '').toLowerCase());
  }

  // Recognise a ProvSQL-extended column type regardless of search_path:
  // `random_variable` and `agg_token` may surface either unqualified
  // (provsql is on the search_path) or qualified (`provsql.random_variable`).
  // Used by the schema panel's column-list pills and by the result-table
  // header to flag the same columns once the data is rendered.
  function matchesProvType(typeName, base) {
    const s = String(typeName || '').toLowerCase();
    return s === base || s === `provsql.${base}`;
  }

  // Connection-editor popover anchored to the green/red status dot.
  // Lets the user paste an arbitrary libpq DSN (host, port, user,
  // password, dbname, options) so they can switch server / role
  // without restarting the studio. Uses POST /api/conn { dsn: "…" }.
  function setupConnEditor() {
    const dot    = document.getElementById('conn-dot');
    const panel  = document.getElementById('dsn-panel');
    const input  = document.getElementById('dsn-input');
    const apply  = document.getElementById('dsn-apply');
    const status = document.getElementById('dsn-status');
    if (!dot || !panel || !input || !apply || dot.dataset.bound === '1') return;
    dot.dataset.bound = '1';

    function showStatus(msg, isErr) {
      if (!status) return;
      if (!msg) { status.hidden = true; status.textContent = ''; status.classList.remove('is-error'); return; }
      status.hidden = false;
      status.textContent = msg;
      status.classList.toggle('is-error', !!isErr);
    }
    function open() {
      // Prefill with the password-stripped DSN the server reports.
      // The user retypes the password when switching role/host if
      // needed; we don't echo secrets back.
      if (_currentConn && _currentConn.dsn) input.value = _currentConn.dsn;
      panel.hidden = false;
      dot.setAttribute('aria-expanded', 'true');
      showStatus('');
      input.focus();
      input.select();
    }
    function close() {
      panel.hidden = true;
      dot.setAttribute('aria-expanded', 'false');
    }

    dot.addEventListener('click', (e) => {
      e.stopPropagation();
      if (panel.hidden) open(); else close();
    });
    document.addEventListener('click', (e) => {
      if (!panel.hidden && !panel.contains(e.target) && e.target !== dot) close();
    });
    input.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') { e.preventDefault(); close(); }
      else if (e.key === 'Enter') { e.preventDefault(); apply.click(); }
    });

    apply.addEventListener('click', async () => {
      const dsn = (input.value || '').trim();
      if (!dsn) { showStatus('Empty DSN', true); return; }
      apply.disabled = true;
      showStatus('Connecting…');
      try {
        const resp = await fetch('/api/conn', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ dsn }),
        });
        const data = await resp.json().catch(() => ({}));
        if (!resp.ok) {
          showStatus(data.reason || data.error || `HTTP ${resp.status}`, true);
          return;
        }
        // Refresh the page so cached relations / circuit / result table
        // reflect the new server's contents (same approach as the DB
        // switcher's POST handler). sessionStorage carries the SQL
        // textarea across the reload.
        const ta = document.getElementById('request');
        if (ta) sessionStorage.setItem('ps.sql', ta.value);
        window.location.reload();
      } catch (e) {
        showStatus(`Network error: ${e.message}`, true);
      } finally {
        apply.disabled = false;
      }
    });
  }
  setupConnEditor();

  // Show / clear the "no DB selected" hint banner. Lives at the top of
  // the page (above the wp-shell grid) so it's the first thing the user
  // sees on a freshly-launched studio with no --dsn. Single instance
  // keyed by id so repeated /api/conn polls don't duplicate it.
  function _renderDbAutoHint(show) {
    const id = 'db-auto-hint';
    let el = document.getElementById(id);
    if (!show) {
      if (el) el.remove();
      return;
    }
    if (el) return;
    el = document.createElement('div');
    el.id = id;
    el.className = 'wp-db-hint';
    el.innerHTML =
      `<i class="fas fa-info-circle"></i> `
      + `No database picked at launch: currently on the <code>postgres</code> `
      + `maintenance DB. `
      + `<button type="button" class="wp-db-hint__btn" id="db-auto-pick">`
      + `Pick a database…</button>`;
    // Insert directly after <nav>; using body.firstChild was unsafe
    // because whitespace text nodes sit before the nav element and
    // pushed the banner above it.
    const nav = document.querySelector('.wp-nav');
    if (nav && nav.parentNode) {
      nav.parentNode.insertBefore(el, nav.nextSibling);
    } else {
      document.body.appendChild(el);
    }
    // Open the database menu (the user@dbname chip in the top-right of
    // the nav) on click. The chip's own click toggles the dropdown, so
    // we forward the event to it. Crucially, the original click event
    // ALSO bubbles up to a document-level handler in setupDbSwitcher
    // that closes any open menu when the click target isn't the chip
    // : without stopPropagation here, the menu would open and instantly
    // close again.
    const pick = document.getElementById('db-auto-pick');
    if (pick) pick.onclick = (e) => {
      e.stopPropagation();
      const chip = document.getElementById('conn-info');
      if (chip) chip.click();
    };
  }

})();

// Per-kind metadata used by the result-table `provsql` column header
// pill.  Labels deliberately mirror the schema panel's prov-tid /
// prov-bid / prov-opaque pills so the two affordances read as the
// same idea.  Lives at module scope rather than inside runQuery so
// the hoisted renderBlocks function can read it without hitting the
// const TDZ on the first call.
const CLASSIFIER_LABELS = {
  tid:    'prov-tid',
  bid:    'prov-bid',
  // OPAQUE falls back to the bare "prov" label : the muted-tone
  // styling on the pill is enough to signal "kind unknown" and
  // "prov-opaque" reads as redundant against the explainer tooltip.
  opaque: 'prov',
};
const CLASSIFIER_EXPLAINERS = {
  tid:    'The query result is independent at the row level (TID): '
        + 'distinct output rows have disjoint lineages.',
  bid:    'The query result is block-correlated (BID): rows sharing '
        + 'a block key value are mutually exclusive.',
  opaque: 'The query result is opaque: correlations across rows are '
        + 'not certified by the classifier.',
};

/* Global runQuery: invoked by the form's inline onsubmit. POSTs to /api/exec
   and renders the response into the result section. */
async function runQuery(ev) {
  ev.preventDefault();

  const env = window.__provsqlStudio || { mode: 'where', escapeHtml: s => s, escapeAttr: s => s, formatCell: v => v };
  const sqlText = document.getElementById('request').value;
  const head    = document.getElementById('result-head');
  const body    = document.getElementById('result-body');
  const count   = document.getElementById('result-count');
  const time    = document.getElementById('result-time');

  // Loading state.
  body.innerHTML = `<tr><td style="opacity:.6; text-align:center; padding:1rem">Running…</td></tr>`;
  count.textContent = '…';
  time.textContent = '…';
  // Clear the previous run's truncation hint and notice / error banners
  // so they don't linger next to the new query's "running…" placeholder.
  // renderError still writes into result-banners on a failed POST, so
  // wiping here is safe : the success path repopulates them on render.
  const truncMark = document.getElementById('result-truncated');
  if (truncMark) {
    truncMark.textContent = '';
    truncMark.hidden = true;
  }
  const banners = document.getElementById('result-banners');
  if (banners) banners.innerHTML = '';
  const t0 = performance.now();

  // Wipe any previous circuit so it doesn't linger next to the new query's
  // result. The user may click a UUID cell in the new result to render a
  // fresh DAG; until then the canvas should be empty.
  if (env.mode === 'circuit') {
    window.ProvsqlCircuit?.clearScene?.();
  }

  // Cancel-button wiring: tag the in-flight request so /api/cancel/<id>
  // can resolve it back to the backend pid. The Send -> Cancel swap is
  // deferred 100ms so very fast queries (which return before the timer
  // fires) never flicker the row; clearTimeout in the finally branch
  // cancels the swap, and the same finally restores Send unconditionally.
  const requestId = (window.crypto && crypto.randomUUID)
    ? crypto.randomUUID()
    : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
  const runBtn    = document.getElementById('run-btn');
  const cancelBtn = document.getElementById('cancel-btn');
  if (cancelBtn) {
    cancelBtn.dataset.requestId = requestId;
    cancelBtn.disabled = false;
  }
  const swapTimer = setTimeout(() => {
    if (cancelBtn) cancelBtn.hidden = false;
    if (runBtn)    runBtn.hidden    = true;
  }, 100);

  const upEl = document.getElementById('opt-update-prov');
  const provSchemeEl = document.querySelector('input[name="prov-scheme"]:checked');
  let resp;
  try {
    resp = await fetch('/api/exec', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        sql: sqlText,
        mode: env.mode,
        prov_scheme: env.mode === 'where' ? 'where' : (provSchemeEl ? provSchemeEl.value : 'semiring'),
        update_provenance: upEl ? upEl.checked : false,
        request_id: requestId,
      }),
    });
  } catch (e) {
    renderError(`Network error: ${e.message}`);
    return false;
  } finally {
    clearTimeout(swapTimer);
    if (cancelBtn) cancelBtn.hidden = true;
    if (runBtn)    runBtn.hidden    = false;
  }
  const dt = Math.round(performance.now() - t0);
  time.textContent = dt;

  if (!resp.ok) {
    renderError(`HTTP ${resp.status}`);
    return false;
  }
  const payload = await resp.json();
  renderBlocks(payload.blocks || [], !!payload.wrapped, payload.notices || []);

  // Append the just-submitted query to the persistent history (skipping
  // exact-duplicate consecutive entries). We do this regardless of the
  // server's outcome so users can recall a query that errored to fix it.
  if (env.pushHistory) env.pushHistory(sqlText);

  // Record the just-run SQL so a subsequent mode/database switch knows
  // whether the textarea content was actually executed (the carry handler
  // compares this to the textarea value to decide whether to set the
  // ran-flag, which gates auto-replay in the new mode).
  try { sessionStorage.setItem('ps.lastRunSql', sqlText); } catch {}

  // After every successful exec in where mode, re-fetch relations so
  // add_provenance results show up live.
  if (env.mode === 'where' && env.refreshRelations) env.refreshRelations();

  // Mark all metadata caches dirty: the user may have just run a CREATE
  // TABLE, add_provenance, create_provenance_mapping, or a CREATE
  // FUNCTION that defines a new custom-semiring wrapper. Each panel
  // re-fetches lazily on next open.
  if (window.ProvsqlStudio?.metadata?.invalidateAll) {
    window.ProvsqlStudio.metadata.invalidateAll();
  }

  return false;

  // Render a single NOTICE / WARNING / ERROR / INFO banner. Severity drives
  // colour + icon; the literal severity tag is omitted (the visual style
  // already conveys it). ProvSQL-emitted messages all carry a "ProvSQL: "
  // prefix (provsql_error.h's macros prepend it); strip the prefix and
  // render it as a brand pill so the source is obvious without duplicating
  // it inline.
  function renderDiag(severity, message, sqlstate) {
    const sev = String(severity || 'NOTICE').toUpperCase();
    let cls, icon;
    if (sev === 'ERROR' || sev === 'FATAL' || sev === 'PANIC') {
      cls = 'wp-error';   icon = 'fa-exclamation-circle';
    } else if (sev === 'WARNING') {
      cls = 'wp-warning'; icon = 'fa-exclamation-triangle';
    } else {
      cls = 'wp-notice';  icon = 'fa-info-circle';
    }
    const raw = message || '';
    const m = raw.match(/^ProvSQL:\s*(.*)$/s);
    const badge = m ? '<span class="wp-srcbadge">ProvSQL</span> ' : '';
    const text  = m ? m[1] : raw;
    // XX000 is the generic "internal_error" catch-all that provsql_error()
    // raises (the C macro doesn't set a specific errcode); appending it
    // adds noise without information, so skip it.
    const tail  = (sqlstate && sqlstate !== 'XX000')
      ? ` <code>(SQLSTATE ${env.escapeHtml(sqlstate)})</code>`
      : '';

    // Long messages (parse-tree dumps from provsql.verbose_level >= 50,
    // full pre/post-rewrite SQL at >= 20) collapse behind a <details> so
    // they don't push the result table off-screen. The first line stays
    // visible as the summary; clicking the disclosure triangle reveals
    // the rest.
    const newlineIdx = text.indexOf('\n');
    const isLong = newlineIdx >= 0 && (
      (text.match(/\n/g) || []).length > 1 || text.length > 240
    );
    if (isLong) {
      const head = text.slice(0, newlineIdx);
      const rest = text.slice(newlineIdx + 1);
      return `<details class="${cls} wp-diag--collapsible">`
           + `<summary><i class="fas ${icon}"></i> ${badge}${env.escapeHtml(head)}${tail}</summary>`
           + `<div class="wp-diag__body">${env.escapeHtml(rest)}</div>`
           + `</details>`;
    }
    return `<div class="${cls}"><i class="fas ${icon}"></i> ${badge}${env.escapeHtml(text)}${tail}</div>`;
  }

  // Recognises the classifier NOTICE emitted by the planner hook when
  // provsql.classify_top_level is on. Three shapes :
  //   "ProvSQL: query result is <KIND> (sources: schema.t1, schema.t2)"
  //   "ProvSQL: query result is <KIND> (no provenance-tracked sources)"
  //   "ProvSQL: query result is OPAQUE"
  // The OPAQUE form omits the parenthetical because the source list
  // is only partial when the shape gate trips (a SubLink, set
  // operation, GROUP BY ... hides relations from the rtable walk),
  // and surfacing partial sources would falsely suggest completeness.
  // Returns { kind, sources } on a match (sources is an array of
  // identifier strings, possibly empty), or null otherwise.
  function parseClassifierNotice(message) {
    if (!message) return null;
    const m = String(message).match(
      /^ProvSQL:\s*query result is (TID|BID|OPAQUE)(?:\s*\((.*)\))?\s*$/
    );
    if (!m) return null;
    const kind = m[1].toLowerCase();
    const tail = (m[2] || '').trim();
    let sources = [];
    if (tail.startsWith('sources:')) {
      sources = tail.slice('sources:'.length).split(',')
                    .map(s => s.trim())
                    .filter(s => s.length > 0);
    }
    return { kind, sources };
  }

  function renderBlocks(blocks, wrapped, notices) {
    // /api/exec returns zero or more error blocks (from earlier failed
    // statements) followed by the final block. We render only the final
    // block in the result table; everything informational (failed-prelude
    // errors, server NOTICE / WARNING messages, Studio's own observations
    // via severity=INFO) goes into the dedicated #result-banners slot
    // above the table : putting <div>s straight into <tbody> is invalid
    // HTML and the browser hoists them into the first <td>.
    const final = blocks[blocks.length - 1];
    const earlier = blocks.slice(0, -1);

    // Reset the truncation marker on every render; the rows branch
    // re-shows it when final.truncated. Status / error / empty paths
    // therefore never leak a stale "first N; more available" hint.
    const truncMark = document.getElementById('result-truncated');
    if (truncMark) {
      truncMark.textContent = '';
      truncMark.hidden = true;
    }

    const banners = document.getElementById('result-banners');
    let bannerHtml = '';
    // Earlier-failed prelude statements: render each as an ERROR banner
    // alongside notices/warnings.
    for (const b of earlier) {
      if (b.kind === 'error') {
        bannerHtml += renderDiag('ERROR', b.message, b.sqlstate);
      }
    }
    // Server-side notices/warnings + Studio's own INFO observations.
    // The classifier NOTICE emitted by the provsql.classify_top_level
    // GUC is hoisted out of the banner stream and used to upgrade the
    // result-table's `provsql` column header pill from a plain "prov"
    // to "prov-tid" / "prov-bid" / "prov-opaque" with the sources
    // surfaced in the hover tooltip.  Last one wins when multiple
    // statements emit NOTICEs (we surface the user-visible last
    // SELECT's classification).
    let classifyInfo = null;
    for (const n of (notices || [])) {
      const classified = parseClassifierNotice(n.message);
      if (classified) {
        classifyInfo = classified;
        continue;
      }
      bannerHtml += renderDiag(n.severity, n.message);
    }
    if (banners) banners.innerHTML = bannerHtml;

    if (!final) {
      head.innerHTML = '';
      body.innerHTML = '<tr><td style="opacity:.6">(no statements)</td></tr>';
      count.textContent = 0;
      return;
    }

    if (final.kind === 'error') {
      // Append the final error to the same banner stack as earlier errors
      // and notices, so the visual treatment is uniform (icon + ProvSQL
      // pill + same colours + multi-line preserved). The result table
      // collapses to empty.
      if (banners) {
        banners.innerHTML += renderDiag('ERROR', final.message, final.sqlstate);
      }
      head.innerHTML = '';
      body.innerHTML = '';
      count.textContent = 0;
      return;
    }
    if (final.kind === 'status') {
      head.innerHTML = '';
      body.innerHTML = `<tr><td>${env.escapeHtml(final.message)}${final.rowcount != null ? ` · ${final.rowcount} tuples affected` : ''}</td></tr>`;
      count.textContent = final.rowcount != null ? final.rowcount : 0;
      return;
    }
    if (final.kind === 'rows') {
      const allCols = final.columns;
      const isWhere   = env.mode === 'where';
      const isCircuit = env.mode === 'circuit';
      // The studio session has provsql.aggtoken_text_as_uuid = on, so
      // agg_token cells arrive as bare UUIDs. The server pre-resolves
      // each unique UUID's "value (*)" via agg_token_value_text and
      // ships the map in final.agg_display; the front-end renders the
      // friendly form while keeping the UUID for click-through.
      const aggDisplay = final.agg_display || {};
      // Hide rewriter-injected columns (__prov, __wprov) from display, but
      // keep them indexed so we can still build per-cell data-sources and
      // per-row jump buttons. The bare `provsql` UUID column is hidden in
      // where mode (it duplicates the highlighting metadata) but kept in
      // circuit mode so users can click it to render the row's DAG.
      const displayIdx = [];
      let provIdx = -1, wprovIdx = -1;
      allCols.forEach((c, i) => {
        if (c.name === '__prov')  provIdx = i;
        else if (c.name === '__wprov') wprovIdx = i;
        else if (c.name === 'provsql' && isWhere) { /* hidden in where mode */ }
        else displayIdx.push(i);
      });
      // Pick the row's provenance UUID for the auto-conditioning hint
      // we stamp on each clickable cell below: prefer the rewriter's
      // __prov column when present (always set on tracked queries with
      // provsql.active), otherwise fall back to a user-selected
      // `provsql` column.  Used by circuit-mode click-through so the
      // eval strip's "Condition on" input can default to the row's
      // provenance even when the click target is the `random_variable`
      // cell (whose scene root is the RV itself, not the row's prov).
      let rowProvIdx = provIdx;
      if (rowProvIdx === -1) {
        const i = allCols.findIndex(c => c.name === 'provsql');
        if (i !== -1) rowProvIdx = i;
      }
      const headExtra = (isWhere && wrapped) ? '<th></th>' : '';
      // Header decoration mirrors the schema-panel column list: every <th>
      // gets a title attribute with the Postgres type so the user can hover
      // any column to discover its type, plus a small pill for columns
      // with ProvSQL semantics:
      //  - terracotta `rv` for `random_variable` (operators lifted to gates)
      //  - terracotta `agg` for `agg_token` (UUID + running value)
      //  - purple `prov` for the `provsql` uuid column (the row's
      //    provenance gate from add_provenance)
      // The first two key off type_name; the third keys off the column
      // name because `provsql` is just `uuid` at the type level.
      const matches = env.matchesProvType || ((t, b) => String(t || '').toLowerCase() === b);
      // The `provsql` column pill is upgraded to a kind-aware variant
      // (prov-tid / prov-bid / prov-opaque) when the classifier NOTICE
      // emitted by provsql.classify_top_level identifies the kind of
      // the user's query result.  Hover surfaces the explainer plus
      // the list of provenance-tracked source relations.  Without a
      // classifier NOTICE (older extension, classifier off, no
      // sources walked) the pill stays the plain "prov" form.
      const provLabel = classifyInfo
        ? (CLASSIFIER_LABELS[classifyInfo.kind] || 'prov')
        : 'prov';
      const provClassMod = classifyInfo
        ? ' wp-result__col-prov--' + classifyInfo.kind
        : '';
      let provTip = `provsql: the row's provenance gate UUID (added by add_provenance)`;
      if (classifyInfo) {
        const explain = CLASSIFIER_EXPLAINERS[classifyInfo.kind]
                     || 'Query-time provenance classification.';
        // OPAQUE NOTICEs no longer carry a sources list (the
        // partial form they used to emit was misleading), so we
        // omit the "Sources: ..." line for OPAQUE entirely.
        let srcLine = '';
        if (classifyInfo.kind !== 'opaque') {
          srcLine = classifyInfo.sources.length
                  ? 'Sources: ' + classifyInfo.sources.join(', ')
                  : 'No provenance-tracked sources.';
        }
        provTip = srcLine ? explain + '\n\n' + srcLine : explain;
      }
      const headerPill = (col) => {
        const typeName = col.type_name || '';
        if (matches(typeName, 'random_variable')) {
          return `<span class="wp-result__col-rv" title="random_variable: query operators on this column lift into provenance gates at planning time">rv</span>`;
        }
        if (matches(typeName, 'agg_token')) {
          return `<span class="wp-result__col-agg" title="agg_token: each value carries a circuit UUID; click cells to inspect the underlying gate">agg</span>`;
        }
        if (col.name === 'provsql') {
          return `<span class="wp-result__col-prov${provClassMod}" `
               + `title="${env.escapeAttr(provTip)}">${provLabel}</span>`;
        }
        return '';
      };
      head.innerHTML = displayIdx.map(i => {
        const col = allCols[i];
        const typeName = col.type_name || '';
        const alignCls = env.isRightAlignedType(typeName) ? ' is-right' : '';
        const titleAttr = typeName ? ` title="${env.escapeAttr(typeName)}"` : '';
        // Wrap the column name in its own span so callers that read
        // header text (e.g. tests, accessibility tooling) can grab the
        // name independently of the trailing pill.
        const nameHtml = `<span class="wp-result__col-name">${env.escapeHtml(col.name)}</span>`;
        return `<th class="${alignCls.trim()}"${titleAttr}>${nameHtml}${headerPill(col)}</th>`;
      }).join('') + headExtra;
      body.innerHTML = final.rows.map(r => {
        const sources = wrapped && wprovIdx >= 0
          ? parseWhereProvenance(r[wprovIdx], displayIdx)
          : null;
        // Row's provenance UUID, attached to every clickable cell of
        // this row so circuit-mode click-through can carry the row
        // context into the eval strip's "Condition on" auto-preset.
        const rowProv = rowProvIdx >= 0 && r[rowProvIdx]
          ? String(r[rowProvIdx])
          : '';
        const rowProvAttr = rowProv
          ? ` data-row-prov="${env.escapeAttr(rowProv)}"`
          : '';
        const cells = displayIdx.map((idx, di) => {
          const col = allCols[idx];
          const typeName = (col.type_name || '').toLowerCase();
          const value = r[idx];
          const dataSrc = sources ? sources[di] || '' : '';
          const sourcesAttr = dataSrc ? ` data-sources="${env.escapeAttr(dataSrc)}"` : '';
          let extraCls = '';
          let extraAttr = '';
          // random_variable is binary-coercible with uuid and its on-wire
          // text form is a bare UUID, so it click-throughs the same way
          // a uuid cell does.
          if (isCircuit && (typeName === 'uuid' || typeName === 'random_variable') && value) {
            extraCls  = ' is-clickable';
            extraAttr = ` data-circuit-uuid="${env.escapeAttr(String(value))}"${rowProvAttr}`;
          }
          // agg_token cells: their on-wire text is the underlying UUID
          // (because provsql.aggtoken_text_as_uuid is on for studio
          // sessions). Make them clickable in circuit mode like
          // regular UUID cells; the cell's text content is replaced
          // with the "value (*)" form pulled from agg_display, and the
          // tooltip carries the UUID so users can confirm which
          // circuit the cell points at without inspecting the DOM.
          let displayValue = value;
          if (typeName === 'agg_token' && value) {
            if (isCircuit) {
              extraCls  = (extraCls + ' is-clickable').trim();
              extraAttr = ` data-circuit-uuid="${env.escapeAttr(String(value))}"${rowProvAttr}`;
              if (extraCls.length) extraCls = ' ' + extraCls;
            }
            extraAttr += ` title="${env.escapeAttr(String(value))}"`;
            const friendly = aggDisplay[value];
            if (friendly) displayValue = friendly;
          }
          if (env.isRightAlignedType(typeName)) extraCls += ' is-right';
          return `<td class="wp-result__cell${extraCls}"${sourcesAttr}${extraAttr}>${env.formatCell(displayValue, col.type_name)}</td>`;
        }).join('');
        const jumpBtn = (isWhere && wrapped && provIdx >= 0 && r[provIdx])
          ? `<td class="wp-result__cell--actions"><button class="wp-btn wp-btn--mini" type="button" `
            + `data-jump-circuit="${env.escapeAttr(String(r[provIdx]))}" title="Open circuit DAG"><i class="fas fa-project-diagram"></i> Circuit</button></td>`
          : '';
        return `<tr>${cells}${jumpBtn}</tr>`;
      }).join('');
      count.textContent = final.rows.length;
      const truncated = document.getElementById('result-truncated');
      if (truncated) {
        if (final.truncated && final.max_rows != null) {
          truncated.textContent = ` (first ${final.max_rows}; more available)`;
          truncated.hidden = false;
        } else {
          truncated.textContent = '';
          truncated.hidden = true;
        }
      }
      // Auto-render the single clickable UUID: when a circuit-mode
      // query returns exactly one cell the user could click through
      // to a DAG, save them the click.  Two+ candidates stay
      // ambiguous (let the user pick); zero means nothing to render.
      // A subsequent where-mode-jump preloadCircuit still runs after
      // this via setupCircuitMode's then() callback and overwrites
      // the auto-rendered scene with the user-chosen one.
      //
      // Implementation: dispatch a synthetic click on the cell so the
      // existing click handler (wired inside the setupCircuitMode IIFE)
      // takes care of loadCircuit + ensureCircuitLib + the eval-strip
      // re-bind.  This file's `renderBlocks` is at module-global
      // scope and can't reach `loadCircuit` directly, but clicking the
      // cell DOM element travels through whichever listener was
      // installed for the current mode.
      if (isCircuit) {
        const clickable = body.querySelectorAll('[data-circuit-uuid]');
        if (clickable.length === 1) {
          clickable[0].click();
        }
      }
    }
  }

  function renderError(msg) {
    const banners = document.getElementById('result-banners');
    if (banners) banners.innerHTML = renderDiag('ERROR', msg);
    head.innerHTML = '';
    body.innerHTML = '';
    count.textContent = 0;
  }

  /* Parse the text returned by where_provenance(provenance()).
     Format (from src/WhereCircuit.cpp / where_provenance.cpp):
       {[table:uuid:col;table:uuid:col],[table:uuid:col],[]}
     Outer braces, comma-separated groups (one per output column of the
     wrapped query), each group is square-bracketed, semicolon-separated
     `table:uuid:col` entries. The groups are in inner-SELECT column order,
     which matches `allCols` exactly, so groups[displayIdx[i]] is the
     source list for the i-th displayed column. We return one
     ready-to-set `data-sources` string per displayed column. */
  function parseWhereProvenance(wprovText, displayIdx) {
    if (!wprovText) return null;
    const m = String(wprovText).match(/^\{(.*)\}$/s);
    if (!m) return null;
    const inner = m[1];

    // Split top-level groups by `,`. Groups themselves don't contain commas
    // (they use `;` between items), so this is safe.
    const groups = inner.length === 0 ? [] : splitTopLevel(inner, ',');
    const perGroup = groups.map(g => {
      const gm = g.match(/^\[(.*)\]$/s);
      if (!gm) return '';
      return gm[1];  // already `table:uuid:col;table:uuid:col`
    });
    // Map each displayed column to its group in the inner-SELECT order.
    // Hidden columns (provsql, __prov, __wprov) are skipped over.
    return displayIdx.map(i => perGroup[i] || '');
  }

  function splitTopLevel(s, sep) {
    const out = [];
    let depth = 0, last = 0;
    for (let i = 0; i < s.length; ++i) {
      const c = s[i];
      if (c === '[' || c === '{' || c === '(') depth++;
      else if (c === ']' || c === '}' || c === ')') depth--;
      else if (c === sep && depth === 0) {
        out.push(s.slice(last, i));
        last = i + 1;
      }
    }
    out.push(s.slice(last));
    return out;
  }
}
