#!/usr/bin/env node
/**
 * Run HS behavioral tests — single process, synchronous, with step-limit timeout.
 * Uses the OCaml VM's built-in step_limit to break infinite loops.
 */
const fs = require('fs');
const path = require('path');

const PROJECT = path.resolve(__dirname, '..');
const WASM_DIR = path.join(PROJECT, 'shared/static/wasm');
const SX_DIR = path.join(WASM_DIR, 'sx');

// Load WASM kernel
eval(fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8'));
const K = globalThis.SxKernel;

process.on('unhandledRejection', () => {});

let _fakeTimers = [];
let _fakeTimerIdCtr = 0;
const _realSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = function(cb, _delay) {
  const id = ++_fakeTimerIdCtr;
  _fakeTimers.push({ id, cb });
  return id;
};
globalThis.clearTimeout = function(id) {
  const idx = _fakeTimers.findIndex(t => t.id === id);
  if (idx >= 0) _fakeTimers.splice(idx, 1);
};
globalThis.__hsFlushTimers = { call: function() {
  const batch = _fakeTimers.splice(0);
  for (const { cb } of batch) { try { cb(); } catch (_) {} }
}};

// Auto-unwrap shim: the post-JIT-Phase-1 kernel returns numbers, strings,
// booleans, and nil as opaque value handles ({_type, __sx_handle}). Tests
// expect plain JS values from K.eval like the pre-rewrite kernel did. Wrap
// once at boot rather than touching all 23 K.eval call sites.
if (K && typeof K.eval === 'function' && K.stringify) {
  const _kEval = K.eval.bind(K);
  K.eval = function(expr) {
    const r = _kEval(expr);
    if (r && typeof r === 'object' && typeof r._type === 'string') {
      switch (r._type) {
        case 'number':  { const s = K.stringify(r); const n = Number(s);
                          return Number.isInteger(n) || /^-?\d+$/.test(s) ? n : (Number.isNaN(n) ? r : n); }
        case 'string':  return K.stringify(r);
        case 'boolean': return K.stringify(r) === 'true';
        case 'nil':     return null;
        default:        return r;  // list/dict/symbol — leave as handle
      }
    }
    return r;
  };
}

// Value-handle unwrap helper for native interop. The new kernel wraps atoms
// (number, string, boolean, nil) in {_type, __sx_handle} handles. JS natives
// receiving these in argument lists would do reference-equality on the handle
// instead of value-equality on the underlying primitive — breaking things
// like JS Set dedup (each literal `1` becomes a new handle). Unwrap before
// handing off to native JS.
function _unwrapHandle(v) {
  if (v && typeof v === 'object' && typeof v._type === 'string' && K.stringify) {
    switch (v._type) {
      case 'number':  { const s = K.stringify(v); const n = Number(s);
                        return Number.isInteger(n) || /^-?\d+$/.test(s) ? n : n; }
      case 'string':  return K.stringify(v);
      case 'boolean': return K.stringify(v) === 'true';
      case 'nil':     return null;
      default:        return v;
    }
  }
  return v;
}

// Step limit API — exposed from OCaml kernel
const STEP_LIMIT = parseInt(process.env.HS_STEP_LIMIT || '1000000');

function setStepLimit(n) { K.setStepLimit(n); }
function resetStepCount() { K.resetStepCount(); }

// ─── DOM mock ──────────────────────────────────────────────────
// Resize observer support (cluster 26): `on resize` routes through the
// `resize` DOM event. The mock style proxy dispatches that event
// whenever width/height changes programmatically, with
// `detail = {width, height}` parsed from the current inline style.
// A registry is kept for an optional ResizeObserver-style API, but the
// runtime path uses plain addEventListener("resize").
globalThis.__hsResizeRegistry = globalThis.__hsResizeRegistry || new Map();
function _parsePx(v) {
  if (v == null) return 0;
  const s = String(v).trim();
  const m = s.match(/^(-?[\d.]+)/);
  return m ? parseFloat(m[1]) : 0;
}
globalThis.__hsFireResize = function(el) {
  if (!el || !el._isEl) return;
  const w = _parsePx(el.style && el.style.width);
  const h = _parsePx(el.style && el.style.height);
  const ev = new Ev('resize', { bubbles: false, detail: { width: w, height: h } });
  try { el.dispatchEvent(ev); } catch (e) {}
  // Also notify any ResizeObserver instances (future-proofing).
  const cbs = globalThis.__hsResizeRegistry.get(el);
  if (cbs) for (const cb of cbs) { try { cb([{ target: el, contentRect: { width: w, height: h } }]); } catch (e) {} }
};
// Default CSS values for unset inline styles (matches browser UA defaults
// for block-level elements — our tests mostly exercise divs).
function mkStyle(tag, ownerEl) {
  const inline = ['SPAN','A','B','I','EM','STRONG','CODE','LABEL','SMALL','SUB','SUP','U','MARK'];
  const display = (tag && inline.includes(tag.toUpperCase())) ? 'inline' : 'block';
  const s = { display, visibility: 'visible', opacity: '1' };
  s.setProperty = function(p, v) {
    const prev = s[p];
    s[p] = v;
    if ((p === 'width' || p === 'height') && ownerEl && String(prev) !== String(v)) {
      if (globalThis.__hsFireResize) globalThis.__hsFireResize(ownerEl);
    }
  };
  s.getPropertyValue = function(p) { return s[p] || ''; };
  s.removeProperty = function(p) { delete s[p]; };
  if (typeof Proxy !== 'undefined') {
    return new Proxy(s, {
      set(t, k, v) {
        const prev = t[k];
        t[k] = v;
        if ((k === 'width' || k === 'height') && ownerEl && String(prev) !== String(v)) {
          if (globalThis.__hsFireResize) globalThis.__hsFireResize(ownerEl);
        }
        return true;
      }
    });
  }
  return s;
}
class El {
  constructor(t) { this.tagName=t.toUpperCase(); this.nodeName=this.tagName; this.nodeType=1; this._isEl=true; this.id=''; this.className=''; this.classList=new CL(this); this.style=mkStyle(this.tagName, this); this.attributes={}; this.children=[]; this.childNodes=[]; this.childNodes.item=function(i){return this[i]||null;}; this.parentElement=null; this.parentNode=null; this.textContent=''; this.innerHTML=''; this._listeners={}; const _el=this; const _datasetBacking={}; this.dataset=new Proxy(_datasetBacking,{set(o,k,v){o[k]=v;if(typeof k==='string'){const attr='data-'+k.replace(/[A-Z]/g,c=>'-'+c.toLowerCase());_el.attributes[attr]=String(v);}return true;},deleteProperty(o,k){delete o[k];if(typeof k==='string'){const attr='data-'+k.replace(/[A-Z]/g,c=>'-'+c.toLowerCase());delete _el.attributes[attr];}return true;}}); this.open=false; this.value=''; this.defaultValue=''; this.checked=false; this.defaultChecked=false; this.disabled=false; this.type=''; this.name=''; this.selectedIndex=-1; this.defaultSelected=false; this.selected=false; this.options=[]; }
  setAttribute(n,v) { this.attributes[n]=String(v); if(n==='id')this.id=v; if(n==='class'){this.className=v;this.classList._sync(v);} if(n==='value'){this.value=v;this.defaultValue=v;} if(n==='name')this.name=v; if(n==='type')this.type=v; if(n==='checked'){this.checked=true;this.defaultChecked=true;} if(n==='selected'){this.selected=true;this.defaultSelected=true;} if(n==='multiple')this.multiple=true; if(n==='disabled')this.disabled=true; if(typeof n==='string'&&n.startsWith('data-')){const key=n.slice(5).replace(/-([a-z])/g,(_,c)=>c.toUpperCase());this.dataset[key]=String(v);} if(n==='style'){const s=String(v);for(const d of s.split(';')){const c=d.indexOf(':');if(c>0){const k=d.slice(0,c).trim();const val=d.slice(c+1).trim();if(k)this.style.setProperty(k,val);}} } }
  getAttribute(n) { return this.attributes[n]!==undefined?this.attributes[n]:null; }
  removeAttribute(n) { delete this.attributes[n]; if(n==='disabled')this.disabled=false; }
  hasAttribute(n) { return n in this.attributes; }
  addEventListener(e,f) { if(!this._listeners[e])this._listeners[e]=[]; this._listeners[e].push(f); }
  removeEventListener(e,f) { if(this._listeners[e])this._listeners[e]=this._listeners[e].filter(x=>x!==f); }
  dispatchEvent(ev) { ev.target=ev.target||this; ev.currentTarget=this; const fns=[...(this._listeners[ev.type]||[])]; for(const f of fns){if(ev._si)break;try{f.call(this,ev);}catch(e){}} if(ev.bubbles&&!ev._sp){if(this.parentElement){this.parentElement.dispatchEvent(ev);}else if(globalThis._windowListeners){globalThis.dispatchEvent(ev);}} return !ev.defaultPrevented; }
  appendChild(c) { if(c.parentElement)c.parentElement.removeChild(c); c.parentElement=this; c.parentNode=this; this.children.push(c); this.childNodes.push(c); if(this.tagName==='SELECT'&&c.tagName==='OPTION'){this.options.push(c);if(c.selected&&this.selectedIndex<0)this.selectedIndex=this.options.length-1;} this._syncText(); return c; }
  removeChild(c) { this.children=this.children.filter(x=>x!==c); this.childNodes=this.childNodes.filter(x=>x!==c); c.parentElement=null; c.parentNode=null; this._syncText(); return c; }
  insertBefore(n,r) { if(n.parentElement)n.parentElement.removeChild(n); const i=this.children.indexOf(r); if(i>=0){this.children.splice(i,0,n);this.childNodes.splice(i,0,n);}else{this.children.push(n);this.childNodes.push(n);} n.parentElement=this;n.parentNode=this; this._syncText(); return n; }
  replaceChild(n,o) { const i=this.children.indexOf(o); if(i>=0){this.children[i]=n;this.childNodes[i]=n;} n.parentElement=this;n.parentNode=this; o.parentElement=null;o.parentNode=null; this._syncText(); return o; }
  querySelector(s) { return fnd(this,s); }
  querySelectorAll(s) { return fndAll(this,s); }
  closest(s) { let e=this; while(e){if(mt(e,s))return e; e=e.parentElement;} return null; }
  matches(s) { return mt(this,s); }
  contains(o) { if(o===this)return true; for(const c of this.children)if(c===o||c.contains(o))return true; return false; }
  cloneNode(d) { const e=new El(this.tagName.toLowerCase()); Object.assign(e.attributes,this.attributes); e.id=this.id; e.className=this.className; e.classList._sync(this.className); for(const k of Object.keys(this.style)){if(typeof this.style[k]!=='function')e.style[k]=this.style[k];} e.textContent=this.textContent; e.innerHTML=this.innerHTML; e.value=this.value; if(d)for(const c of this.children)e.appendChild(c.cloneNode(true)); return e; }
  focus(){} blur(){} click(){this.dispatchEvent(new Ev('click',{bubbles:true}));} remove(){if(this.parentElement)this.parentElement.removeChild(this);}
  get nextSibling(){const p=this.parentElement;if(!p)return null;const i=p.children.indexOf(this);return i>=0&&i<p.children.length-1?p.children[i+1]:null;}
  get nextElementSibling(){return this.nextSibling;}
  get previousSibling(){const p=this.parentElement;if(!p)return null;const i=p.children.indexOf(this);return i>0?p.children[i-1]:null;}
  get previousElementSibling(){return this.previousSibling;}
  reset(){
    // Form reset: walk descendants, restore defaultValue / defaultChecked /
    // defaultSelected.
    const walk = (el) => {
      for (const c of (el.children || [])) {
        const tag = c.tagName;
        if (tag === 'INPUT' || tag === 'TEXTAREA') {
          if (c.type === 'checkbox' || c.type === 'radio') c.checked = !!c.defaultChecked;
          else c.value = c.defaultValue || '';
        } else if (tag === 'OPTION') {
          c.selected = !!c.defaultSelected;
        } else if (tag === 'SELECT') {
          for (const o of (c.options || [])) o.selected = !!o.defaultSelected;
          const first = (c.options || []).findIndex(o => o.selected);
          c.selectedIndex = first >= 0 ? first : 0;
          c.value = first >= 0 && c.options[first] ? (c.options[first].value || '') : '';
        }
        walk(c);
      }
    };
    walk(this);
  }
  _syncText() {
    // Sync textContent from children
    const t = this.children.map(c => c.textContent || '').join('');
    if (t) this.textContent = t;
  }
  _setInnerHTML(html) {
    // Clear children
    for (const c of this.children) { c.parentElement = null; c.parentNode = null; }
    this.children = []; this.childNodes = [];
    this.innerHTML = html;
    // Parse simple HTML and add children
    if (html) {
      const parsed = parseHTMLFragments(html);
      for (const c of parsed) this.appendChild(c);
      this.textContent = this.children.map(c => c.textContent || '').join('') || html.replace(/<[^>]*>/g, '');
    } else {
      this.textContent = '';
    }
    // Textarea: its value is its textContent. Capture defaultValue on first
    // set so later reset() can restore.
    if (this.tagName === 'TEXTAREA') {
      this.value = this.textContent;
      if (!('_origDefault' in this)) { this.defaultValue = this.textContent; this._origDefault = true; }
    }
  }
  get outerHTML() {
    const tag = this.tagName.toLowerCase();
    // Merge .id / .className (set directly) into attribute output.
    const attrOrder = [];
    const attrMap = {};
    if (this.id) { attrMap['id'] = this.id; attrOrder.push('id'); }
    if (this.className) { attrMap['class'] = this.className; attrOrder.push('class'); }
    for (const k of Object.keys(this.attributes)) {
      if (!(k in attrMap)) { attrMap[k] = this.attributes[k]; attrOrder.push(k); }
      else attrMap[k] = this.attributes[k]; // prefer explicit attr value when set
    }
    const attrs = attrOrder.map(k => ` ${k}="${String(attrMap[k]).replace(/"/g, '&quot;')}"`).join('');
    if (VOID_TAGS.has(tag)) return `<${tag}${attrs}>`;
    let inner = '';
    if (this.children && this.children.length) {
      inner = this.children.map(c => c.outerHTML || c.textContent || '').join('');
    } else if (this.innerHTML) {
      inner = this.innerHTML;
    } else if (this.innerText !== undefined && this.innerText !== '') {
      inner = this.innerText;
    } else if (this.textContent) {
      inner = this.textContent;
    }
    return `<${tag}${attrs}>${inner}</${tag}>`;
  }
  get childElementCount() { return this.children.length; }
  toString() { return this.nodeType === 11 ? '[object DocumentFragment]' : '[object Object]'; }
  get firstElementChild() { return this.children[0]||null; }
  get lastElementChild() { return this.children[this.children.length-1]||null; }
  get nextElementSibling() { if(!this.parentElement)return null; const i=this.parentElement.children.indexOf(this); return this.parentElement.children[i+1]||null; }
  get previousElementSibling() { if(!this.parentElement)return null; const i=this.parentElement.children.indexOf(this); return i>0?this.parentElement.children[i-1]:null; }
  showModal(){this.open=true;this.setAttribute('open','');} show(){this.open=true;} close(){this.open=false;this.removeAttribute('open');}
  getAnimations(){return [];} getBoundingClientRect(){return{top:0,left:0,width:100,height:100,right:100,bottom:100};} scrollIntoView(){}
  get ownerDocument() { return document; }
  get offsetParent() { return this.parentElement; }
  get offsetTop() { return 0; } get offsetLeft() { return 0; }
  get scrollTop() { return 0; } set scrollTop(v) {} get scrollLeft() { return 0; } set scrollLeft(v) {}
  get scrollHeight() { return 100; } get scrollWidth() { return 100; }
  get clientHeight() { return 100; } get clientWidth() { return 100; }
  insertAdjacentHTML(pos, html) {
    // Parse the HTML into real El instances so they can be queried, clicked,
    // and HS-activated. Text-only content becomes a textContent append.
    if (typeof html !== 'string') html = String(html);
    const parsed = parseHTMLFragments(html);
    const p = (pos || '').toLowerCase();
    if (parsed.length === 0) {
      if (p === 'beforeend' || p === 'afterbegin') this.textContent = (this.textContent || '') + html;
      return;
    }
    if (p === 'beforeend') {
      for (const c of parsed) this.appendChild(c);
      this.innerHTML = (this.innerHTML || '') + html;
    } else if (p === 'afterbegin') {
      const first = this.children[0] || null;
      for (const c of parsed) { if (first) this.insertBefore(c, first); else this.appendChild(c); }
      this.innerHTML = html + (this.innerHTML || '');
    } else if (p === 'beforebegin' && this.parentElement) {
      for (const c of parsed) this.parentElement.insertBefore(c, this);
    } else if (p === 'afterend' && this.parentElement) {
      const parent = this.parentElement;
      const idx = parent.children.indexOf(this);
      const nextSibling = parent.children[idx + 1] || null;
      for (const c of parsed) {
        if (nextSibling) parent.insertBefore(c, nextSibling);
        else parent.appendChild(c);
      }
    }
  }
}
class CL { constructor(e){this._el=e;this._set=new Set();} _sync(str){this._set=new Set((str||'').split(/\s+/).filter(Boolean));} add(...c){for(const x of c)this._set.add(x);this._el.className=[...this._set].join(' ');this._el.attributes['class']=this._el.className;} remove(...c){for(const x of c)this._set.delete(x);this._el.className=[...this._set].join(' ');this._el.attributes['class']=this._el.className;} toggle(c,f){if(f!==undefined){if(f)this.add(c);else this.remove(c);return f;} if(this._set.has(c)){this.remove(c);return false;}else{this.add(c);return true;}} contains(c){return this._set.has(c);} get length(){return this._set.size;} [Symbol.iterator](){return this._set[Symbol.iterator]();} }
class Ev { constructor(t,o={}){this.type=t;this.bubbles=o.bubbles||false;this.cancelable=o.cancelable!==false;this.defaultPrevented=false;this._sp=false;this._si=false;this.target=null;this.currentTarget=null;this.detail=o.detail||null;} preventDefault(){this.defaultPrevented=true;} stopPropagation(){this._sp=true;} stopImmediatePropagation(){this._sp=true;this._si=true;} }

const VOID_TAGS = new Set(['input','br','hr','img','meta','link','area','base','col','embed','source','track','wbr']);
function parseHTMLFragments(html) {
  const results = [];
  // Match self-closing `<tag .../>`, paired `<tag>...</tag>`, or void
  // elements with no close tag (input, br, hr, etc.).
  const re = /<(\w+)([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>|>)/g;
  let m;
  let lastIndex = 0;
  while ((m = re.exec(html)) !== null) {
    // Text before this tag
    if (m.index > lastIndex) {
      const text = html.slice(lastIndex, m.index).trim();
      if (text) {
        const tn = {nodeType:3, textContent:text, data:text};
        // Can't push text nodes directly to El children; wrap if needed
      }
    }
    const tag = m[1]; const attrs = m[2]; let inner = m[3] || '';
    // If the regex matched a void-style `<tag ...>` with no close tag,
    // ensure we don't treat subsequent markup as inner content.
    if (inner === '' && !m[0].endsWith('/>') && !VOID_TAGS.has(tag.toLowerCase()) && m[0].endsWith('>')) {
      // Generic `<tag ...>` with no close tag — still leave inner empty;
      // this keeps behaviour lenient without running past the next tag.
    }
    const el = new El(tag);
    const attrRe = /([\w-]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>"'\/>][^\s>]*)))?/g; let am;
    while ((am = attrRe.exec(attrs))) {
      const nm = am[1]; const val = am[2] !== undefined ? am[2] : am[3] !== undefined ? am[3] : am[4];
      if (val !== undefined) el.setAttribute(nm, val);
      else el.setAttribute(nm, '');
    }
    // Also handle boolean attrs like disabled
    const boolRe = /\s(\w+)(?=\s|\/|>|$)/g;
    if (inner) {
      // Recursively parse inner HTML
      const innerEls = parseHTMLFragments(inner);
      if (innerEls.length > 0) {
        for (const c of innerEls) el.appendChild(c);
        el.textContent = innerEls.map(c => c.textContent || '').join('');
      } else {
        el.textContent = inner;
      }
      el.innerHTML = inner;
      // Textarea: its "value" comes from inner text, not an attr.
      if (el.tagName === 'TEXTAREA') {
        el.value = inner;
        el.defaultValue = inner;
      }
      // Option: textContent is the label; if no value attr, it defaults to
      // the label. Track defaultSelected separately from runtime selected.
      if (el.tagName === 'OPTION' && !el.attributes.value) {
        el.value = inner.trim();
      }
    }
    results.push(el);
    lastIndex = re.lastIndex;
  }
  // If no tags found, treat as text — create a span with textContent
  if (results.length === 0 && html.trim()) {
    const el = new El('span');
    el.textContent = html.replace(/<[^>]*>/g, '');
    el.innerHTML = html;
    results.push(el);
  }
  return results;
}

function mt(e,s) {
  if(!e||!e.tagName)return false;
  s = s.trim();
  // Comma-separated selector list — any clause matches
  if (s.includes(',')) {
    return s.split(',').some(part => mt(e, part.trim()));
  }
  // Strip pseudo-classes we handle at the fnd() level (nth-of-type, first/last-of-type)
  const pseudo = s.match(/^(.*?):(nth-of-type|first-of-type|last-of-type)(?:\((\d+)\))?$/);
  const base = pseudo ? pseudo[1] : s;
  if(base.startsWith('#'))return e.id===base.slice(1);
  if(base.startsWith('.'))return e.classList.contains(base.slice(1));
  if(base.startsWith('[')) {
    const m = base.match(/^\[([^\]=]+)(?:="([^"]*)")?\]$/);
    if(m) return m[2] !== undefined ? e.getAttribute(m[1]) === m[2] : e.hasAttribute(m[1]);
  }
  // Compound tag[attr=val] e.g. input[type=checkbox] or input[type="checkbox"]
  if(base.includes('[')) {
    const cm = base.match(/^([\w-]+)(\[.+\])$/);
    if(cm) {
      if(e.tagName.toLowerCase() !== cm[1]) return false;
      const attrParts = cm[2].match(/^\[([^\]=]+)(?:=["']?([^"'\]]+)["']?)?\]$/);
      if(attrParts) return attrParts[2] !== undefined ? e.getAttribute(attrParts[1]) === attrParts[2] : e.hasAttribute(attrParts[1]);
    }
  }
  if(base.includes('.')) { const [tag, cls] = base.split('.'); return e.tagName.toLowerCase() === tag && e.classList.contains(cls); }
  if(base.includes('#')) { const [tag, id] = base.split('#'); return e.tagName.toLowerCase() === tag && e.id === id; }
  return e.tagName.toLowerCase() === base.toLowerCase();
}
function fnd(e,s) {
  const pseudo = s && typeof s==='string' ? s.match(/^(.*?):(nth-of-type|first-of-type|last-of-type)(?:\((\d+)\))?$/) : null;
  if (pseudo) {
    const all = fndAll(e, pseudo[1]);
    if (pseudo[2] === 'first-of-type') return all[0] || null;
    if (pseudo[2] === 'last-of-type') return all[all.length - 1] || null;
    const idx = parseInt(pseudo[3] || '1') - 1;
    return all[idx] || null;
  }
  for(const c of(e.children||[])){if(mt(c,s))return c;const f=fnd(c,s);if(f)return f;} return null;
}
function fndAll(e,s) { const r=[];for(const c of(e.children||[])){if(mt(c,s))r.push(c);r.push(...fndAll(c,s));}r.item=function(i){return r[i]||null;};return r; }

const _body = new El('body');
const _html = new El('html');
_html.appendChild(_body);
const document = {
  body: _body, documentElement: _html,
  createElement(t){return new El(t);}, createElementNS(n,t){return new El(t);},
  createDocumentFragment(){const f=new El('fragment');f.nodeType=11;return f;},
  createTextNode(t){return{nodeType:3,textContent:t,data:t};},
  getElementById(i){return fnd(_body,'#'+i);},
  querySelector(s){if(s==='html')return _html;if(s==='body')return _body;return fnd(_body,s)||(mt(_html,s)?_html:null);}, querySelectorAll(s){const r=fndAll(_body,s);if(s==='html'||mt(_html,s))r.unshift(_html);return r;},
  createEvent(t){return new Ev(t);}, addEventListener(){}, removeEventListener(){},
};
globalThis.document=document; globalThis.window=globalThis; globalThis.HTMLElement=El; globalThis.Element=El;
// window event-target shim (for hyperscript:beforeFetch and similar bubbled events)
globalThis._windowListeners={};
globalThis.addEventListener=function(e,f){if(!globalThis._windowListeners[e])globalThis._windowListeners[e]=[];globalThis._windowListeners[e].push(f);};
globalThis.removeEventListener=function(e,f){if(globalThis._windowListeners[e])globalThis._windowListeners[e]=globalThis._windowListeners[e].filter(x=>x!==f);};
globalThis.dispatchEvent=function(ev){const fns=[...(globalThis._windowListeners[ev.type]||[])];for(const f of fns){if(ev&&ev._si)break;try{f.call(globalThis,ev);}catch(e){}}return ev?!ev.defaultPrevented:true;};
// cluster-33: cookie store + document.cookie + cookies Proxy.
globalThis.__hsCookieStore = new Map();
Object.defineProperty(document, 'cookie', {
  get(){ const out=[]; for(const[k,v] of globalThis.__hsCookieStore) out.push(k+'='+v); return out.join('; '); },
  set(s){
    const str=String(s||'');
    const m=str.match(/^\s*([^=]+?)\s*=\s*([^;]*)/);
    if(!m) return;
    const name=m[1].trim();
    const val=m[2];
    if(/expires=Thu,?\s*01\s*Jan\s*1970/i.test(str) || val==='') globalThis.__hsCookieStore.delete(name);
    else globalThis.__hsCookieStore.set(name, val);
  },
  configurable: true,
});
globalThis.cookies = new Proxy({}, {
  get(_, k){
    if(k==='length') return globalThis.__hsCookieStore.size;
    if(k==='clear') return (name)=>globalThis.__hsCookieStore.delete(String(name));
    if(k===Symbol.iterator) { return function() { const entries = []; for (const [name, value] of globalThis.__hsCookieStore) entries.push({_type:'dict', name, value}); return entries[Symbol.iterator](); }; }
    if(typeof k==='symbol' || k==='_order') return undefined;
    return globalThis.__hsCookieStore.has(k) ? globalThis.__hsCookieStore.get(k) : null;
  },
  set(_, k, v){ globalThis.__hsCookieStore.set(String(k), String(v)); return true; },
  has(_, k){ return globalThis.__hsCookieStore.has(k); },
  ownKeys(){ return Array.from(globalThis.__hsCookieStore.keys()); },
  getOwnPropertyDescriptor(_, k){
    if(globalThis.__hsCookieStore.has(k)) return {value: globalThis.__hsCookieStore.get(k), enumerable: true, configurable: true};
    return undefined;
  },
  [Symbol.iterator]() {
    const entries = [];
    for (const [name, value] of globalThis.__hsCookieStore) entries.push({_type:'dict', name, value});
    return entries[Symbol.iterator]();
  },
});
// cluster-28: test-name-keyed confirm/prompt/alert mocks. The upstream
// ask/answer tests each expect a deterministic return value. Keyed on
// globalThis.__currentHsTestName which the test loop sets before each test.
globalThis.alert = function(){};
globalThis.confirm = function(_msg){
  const n = globalThis.__currentHsTestName || '';
  if (n === 'confirm returns first choice on OK') return true;
  if (n === 'confirm returns second choice on cancel') return false;
  return true;
};
globalThis.prompt = function(_msg){
  const n = globalThis.__currentHsTestName || '';
  if (n === 'prompts and puts result in it') return 'Alice';
  if (n === 'returns null on cancel') return null;
  return '';
};
globalThis.Event=Ev; globalThis.CustomEvent=Ev; globalThis.NodeList=Array; globalThis.HTMLCollection=Array;
globalThis.getComputedStyle=(e)=>e?e.style:{}; globalThis.requestAnimationFrame=(f)=>{f();return 0;};
globalThis.cancelAnimationFrame=()=>{};
// cluster-36b: globalFunction mock for "can call functions" test.
// The test calls globalFunction("foo") via hyperscript and checks window.calledWith.
globalThis.globalFunction = function(x) { globalThis.calledWith = x; };
// asyncCheck: async-when test needs a truthy-returning global (simulates async guard).
globalThis.asyncCheck = function() { return true; };
// cluster-asyncError: function that returns a rejected promise.
globalThis.failAsync = function() { return Promise.reject(new Error("boom")); };
// HsMutationObserver — cluster-32 mutation mock. Maintains a global
// registry; setAttribute/appendChild/removeChild/_setInnerHTML hooks below
// fire matching observers synchronously. A re-entry guard
// (__hsMutationActive) prevents infinite loops when handler bodies mutate.
globalThis.__hsMutationRegistry = [];
globalThis.__hsMutationActive = false;
function _hsMutAncestorOrEqual(ancestor, target) {
  let cur = target;
  while (cur) { if (cur === ancestor) return true; cur = cur.parentElement; }
  return false;
}
function _hsMutMatches(reg, rec) {
  const o = reg.opts;
  if (!_hsMutAncestorOrEqual(reg.target, rec.target)) return false;
  if (rec.type === 'attributes') {
    if (!o.attributes) return false;
    if (o.attributeFilter && o.attributeFilter.length > 0) {
      if (!o.attributeFilter.includes(rec.attributeName)) return false;
    }
    return true;
  }
  if (rec.type === 'childList') return !!o.childList;
  if (rec.type === 'characterData') return !!o.characterData;
  return false;
}
function _hsFireMutations(records) {
  if (globalThis.__hsMutationActive) return;
  if (!records || records.length === 0) return;
  const byObs = new Map();
  for (const r of records) {
    for (const reg of globalThis.__hsMutationRegistry) {
      if (!_hsMutMatches(reg, r)) continue;
      if (!byObs.has(reg.observer)) byObs.set(reg.observer, []);
      byObs.get(reg.observer).push(r);
    }
  }
  if (byObs.size === 0) return;
  globalThis.__hsMutationActive = true;
  try {
    for (const [obs, recs] of byObs) {
      try { obs._cb(recs, obs); } catch (e) {}
    }
  } finally {
    globalThis.__hsMutationActive = false;
  }
}
class HsMutationObserver {
  constructor(cb) { this._cb = cb; this._regs = []; }
  observe(el, opts) {
    if (!el) return;
    // opts is an SX dict: read fields directly. attributeFilter is an SX list
    // ({_type:'list', items:[...]}) OR a JS array.
    let af = opts && opts.attributeFilter;
    if (af && af._type === 'list') af = af.items;
    const o = {
      attributes: !!(opts && opts.attributes),
      childList: !!(opts && opts.childList),
      characterData: !!(opts && opts.characterData),
      subtree: !!(opts && opts.subtree),
      attributeFilter: af || null,
    };
    const reg = { observer: this, target: el, opts: o };
    this._regs.push(reg);
    globalThis.__hsMutationRegistry.push(reg);
  }
  disconnect() {
    for (const r of this._regs) {
      const i = globalThis.__hsMutationRegistry.indexOf(r);
      if (i >= 0) globalThis.__hsMutationRegistry.splice(i, 1);
    }
    this._regs = [];
  }
  takeRecords() { return []; }
}
globalThis.MutationObserver = HsMutationObserver;
// Hook El prototype methods so mutations fire registered observers.
// Hooks are no-ops while __hsMutationActive=true (prevents re-entry from
// handler bodies that themselves mutate the DOM).
(function _hookElForMutations() {
  const _setAttr = El.prototype.setAttribute;
  El.prototype.setAttribute = function(n, v) {
    const r = _setAttr.call(this, n, v);
    if (globalThis.__hsMutationRegistry.length)
      _hsFireMutations([{ type: 'attributes', target: this, attributeName: String(n), oldValue: null }]);
    return r;
  };
  const _append = El.prototype.appendChild;
  El.prototype.appendChild = function(c) {
    const r = _append.call(this, c);
    if (globalThis.__hsMutationRegistry.length)
      _hsFireMutations([{ type: 'childList', target: this, addedNodes: [c], removedNodes: [] }]);
    return r;
  };
  const _remove = El.prototype.removeChild;
  El.prototype.removeChild = function(c) {
    const r = _remove.call(this, c);
    if (globalThis.__hsMutationRegistry.length)
      _hsFireMutations([{ type: 'childList', target: this, addedNodes: [], removedNodes: [c] }]);
    return r;
  };
  const _setIH = El.prototype._setInnerHTML;
  El.prototype._setInnerHTML = function(html) {
    const r = _setIH.call(this, html);
    if (globalThis.__hsMutationRegistry.length)
      _hsFireMutations([{ type: 'childList', target: this, addedNodes: [], removedNodes: [] }]);
    return r;
  };
})();
// HsResizeObserver — cluster-26 resize mock. Keeps a per-element callback
// registry so code that observes via `new ResizeObserver(cb)` still works,
// but HS's `on resize` uses the plain `resize` DOM event dispatched by the
// style proxy in mkStyle (above).
class HsResizeObserver {
  constructor(cb) { this._cb = cb; this._els = new Set(); }
  observe(el) {
    if (!el) return; this._els.add(el);
    const reg = globalThis.__hsResizeRegistry;
    if (!reg.has(el)) reg.set(el, new Set());
    reg.get(el).add(this._cb);
  }
  unobserve(el) {
    if (!el) return; this._els.delete(el);
    const reg = globalThis.__hsResizeRegistry;
    if (reg.has(el)) reg.get(el).delete(this._cb);
  }
  disconnect() {
    const reg = globalThis.__hsResizeRegistry;
    for (const el of this._els) { if (reg.has(el)) reg.get(el).delete(this._cb); }
    this._els.clear();
  }
}
globalThis.ResizeObserver=HsResizeObserver; globalThis.ResizeObserverEntry=class{};
// HsIntersectionObserver — cluster-27 intersection mock. Fires the callback
// synchronously on observe() with isIntersecting=true so `on intersection`
// handlers run during activation. `margin`/`threshold` options are parsed
// but ignored (tests only assert the handler fires).
class HsIntersectionObserver {
  constructor(cb, opts) { this._cb = cb; this._opts = opts || {}; this._els = new Set(); }
  observe(el) {
    if (!el) return;
    this._els.add(el);
    const entry = { target: el, isIntersecting: true, intersectionRatio: 1,
                    boundingClientRect: (el.getBoundingClientRect && el.getBoundingClientRect()) || {},
                    intersectionRect: {}, rootBounds: null, time: 0 };
    try { this._cb([entry], this); } catch (e) {}
  }
  unobserve(el) { if (el) this._els.delete(el); }
  disconnect() { this._els.clear(); }
  takeRecords() { return []; }
}
globalThis.IntersectionObserver = HsIntersectionObserver;
globalThis.IntersectionObserverEntry = class {};
// WebSocket mock for socket feature tests (E36)
globalThis.WebSocket = function HsWebSocket(url) {
  const sock = {
    url, readyState: 1, onmessage: null, onclose: null, onerror: null, onopen: null,
    _listeners: {}, _sent: [],
    send(msg) { sock._sent.push(msg); },
    addEventListener(t, h) { (sock._listeners[t] = sock._listeners[t] || []).push(h); },
    removeEventListener(t, h) { if (sock._listeners[t]) sock._listeners[t] = sock._listeners[t].filter(x => x !== h); },
    close() { sock.readyState = 3; (sock._listeners['close'] || []).forEach(h => h({})); if (sock.onclose) sock.onclose({}); }
  };
  globalThis.__hs_ws_created = globalThis.__hs_ws_created || [];
  globalThis.__hs_ws_created.push(sock);
  return sock;
};
globalThis.WebSocket.CONNECTING = 0; globalThis.WebSocket.OPEN = 1; globalThis.WebSocket.CLOSING = 2; globalThis.WebSocket.CLOSED = 3;
var _iidCounter = 0;
function _hsRpcCall(wrapper, fnName, args, timeout) {
  if (wrapper._closed) {
    const ws2 = new (wrapper._WS || globalThis.WebSocket)(wrapper._url);
    wrapper._ws = ws2; wrapper._closed = false;
    if (wrapper._onmessage_handler) ws2.onmessage = wrapper._onmessage_handler;
    ws2.addEventListener('close', () => { wrapper._closed = true; });
  }
  return new Promise((resolve, reject) => {
    const iid = String(++_iidCounter);
    const ws = wrapper._ws;
    if (!wrapper._pending) wrapper._pending = {};
    wrapper._pending[iid] = { resolve, reject };
    if (ws && ws.send) ws.send(JSON.stringify({ iid, function: fnName, args }));
    if (timeout !== Infinity && timeout != null) {
      setTimeout(() => {
        if (wrapper._pending && wrapper._pending[iid]) {
          delete wrapper._pending[iid];
          reject('Timed out');
        }
      }, timeout);
    }
  });
}
function _hsMakeRpcProxy(wrapper, overrides) {
  overrides = overrides || {};
  // The OCaml WASM kernel cannot store values created inside a JS Proxy's get trap —
  // they arrive as nil. Use a dispatch-object pattern instead: host-get detects
  // _hsRpcDispatch and calls it directly, bypassing Proxy trap issues.
  const rpc = function() {};
  rpc._hsRpcDispatch = function(name) {
    name = String(name);
    if (['then', 'catch', 'length', 'toJSON'].includes(name)) return null;
    if (name === 'noTimeout') return _hsMakeRpcProxy(wrapper, Object.assign({}, overrides, { timeout: Infinity }));
    if (name === 'timeout') return function(n) { return _hsMakeRpcProxy(wrapper, Object.assign({}, overrides, { timeout: n })); };
    const t = overrides.timeout !== undefined ? overrides.timeout : (wrapper._timeout != null ? wrapper._timeout : 0);
    return function() { return _hsRpcCall(wrapper, name, Array.from(arguments), t); };
  };
  return rpc;
}
globalThis._hs_make_rpc_proxy = _hsMakeRpcProxy;
function _hsSetupSocket(wrapper) {
  wrapper.dispatchEvent = function(evt) {
    if (wrapper._closed) {
      const ws2 = new (wrapper._WS || globalThis.WebSocket)(wrapper._url);
      wrapper._ws = ws2; wrapper._closed = false;
      if (wrapper._onmessage_handler) ws2.onmessage = wrapper._onmessage_handler;
      ws2.addEventListener('close', () => { wrapper._closed = true; });
    }
    const ws = wrapper._ws;
    if (!ws) return;
    const payload = { type: evt.type };
    const detail = evt.detail || {};
    for (const k of Object.keys(detail)) {
      if (k !== 'sender' && k !== '_namedArgList_' && k !== '_type') payload[k] = detail[k];
    }
    ws.send(JSON.stringify(payload));
  };
  wrapper.rpc = _hsMakeRpcProxy(wrapper, {});
  return wrapper;
}
globalThis._hsSetupSocket = _hsSetupSocket;
globalThis.navigator={userAgent:'node'}; globalThis.location={href:'http://localhost/',pathname:'/',search:'',hash:'',protocol:'http:',host:'localhost',hostname:'localhost',port:''};
globalThis.history={pushState(){},replaceState(){},back(){},forward(){}};
globalThis.getSelection=()=>({toString:()=>(globalThis.__test_selection||'')});
const _origLog = console.log;
globalThis.console = { log: () => {}, error: () => {}, warn: () => {}, info: () => {}, debug: () => {} }; // suppress ALL console noise
const _log = _origLog; // keep reference for our own output

// ─── FFI ────────────────────────────────────────────────────────
// JS-level reference equality for host objects (works around OCaml boxing).
// The SX `=` primitive doesn't do JS === for host objects in the WASM kernel.
K.registerNative('hs-ref-eq',a=>a[0]===a[1]);
K.registerNative('host-global',a=>{const n=_unwrapHandle(a[0]);return(n in globalThis)?globalThis[n]:null;});
K.registerNative('host-get',a=>{
  if(a[0]==null)return null;
  const k=_unwrapHandle(a[1]);
  // SX lists (arrive as {_type:'list', items:[...]}) don't expose length/size
  // through JS property access. Hand-roll common collection queries so
  // compiled HS `x.length` / `x.size` works on scoped lists.
  if(a[0] && a[0]._type==='list' && (k==='length' || k==='size')) return a[0].items.length;
  if(a[0] && a[0]._type==='list' && typeof k==='number') return a[0].items[k]!==undefined?a[0].items[k]:null;
  if(a[0] && a[0]._type==='dict' && k==='size') return Object.keys(a[0]).filter(x=>x!=='_type').length;
  // innerText is DOM-level alias for textContent (close enough for mock purposes)
  if(a[0] instanceof El && k==='innerText') return String(a[0].textContent||'');
  // RPC dispatch object: _hsRpcDispatch bypasses Proxy-in-WASM-kernel nil issue
  if(a[0] && typeof a[0]._hsRpcDispatch==='function'){const rv=a[0]._hsRpcDispatch(String(k));return rv===undefined?null:rv;}
  let v=a[0][k];
  if(v===undefined)return null;
  // Only coerce DOM property strings for actual DOM elements — plain JS objects
  // (e.g. promise-state dicts with a "value" key) must not be stringified.
  if(a[0] instanceof El&&(k==='innerHTML'||k==='textContent'||k==='value'||k==='className')&&typeof v!=='string')v=String(v!=null?v:'');
  return v;
});
K.registerNative('host-set!',a=>{if(a[0]!=null){const k=_unwrapHandle(a[1]);const v=_unwrapHandle(a[2]); if(k==='innerHTML'&&a[0] instanceof El){const s=v===null?'null':v===undefined?'':String(v);a[0]._setInnerHTML(s);a[0][k]=a[0].innerHTML;} else if(k==='textContent'&&a[0] instanceof El){const s=v===null?'null':v===undefined?'':String(v);a[0].textContent=s;a[0].innerHTML=s;for(const c of a[0].children){c.parentElement=null;c.parentNode=null;}a[0].children=[];a[0].childNodes=[];} else{a[0][k]=v;}} return a[2];});
K.registerNative('host-call',a=>{if(_testDeadline&&Date.now()>_testDeadline)throw new Error('TIMEOUT: wall clock exceeded');const[o,mRaw,...r]=a;const m=_unwrapHandle(mRaw);if(o==null){const f=globalThis[m];return typeof f==='function'?f.apply(null,r.map(_unwrapHandle)):null;}if(o&&typeof o[m]==='function'){try{const v=o[m].apply(o,r.map(_unwrapHandle));return v===undefined?null:v;}catch(e){return null;}}return null;});
K.registerNative('host-call-fn',a=>{const[fn,argList]=a;if(typeof fn!=='function'&&!(fn&&fn.__sx_handle!==undefined))return null;const callArgs=(argList&&argList._type==='list'&&argList.items)?Array.from(argList.items):(Array.isArray(argList)?argList:[]);if(fn&&fn.__sx_handle!==undefined){try{return K.callFn(fn,callArgs);}catch(e){const msg=e&&e.message||'';if(String(msg).includes('TIMEOUT'))throw e;return null;}}function sxToJs(v){if(v&&v._type==='list'&&v.items)return Array.from(v.items).map(sxToJs);return _unwrapHandle(v);}try{const v=fn.apply(null,callArgs.map(sxToJs));return v===undefined?null:v;}catch(e){return null;}});
K.registerNative('host-new',a=>{const nameOrCtor=_unwrapHandle(a[0]);const C=typeof nameOrCtor==='string'?globalThis[nameOrCtor]:nameOrCtor;return typeof C==='function'?new C(...a.slice(1).map(_unwrapHandle)):null;});
K.registerNative('host-callback',a=>{const fn=a[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));if(globalThis._driveAsync)globalThis._driveAsync(r);return r;};return function(){};});
K.registerNative('host-make-js-thrower',a=>{const val=_unwrapHandle(a[0]);return function(){throw val;};});
K.registerNative('host-typeof',a=>{let o=a[0];if(o==null)return'nil';if(o&&typeof o==='object'&&typeof o._type==='string'&&'__sx_handle' in o)return o._type;if(o instanceof El)return'element';if(o&&o.nodeType===3)return'text';if(o instanceof Ev)return'event';if(o instanceof Promise)return'promise';return typeof o;});
K.registerNative('host-iter?',([obj])=>{const o=_unwrapHandle(obj);return o!=null&&typeof o[Symbol.iterator]==='function';});
K.registerNative('host-to-list',([obj])=>{const o=_unwrapHandle(obj);try{return[...o];}catch(e){return[];}});
K.registerNative('host-await',a=>{});
K.registerNative('load-library!',()=>false);
K.registerNative('hs-is-set?',a=>a[0] instanceof Set);
K.registerNative('hs-is-map?',a=>a[0] instanceof Map);
// Upstream test fixtures: synchronous stubs matching OCaml run_tests.ml registrations
globalThis.promiseAString = () => 'foo';
globalThis.promiseAnInt = () => 42;
globalThis.promiseAnIntIn = (n) => Promise.resolve(42);
globalThis.promiseValueBackIn = (v, n) => Promise.resolve(v);
globalThis.throwBar = function() { throw "bar"; };
globalThis.identity = x => x;

// ── JS block execution support ─────────────────────────────────
// Track promise states for synchronous introspection in hs-js-exec
const _promiseStates = new WeakMap();
const _origPReject = Promise.reject.bind(Promise);
const _origPResolve = Promise.resolve.bind(Promise);
Promise.reject = function(v) {
  const p = _origPReject(v);
  _promiseStates.set(p, {ok: false, value: v});
  p.catch(() => {}); // suppress unhandled rejection warning
  return p;
};
Promise.resolve = function(v) {
  if (v && typeof v === 'object' && typeof v.then === 'function') return _origPResolve(v);
  const p = _origPResolve(v);
  _promiseStates.set(p, {ok: true, value: v});
  return p;
};

K.registerNative('host-new-function', a => {
  const paramList = a[0];
  const src = _unwrapHandle(a[1]);
  const params = paramList && paramList._type === 'list' && paramList.items
    ? Array.from(paramList.items).map(_unwrapHandle)
    : Array.isArray(paramList) ? paramList.map(_unwrapHandle) : [];
  try { return new Function(...params, src); } catch(e) { return null; }
});

K.registerNative('host-promise-state', a => {
  const p = a[0];
  if (!p || typeof p.then !== 'function') return null;
  const s = _promiseStates.get(p);
  if (!s) return null;
  // Wrap Error objects as plain dicts — the WASM bridge serializes arbitrary
  // JS objects to strings, so we extract message before crossing the boundary.
  const val = s.value instanceof Error
    ? {message: s.value.message}
    : (s.value != null ? s.value : null);
  return {ok: s.ok, value: val};
});

// Normalize exception in catch blocks: if this is the async-error sentinel string,
// retrieve the original error object from the side-channel global instead.
K.registerNative('host-hs-normalize-exc', a => {
  const val = a[0];
  const pending = globalThis.__hs_async_error;
  if (pending !== undefined && pending !== null && val === '__hs_async_error__') {
    globalThis.__hs_async_error = null;
    return pending;
  }
  globalThis.__hs_async_error = null;
  return val;
});

// Like host-call-fn but propagates native JS exceptions via sentinel rather than swallowing them.
// Also synchronously unwraps Promise.resolve() results so async tests work in sync env.
K.registerNative('host-call-fn-raising', a => {
  const [fn, argList] = a;
  if (typeof fn !== 'function' && !(fn && fn.__sx_handle !== undefined)) return null;
  const callArgs = (argList && argList._type === 'list' && argList.items)
    ? Array.from(argList.items)
    : (Array.isArray(argList) ? argList : []);
  function sxToJs(v) {
    if (v && v._type === 'list' && v.items) return Array.from(v.items).map(sxToJs);
    return v;
  }
  if (fn && fn.__sx_handle !== undefined) {
    try {
      const r = K.callFn(fn, callArgs);
      if (globalThis._driveAsync) globalThis._driveAsync(r);
      return r !== undefined ? r : null;
    } catch(e) {
      const msg = (e && e.message) || '';
      if (String(msg).includes('TIMEOUT')) throw e;
      globalThis.__hs_js_throw = String(e != null ? e : '');
      return '__hs_js_throw__';
    }
  }
  try {
    const v = fn.apply(null, callArgs.map(sxToJs));
    if (v === undefined) return null;
    if (v instanceof Promise) {
      const s = _promiseStates.get(v);
      if (s) {
        if (!s.ok) {
          globalThis.__hs_async_error = (s.value instanceof Error) ? {message: s.value.message} : s.value;
          return '__hs_async_error__';
        }
        return (s.value !== undefined && s.value !== null) ? s.value : null;
      }
      return null;
    }
    return v;
  } catch(e) {
    const msg = (e instanceof Error) ? e.message : String(e != null ? e : '');
    globalThis.__hs_js_throw = msg;
    return '__hs_js_throw__';
  }
});
K.registerNative('host-take-js-throw', a => {
  const v = globalThis.__hs_js_throw;
  globalThis.__hs_js_throw = null;
  return (v != null) ? String(v) : '';
});

let _testDeadline = 0;
// Mock fetch routes
const _fetchRoutes = {
  '/test': { status: 200, body: 'yay', json: '{"foo":1}', html: '<div>yay</div>', number: '1.2' },
  '/test-json': { status: 200, body: '{"foo":1}', json: '{"foo":1}' },
  '/404': { status: 404, body: 'the body' },
  '/number': { status: 200, body: '1.2' },
  '/users/Joe': { status: 200, body: 'Joe', json: '{"name":"Joe"}' },
};
// Per-test fetch overrides keyed by test name; takes priority over _fetchRoutes.
const _fetchScripts = {
  "as response does not throw on 404":
      { "/test": { status: 404, body: "not found" } },
  "do not throw passes through 404 response":
      { "/test": { status: 404, body: "the body" } },
  "don't throw passes through 404 response":
      { "/test": { status: 404, body: "the body" } },
  "throws on non-2xx response by default":
      { "/test": { status: 404, body: "not found" } },
  "Response can be converted to JSON via as JSON":
      { "/test": { status: 200, body: '{"name":"Joe"}', json: '{"name":"Joe"}',
                   contentType: "application/json" } },
  "can catch an error that occurs when using fetch":
      { "/test": { networkError: true } },
  "triggers an event just before fetching":
      { "/test": { status: 200, body: "yay", contentType: "text/html" } },
  "can do a simple fetch w/ a custom conversion":
      { "/test": { status: 200, body: "1.2" } },
  "can do a simple fetch w/ html":
      { "/test": { status: 200, body: "<p>hello</p>", html: "<p>hello</p>", contentType: "text/html" } },
};
function _mockFetch(url) {
  const scriptRoutes = _fetchScripts[globalThis.__currentHsTestName];
  const route = (scriptRoutes && scriptRoutes[url]) || _fetchRoutes[url] || _fetchRoutes['/test'];
  return { ok: (route.status||200) < 400, status: route.status || 200, url: url || '/test',
           _body: route.body || '', _json: route.json || route.body || '', _html: route.html || route.body || '' };
}
globalThis._driveAsync=function driveAsync(r,d){d=d||0;if(_testDeadline && Date.now()>_testDeadline)throw new Error('TIMEOUT: wall clock exceeded');if(globalThis._hs_null_error)return;if(d>500||!r||!r.suspended)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);
  function doResume(v){try{const x=r.resume(v);driveAsync(x,d+1);}catch(e){const msg=e&&(e.message||(Array.isArray(e)&&typeof e[2]==='string'&&e[2])||'');if(String(msg).includes('TIMEOUT'))throw e;}}
  if(opName==='io-sleep'||opName==='wait')doResume(null);
  else if(opName==='io-fetch'){
    const url=typeof items[1]==='string'?items[1]:'/test';
    const scriptRoutes=_fetchScripts[globalThis.__currentHsTestName];
    const route=(scriptRoutes&&scriptRoutes[url])||_fetchRoutes[url]||_fetchRoutes['/test'];
    if(route&&route.networkError){doResume({_type:'dict','_network-error':true,message:'aborted'});}
    else{const st=route.status||200;doResume({_type:'dict',ok:st<400,status:st,url,_body:route.body||'',_json:route.json||route.body||'',_html:route.html||route.body||'',_number:route.number||route.body||''});}
  }
  else if(opName==='io-parse-text'){const resp=items&&items[1];doResume(resp&&resp._body?resp._body:typeof resp==='string'?resp:'');}
  else if(opName==='io-parse-json'){const resp=items&&items[1];try{doResume(JSON.parse(typeof resp==='string'?resp:resp&&resp._json?resp._json:'{}'));}catch(e){doResume(null);}}
  else if(opName==='io-parse-html'){const resp=items&&items[1];const htmlStr=resp&&(resp._html||resp._body)?String(resp._html||resp._body):'';const frag=new El('fragment');frag.nodeType=11;if(htmlStr)frag._setInnerHTML(htmlStr);doResume(frag);}
  else if(opName==='io-settle')doResume(null);
  else if(opName==='io-wait-event'){
    const target=_unwrapHandle(items&&items[1]);
    const evNameRaw=_unwrapHandle(items&&items[2]);
    const evName=typeof evNameRaw==='string'?evNameRaw:'';
    const timeoutRaw=items&&items.length>3?_unwrapHandle(items[3]):undefined;
    const timeout=typeof timeoutRaw==='number'?timeoutRaw:undefined;
    if(typeof timeout==='number'){
      // `wait for EV or Nms` — timeout wins immediately in the mock (tests use 0ms)
      doResume(null);
    } else if(target && target instanceof El && evName){
      const handler=function(ev){
        target.removeEventListener(evName,handler);
        doResume(ev);
      };
      target.addEventListener(evName,handler);
    } else {
      doResume(null);
    }
  }
  else if(opName==='io-transition')doResume(null);
};

K.eval('(define SX_VERSION "hs-test-1.0")');K.eval('(define SX_ENGINE "ocaml-vm-sandbox")');
K.eval('(define parse sx-parse)');K.eval('(define serialize sx-serialize)');

// ─── Load modules ──────────────────────────────────────────────
process.stderr.write('Loading modules...\n');
const t_mod = Date.now();
const WEB=['render','core-signals','signals','deps','router','page-helpers','freeze','dom','browser','adapter-html','adapter-sx','adapter-dom','boot-helpers','hypersx','engine','orchestration','boot'];
const HS=['hs-tokenizer','hs-parser','hs-compiler','hs-runtime','hs-integration'];
K.beginModuleLoad();
// hs-* modules: prefer lib/hyperscript/ (source of truth for conformance work) over WASM sx dir
for(const mod of[...WEB,...HS]){const sp=path.join(SX_DIR,mod+'.sx');const lp=path.join(PROJECT,'lib/hyperscript',mod.replace(/^hs-/,'')+'.sx');let s;try{const lpExists=mod.startsWith('hs-')&&fs.existsSync(lp);s=lpExists?fs.readFileSync(lp,'utf8'):(fs.existsSync(sp)?fs.readFileSync(sp,'utf8'):fs.readFileSync(lp,'utf8'));}catch(e){continue;}try{K.load(s);}catch(e){process.stderr.write(`LOAD ERROR: ${mod}: ${e.message}\n`);}}
K.endModuleLoad();
process.stderr.write(`Modules loaded in ${Date.now()-t_mod}ms\n`);

// ─── Test framework ────────────────────────────────────────────
K.eval('(define _test-registry (list))');K.eval('(define _test-suite "")');
K.eval('(define push-suite (fn (name) (set! _test-suite name)))');
K.eval('(define pop-suite (fn () (set! _test-suite "")))');
K.eval('(define _current-test-name "")');
K.eval('(define try-call (fn (thunk) (set! _test-registry (append _test-registry (list {:suite _test-suite :name _current-test-name :thunk thunk}))) {:ok true}))');
K.eval('(define report-pass (fn (n) true))');
K.eval('(define report-fail (fn (n e) true))');
K.eval(`(define _run-test-thunk
  (fn (thunk)
    (guard (exn
             (true {:ok false :error (if (string? exn) exn (str exn))}))
      (thunk)
      {:ok true})))`);
process.stderr.write('Loading tests...\n');
const t_tests = Date.now();
for(const f of['spec/harness.sx','spec/tests/test-framework.sx','spec/tests/test-hyperscript-behavioral.sx']){
  const t0=Date.now();
  try{K.load(fs.readFileSync(path.join(PROJECT,f),'utf8'));}catch(e){process.stderr.write(`TEST LOAD ERROR: ${f}: ${e.message}\n`);}
  process.stderr.write(`  ${path.basename(f)}: ${Date.now()-t0}ms\n`);
  if(f==='spec/tests/test-framework.sx'){
    K.eval(`(defmacro deftest (name &rest body)
      \`(do (set! _current-test-name ,name)
            (let ((result (try-call (fn () ,@body))))
              (if (get result "ok")
                (report-pass ,name)
                (report-fail ,name (get result "error"))))))`);
  }
}
process.stderr.write(`Tests loaded in ${Date.now()-t_tests}ms\n`);

// Redefine try-call to actually catch errors for assert-throws.
// During loading it was the registration version (stores thunks, returns {:ok true}).
// Now that tests are registered, redefine it to run the thunk and catch any exception.
K.eval('(define try-call _run-test-thunk)');

// Override eval-hs-error for runtimeErrors tests: hs-null-raise!/hs-empty-raise!/hs-win-call
// each wrap their (raise msg) in a self-contained guard so the raise is swallowed before
// it can escape through the empty JIT kont and trigger the slow host_error path (~34s).
// The null error message is stored in window._hs_null_error (side channel) before the raise,
// so we can recover it here even when eval-hs returns normally.
K.eval(`(define eval-hs-error
  (fn (src)
    (host-set! (host-global "window") "_hs_null_error" nil)
    (let ((result
      (guard (_e (true (if (string? _e) _e (str _e))))
        (eval-hs src)
        nil)))
      (or (host-get (host-global "window") "_hs_null_error") result))))`);
K.eval('(define x nil)(define y nil)(define z nil)');

const testCount = K.eval('(len _test-registry)');
// Pre-read names
const names = [];
for(let i=0;i<testCount;i++) names.push({
  s: K.eval(`(get (nth _test-registry ${i}) "suite")`)||'',
  n: K.eval(`(get (nth _test-registry ${i}) "name")`)||`test-${i}`,
});

const startTest = parseInt(process.env.HS_START || '0');
const endTest = parseInt(process.env.HS_END || String(testCount));
process.stdout.write(`Running tests ${startTest}-${endTest-1} of ${testCount} (step limit: ${STEP_LIMIT})...\n`);

let passed=0,failed=0;
const cats={};const errTypes={};

const SUITE_FILTER = process.env.HS_SUITE;
for(let i=startTest;i<Math.min(endTest,testCount);i++){
  if(SUITE_FILTER && names[i].s!==SUITE_FILTER) continue;
  const {s:suite,n:name}=names[i];
  if(!cats[suite])cats[suite]={p:0,f:0,errs:[]};

  // Reset body
  _body.children=[];_body.childNodes=[];_body.innerHTML='';_body.textContent='';
  globalThis._hs_null_error=null;
  globalThis.__test_selection='';
  globalThis.__hsCookieStore.clear();
  globalThis.__hsMutationRegistry.length = 0;
  globalThis.__hsMutationActive = false;
  globalThis._windowListeners={};
  globalThis.__currentHsTestName = name;

  // Hypertrace tests use async wait loops that legitimately exceed the step limit.
  // Disable CEK step counting for these — wall-clock deadline still applies.
  // Tests that require async event dispatch not supported in the sync test runner.
  // These tests hang indefinitely because io-wait-event suspends the OCaml kernel
  // waiting for an event that is never fired from outside the K.eval call chain.
  const _SKIP_TESTS = new Set([]);
  if (_SKIP_TESTS.has(name)) continue;

  const _NO_STEP_LIMIT = new Set([
    "async hypertrace is reasonable",
    "hypertrace from javascript is reasonable",
    "hypertrace is reasonable",
    "repeat forever works",
    "repeat forever works w/o keyword",
    "receives named events",
    "passes the sieve test",
  ]);
  // Suites where JIT cascade legitimately exceeds the per-test step limit.
  const _NO_STEP_LIMIT_SUITES = new Set([
    "hs-upstream-core/runtimeErrors",
    "hs-upstream-core/sourceInfo",
    "hs-upstream-expressions/collectionExpressions",
    "hs-upstream-expressions/typecheck",
    "hs-upstream-socket",
    // these suites do scoped variable + array operations that cascade step counts
    "hs-upstream-default",
    "hs-upstream-def",
    "hs-upstream-empty",
    "hs-upstream-core/scoping",
    "hs-upstream-core/tokenizer",
    "hs-upstream-expressions/arrayIndex",
  ]);
  // Enable step limit for timeout protection — reset counter first so accumulation
  // across tests doesn't cause signed-32-bit wraparound (~2B extra steps before limit fires).
  // Hypertrace tests instrument every evaluation and legitimately exceed the step limit.
  resetStepCount();
  setStepLimit((_NO_STEP_LIMIT.has(name) || _NO_STEP_LIMIT_SUITES.has(suite)) ? 0 : STEP_LIMIT);
  const _SLOW_DEADLINE = {
    "async hypertrace is reasonable": 30000,
    "hypertrace from javascript is reasonable": 30000,
    "hypertrace is reasonable": 30000,
    "passes the sieve test": 600000,
    "behavior scoping is isolated from other behaviors": 60000,
    "behavior scoping is isolated from the core element scope": 60000,
    // repeat suite: two JIT preheat calls each take 7-12s cold
    "can nest loops": 60000,
    "only executes the init expression once": 60000,
    "repeat forever works": 60000,
    "repeat forever works w/o keyword": 60000,
    "until keyword works": 60000,
    "while keyword works": 60000,
    // additional slow tests: complex JIT compilation, multi-step iteration
    "loop continue works": 60000,
    "where clause can use the for loop variable name": 60000,
    "can swap a variable with a property": 60000,
    "can swap array elements": 60000,
    "can swap two properties": 60000,
    "string templates preserve white space": 60000,
    "return inside a def called from a view transition skips the animation": 60000,
    // first test in suite — JIT warmup
    "can add a value to a set": 30000,
  };
  const _SLOW_DEADLINE_SUITES = {
    "hs-upstream-core/runtimeErrors": 30000,
    "hs-upstream-core/scoping": 60000,
    "hs-upstream-core/tokenizer": 60000,
    "hs-upstream-expressions/collectionExpressions": 60000,
    "hs-upstream-expressions/typecheck": 30000,
    "hs-upstream-expressions/arrayIndex": 60000,
    "hs-upstream-behavior": 20000,
    // eventsource: JIT saturation after multiple compilations in suite sequence
    "hs-upstream-ext/eventsource": 30000,
    // socket: first call to hs-socket-register! triggers JIT compilation, no step limit
    "hs-upstream-socket": 30000,
    // in: 4× eval-hs per test triggers repeated JIT warmup > 10s default
    "hs-upstream-expressions/in": 60000,
  };
  _testDeadline = Date.now() + (_SLOW_DEADLINE[name] || _SLOW_DEADLINE_SUITES[suite] || 10000);
  globalThis.__hs_deadline = _testDeadline; // expose to WASM cek_step_loop
  if(process.env.HS_VERBOSE)process.stderr.write(`T${i} `);

  let ok=false,err=null;
  try{
    // Use SX-level guard to catch errors, avoiding __sxR side-channel issues
    // Returns a dict with :ok and :error keys.
    // Note: api_eval returns "Error: <msg>" string (not throw) for SX exceptions,
    // so K.eval may return an error string rather than throwing. Check for this.
    const defineR = K.eval(`(define _test-result (_run-test-thunk (get (nth _test-registry ${i}) "thunk")))`);
    // Clear deadline immediately: once the test thunk finishes (or times out and
    // the guard catches it), further K.eval calls for result inspection must not
    // keep re-firing the deadline check on every 10k steps.
    globalThis.__hs_deadline = 0;
    if(typeof defineR==='string' && defineR.startsWith('Error: ')){
      err=defineR.slice(7,157); // strip "Error: " prefix
    } else {
      const isOk=K.eval('(get _test-result "ok")');
      if(isOk===true){ok=true;}
      else{
        const errMsg=K.eval('(get _test-result "error")');
        err=errMsg?String(errMsg).slice(0,150):'unknown error';
      }
    }
  }catch(e){err=(e.message||'').slice(0,150);}
  setStepLimit(0); // disable step limit between tests

  const elapsed = Date.now() - (_testDeadline - 3000); // ms since test start
  if(ok){passed++;cats[suite].p++;}
  else{
    failed++;cats[suite].f++;cats[suite].errs.push({name,err});
    let t='other';
    if(err&&err.includes('TIMEOUT'))t='timeout';
    else if(err&&err.includes('NOT IMPLEMENTED'))t='stub';
    else if(err&&err.includes('Assertion'))t='assert-fail';
    else if(err&&err.includes('Expected'))t='wrong-value';
    else if(err&&err.includes('Undefined symbol'))t='undef-sym';
    else if(err&&err.includes('Unhandled'))t='unhandled';
    errTypes[t]=(errTypes[t]||0)+1;
  }
  _testDeadline = 0; globalThis.__hs_deadline = 0;
  if((i+1)%100===0)process.stdout.write(`  ${i+1}/${testCount} (${passed} pass, ${failed} fail)\n`);
  if(elapsed > 5000)process.stdout.write(`  SLOW: test ${i} took ${elapsed}ms [${suite}] ${name}\n`);
  if(!ok && err && err.includes('TIMEOUT'))process.stdout.write(`  TIMEOUT: test ${i} [${suite}] ${name}\n`);
  if(!ok && err && err.includes('Expected') && err.includes(', got '))process.stdout.write(`  WRONG: test ${i} [${suite}] ${name} — ${err}\n`);
  if(!ok && err && err.includes("at position"))process.stdout.write(`  PARSE: test ${i} [${suite}] ${name} — ${err}\n`);
}

process.stdout.write(`\nResults: ${passed}/${passed+failed} (${(100*passed/(passed+failed)).toFixed(0)}%)\n\n`);
process.stdout.write('By category:\n');
for(const[cat,s]of Object.entries(cats).sort((a,b)=>{const ra=a[1].p/(a[1].p+a[1].f);const rb=b[1].p/(b[1].p+b[1].f);return rb-ra;})){
  const total=s.p+s.f;const pct=(100*s.p/total).toFixed(0);
  const mark=s.f===0?`✓ ${s.p}`:`${s.p}/${total} (${pct}%)`;
  process.stdout.write(`  ${cat}: ${mark}\n`);
}
process.stdout.write('\nFailure types:\n');
for(const[t,n]of Object.entries(errTypes).sort((a,b)=>b[1]-a[1])) process.stdout.write(`  ${t}: ${n}\n`);
const ue={};
for(const[cat,s]of Object.entries(cats))for(const{err}of s.errs){const e=(err||'').slice(0,100);ue[e]=(ue[e]||0)+1;}
process.stdout.write(`\nUnique errors (${Object.keys(ue).length}):\n`);
for(const[e,n]of Object.entries(ue).sort((a,b)=>b[1]-a[1]).slice(0,30)) process.stdout.write(`  [${n}x] ${e}\n`);

// Full failure list
process.stdout.write('\nAll failures:\n');
for(const[cat,s]of Object.entries(cats))for(const{name,err}of s.errs){
  process.stdout.write(`  [${cat}] ${name}: ${err}\n`);
}

// Test-specific debug dump  
if(process.env.HS_DEBUG_TEST){
  const target = process.env.HS_DEBUG_TEST;
  for(let i=0;i<names.length;i++){
    if(names[i].n===target){
      console.log('FOUND TARGET INDEX:', i, 'suite:', names[i].s);
      break;
    }
  }
}
