#!/usr/bin/env node
// sx-inspect.js — SX-aware Playwright page inspector
// Usage: node sx-inspect.js '{"mode":"...","url":"/",...}'
// Modes: inspect, diff, hydrate, eval, interact, screenshot, trace-boot, hydrate-debug, eval-at
// Output: JSON to stdout

const { chromium } = require('playwright');

const BASE_URL = process.env.SX_TEST_URL || 'http://localhost:8013';
const SCREENSHOT_DIR = process.env.SX_SCREENSHOT_DIR || '/tmp';

// Code display markers — elements that intentionally show SX source
const CODE_DISPLAY_SELECTORS = [
  '[data-code-view]',
  'pre code',
  '.sx-source',
  '[data-sx-source]',
  '.font-mono[style*="font-size:0.5rem"]',  // stepper code view
].join(', ');

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Recursive DOM tree snapshot — tag, id, classes, data-sx-* attrs, text */
function snapshotScript() {
  return `(function snapshot(el) {
    if (el.nodeType === 3) {
      const text = el.textContent.trim();
      return text ? { type: 'text', value: text } : null;
    }
    if (el.nodeType !== 1) return null;
    const node = { tag: el.tagName.toLowerCase() };
    if (el.id) node.id = el.id;
    const cls = Array.from(el.classList).sort().join(' ');
    if (cls) node.cls = cls;
    const island = el.getAttribute('data-sx-island');
    if (island) node.island = island;
    const lake = el.getAttribute('data-sx-lake');
    if (lake) node.lake = lake;
    const marsh = el.getAttribute('data-sx-marsh');
    if (marsh) node.marsh = marsh;
    const signal = el.getAttribute('data-sx-signal');
    if (signal) node.signal = signal;
    const reactiveAttrs = el.getAttribute('data-sx-reactive-attrs');
    if (reactiveAttrs) node.reactiveAttrs = reactiveAttrs;
    const style = el.getAttribute('style');
    if (style) node.style = style;
    const children = [];
    for (const child of el.childNodes) {
      const s = snapshot(child);
      if (s) children.push(s);
    }
    if (children.length) node.children = children;
    return node;
  })`;
}

/** Diff two tree snapshots, returning list of changes */
function diffTrees(a, b, path = '') {
  const changes = [];
  if (!a && !b) return changes;
  if (!a) { changes.push({ path, kind: 'added', node: summarize(b) }); return changes; }
  if (!b) { changes.push({ path, kind: 'removed', node: summarize(a) }); return changes; }
  if (a.type === 'text' && b.type === 'text') {
    if (a.value !== b.value)
      changes.push({ path: path || 'text', kind: 'text-changed', ssr: a.value.substring(0, 120), hydrated: b.value.substring(0, 120) });
    return changes;
  }
  if (a.type === 'text' || b.type === 'text') {
    changes.push({ path, kind: 'type-changed', ssr: summarize(a), hydrated: summarize(b) });
    return changes;
  }
  if (a.tag !== b.tag) changes.push({ path, kind: 'tag-changed', ssr: a.tag, hydrated: b.tag });
  if (a.cls !== b.cls) changes.push({ path: path || a.tag, kind: 'class-changed', ssr: a.cls || '', hydrated: b.cls || '' });
  if (a.island !== b.island) changes.push({ path, kind: 'island-changed', ssr: a.island, hydrated: b.island });
  if (a.style !== b.style) changes.push({ path: path || a.tag, kind: 'style-changed', ssr: a.style || '', hydrated: b.style || '' });

  const ac = a.children || [];
  const bc = b.children || [];
  const maxLen = Math.max(ac.length, bc.length);
  for (let i = 0; i < maxLen; i++) {
    const childPath = path ? `${path} > [${i}]` : `[${i}]`;
    const aTag = ac[i]?.tag || ac[i]?.type || '';
    const bTag = bc[i]?.tag || bc[i]?.type || '';
    const label = bTag ? `${childPath} <${bTag}>` : childPath;
    changes.push(...diffTrees(ac[i] || null, bc[i] || null, label));
  }
  return changes;
}

function summarize(node) {
  if (!node) return 'null';
  if (node.type === 'text') return `"${node.value.substring(0, 60)}"`;
  let s = `<${node.tag}`;
  if (node.island) s += ` island="${node.island}"`;
  if (node.lake) s += ` lake="${node.lake}"`;
  if (node.id) s += ` #${node.id}`;
  s += '>';
  return s;
}

async function waitForHydration(page) {
  try {
    await page.waitForSelector('[data-sx-island]', { timeout: 8000 });
    await page.waitForTimeout(1500);
  } catch (e) {
    // No islands on page — that's OK
  }
}

// ---------------------------------------------------------------------------
// Leak detection — excludes code display elements
// ---------------------------------------------------------------------------

function leakDetectionScript(codeSelectors) {
  return `(function(els) {
    const codeEls = new Set();
    document.querySelectorAll(${JSON.stringify(codeSelectors)}).forEach(el => {
      // Mark all code-display elements and their descendants
      el.querySelectorAll('*').forEach(d => codeEls.add(d));
      codeEls.add(el);
    });

    return els.flatMap(el => {
      const name = el.getAttribute('data-sx-island');
      const leaks = [];

      // Walk text nodes, skipping code display areas
      const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
        acceptNode: (node) => {
          let p = node.parentElement;
          while (p && p !== el) {
            if (codeEls.has(p)) return NodeFilter.FILTER_REJECT;
            p = p.parentElement;
          }
          return NodeFilter.FILTER_ACCEPT;
        }
      });

      let fullText = '';
      let node;
      while (node = walker.nextNode()) fullText += node.textContent;

      // Raw dict patterns (SX keyword-keyed dicts)
      const dictMatch = fullText.match(/\\{:(?:type|tag|expr|spreads|attrs)\\s[^}]{0,80}/);
      if (dictMatch) leaks.push({ island: name, kind: 'raw-dict', sample: dictMatch[0] });
      // Unresolved component calls
      const compMatch = fullText.match(/\\(~[\\w/]+[^)]{0,60}/);
      if (compMatch) leaks.push({ island: name, kind: 'unresolved-component', sample: compMatch[0] });
      // CSSX tokens leaked as text
      const cssxMatch = fullText.match(/:tokens\\s+"[^"]{0,60}/);
      if (cssxMatch) leaks.push({ island: name, kind: 'cssx-leak', sample: cssxMatch[0] });
      // String-keyed dicts (JS evaluator format)
      const strDictMatch = fullText.match(/\\{"(?:type|tag|expr|text|cls|step)"\\s[^}]{0,80}/);
      if (strDictMatch) leaks.push({ island: name, kind: 'raw-dict-str', sample: strDictMatch[0] });
      // Raw SX list as text (parens around tag names)
      const sxListMatch = fullText.match(/\\((?:div|span|h[1-6]|p|a|button|ul|li|section|article)\\s+:(?:class|id|style)/);
      if (sxListMatch) leaks.push({ island: name, kind: 'raw-sx-element', sample: sxListMatch[0] });
      return leaks;
    });
  })`;
}

// ---------------------------------------------------------------------------
// Handler audit — check if event handlers are wired
// ---------------------------------------------------------------------------

function handlerAuditScript() {
  return `(function(island) {
    const buttons = island.querySelectorAll('button, [on-click], [data-sx-on-click]');
    const inputs = island.querySelectorAll('input, textarea, select');
    const results = [];

    buttons.forEach((btn, i) => {
      const tag = btn.tagName.toLowerCase();
      const text = btn.textContent.trim().substring(0, 30);
      const hasReactiveClass = btn.hasAttribute('data-sx-reactive-attrs');
      // Check for attached event listeners via getEventListeners (Chrome DevTools only)
      // Fallback: check for onclick attribute or __sx handler
      const hasOnclick = btn.hasAttribute('onclick') || btn.onclick !== null;
      const hasSxHandler = !!(btn.__sx_listeners || btn._sxListeners);
      // Check all registered listeners
      const listeners = [];
      try {
        const evts = getEventListeners ? getEventListeners(btn) : {};
        for (const [evt, handlers] of Object.entries(evts)) {
          listeners.push({ event: evt, count: handlers.length });
        }
      } catch(e) { /* getEventListeners not available outside DevTools */ }

      results.push({
        element: tag,
        index: i,
        text: text,
        reactiveAttrs: hasReactiveClass,
        hasOnclick: hasOnclick,
        listenerCount: listeners.length || (hasOnclick ? 1 : 0),
        wired: hasOnclick || listeners.length > 0
      });
    });

    inputs.forEach((inp, i) => {
      const tag = inp.tagName.toLowerCase();
      const type = inp.type || '';
      const hasBind = inp.hasAttribute('data-sx-bind');
      const hasOnInput = inp.oninput !== null;
      results.push({
        element: tag,
        type: type,
        index: i,
        bind: hasBind,
        hasOnInput: hasOnInput,
        wired: hasOnInput || hasBind
      });
    });

    return results;
  })`;
}

// ---------------------------------------------------------------------------
// Mode: inspect
// ---------------------------------------------------------------------------

async function modeInspect(page, url, islandFilter) {
  await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
  await waitForHydration(page);

  const pageErrors = [];
  page.on('pageerror', err => pageErrors.push(err.message));

  const selector = islandFilter
    ? `[data-sx-island*="${islandFilter}"]`
    : '[data-sx-island]';

  const islands = await page.$$eval(selector, (els, codeSelectors) => {
    return els.map(el => {
      const name = el.getAttribute('data-sx-island');

      // Lakes with detail
      const lakes = [...el.querySelectorAll('[data-sx-lake]')].map(l => {
        const id = l.getAttribute('data-sx-lake');
        const html = l.innerHTML;
        const hasElements = l.children.length > 0;
        const textOnly = !hasElements && l.textContent.trim().length > 0;
        const looksLikeSx = /^\s*\(/.test(l.textContent.trim());
        return {
          id,
          htmlLength: html.length,
          childElements: l.children.length,
          textPreview: l.textContent.trim().substring(0, 100),
          status: hasElements ? 'rendered' : (looksLikeSx ? 'raw-sx-text' : (textOnly ? 'text-only' : 'empty'))
        };
      });

      // Marshes
      const marshes = [...el.querySelectorAll('[data-sx-marsh]')].map(m => ({
        id: m.getAttribute('data-sx-marsh'),
        transform: m.getAttribute('data-sx-transform') || null
      }));

      // Signals
      const signals = [...el.querySelectorAll('[data-sx-signal]')].map(s => {
        const spec = s.getAttribute('data-sx-signal');
        const colonIdx = spec.indexOf(':');
        return {
          store: colonIdx > 0 ? spec.substring(0, colonIdx) : spec,
          value: colonIdx > 0 ? spec.substring(colonIdx + 1).substring(0, 50) : null
        };
      });

      // Reactive attrs
      const reactiveEls = [...el.querySelectorAll('[data-sx-reactive-attrs]')].map(r => ({
        tag: r.tagName.toLowerCase(),
        attrs: r.getAttribute('data-sx-reactive-attrs'),
        preview: r.outerHTML.substring(0, 80)
      }));

      return {
        name,
        tag: el.tagName.toLowerCase(),
        stateSize: (el.getAttribute('data-sx-state') || '').length,
        textLength: el.textContent.length,
        textPreview: el.textContent.replace(/\s+/g, ' ').trim().substring(0, 150),
        lakes,
        marshes,
        signals,
        reactiveElements: reactiveEls.length,
        reactiveDetail: reactiveEls.slice(0, 10)
      };
    });
  }, CODE_DISPLAY_SELECTORS);

  // Leak detection with code display exclusion
  const leaks = await page.$$eval(selector, (els, codeSelectors) => {
    // Build the leak detection inline (can't pass function refs to $$eval)
    const codeEls = new Set();
    document.querySelectorAll(codeSelectors).forEach(el => {
      el.querySelectorAll('*').forEach(d => codeEls.add(d));
      codeEls.add(el);
    });

    return els.flatMap(el => {
      const name = el.getAttribute('data-sx-island');
      const leaks = [];

      // Walk text nodes, skipping code display areas
      const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
        acceptNode: (node) => {
          let p = node.parentElement;
          while (p && p !== el) {
            if (codeEls.has(p)) return NodeFilter.FILTER_REJECT;
            p = p.parentElement;
          }
          return NodeFilter.FILTER_ACCEPT;
        }
      });

      let fullText = '';
      let node;
      while (node = walker.nextNode()) fullText += node.textContent;

      const dictMatch = fullText.match(/\{:(?:type|tag|expr|spreads|attrs)\s[^}]{0,80}/);
      if (dictMatch) leaks.push({ island: name, kind: 'raw-dict', sample: dictMatch[0] });
      const compMatch = fullText.match(/\(~[\w/]+[^)]{0,60}/);
      if (compMatch) leaks.push({ island: name, kind: 'unresolved-component', sample: compMatch[0] });
      const cssxMatch = fullText.match(/:tokens\s+"[^"]{0,60}/);
      if (cssxMatch) leaks.push({ island: name, kind: 'cssx-leak', sample: cssxMatch[0] });
      const strDictMatch = fullText.match(/\{"(?:type|tag|expr|text|cls|step)"\s[^}]{0,80}/);
      if (strDictMatch) leaks.push({ island: name, kind: 'raw-dict-str', sample: strDictMatch[0] });
      const sxListMatch = fullText.match(/\((?:div|span|h[1-6]|p|a|button|ul|li|section|article)\s+:(?:class|id|style)/);
      if (sxListMatch) leaks.push({ island: name, kind: 'raw-sx-element', sample: sxListMatch[0] });
      return leaks;
    });
  }, CODE_DISPLAY_SELECTORS);

  // Handler audit for each island
  const handlers = await page.$$eval(selector, (els) => {
    return els.map(el => {
      const name = el.getAttribute('data-sx-island');
      const buttons = [...el.querySelectorAll('button')];
      const inputs = [...el.querySelectorAll('input, textarea, select')];

      const buttonAudit = buttons.map((btn, i) => {
        const hasOnclick = btn.onclick !== null;
        const hasReactive = btn.hasAttribute('data-sx-reactive-attrs');
        // SX wires handlers via addEventListener, not onclick property.
        // We can't detect addEventListener from JS. Heuristic: if the
        // button has reactive attrs, it was rendered by an island and
        // likely has handlers. Only flag buttons with NO island markers.
        const inIsland = !!btn.closest('[data-sx-island]');
        const likelyWired = hasOnclick || (inIsland && hasReactive);
        return {
          text: btn.textContent.trim().substring(0, 20),
          hasOnclick,
          reactiveAttrs: hasReactive,
          inIsland,
          wired: likelyWired ? 'yes' : (inIsland ? 'probable' : 'unknown')
        };
      });

      const inputAudit = inputs.map((inp, i) => {
        const hasBind = inp.hasAttribute('data-sx-bind');
        const hasOnInput = inp.oninput !== null;
        const inIsland = !!inp.closest('[data-sx-island]');
        return {
          type: inp.type || inp.tagName.toLowerCase(),
          hasBind,
          hasOnInput,
          inIsland,
          wired: hasOnInput || hasBind ? 'yes' : (inIsland ? 'probable' : 'unknown')
        };
      });

      const suspectButtons = buttonAudit.filter(b => b.wired === 'unknown');
      const suspectInputs = inputAudit.filter(i => i.wired === 'unknown');

      return {
        island: name,
        buttons: buttonAudit.length,
        inputs: inputAudit.length,
        suspectButtons: suspectButtons.length,
        suspectInputs: suspectInputs.length,
        allButtons: buttonAudit,
        detail: suspectButtons.length + suspectInputs.length > 0
          ? { suspectButtons, suspectInputs }
          : undefined
      };
    });
  });

  const globalLeaks = await page.$eval('#sx-root', el => {
    const text = el.textContent;
    const leaks = [];
    const dictMatch = text.match(/\{:(?:type|tag|expr)\s[^}]{0,80}/);
    if (dictMatch) leaks.push({ kind: 'raw-dict-outside-island', sample: dictMatch[0] });
    return leaks;
  }).catch(() => []);

  return { url, islands, leaks: [...leaks, ...globalLeaks], handlers, pageErrors };
}

// ---------------------------------------------------------------------------
// Mode: diff — SSR vs hydrated DOM (full page)
// ---------------------------------------------------------------------------

async function modeDiff(browser, url) {
  const snap = snapshotScript();

  const ssrCtx = await browser.newContext({ javaScriptEnabled: false });
  const ssrPage = await ssrCtx.newPage();
  await ssrPage.goto(BASE_URL + url, { waitUntil: 'domcontentloaded', timeout: 15000 });
  const ssrTree = await ssrPage.evaluate(`${snap}(document.getElementById('sx-root'))`);
  const ssrText = await ssrPage.evaluate(() => {
    const root = document.getElementById('sx-root');
    return root ? root.innerText.replace(/\s+/g, ' ').trim().substring(0, 500) : '';
  });
  await ssrCtx.close();

  const hydCtx = await browser.newContext({ javaScriptEnabled: true });
  const hydPage = await hydCtx.newPage();
  const pageErrors = [];
  hydPage.on('pageerror', err => pageErrors.push(err.message));
  await hydPage.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
  await waitForHydration(hydPage);
  const hydTree = await hydPage.evaluate(`${snap}(document.getElementById('sx-root'))`);
  const hydText = await hydPage.evaluate(() => {
    const root = document.getElementById('sx-root');
    return root ? root.innerText.replace(/\s+/g, ' ').trim().substring(0, 500) : '';
  });
  await hydCtx.close();

  const changes = diffTrees(ssrTree, hydTree);
  const textMatch = ssrText === hydText;

  return {
    url,
    ssrTextPreview: ssrText.substring(0, 300),
    hydratedTextPreview: hydText.substring(0, 300),
    textMatch,
    structuralChanges: changes.length,
    changes: changes.slice(0, 50),
    pageErrors
  };
}

// ---------------------------------------------------------------------------
// Mode: hydrate — SSR vs hydrated comparison focused on lakes
// ---------------------------------------------------------------------------

async function modeHydrate(browser, url) {
  // SSR: capture lake innerHTML without JS
  const ssrCtx = await browser.newContext({ javaScriptEnabled: false });
  const ssrPage = await ssrCtx.newPage();
  await ssrPage.goto(BASE_URL + url, { waitUntil: 'domcontentloaded', timeout: 15000 });

  const ssrLakes = await ssrPage.$$eval('[data-sx-lake]', els =>
    els.map(el => ({
      id: el.getAttribute('data-sx-lake'),
      island: el.closest('[data-sx-island]')?.getAttribute('data-sx-island') || null,
      html: el.innerHTML.substring(0, 500),
      hasElements: el.children.length > 0,
      text: el.textContent.trim().substring(0, 200)
    })));

  const ssrIslands = await ssrPage.$$eval('[data-sx-island]', els =>
    els.map(el => ({
      name: el.getAttribute('data-sx-island'),
      text: el.textContent.replace(/\s+/g, ' ').trim().substring(0, 200),
      hasError: /Island error:|Undefined symbol:/.test(el.textContent)
    })));

  await ssrCtx.close();

  // Hydrated: capture same after JS runs
  const hydCtx = await browser.newContext({ javaScriptEnabled: true });
  const hydPage = await hydCtx.newPage();
  const pageErrors = [];
  hydPage.on('pageerror', err => pageErrors.push(err.message));
  await hydPage.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
  await waitForHydration(hydPage);

  const hydLakes = await hydPage.$$eval('[data-sx-lake]', els =>
    els.map(el => ({
      id: el.getAttribute('data-sx-lake'),
      island: el.closest('[data-sx-island]')?.getAttribute('data-sx-island') || null,
      html: el.innerHTML.substring(0, 500),
      hasElements: el.children.length > 0,
      text: el.textContent.trim().substring(0, 200)
    })));

  const hydIslands = await hydPage.$$eval('[data-sx-island]', els =>
    els.map(el => ({
      name: el.getAttribute('data-sx-island'),
      text: el.textContent.replace(/\s+/g, ' ').trim().substring(0, 200),
      hasError: /Island error:|Undefined symbol:/.test(el.textContent)
    })));

  await hydCtx.close();

  // Compare lakes
  const lakeReport = [];
  const ssrMap = Object.fromEntries(ssrLakes.map(l => [l.id, l]));
  const hydMap = Object.fromEntries(hydLakes.map(l => [l.id, l]));
  const allIds = new Set([...ssrLakes.map(l => l.id), ...hydLakes.map(l => l.id)]);

  for (const id of allIds) {
    const ssr = ssrMap[id];
    const hyd = hydMap[id];
    const entry = { id, island: ssr?.island || hyd?.island };

    if (!ssr) { entry.status = 'added-by-hydration'; }
    else if (!hyd) { entry.status = 'removed-by-hydration'; }
    else if (ssr.html === hyd.html) { entry.status = 'identical'; }
    else {
      const ssrHasEls = ssr.hasElements;
      const hydHasEls = hyd.hasElements;
      if (ssrHasEls && !hydHasEls) {
        entry.status = 'CLOBBERED';
        entry.detail = 'SSR had DOM elements, hydration replaced with text';
        entry.ssrPreview = ssr.text.substring(0, 100);
        entry.hydPreview = hyd.text.substring(0, 100);
      } else if (!ssrHasEls && hydHasEls) {
        entry.status = 'upgraded';
        entry.detail = 'SSR had text, hydration rendered DOM';
      } else {
        entry.status = 'changed';
        entry.ssrPreview = ssr.text.substring(0, 100);
        entry.hydPreview = hyd.text.substring(0, 100);
      }
    }
    lakeReport.push(entry);
  }

  // Compare islands
  const islandReport = [];
  const ssrIslandMap = Object.fromEntries(ssrIslands.map(i => [i.name, i]));
  const hydIslandMap = Object.fromEntries(hydIslands.map(i => [i.name, i]));

  for (const name of new Set([...ssrIslands.map(i => i.name), ...hydIslands.map(i => i.name)])) {
    const ssr = ssrIslandMap[name];
    const hyd = hydIslandMap[name];
    const entry = { name };

    if (ssr?.hasError) entry.ssrError = true;
    if (hyd?.hasError) entry.hydrationError = true;
    if (ssr && hyd && ssr.text === hyd.text) entry.textMatch = true;
    else if (ssr && hyd) {
      entry.textMatch = false;
      entry.ssrPreview = ssr.text.substring(0, 100);
      entry.hydPreview = hyd.text.substring(0, 100);
    }
    islandReport.push(entry);
  }

  const clobbered = lakeReport.filter(l => l.status === 'CLOBBERED');

  return {
    url,
    summary: {
      lakes: lakeReport.length,
      identical: lakeReport.filter(l => l.status === 'identical').length,
      clobbered: clobbered.length,
      changed: lakeReport.filter(l => l.status === 'changed').length
    },
    lakes: lakeReport,
    islands: islandReport,
    pageErrors
  };
}

// ---------------------------------------------------------------------------
// Mode: eval — evaluate JS expression
// ---------------------------------------------------------------------------

async function modeEval(page, url, expr) {
  // Capture ALL console during page load (before goto)
  const bootLog = [];
  page.on('console', msg => {
    bootLog.push({ type: msg.type(), text: msg.text().slice(0, 300) });
  });
  await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
  await waitForHydration(page);
  const result = await page.evaluate(expr);
  // Include boot log: errors always, all [sx] lines on request
  const issues = bootLog.filter(l => l.type === 'error' || l.type === 'warning' || l.text.includes('FAIL') || l.text.includes('Error') || l.text.startsWith('[sx]'));
  return { url, expr, result, bootLog: issues.length > 0 ? issues : undefined };
}

// ---------------------------------------------------------------------------
// Mode: interact — action sequence
// ---------------------------------------------------------------------------

async function modeInteract(page, url, actionsStr) {
  await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
  await waitForHydration(page);

  // Capture console during interaction
  const consoleLogs = [];
  page.on('console', msg => {
    consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 300) });
  });

  const actions = actionsStr.split(';').map(a => a.trim()).filter(Boolean);
  const results = [];

  for (const action of actions) {
    const [cmd, ...rest] = action.split(':');
    const arg = rest.join(':');

    try {
      switch (cmd) {
        case 'click':
          await page.locator(arg).first().click();
          results.push({ action: 'click', selector: arg, ok: true });
          break;
        case 'fill': {
          const [sel, ...valParts] = arg.split(':');
          const val = valParts.join(':');
          await page.locator(sel).first().fill(val);
          results.push({ action: 'fill', selector: sel, value: val, ok: true });
          break;
        }
        case 'wait':
          await page.waitForTimeout(parseInt(arg) || 500);
          results.push({ action: 'wait', ms: parseInt(arg) || 500 });
          break;
        case 'text': {
          const text = await page.locator(arg).first().textContent();
          results.push({ action: 'text', selector: arg, value: text?.trim() });
          break;
        }
        case 'html': {
          const html = await page.locator(arg).first().innerHTML();
          results.push({ action: 'html', selector: arg, value: html?.substring(0, 500) });
          break;
        }
        case 'attrs': {
          const attrs = await page.locator(arg).first().evaluate(el => {
            const a = {};
            for (const attr of el.attributes) a[attr.name] = attr.value.substring(0, 200);
            return a;
          });
          results.push({ action: 'attrs', selector: arg, value: attrs });
          break;
        }
        case 'screenshot': {
          const ts = Date.now();
          const path = `${SCREENSHOT_DIR}/sx-inspect-${ts}.png`;
          if (arg) {
            await page.locator(arg).first().screenshot({ path });
          } else {
            await page.screenshot({ path });
          }
          results.push({ action: 'screenshot', selector: arg || 'full-page', path });
          break;
        }
        case 'count': {
          const count = await page.locator(arg).count();
          results.push({ action: 'count', selector: arg, value: count });
          break;
        }
        case 'visible': {
          const visible = await page.locator(arg).first().isVisible().catch(() => false);
          results.push({ action: 'visible', selector: arg, value: visible });
          break;
        }
        default:
          results.push({ action: cmd, error: 'unknown action' });
      }
    } catch (e) {
      results.push({ action: cmd, selector: arg, error: e.message });
    }
  }

  // Include console errors/warnings in output
  const errors = consoleLogs.filter(l => l.type === 'error' || l.type === 'warning');
  return { url, results, console: errors.length > 0 ? errors : undefined };
}

// ---------------------------------------------------------------------------
// Mode: screenshot
// ---------------------------------------------------------------------------

async function modeScreenshot(page, url, selector) {
  await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
  await waitForHydration(page);

  const ts = Date.now();
  const path = `${SCREENSHOT_DIR}/sx-screenshot-${ts}.png`;

  if (selector) {
    await page.locator(selector).first().screenshot({ path });
  } else {
    await page.screenshot({ path });
  }

  return { url, selector: selector || 'full-page', path };
}

// ---------------------------------------------------------------------------
// Mode: listeners — CDP event listener inspection
// ---------------------------------------------------------------------------

async function modeListeners(page, url, selector) {
  await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
  await waitForHydration(page);

  const cdp = await page.context().newCDPSession(page);
  const results = {};

  // Helper: get listeners for a JS expression
  async function getListeners(expr, label) {
    try {
      const { result } = await cdp.send('Runtime.evaluate', { expression: expr });
      if (!result?.objectId) return [];
      const { listeners } = await cdp.send('DOMDebugger.getEventListeners', {
        objectId: result.objectId, depth: 0,
      });
      return listeners.map(l => ({
        type: l.type,
        useCapture: l.useCapture,
        passive: l.passive,
        once: l.once,
        handler: l.handler?.description?.slice(0, 200) || 'native',
        scriptId: l.scriptId,
        lineNumber: l.lineNumber,
        columnNumber: l.columnNumber,
      }));
    } catch (e) { return [{ error: e.message }]; }
  }

  // Get listeners on the target element(s)
  if (selector) {
    const els = await page.$$(selector);
    for (let i = 0; i < Math.min(els.length, 5); i++) {
      const { result } = await cdp.send('Runtime.evaluate', {
        expression: `document.querySelectorAll(${JSON.stringify(selector)})[${i}]`,
      });
      if (result?.objectId) {
        const { listeners } = await cdp.send('DOMDebugger.getEventListeners', {
          objectId: result.objectId, depth: 0,
        });
        const tag = await page.evaluate(
          ({sel, idx}) => {
            const el = document.querySelectorAll(sel)[idx];
            return el ? `<${el.tagName.toLowerCase()} ${el.getAttribute('href') || el.getAttribute('data-sx-island') || ''}>` : '?';
          }, {sel: selector, idx: i});
        results[`element[${i}] ${tag}`] = listeners.map(l => ({
          type: l.type,
          handler: l.handler?.description?.slice(0, 150) || 'native',
          capture: l.useCapture,
        }));
      }
    }
  }

  // Document listeners
  results['document'] = (await getListeners('document', 'document'))
    .filter(l => ['click', 'submit', 'input', 'change', 'keydown', 'keyup'].includes(l.type));

  // Window listeners
  results['window'] = (await getListeners('window', 'window'))
    .filter(l => ['click', 'popstate', 'hashchange', 'beforeunload', 'load', 'DOMContentLoaded'].includes(l.type));

  return { url, selector, listeners: results };
}

// ---------------------------------------------------------------------------
// Mode: trace — click and capture full execution trace
// ---------------------------------------------------------------------------

async function modeTrace(page, url, selector) {
  await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
  await waitForHydration(page);

  const trace = {
    console: [],
    network: [],
    pushState: [],
    errors: [],
  };

  // Capture console
  page.on('console', msg => {
    trace.console.push({
      type: msg.type(),
      text: msg.text().slice(0, 300),
    });
  });

  // Capture page errors
  page.on('pageerror', err => {
    trace.errors.push(err.message.slice(0, 300));
  });

  // Capture network requests
  page.on('request', req => {
    if (req.resourceType() === 'document' || req.resourceType() === 'xhr' || req.resourceType() === 'fetch') {
      trace.network.push({
        type: req.resourceType(),
        method: req.method(),
        url: req.url(),
        isNav: req.isNavigationRequest(),
      });
    }
  });

  // Inject pushState/replaceState monitoring
  await page.evaluate(() => {
    for (const method of ['pushState', 'replaceState']) {
      const orig = history[method];
      history[method] = function() {
        console.log(`[spa-trace] ${method}: ${arguments[2]}`);
        return orig.apply(this, arguments);
      };
    }
  });

  // Snapshot before
  const before = await page.evaluate(() => ({
    url: location.href,
    title: document.title,
  }));

  // Perform the action
  if (!selector) {
    return { error: 'trace mode requires a selector to click' };
  }

  const el = page.locator(selector).first();
  const elInfo = await page.evaluate((sel) => {
    const el = document.querySelector(sel);
    return el ? { tag: el.tagName, text: el.textContent?.slice(0, 50), href: el.getAttribute('href') } : null;
  }, selector);

  let navigated = false;
  page.once('framenavigated', () => { navigated = true; });

  await el.click();
  await page.waitForTimeout(2000);

  // Snapshot after
  const after = await page.evaluate(() => ({
    url: location.href,
    title: document.title,
  }));

  return {
    url,
    selector,
    element: elInfo,
    before,
    after,
    navigated,
    urlChanged: before.url !== after.url,
    trace,
  };
}

// ---------------------------------------------------------------------------
// Mode: cdp — raw Chrome DevTools Protocol command
// ---------------------------------------------------------------------------

async function modeCdp(page, url, expr) {
  await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
  await waitForHydration(page);

  const cdp = await page.context().newCDPSession(page);

  // expr format: "Domain.method {json params}" e.g. "Runtime.evaluate {\"expression\":\"1+1\"}"
  const spaceIdx = expr.indexOf(' ');
  const method = spaceIdx > 0 ? expr.slice(0, spaceIdx) : expr;
  const params = spaceIdx > 0 ? JSON.parse(expr.slice(spaceIdx + 1)) : {};

  try {
    const result = await cdp.send(method, params);
    return { method, params, result };
  } catch (e) {
    return { method, params, error: e.message };
  }
}

// ---------------------------------------------------------------------------
// Mode: reactive — debug reactive island signal/DOM state across interactions
//
// Instruments the live WASM kernel's signal system via global set! hooks,
// tags island DOM elements for stability tracking, then runs a sequence of
// actions. After each action, captures:
//   - DOM text of reactive elements
//   - Node stability (same/replaced/new)
//   - Signal trace: swap!, set!, flush, add-sub, remove-sub, deref context
//   - Console errors
//
// Hook strategy: SX-defined globals (swap!, deref, signal-set-value!, etc.)
// can be wrapped via set! because bytecode calls them through GLOBAL_GET.
// OCaml-native primitives (scope-push!, scope-peek) use CALL_PRIM and
// bypass globals — those are observed indirectly via deref's context check.
// ---------------------------------------------------------------------------

const REACTIVE_HOOKS = `
(do
  ;; signal-set-value! — log every signal mutation
  (let ((orig signal-set-value!))
    (set! signal-set-value!
      (fn (s v)
        (log-info (str "RX:SIG:" (signal-value s) "->" v))
        (orig s v))))

  ;; flush-subscribers — log subscriber count at flush time
  (let ((orig flush-subscribers))
    (set! flush-subscribers
      (fn (s)
        (log-info (str "RX:FLUSH:subs=" (len (signal-subscribers s)) ":val=" (signal-value s)))
        (orig s))))

  ;; swap! — log the swap call (note: 2 explicit params, no &rest)
  (let ((orig swap!))
    (set! swap!
      (fn (s f)
        (log-info (str "RX:SWAP:old=" (signal-value s)))
        (orig s f))))

  ;; signal-add-sub! — log re-subscriptions
  (let ((orig signal-add-sub!))
    (set! signal-add-sub!
      (fn (s f)
        (log-info (str "RX:ADD:subs=" (len (signal-subscribers s)) ":val=" (signal-value s)))
        (orig s f))))

  ;; signal-remove-sub! — log unsubscriptions
  (let ((orig signal-remove-sub!))
    (set! signal-remove-sub!
      (fn (s f)
        (log-info (str "RX:RM:subs=" (len (signal-subscribers s)) ":val=" (signal-value s)))
        (orig s f))))

  ;; deref — log whether reactive context is visible (the key diagnostic)
  (let ((orig deref))
    (set! deref
      (fn (s)
        (when (signal? s)
          (let ((ctx (scope-peek "sx-reactive")))
            (log-info (str "RX:DEREF:ctx=" (if (nil? ctx) "nil" "ok") ":val=" (signal-value s)))))
        (orig s))))

  true)
`;

async function modeReactive(page, url, island, actionsStr) {
  await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
  await waitForHydration(page);

  const consoleLogs = [];
  page.on('console', msg => {
    consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
  });

  // Install hooks + tag elements
  const setup = await page.evaluate(({ islandName, hooks }) => {
    const K = window.SxKernel;
    if (!K || !K.eval) return { ok: false, error: 'SxKernel not found' };

    try { K.eval(hooks); }
    catch (e) { return { ok: false, error: 'Hook install: ' + e.message }; }

    const el = document.querySelector(`[data-sx-island="${islandName}"]`);
    if (!el) return { ok: false, error: `Island "${islandName}" not found` };

    const all = el.querySelectorAll('*');
    for (let i = 0; i < all.length; i++) all[i].__rxId = i;
    return { ok: true, tagged: all.length };
  }, { islandName: island, hooks: REACTIVE_HOOKS });

  if (!setup.ok) return { url, island, error: setup.error };

  // --- Snapshot: DOM state + node stability ---
  const snapshot = async () => {
    return page.evaluate((name) => {
      const root = document.querySelector(`[data-sx-island="${name}"]`);
      if (!root) return { error: 'island gone' };

      // Reactive element text
      const dom = {};
      for (const el of root.querySelectorAll('[data-sx-reactive-attrs]')) {
        const tag = el.tagName.toLowerCase();
        const cls = (el.className || '').split(' ').find(c => /^(text-|font-)/.test(c)) || '';
        let key = tag + (cls ? '.' + cls : '');
        while (dom[key] !== undefined) key += '+';
        dom[key] = el.textContent?.trim().slice(0, 120);
      }

      // Node stability
      const all = root.querySelectorAll('*');
      let same = 0, fresh = 0;
      for (const el of all) {
        if (el.__rxId !== undefined) same++; else fresh++;
      }

      // Buttons
      const buttons = Array.from(root.querySelectorAll('button')).map(b => ({
        text: b.textContent?.trim(), same: b.__rxId !== undefined
      }));

      return { dom, nodes: { same, fresh, total: all.length }, buttons };
    }, island);
  };

  // --- Drain console logs between steps ---
  let cursor = consoleLogs.length;
  const drain = () => {
    const fresh = consoleLogs.slice(cursor);
    cursor = consoleLogs.length;
    const rx = fresh.filter(l => l.text.includes('RX:')).map(l => l.text.replace('[sx] ', ''));
    const errors = fresh.filter(l => l.type === 'error').map(l => l.text);
    return { rx, errors: errors.length ? errors : undefined };
  };

  // --- Run ---
  const steps = [];
  steps.push({ step: 'initial', ...await snapshot(), ...drain() });

  for (const action of (actionsStr || '').split(';').map(s => s.trim()).filter(Boolean)) {
    const [cmd, ...rest] = action.split(':');
    const arg = rest.join(':');
    try {
      if (cmd === 'click') {
        await page.locator(arg).first().click();
        await page.waitForTimeout(150);
      } else if (cmd === 'wait') {
        await page.waitForTimeout(parseInt(arg) || 200);
      } else {
        steps.push({ step: action, error: 'unknown' });
        continue;
      }
    } catch (e) {
      steps.push({ step: action, error: e.message });
      continue;
    }
    steps.push({ step: action, ...await snapshot(), ...drain() });
  }

  return { url, island, tagged: setup.tagged, steps };
}

// ---------------------------------------------------------------------------
// Main

// ---------------------------------------------------------------------------
// Mode: trace-boot — full console capture during boot (ALL prefixes)
// ---------------------------------------------------------------------------

async function modeTraceBoot(page, url, filterPrefix) {
  const allLogs = [];
  page.on('console', msg => {
    allLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
  });
  page.on('pageerror', err => {
    allLogs.push({ type: 'pageerror', text: err.message.slice(0, 500) });
  });

  await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
  await waitForHydration(page);

  // Categorise
  const phases = { platform: [], boot: [], errors: [], warnings: [], other: [] };
  for (const l of allLogs) {
    if (l.type === 'error' || l.type === 'pageerror') phases.errors.push(l);
    else if (l.type === 'warning') phases.warnings.push(l);
    else if (l.text.startsWith('[sx-platform]')) phases.platform.push(l);
    else if (l.text.startsWith('[sx]')) phases.boot.push(l);
    else phases.other.push(l);
  }

  // Apply optional prefix filter
  const filtered = filterPrefix
    ? allLogs.filter(l => l.text.includes(filterPrefix))
    : null;

  return {
    url,
    total: allLogs.length,
    summary: {
      platform: phases.platform.length,
      boot: phases.boot.length,
      errors: phases.errors.length,
      warnings: phases.warnings.length,
      other: phases.other.length,
    },
    phases,
    ...(filtered ? { filtered } : {}),
  };
}

// ---------------------------------------------------------------------------
// Mode: hydrate-debug — re-run hydration on one island with full tracing
// ---------------------------------------------------------------------------

async function modeHydrateDebug(page, url, islandName) {
  const allLogs = [];
  page.on('console', msg => {
    allLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
  });
  page.on('pageerror', err => {
    allLogs.push({ type: 'pageerror', text: err.message.slice(0, 500) });
  });

  await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
  await waitForHydration(page);

  // Capture SSR HTML before re-hydration
  const ssrHtml = await page.evaluate((name) => {
    const el = name
      ? document.querySelector(`[data-sx-island="${name}"]`)
      : document.querySelector('[data-sx-island]');
    return el ? { name: el.getAttribute('data-sx-island'), html: el.innerHTML.substring(0, 2000) } : null;
  }, islandName);

  if (!ssrHtml) {
    return { url, error: `Island not found: ${islandName || '(first)'}` };
  }

  const targetIsland = ssrHtml.name;

  // Mark console position
  const logCursorBefore = allLogs.length;

  // Run instrumented re-hydration: clear flag, inject tracing, re-hydrate
  const result = await page.evaluate((name) => {
    const K = globalThis.SxKernel;
    if (!K) return { error: 'SxKernel not available' };

    const el = document.querySelector(`[data-sx-island="${name}"]`);
    if (!el) return { error: `Element not found: ${name}` };

    // Clear hydration flag
    el.removeAttribute('data-sx-hydrated');
    delete el._sxBoundislandhydrated;
    delete el['_sxBound' + 'island-hydrated'];

    // Check env state
    const compName = '~' + name;
    const checks = {};
    checks.compType = K.eval(`(type-of ${compName})`);
    checks.renderDomList = K.eval('(type-of render-dom-list)');
    checks.cssx = K.eval('(type-of ~cssx/tw)');
    checks.stateAttr = el.getAttribute('data-sx-state') || '{}';

    // Parse state
    window.__hd_state = checks.stateAttr;
    checks.stateParsed = K.eval('(inspect (or (first (sx-parse (host-global "__hd_state"))) {}))');

    // Get component params
    checks.params = K.eval(`(inspect (component-params (env-get (global-env) "${compName}")))`);

    // Manual render with error capture (NOT cek-try — let errors propagate)
    let renderResult;
    try {
      window.__hd_state2 = checks.stateAttr;
      const rendered = K.eval(`
        (let ((comp (env-get (global-env) "${compName}"))
              (kwargs (or (first (sx-parse (host-global "__hd_state2"))) {})))
          (let ((local (env-merge (component-closure comp) (get-render-env nil))))
            (for-each
              (fn (p) (env-bind! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
              (component-params comp))
            (let ((el (render-to-dom (component-body comp) local nil)))
              (host-get el "outerHTML"))))
      `);
      renderResult = { ok: true, html: (typeof rendered === 'string' ? rendered.substring(0, 2000) : String(rendered).substring(0, 2000)) };
    } catch (e) {
      renderResult = { ok: false, error: e.message };
    }

    // Now run the actual hydrate-island via boot.sx
    try {
      K.eval(`(hydrate-island (host-global "__hd_el"))`);
    } catch (e) {
      // ignore — we already got the manual render
    }

    // Capture hydrated HTML
    const hydratedHtml = el.innerHTML.substring(0, 2000);

    delete window.__hd_state;
    delete window.__hd_state2;

    return { checks, renderResult, hydratedHtml };
  }, targetIsland);

  // Collect console messages from the re-hydration
  const hydrateLogs = allLogs.slice(logCursorBefore);

  // Compare SSR vs hydrated
  const ssrClasses = (ssrHtml.html.match(/class="[^"]*"/g) || []).length;
  const hydClasses = (result.hydratedHtml || '').match(/class="[^"]*"/g) || [];
  const manualClasses = (result.renderResult?.html || '').match(/class="[^"]*"/g) || [];

  return {
    url,
    island: targetIsland,
    ssrHtml: ssrHtml.html.substring(0, 500),
    checks: result.checks,
    manualRender: result.renderResult,
    hydratedHtml: (result.hydratedHtml || '').substring(0, 500),
    classCounts: {
      ssr: ssrClasses,
      manual: manualClasses.length,
      hydrated: hydClasses.length,
    },
    hydrateLogs: hydrateLogs.length > 0 ? hydrateLogs : undefined,
  };
}

// ---------------------------------------------------------------------------
// Mode: eval-at — inject eval breakpoint at a specific boot phase
// ---------------------------------------------------------------------------

async function modeEvalAt(browser, url, phase, expr) {
  const validPhases = [
    'before-modules', 'after-modules',
    'before-pages', 'after-pages',
    'before-components', 'after-components',
    'before-hydrate', 'after-hydrate',
    'after-boot',
  ];
  if (!validPhases.includes(phase)) {
    return { error: `Invalid phase: ${phase}. Valid: ${validPhases.join(', ')}` };
  }

  const context = await browser.newContext();
  const page = await context.newPage();

  const allLogs = [];
  page.on('console', msg => {
    allLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
  });

  // Inject a hook that pauses the boot at the desired phase.
  // We do this by intercepting sx-platform.js and injecting eval calls.
  await page.route('**/*sx-platform.js*', async (route) => {
    const resp = await route.fetch();
    let body = await resp.text();

    // Pass expression via a global to avoid JS/SX quoting hell.
    // K.eval() handles parsing internally, so we just pass the SX string.
    // Also set up common DOM helpers as globals for easy SX access.
    const evalCode = [
      `window.__sxEvalAtExpr = ${JSON.stringify(expr)};`,
      `window.__sxEl0 = document.querySelectorAll('[data-sx-island]')[0] || null;`,
      `window.__sxEl1 = document.querySelectorAll('[data-sx-island]')[1] || null;`,
      `try { console.log("[sx-eval-at] ${phase}: " + K.eval(window.__sxEvalAtExpr)); }`,
      `catch(e) { console.log("[sx-eval-at] ${phase}: JS-ERROR: " + e.message); }`,
    ].join('\n        ');

    // Map phase names to injection points in the platform code
    const injections = {
      'before-modules':    { before: 'loadWebStack();' },
      'after-modules':     { after:  'loadWebStack();' },
      'before-pages':      { before: 'K.eval("(process-page-scripts)");' },
      'after-pages':       { after:  'K.eval("(process-page-scripts)");' },
      'before-components': { before: 'K.eval("(process-sx-scripts nil)");' },
      'after-components':  { after:  'K.eval("(process-sx-scripts nil)");' },
      'before-hydrate':    { before: 'K.eval("(sx-hydrate-islands nil)");' },
      'after-hydrate':     { after:  'K.eval("(sx-hydrate-islands nil)");' },
      'after-boot':        { after:  'console.log("[sx] boot done");' },
    };

    const inj = injections[phase];
    if (inj.before) {
      body = body.replace(inj.before, evalCode + '\n        ' + inj.before);
    } else if (inj.after) {
      body = body.replace(inj.after, inj.after + '\n        ' + evalCode);
    }

    await route.fulfill({ body, headers: { ...resp.headers(), 'content-type': 'application/javascript' } });
  });

  await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
  await waitForHydration(page);

  // Extract our eval-at result from console
  const evalAtLogs = allLogs.filter(l => l.text.startsWith('[sx-eval-at]'));
  const evalResult = evalAtLogs.length > 0
    ? evalAtLogs[0].text.replace(`[sx-eval-at] ${phase}: `, '')
    : 'INJECTION FAILED — phase marker not found in platform code';

  // Also collect boot sequence for context
  const bootLogs = allLogs.filter(l =>
    l.text.startsWith('[sx]') || l.text.startsWith('[sx-platform]') || l.text.startsWith('[sx-eval-at]') ||
    l.type === 'error' || l.type === 'warning'
  );

  await context.close();
  return { url, phase, expr, result: evalResult, bootLog: bootLogs };
}

// ---------------------------------------------------------------------------
// Mode: sandbox stack=site — full website via local OCaml HTTP server
//
// Starts sx_server --http as a subprocess, navigates Playwright to it.
// Full SSR, island hydration, HS activation, SPA navigation — no Docker.
//
// Usage:
//   sx_playwright mode=sandbox stack=site url=/sx/(applications.(hyperscript))
//   sx_playwright mode=sandbox stack=site url=/ expr="document.title"
// ---------------------------------------------------------------------------

function startSiteServer(projectRoot) {
  const { spawn } = require('child_process');
  const path = require('path');
  const port = 49152 + Math.floor(Math.random() * 16000);
  const serverBin = path.join(projectRoot, 'hosts/ocaml/_build/default/bin/sx_server.exe');

  const proc = spawn(serverBin, ['--http', String(port)], {
    cwd: projectRoot,
    env: { ...process.env, SX_PROJECT_DIR: projectRoot, OCAMLRUNPARAM: 'b' },
    stdio: ['ignore', 'pipe', 'pipe'],
  });

  let stderrBuf = '';
  let stdoutBuf = '';
  proc.stderr.on('data', chunk => { stderrBuf += chunk.toString(); });
  proc.stdout.on('data', chunk => { stdoutBuf += chunk.toString(); });

  const ready = new Promise((resolve, reject) => {
    const timeout = setTimeout(() => reject(new Error('Server did not start within 30s\n' + stderrBuf.slice(-1000))), 30000);
    proc.stderr.on('data', () => {
      if (stderrBuf.includes('Listening on port')) {
        clearTimeout(timeout);
        resolve();
      }
    });
    proc.on('error', err => { clearTimeout(timeout); reject(err); });
    proc.on('exit', code => { clearTimeout(timeout); reject(new Error('Server exited with code ' + code + '\n' + stderrBuf.slice(-1000))); });
  });

  return { proc, port, ready, getLog: () => stderrBuf + stdoutBuf };
}

function stopSiteServer(server) {
  if (server && server.proc && !server.proc.killed) {
    server.proc.kill('SIGTERM');
    setTimeout(() => { if (!server.proc.killed) server.proc.kill('SIGKILL'); }, 2000);
  }
}

async function modeSandboxSite(page, expr, url, setup) {
  const path = require('path');
  const PROJECT_ROOT = path.resolve(__dirname, '../..');
  const consoleLogs = [];
  page.on('console', msg => {
    consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
  });

  const server = startSiteServer(PROJECT_ROOT);
  process.on('exit', () => stopSiteServer(server));

  try {
    await server.ready;
  } catch (err) {
    stopSiteServer(server);
    return { mode: 'sandbox', stack: 'site', error: err.message };
  }

  const baseUrl = 'http://localhost:' + server.port;
  const targetUrl = url || '/';

  try {
    await page.goto(baseUrl + targetUrl, { waitUntil: 'networkidle', timeout: 30000 });

    // Wait for SX boot to complete
    await page.waitForFunction(
      () => document.documentElement.getAttribute('data-sx-ready') === 'true',
      { timeout: 15000 }
    ).catch(() => {}); // boot might not set this on all pages

    // Run setup SX expression if provided
    if (setup) {
      await page.evaluate(s => {
        try { window.SxKernel.eval(s); } catch(e) { console.error('[site-setup]', e.message); }
      }, setup);
    }

    // Evaluate expr — JS if it starts with "document." or "window.", SX otherwise
    const result = await page.evaluate(expr => {
      if (!expr) return { result: 'nil' };
      const isJs = /^(document\.|window\.|globalThis\.|[\(\[]|function|typeof|!|true|false|\d)/.test(expr.trim());
      if (isJs) {
        try { return { result: String(eval(expr)) }; }
        catch(e) { return { result: 'JS Error: ' + e.message }; }
      }
      const K = window.SxKernel;
      if (!K) return { result: 'Error: SxKernel not available' };
      try {
        const r = K.eval(expr);
        if (r === null || r === undefined) return { result: 'nil' };
        if (typeof r === 'string') return { result: r };
        return { result: JSON.stringify(r) };
      } catch(e) { return { result: 'Error: ' + e.message }; }
    }, expr);

    const logs = consoleLogs.filter(l =>
      l.text.includes('[sx]') || l.text.includes('[sx-platform]') ||
      l.type === 'error' || l.type === 'warning'
    );

    return {
      mode: 'sandbox',
      stack: 'site',
      url: targetUrl,
      port: server.port,
      result: result.result,
      log: logs.length > 0 ? logs : undefined,
    };
  } finally {
    stopSiteServer(server);
  }
}

// ---------------------------------------------------------------------------
// Mode: sandbox — offline WASM kernel in a blank page, no server needed
//
// General-purpose browser test environment. Injects the real WASM kernel
// into about:blank with full FFI, IO suspension, and DOM.
//
// Predefined stacks (via stack param):
//   "core"  — kernel + FFI only (default)
//   "web"   — full web stack (render, signals, DOM, adapters, boot)
//   "hs"    — web + hyperscript modules
//   "test"  — web + test framework
//
// Custom files loaded after the stack. Setup expression runs before eval.
// All IO suspensions are traced. Results formatted via SX inspect.
// ---------------------------------------------------------------------------

const SANDBOX_STACKS = {
  // Paths relative to shared/static/wasm/sx/
  web: [
    'render', 'core-signals', 'signals', 'deps', 'router',
    'page-helpers', 'freeze', 'dom', 'browser',
    'adapter-html', 'adapter-sx', 'adapter-dom',
    'boot-helpers', 'hypersx', 'engine', 'orchestration', 'boot',
  ],
  hs: [
    // web stack first, then hyperscript from lib/
    '_web_',
    'hs-tokenizer', 'hs-parser', 'hs-compiler', 'hs-runtime', 'hs-integration',
  ],
  test: [
    '_web_',
    'harness', 'harness-reactive', 'harness-web',
  ],
};

async function modeSandbox(page, expr, files, setup, stack, bytecode, url) {
  const fs = require('fs');
  const path = require('path');
  const PROJECT_ROOT = path.resolve(__dirname, '../..');
  const WASM_DIR = path.join(PROJECT_ROOT, 'shared/static/wasm');
  const SX_DIR = path.join(WASM_DIR, 'sx');

  // Full website sandbox — start real OCaml server, navigate directly
  if (stack === 'site') {
    return await modeSandboxSite(page, expr, url || setup, setup);
  }

  const usePlatform = stack === 'platform' || stack === 'platform-hs';

  const consoleLogs = [];
  page.on('console', msg => {
    consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
  });

  // 1. Navigate to blank page (use a real URL when platform mode needs XHR)
  if (usePlatform) {
    await page.route('**/sandbox.html', route => {
      route.fulfill({ body: '<!doctype html><html><head></head><body></body></html>', contentType: 'text/html' });
    });
    await page.goto('http://localhost/wasm/sandbox.html');
  } else {
    await page.goto('about:blank');
  }

  // 2. Inject WASM kernel
  const kernelSrc = fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8');
  await page.addScriptTag({ content: kernelSrc });
  await page.waitForFunction('!!window.SxKernel', { timeout: 10000 });

  // 3. Register FFI primitives + IO suspension driver
  if (usePlatform) {
    // Inject the real sx-platform.js — gives us manifest loader, __sxLoadLibrary,
    // __resolve-symbol, beginModuleLoad/endModuleLoad cycle, the works.
    // Serve the WASM dir as a file:// base so the platform can fetch manifests & .sxbc
    const platformSrc = fs.readFileSync(path.join(WASM_DIR, 'sx-platform.js'), 'utf8');
    // Set up a base URL the platform can fetch from — we'll intercept requests
    await page.route('**/wasm/**', route => {
      const url = new URL(route.request().url());
      const filePath = path.join(WASM_DIR, url.pathname.replace(/.*\/wasm\//, ''));
      if (fs.existsSync(filePath)) {
        route.fulfill({ body: fs.readFileSync(filePath), contentType: 'application/octet-stream' });
      } else {
        route.fulfill({ status: 404, body: 'Not found: ' + filePath });
      }
    });
    // Inject platform JS as inline script with a fake src for base URL detection
    await page.addScriptTag({ content: platformSrc });
    // Wait for platform to boot — it runs synchronously since readyState != "loading"
    await page.waitForFunction(() => {
      return document.documentElement.getAttribute('data-sx-ready') === 'true'
        || !!window.__sxLoadLibrary;
    }, { timeout: 30000 });
    // Give setTimeout-based JIT enable time to run
    await page.waitForTimeout(200);
  } else {
  await page.evaluate(() => {
    const K = window.SxKernel;

    K.registerNative('host-global', args => {
      const n = args[0];
      return (n in globalThis) ? globalThis[n] : null;
    });
    K.registerNative('host-get', args => {
      if (args[0] == null) return null;
      const v = args[0][args[1]];
      return v === undefined ? null : v;
    });
    K.registerNative('host-set!', args => {
      if (args[0] != null) args[0][args[1]] = args[2];
      return args[2];
    });
    K.registerNative('host-call', args => {
      const [obj, method, ...rest] = args;
      if (obj == null) {
        const fn = globalThis[method];
        if (typeof fn === 'function') return fn.apply(null, rest);
        return null;
      }
      if (typeof obj[method] !== 'function') return null;
      try { const r = obj[method].apply(obj, rest); return r === undefined ? null : r; }
      catch(e) { console.error('[sandbox] host-call error:', method, e.message); return null; }
    });
    K.registerNative('host-new', args => {
      const name = args[0];
      const cArgs = args.slice(1);
      const Ctor = typeof name === 'string' ? globalThis[name] : name;
      if (typeof Ctor !== 'function') return null;
      return new Ctor(...cArgs);
    });
    K.registerNative('host-callback', args => {
      const fn = args[0];
      if (typeof fn === 'function' && fn.__sx_handle === undefined) return fn;
      if (fn && fn.__sx_handle !== undefined) {
        return function() {
          const r = K.callFn(fn, Array.from(arguments));
          window._driveAsync(r);
          return r;
        };
      }
      return function() {};
    });
    K.registerNative('host-typeof', args => {
      const obj = args[0];
      if (obj == null) return 'nil';
      if (obj instanceof Element) return 'element';
      if (obj instanceof Text) return 'text';
      if (obj instanceof DocumentFragment) return 'fragment';
      if (obj instanceof Document) return 'document';
      if (obj instanceof Event) return 'event';
      if (obj instanceof Promise) return 'promise';
      return typeof obj;
    });
    K.registerNative('host-await', args => {
      const [promise, callback] = args;
      if (promise && typeof promise.then === 'function') {
        const cb = (callback && callback.__sx_handle !== undefined)
          ? v => K.callFn(callback, [v])
          : () => {};
        promise.then(cb);
      }
    });
    K.registerNative('load-library!', args => false);

    // IO suspension driver — traces every suspension/resume
    // _asyncPending tracks in-flight IO chains so the sandbox can wait for completion
    window._ioTrace = [];
    window._asyncPending = 0;
    window._driveAsync = function driveAsync(result) {
      if (!result || !result.suspended) { return; }
      window._asyncPending++;
      const req = result.request;
      const items = req && (req.items || req);
      const op = items && items[0];
      const opName = typeof op === 'string' ? op : (op && op.name) || String(op);
      const arg = items && items[1];
      window._ioTrace.push({ op: opName, arg: arg });

      function doResume(val, delay) {
        setTimeout(() => {
          try {
            const resumed = result.resume(val);
            window._asyncPending--;
            driveAsync(resumed);
          } catch(e) {
            window._asyncPending--;
            console.error('[sandbox] resume error:', e.message);
            window._ioTrace.push({ op: 'ERROR', arg: e.message });
          }
        }, delay);
      }

      if (opName === 'io-sleep' || opName === 'wait') {
        doResume(null, Math.min(typeof arg === 'number' ? arg : 0, 10));
      } else if (opName === 'io-navigate') {
        window._asyncPending--;
      } else if (opName === 'io-fetch') {
        doResume({ ok: true, text: '' }, 1);
      } else {
        window._asyncPending--;
        console.warn('[sandbox] unhandled IO:', opName);
        window._ioTrace.push({ op: 'unhandled', arg: opName });
      }
    };

    K.eval('(define SX_VERSION "sandbox-1.0")');
    K.eval('(define SX_ENGINE "ocaml-vm-sandbox")');
    K.eval('(define parse sx-parse)');
    K.eval('(define serialize sx-serialize)');
  });
  } // end if/else usePlatform

  // When using platform stack, modules are already loaded by sx-platform.js.
  // Skip the manual module loading below.
  if (usePlatform) {
    // Platform already loaded everything. Just load extra HS modules if platform-hs.
    if (stack === 'platform-hs') {
      await page.evaluate(() => window.__sxLoadLibrary('hs-integration'));
    }
  }

  // 4. Load stack modules
  const loadErrors = [];
  const loadedModules = [];

  function resolveStack(name) {
    const mods = SANDBOX_STACKS[name] || [];
    const result = [];
    for (const m of mods) {
      if (m === '_web_') result.push(...resolveStack('web'));
      else result.push(m);
    }
    return result;
  }

  if (stack && stack !== 'core' && !usePlatform) {
    const modules = resolveStack(stack);
    if (modules.length > 0) {
      // beginModuleLoad if available
      await page.evaluate(() => {
        if (window.SxKernel.beginModuleLoad) window.SxKernel.beginModuleLoad();
      });
      for (const mod of modules) {
        let loaded = false;
        // Try bytecode first if requested
        if (bytecode) {
          const bcPath = path.join(SX_DIR, mod + '.sxbc');
          if (fs.existsSync(bcPath)) {
            const bcSrc = fs.readFileSync(bcPath, 'utf8');
            const err = await page.evaluate(src => {
              try {
                const K = window.SxKernel;
                // Use K.load with (load-sxbc ...) wrapper — handles IO suspension in CEK
                window.__sxbcText = src;
                const r = K.load('(load-sxbc (first (parse (host-global "__sxbcText"))))');
                delete window.__sxbcText;
                if (typeof r === 'string' && r.startsWith('Error')) return r;
                return null;
              } catch(e) { delete window.__sxbcText; return 'CATCH: ' + e.message; }
            }, bcSrc);
            if (err) loadErrors.push(mod + '.sxbc: ' + err);
            else { loaded = true; loadedModules.push(mod + '.sxbc'); }
          }
        }
        // Fall back to .sx source
        if (!loaded) {
          const sxPath = path.join(SX_DIR, mod + '.sx');
          const libPath = path.join(PROJECT_ROOT, 'lib/hyperscript', mod.replace(/^hs-/, '') + '.sx');
          let src;
          try {
            src = fs.existsSync(sxPath)
              ? fs.readFileSync(sxPath, 'utf8')
              : fs.readFileSync(libPath, 'utf8');
          } catch(e) {
            loadErrors.push(mod + ': file not found');
            continue;
          }
          const err = await page.evaluate(src => {
            try { window.SxKernel.load(src); return null; }
            catch(e) { return e.message; }
          }, src);
          if (err) loadErrors.push(mod + ': ' + err);
          else loadedModules.push(mod);
        }
      }
      await page.evaluate(() => {
        if (window.SxKernel.endModuleLoad) window.SxKernel.endModuleLoad();
      });
    }
  }

  // 5. Load custom files
  for (const f of files) {
    const abs = path.isAbsolute(f) ? f : path.join(PROJECT_ROOT, f);
    try {
      const src = fs.readFileSync(abs, 'utf8');
      const err = await page.evaluate(src => {
        try { window.SxKernel.load(src); return null; }
        catch(e) { return e.message; }
      }, src);
      if (err) loadErrors.push(f + ': ' + err);
      else loadedModules.push(path.basename(f));
    } catch(e) {
      loadErrors.push(f + ': ' + e.message);
    }
  }

  // 6. Run setup expression
  if (setup) {
    const setupErr = await page.evaluate(s => {
      try { window.SxKernel.eval(s); return null; }
      catch(e) { return e.message; }
    }, setup);
    if (setupErr) loadErrors.push('setup: ' + setupErr);
  }

  // 7. Evaluate — wraps in thunk + callFn for IO suspension support.
  //    After synchronous eval, waits for all _driveAsync chains to settle.
  const evalResult = await page.evaluate(expr => {
    return new Promise(resolve => {
      const K = window.SxKernel;
      window._ioTrace = [];
      window._asyncPending = 0;
      let syncResult;
      try {
        // First try K.eval directly (handles most expressions)
        syncResult = K.eval(expr);
      } catch(e) {
        // If eval fails with IO suspension, retry via callFn thunk
        if (e.message && e.message.includes('suspension')) {
          try {
            K.eval('(define _sandbox_thunk (fn () ' + expr + '))');
            syncResult = K.callFn(K.eval('_sandbox_thunk'), []);
          } catch(e2) {
            resolve({ result: 'Error: ' + e2.message, ioTrace: window._ioTrace });
            return;
          }
        } else {
          resolve({ result: 'Error: ' + e.message, ioTrace: window._ioTrace });
          return;
        }
      }

      // If the thunk itself suspended (direct perform), drive it inline
      if (syncResult && syncResult.suspended) {
        function tracingDrive(r) {
          if (!r || !r.suspended) {
            waitAndResolve(r);
            return;
          }
          const req = r.request;
          const items = req && (req.items || req);
          const op = items && items[0];
          const opName = typeof op === 'string' ? op : (op && op.name) || String(op);
          const arg = items && items[1];
          window._ioTrace.push({ op: opName, arg: arg });
          if (opName === 'io-sleep' || opName === 'wait') {
            setTimeout(() => {
              try { tracingDrive(r.resume(null)); }
              catch(e) { resolve({ result: 'Error: ' + e.message, ioTrace: window._ioTrace }); }
            }, Math.min(typeof arg === 'number' ? arg : 0, 10));
          } else if (opName === 'io-fetch') {
            setTimeout(() => {
              try { tracingDrive(r.resume({ ok: true, text: '' })); }
              catch(e) { resolve({ result: 'Error: ' + e.message, ioTrace: window._ioTrace }); }
            }, 1);
          } else {
            resolve({ result: 'unhandled IO: ' + opName, ioTrace: window._ioTrace });
          }
        }
        tracingDrive(syncResult);
        return;
      }

      // Synchronous result — but there may be pending _driveAsync chains
      // (e.g. from host-callback event handlers triggered during eval)
      waitAndResolve(syncResult);

      function waitAndResolve(r) {
        const display = (r === null || r === undefined) ? 'nil'
          : typeof r === 'string' ? r
          : typeof r === 'object' ? JSON.stringify(r) : String(r);
        // Poll for _asyncPending to reach 0 (max 5s)
        let waited = 0;
        function check() {
          if (window._asyncPending <= 0 || waited > 5000) {
            resolve({ result: display, ioTrace: window._ioTrace });
          } else {
            waited += 20;
            setTimeout(check, 20);
          }
        }
        // Give first tick a chance to start
        setTimeout(check, 20);
      }
    });
  }, expr);

  // 8. Collect logs
  const ioLogs = consoleLogs.filter(l =>
    l.text.includes('[sandbox]') || l.text.includes('[sx]') ||
    l.type === 'error' || l.type === 'warning'
  );

  return {
    mode: 'sandbox',
    stack: stack || 'core',
    modules: loadedModules.length > 0 ? loadedModules : undefined,
    files: files.length > 0 ? files.map(f => path.basename(f)) : undefined,
    result: evalResult.result,
    ioTrace: evalResult.ioTrace.length > 0 ? evalResult.ioTrace : undefined,
    loadErrors: loadErrors.length > 0 ? loadErrors : undefined,
    log: ioLogs.length > 0 ? ioLogs : undefined,
  };
}

async function main() {
  const argsJson = process.argv[2] || '{}';
  let args;
  try {
    args = JSON.parse(argsJson);
  } catch (e) {
    console.log(JSON.stringify({ error: `Invalid JSON args: ${e.message}` }));
    process.exit(1);
  }

  const mode = args.mode || 'inspect';
  const url = args.url || '/';

  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage();

  try {
    let result;
    switch (mode) {
      case 'inspect':
        result = await modeInspect(page, url, args.island);
        break;
      case 'diff':
        result = await modeDiff(browser, url);
        break;
      case 'hydrate':
        result = await modeHydrate(browser, url);
        break;
      case 'eval':
        result = await modeEval(page, url, args.expr || 'document.title');
        break;
      case 'interact':
        result = await modeInteract(page, url, args.actions || '');
        break;
      case 'screenshot':
        result = await modeScreenshot(page, url, args.selector);
        break;
      case 'listeners':
        result = await modeListeners(page, url, args.selector || args.expr);
        break;
      case 'trace':
        result = await modeTrace(page, url, args.selector || args.expr);
        break;
      case 'cdp':
        result = await modeCdp(page, url, args.expr || '');
        break;
      case 'reactive':
        result = await modeReactive(page, url, args.island || '', args.actions || '');
        break;
      case 'trace-boot':
        result = await modeTraceBoot(page, url, args.filter || '');
        break;
      case 'hydrate-debug':
        result = await modeHydrateDebug(page, url, args.island || '');
        break;
      case 'eval-at':
        result = await modeEvalAt(browser, url, args.phase || 'before-hydrate', args.expr || '(type-of ~cssx/tw)');
        break;
      case 'sandbox':
        result = await modeSandbox(page, args.expr || '"hello"', args.files || [], args.setup || '', args.stack || '', !!args.bytecode, args.url || '');
        break;
      default:
        result = { error: `Unknown mode: ${mode}` };
    }
    console.log(JSON.stringify(result, null, 2));
  } catch (e) {
    console.log(JSON.stringify({ error: e.message, stack: e.stack?.split('\n').slice(0, 5) }));
  } finally {
    await browser.close();
  }
}

main();
