/*
 * ============================================================================
 * test-hooks.js — SVGETest: STABLE, DOCUMENTED headless test hook namespace
 * ============================================================================
 * Feature #636. A thin, high-level, deterministic API for automated tests,
 * callable through the #634 headless script channel WITHOUT click/mac-control:
 *
 *     osascript -e 'tell application "SVG Editor" to do script "SVGETest.getSVGString()"'
 *
 * ---------------------------------------------------------------------------
 *  STABLE API — DO NOT CHANGE A SIGNATURE OR RETURN SHAPE WITHOUT
 *  (a) bumping SVGETest.VERSION and (b) adding a deprecation note below.
 * ---------------------------------------------------------------------------
 *
 * Design:
 *  - Built ON TOP of ai-dom.js (global AIDOM: app / activeDocument / PathItem …)
 *    and svgCanvas + svgedit.* utilities. It does NOT reinvent creation,
 *    undo, grouping, layers or matrices — it wraps them and fills the gaps the
 *    raw API does not cleanly give (a JSON assert primitive over the selection,
 *    a serialized-SVG reader, the next-undo label).
 *  - Every hook returns a JSON-serializable value AND prints it (JSON) to the
 *    script OUTPUT via $.writeln, so `do script` surfaces it as text through
 *    ai-runner's result. You therefore call the hook bare — do NOT wrap it in
 *    another $.writeln / JSON.stringify, that would double-print.
 *  - Every hook is crash-safe: on failure it returns {ok:false, error:"…"}.
 *  - Coordinates are SVG user units (px), origin top-left — identical to the
 *    document's own coordinate space (ai-dom runs pointsPerUnit=1, topdown).
 *
 * Loaded from index.html immediately AFTER ai-dom.js and ai-runner.js, so
 * AIDOM, svgCanvas and svgedit are all defined when this file evaluates. This
 * file only READS those at call time (never at load time), so load order only
 * needs AIDOM to exist by the first hook call.
 *
 * ---------------------------------------------------------------------------
 * DEPRECATION LOG
 *   v1.0.0  (2026-07) initial stable surface.
 *   v1.1.0  (2026-07) ADDITIVE, no breaking changes:
 *     • #663 scripted create/setPaint/transform now register a proper undo step
 *       IMMEDIATELY (via ai-dom AI.undoable), so nextUndoLabel() reflects them in
 *       the same run and undo() reverses exactly that primitive. Return shapes
 *       are UNCHANGED.
 *     • #647 assertion API: assert(), assertEq(), expectSelection(), results(),
 *       clearAsserts().
 *     • #649 loadFixture(svg) — reset + load a substrate SVG in one call.
 *     (#648 structured `do script` return is in ai-runner.js/SVGEDoScriptCommand,
 *      not a signature change here.)
 * ---------------------------------------------------------------------------
 */
(function (global) {
  'use strict';

  var VERSION = '1.1.0';

  // ---- low-level accessors (resolved lazily, per call) ----------------------
  function dom() {
    if (!global.AIDOM) throw new Error('AIDOM not loaded (ai-dom.js missing)');
    return global.AIDOM;
  }
  function cv() {
    if (!global.svgCanvas) throw new Error('svgCanvas not ready');
    return global.svgCanvas;
  }
  function doc() { return dom().app.activeDocument; }
  function util() { return global.svgedit && global.svgedit.utilities; }
  function mathu() { return global.svgedit && global.svgedit.math; }

  function round(n) {
    if (typeof n !== 'number' || !isFinite(n)) return n;
    return Math.round(n * 1000) / 1000;
  }

  // Print the JSON result to the runner OUTPUT (so `do script` returns it) and
  // pass the value through. $ is ai-runner's ExtendScript shim during a run;
  // outside a run this is a silent no-op.
  function emit(v) {
    try {
      if (global.$ && typeof global.$.writeln === 'function') {
        global.$.writeln(JSON.stringify(v));
      }
    } catch (e) {}
    return v;
  }

  // Uniform crash-safe executor: run fn, emit its result (or an error object).
  function run(fn) {
    var res;
    try { res = fn(); }
    catch (e) { res = { ok: false, error: (e && e.message) ? e.message : String(e) }; }
    return emit(res);
  }

  // Run `fn` as ONE atomic, immediately-committed undo step (feature #663). Each
  // scripted-input primitive (create*/setPaint/transform) wraps its mutation here
  // via ai-dom's AI.undoable, so `nextUndoLabel()` reflects it RIGHT AWAY in the
  // same do-script run and `undo()` reverses exactly that primitive. Falls back to
  // a bare call on an older ai-dom that predates AI.undoable.
  function undoable(label, fn) {
    var AI = dom().AI;
    if (AI && typeof AI.undoable === 'function') return AI.undoable(label, fn);
    return fn();
  }

  // ---- assertion accumulator (#647) -----------------------------------------
  // A machine-readable pass/fail oracle. SVGETest.assert*/expectSelection push a
  // check; SVGETest.results() returns {passed,failed,checks:[…]} and clearAsserts()
  // resets it. Persisted on SVGETest so it survives across do-script runs until
  // explicitly cleared (reset()/loadFixture() also clear it for a clean slate).
  var CHECKS = [];
  function pushCheck(c) {
    c = c || {};
    c.ok = !!c.ok;
    CHECKS.push(c);
    return c;
  }
  // Structural-ish equality good enough for test oracles (scalars, arrays, plain
  // objects, with a small epsilon for floats).
  function eq(a, b) {
    if (a === b) return true;
    if (typeof a === 'number' && typeof b === 'number') {
      if (isNaN(a) && isNaN(b)) return true;
      return Math.abs(a - b) < 1e-6;
    }
    if (a && b && typeof a === 'object' && typeof b === 'object') {
      var ka = Object.keys(a), kb = Object.keys(b);
      if (Array.isArray(a) !== Array.isArray(b)) return false;
      if (ka.length !== kb.length) return false;
      for (var i = 0; i < ka.length; i++) {
        if (!Object.prototype.hasOwnProperty.call(b, ka[i])) return false;
        if (!eq(a[ka[i]], b[ka[i]])) return false;
      }
      return true;
    }
    return false;
  }

  // ---- headless visual capture helpers (#651) -------------------------------
  // Byte length of a base64 payload (for snapshotPNG's reported size when native
  // does not echo it back).
  function b64ByteLen(b64) {
    if (!b64) return 0;
    var n = b64.length, pad = 0;
    if (b64.charAt(n - 1) === '=') pad++;
    if (b64.charAt(n - 2) === '=') pad++;
    return Math.floor(n * 3 / 4) - pad;
  }
  // Hand base64 file bytes to native for writing (JS in a WKWebView cannot write
  // files). Uses the standard `msg` script-message channel: SVGEEditorJS writes the
  // file SYNCHRONOUSLY on the main thread while handling this message, and WebKit
  // delivers script messages BEFORE the do-script's evaluateJavaScript completion
  // (same IPC connection, FIFO; the postMessage below runs earlier in the script than
  // its return value), so the file is on disk by the time `do script` resolves.
  // (A synchronous window.prompt() bridge is NOT usable — WebKit suppresses JS dialogs
  // invoked from within evaluateJavaScript, which is exactly the do-script path.)
  // Returns {ok:true} once the write is dispatched, or {ok:false,error} if the bridge
  // is unreachable. The caller reports width/height/bytes computed locally.
  function writeFileNative(path, b64) {
    try {
      if (!window.webkit || !window.webkit.messageHandlers || !window.webkit.messageHandlers.msg)
        return { ok: false, error: 'native msg bridge unavailable' };
      window.webkit.messageHandlers.msg.postMessage({ svge_write_file: { path: String(path), b64: String(b64) } });
      return { ok: true };
    } catch (e) { return { ok: false, error: (e && e.message) ? e.message : String(e) }; }
  }

  // ---- selection helpers ----------------------------------------------------
  function selectedEls() {
    try { return cv().getSelectedElems().filter(Boolean); }
    catch (e) { return []; }
  }

  // Transform-AWARE, axis-aligned bbox in canvas user units. getBBox() alone is
  // LOCAL (pre-transform); we push the 4 corners through the element's
  // consolidated matrix so a rotated/scaled item reports its real placement.
  function bboxOf(el) {
    try {
      var b = util().getBBox(el);
      if (!b) return null;
      var m = mathu() && mathu().getMatrix(el);
      if (m) {
        var t = mathu().transformBox(b.x, b.y, b.width, b.height, m).aabox;
        return { x: round(t.x), y: round(t.y), w: round(t.width), h: round(t.height) };
      }
      return { x: round(b.x), y: round(b.y), w: round(b.width), h: round(b.height) };
    } catch (e) {
      try { var g = el.getBBox(); return { x: round(g.x), y: round(g.y), w: round(g.width), h: round(g.height) }; }
      catch (e2) { return null; }
    }
  }

  function attrOf(el) {
    var st = el.style || {};
    return {
      fill:        el.getAttribute('fill')         || st.fill          || null,
      stroke:      el.getAttribute('stroke')       || st.stroke        || null,
      strokeWidth: el.getAttribute('stroke-width') || st.strokeWidth   || null,
      transform:   el.getAttribute('transform')    || null
    };
  }

  function infoOf(el) {
    var a = attrOf(el);
    return {
      id: el.getAttribute('id'),
      tag: el.tagName,
      bbox: bboxOf(el),
      fill: a.fill,
      stroke: a.stroke,
      strokeWidth: a.strokeWidth,
      transform: a.transform
    };
  }

  // Union bbox of the current selection (canvas units) — used as the implicit
  // reference frame for transform reference points.
  function selectionBBox() {
    var els = selectedEls(), minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
    for (var i = 0; i < els.length; i++) {
      var b = bboxOf(els[i]); if (!b) continue;
      minx = Math.min(minx, b.x); miny = Math.min(miny, b.y);
      maxx = Math.max(maxx, b.x + b.w); maxy = Math.max(maxy, b.y + b.h);
    }
    if (minx === Infinity) return null;
    return { x: minx, y: miny, w: maxx - minx, h: maxy - miny };
  }

  // Resolve a reference point: [x,y] literal, or a named anchor over a bbox.
  function refPoint(about, bb) {
    if (about && about.length === 2 && typeof about[0] === 'number') return { x: about[0], y: about[1] };
    bb = bb || selectionBBox() || { x: 0, y: 0, w: 0, h: 0 };
    switch (String(about || 'center')) {
      case 'topleft':     return { x: bb.x,          y: bb.y };
      case 'topright':    return { x: bb.x + bb.w,   y: bb.y };
      case 'bottomleft':  return { x: bb.x,          y: bb.y + bb.h };
      case 'bottomright': return { x: bb.x + bb.w,   y: bb.y + bb.h };
      case 'center':
      default:            return { x: bb.x + bb.w / 2, y: bb.y + bb.h / 2 };
    }
  }

  // Select a single element and return its info (used by every create* hook).
  function selectAndReport(el) {
    try { cv().selectOnly([el]); } catch (e) {}
    return { ok: true, id: el.getAttribute('id'), tag: el.tagName, bbox: bboxOf(el) };
  }

  // ==========================================================================
  //  PUBLIC API
  // ==========================================================================
  var SVGETest = {
    VERSION: VERSION,

    // ---------------------------------------------------------------- reset --
    // reset({w,h,unit,colorMode}) → {ok,w,h,unit,colorMode}
    // New EMPTY document of the given size/background. This app is single-doc
    // per window, so "close the others" == clear the active canvas. Resets the
    // undo stack (a fresh doc has nothing to undo). colorMode is always 'rgb'
    // (SVG is RGB); unit is echoed for the caller's records (canvas is px).
    reset: function (opts) {
      return run(function () {
        opts = opts || {};
        var w = +opts.w || 800, h = +opts.h || 600;
        var color = opts.color || opts.background || null;
        var c = cv();
        c.clear();
        // clear() rebuilds "Layer 1"; guarantee a current layer exists so
        // subsequent create* hooks have somewhere to append (same guard the
        // native new-doc flow uses).
        try {
          var d = c.getCurrentDrawing();
          if (d && !d.getCurrentLayer()) {
            if (c.identifyLayers) c.identifyLayers(); else d.identifyLayers();
            var nm = d.getCurrentLayerName && d.getCurrentLayerName();
            if (nm) c.setCurrentLayer(nm);
          }
        } catch (e) {}
        c.setResolution(w, h);
        if (color) { try { c.setBackground(color); } catch (e) {} }
        try { c.undoMgr.resetUndoStack(); } catch (e) {}
        CHECKS = [];   // fresh oracle for the new test (#647)
        return { ok: true, w: w, h: h, unit: opts.unit || 'px', colorMode: 'rgb' };
      });
    },

    // ------------------------------------------------------------- fixture --
    // loadFixture(svg [, opts]) → {ok,bytes,count}
    // Deterministic substrate in ONE call (feature #649): start fresh and load
    // the given SVG string as the document content. `svg` may be raw SVG markup.
    // Uses svgCanvas.setSvgString (the same path File▸Open / paste uses), then
    // resets the undo stack so the fixture is the baseline (a following undo()
    // does NOT peel the fixture apart). Clears the assertion accumulator too.
    // opts.keepUndo:true skips the undo-stack reset (rare).
    loadFixture: function (svg, opts) {
      return run(function () {
        opts = opts || {};
        if (svg == null) return { ok: false, error: 'loadFixture: svg string required' };
        var c = cv();
        var okLoad = false;
        try { okLoad = c.setSvgString(String(svg)); }
        catch (e) { return { ok: false, error: 'setSvgString failed: ' + (e && e.message ? e.message : String(e)) }; }
        if (okLoad === false) return { ok: false, error: 'setSvgString rejected the SVG (malformed?)' };
        // Guarantee a current layer exists so subsequent create* hooks can append
        // (same guard reset() uses after clear()).
        try {
          var d = c.getCurrentDrawing();
          if (d && !d.getCurrentLayer()) {
            if (c.identifyLayers) c.identifyLayers(); else d.identifyLayers();
            var nm = d.getCurrentLayerName && d.getCurrentLayerName();
            if (nm) c.setCurrentLayer(nm);
          }
        } catch (e) {}
        try { c.clearSelection(); } catch (e) {}   // old selected nodes were replaced
        if (!opts.keepUndo) { try { c.undoMgr.resetUndoStack(); } catch (e) {} }
        CHECKS = [];
        var svgOut = '';
        try { svgOut = c.getSvgString(); } catch (e) {}
        // Count renderable shapes via a DOM query on #svgcontent — reliable
        // regardless of how identifyLayers wrapped loose fixture content.
        var count = 0;
        try {
          var content = document.getElementById('svgcontent');
          if (content) count = content.querySelectorAll('rect,circle,ellipse,line,polyline,polygon,path,text,image,use').length;
        } catch (e) {}
        return { ok: true, bytes: svgOut ? svgOut.length : 0, count: count };
      });
    },

    // --------------------------------------------------------------- create --
    // Each create* selects the new element and returns {ok,id,tag,bbox}.
    // Common paint keys (all optional): fill, stroke, strokeWidth.

    // createRect({x,y,w,h,fill,stroke,strokeWidth})
    createRect: function (o) {
      return run(function () {
        o = o || {};
        return undoable('Create Rectangle', function () {
          var it = doc().pathItems.rectangle(+o.y || 0, +o.x || 0, +o.w || 0, +o.h || 0);
          applyPaint(it._el, o);
          return selectAndReport(it._el);
        });
      });
    },
    // createEllipse({x,y,w,h,...}) — x,y = top-left of the bounding box
    createEllipse: function (o) {
      return run(function () {
        o = o || {};
        return undoable('Create Ellipse', function () {
          var it = doc().pathItems.ellipse(+o.y || 0, +o.x || 0, +o.w || 0, +o.h || 0);
          applyPaint(it._el, o);
          return selectAndReport(it._el);
        });
      });
    },
    // createLine({x1,y1,x2,y2,stroke,strokeWidth})
    createLine: function (o) {
      return run(function () {
        o = o || {};
        return undoable('Create Line', function () {
          var el = dom().AI.create('line', {
            x1: +o.x1 || 0, y1: +o.y1 || 0, x2: +o.x2 || 0, y2: +o.y2 || 0,
            fill: 'none', stroke: o.stroke || '#000000', 'stroke-width': o.strokeWidth != null ? o.strokeWidth : 1
          });
          return selectAndReport(el);
        });
      });
    },
    // createPath({d,fill,stroke,strokeWidth})
    createPath: function (o) {
      return run(function () {
        o = o || {};
        return undoable('Create Path', function () {
          var el = dom().AI.create('path', { d: o.d || 'M 0 0', fill: '#000000', stroke: 'none' });
          applyPaint(el, o);
          return selectAndReport(el);
        });
      });
    },
    // createText({x,y,text,fontSize,fill}) — point text
    createText: function (o) {
      return run(function () {
        o = o || {};
        return undoable('Create Text', function () {
          var it = doc().textFrames.pointText(+o.x || 0, +o.y || 0);
          it.contents = o.text != null ? String(o.text) : '';
          if (o.fontSize != null) dom().AI._setAttr(it._el, 'font-size', o.fontSize);
          if (o.fill != null) dom().AI._setAttr(it._el, 'fill', o.fill);
          return selectAndReport(it._el);
        });
      });
    },
    // createAreaText({x,y,w,h,text,fontSize,fill}) — area text at a box.
    // (SVG has no native area text; wrapped as a point-text frame positioned at
    //  x,y and tagged with the requested box for parity with the AI API.)
    createAreaText: function (o) {
      return run(function () {
        o = o || {};
        return undoable('Create Area Text', function () {
          var it = doc().textFrames.pointText(+o.x || 0, (+o.y || 0) + (+o.fontSize || 16));
          it.contents = o.text != null ? String(o.text) : '';
          if (o.fontSize != null) dom().AI._setAttr(it._el, 'font-size', o.fontSize);
          if (o.fill != null) dom().AI._setAttr(it._el, 'fill', o.fill);
          if (o.w != null) dom().AI._setAttr(it._el, 'data-svge-area-w', o.w);
          if (o.h != null) dom().AI._setAttr(it._el, 'data-svge-area-h', o.h);
          return selectAndReport(it._el);
        });
      });
    },

    // ---------------------------------------------------------------- paint --
    // setPaint({fill,stroke,strokeWidth}) → {ok,count} — apply to the selection
    // (undoable, via ai-dom's change bookkeeping). Omitted keys are left as-is.
    setPaint: function (o) {
      return run(function () {
        o = o || {};
        return undoable('Set Paint', function () {
          var els = selectedEls();
          for (var i = 0; i < els.length; i++) applyPaint(els[i], o);
          return { ok: true, count: els.length };
        });
      });
    },

    // --------------------------------------------------------------- select --
    // selectAll()            → {ok,count}
    selectAll: function () {
      return run(function () { cv().selectAllInCurrentLayer(); return { ok: true, count: selectedEls().length }; });
    },
    // selectById(id)         → {ok,count,found}
    selectById: function (id) {
      return run(function () {
        var el = document.getElementById(id);
        if (!el) return { ok: false, error: 'no element with id ' + id, found: false };
        cv().selectOnly([el]);
        return { ok: true, count: 1, found: true };
      });
    },
    // selectByIndex(i)       → {ok,count,id} — index into the document's page
    // items in z-order (across every layer, back→front).
    selectByIndex: function (i) {
      return run(function () {
        var items = doc().pageItems.toArray();
        var w = items[i];
        if (!w) return { ok: false, error: 'no item at index ' + i };
        cv().selectOnly([w._el]);
        return { ok: true, count: 1, id: w._el.getAttribute('id') };
      });
    },
    // deselect()             → {ok}
    deselect: function () {
      return run(function () { cv().clearSelection(); return { ok: true }; });
    },
    // getSelectionInfo()     → {ok,count,elements:[{id,tag,bbox,fill,stroke,strokeWidth,transform}]}
    // KEY ASSERT PRIMITIVE. bbox is transform-aware (canvas units).
    getSelectionInfo: function () {
      return run(function () {
        var els = selectedEls();
        return { ok: true, count: els.length, elements: els.map(infoOf) };
      });
    },

    // ------------------------------------------------------------ structure --
    // group()   → {ok,id} groups the current selection into one <g>.
    group: function () {
      return run(function () {
        cv().groupSelectedElements();
        var g = selectedEls()[0];
        return { ok: true, id: g ? g.getAttribute('id') : null };
      });
    },
    // ungroup() → {ok,count} explodes the selected group(s).
    ungroup: function () {
      return run(function () { cv().ungroupSelectedElement(); return { ok: true, count: selectedEls().length }; });
    },
    // zOrder(cmd) — cmd: 'front' | 'back' | 'raise' | 'lower' → {ok,cmd}
    zOrder: function (cmd) {
      return run(function () {
        var c = cv();
        switch (String(cmd)) {
          case 'front': c.moveToTopSelectedElement(); break;
          case 'back':  c.moveToBottomSelectedElement(); break;
          case 'raise': c.moveUpDownSelected('Up'); break;
          case 'lower': c.moveUpDownSelected('Down'); break;
          default: return { ok: false, error: 'unknown zOrder cmd: ' + cmd };
        }
        return { ok: true, cmd: cmd };
      });
    },
    // addLayer(name)    → {ok,name,count}
    addLayer: function (name) {
      return run(function () {
        var l = doc().layers.add();
        if (name) l.name = String(name);
        return { ok: true, name: l.name, count: doc().layers.length };
      });
    },
    // removeLayer(name) → {ok,removed,count}
    removeLayer: function (name) {
      return run(function () {
        var layers = doc().layers.toArray(), removed = false;
        for (var i = 0; i < layers.length; i++) {
          if (layers[i].name === String(name)) { layers[i].remove(); removed = true; break; }
        }
        return { ok: removed, removed: removed, count: doc().layers.length };
      });
    },

    // ------------------------------------------------------------ transform --
    // All operate on the current selection and are undoable (ai-dom wrappers).
    // translate({dx,dy})            → {ok,count}
    translate: function (o) {
      return run(function () {
        o = o || {};
        return undoable('Move', function () {
          var els = selectedEls();
          for (var i = 0; i < els.length; i++) dom()._wrapItem(els[i]).translate(+o.dx || 0, +o.dy || 0);
          return { ok: true, count: els.length };
        });
      });
    },
    // scale({sx,sy,about})          → {ok,count} — sx/sy are MULTIPLIERS (1=none).
    // about: 'center'(default)|'topleft'|'topright'|'bottomleft'|'bottomright'|[x,y]
    scale: function (o) {
      return run(function () {
        o = o || {};
        return undoable('Scale', function () {
          var sx = o.sx != null ? +o.sx : 1, sy = o.sy != null ? +o.sy : 1;
          var p = refPoint(o.about, selectionBBox());
          var els = selectedEls();
          for (var i = 0; i < els.length; i++) dom()._wrapItem(els[i]).resize(sx * 100, sy * 100, false, p.x, p.y);
          return { ok: true, count: els.length };
        });
      });
    },
    // rotate({angle,about})         → {ok,count} — angle in degrees (CCW, AI-style).
    rotate: function (o) {
      return run(function () {
        o = o || {};
        return undoable('Rotate', function () {
          var p = refPoint(o.about, selectionBBox());
          var els = selectedEls();
          for (var i = 0; i < els.length; i++) dom()._wrapItem(els[i]).rotate(+o.angle || 0, p.x, p.y);
          return { ok: true, count: els.length };
        });
      });
    },

    // -------------------------------------------------------------- history --
    // undo(n) → {ok,steps,nextUndo,nextRedo}
    undo: function (n) {
      return run(function () {
        n = (n == null) ? 1 : (+n | 0);
        var um = cv().undoMgr, steps = 0;
        for (var i = 0; i < n && um.getUndoStackSize() > 0; i++) { um.undo(); steps++; }
        return { ok: true, steps: steps, nextUndo: um.getNextUndoCommandText() || '', nextRedo: um.getNextRedoCommandText() || '' };
      });
    },
    // redo(n) → {ok,steps,nextUndo,nextRedo}
    redo: function (n) {
      return run(function () {
        n = (n == null) ? 1 : (+n | 0);
        var um = cv().undoMgr, steps = 0;
        for (var i = 0; i < n && um.getRedoStackSize() > 0; i++) { um.redo(); steps++; }
        return { ok: true, steps: steps, nextUndo: um.getNextUndoCommandText() || '', nextRedo: um.getNextRedoCommandText() || '' };
      });
    },
    // nextUndoLabel() → {ok,undo,redo} — the Edit▸Undo / Edit▸Redo command labels.
    nextUndoLabel: function () {
      return run(function () {
        var um = cv().undoMgr;
        return { ok: true, undo: um.getNextUndoCommandText() || '', redo: um.getNextRedoCommandText() || '' };
      });
    },

    // ------------------------------------------------------------- assert --
    // Machine-readable pass/fail ORACLE (feature #647). Each assert* records a
    // check and returns {ok,msg,...}; SVGETest.results() returns the accumulated
    // {passed,failed,checks:[…]}. Read any of these as JSON via the #648 structured
    // do-script return. reset()/loadFixture() clear the accumulator.

    // assert(cond, msg) → {ok,msg}
    assert: function (cond, msg) {
      return run(function () {
        var c = pushCheck({ ok: !!cond, msg: msg != null ? String(msg) : 'assert' });
        return { ok: c.ok, msg: c.msg };
      });
    },
    // assertEq(actual, expected, msg) → {ok,msg,actual,expected}
    // Deep-ish equality (scalars/arrays/plain objects, float epsilon 1e-6).
    assertEq: function (actual, expected, msg) {
      return run(function () {
        var pass = eq(actual, expected);
        var c = pushCheck({
          ok: pass, msg: msg != null ? String(msg) : 'assertEq',
          actual: actual, expected: expected
        });
        return { ok: c.ok, msg: c.msg, actual: actual, expected: expected };
      });
    },
    // expectSelection({count?,fill?,stroke?,strokeWidth?,tag?,id?,bbox?}) →
    //   {ok,msg,checks:[…]} — asserts facts about the CURRENT selection. Each
    //   provided key becomes its own sub-check (also pushed to results()). `bbox`
    //   is compared field-by-field with a tolerance (default 0.5px, override with
    //   bbox.tol); compares against the FIRST selected element's transform-aware
    //   bbox. count is compared against the selection size.
    expectSelection: function (spec) {
      return run(function () {
        spec = spec || {};
        var els = selectedEls();
        var first = els[0] ? infoOf(els[0]) : null;
        var subs = [];
        function check(ok, msg, actual, expected) {
          var c = pushCheck({ ok: !!ok, msg: msg, actual: actual, expected: expected });
          subs.push({ ok: c.ok, msg: c.msg, actual: actual, expected: expected });
        }
        if (spec.count != null)       check(els.length === +spec.count, 'selection count', els.length, +spec.count);
        if (spec.fill != null)        check(!!first && eq(first.fill, spec.fill), 'selection fill', first && first.fill, spec.fill);
        if (spec.stroke != null)      check(!!first && eq(first.stroke, spec.stroke), 'selection stroke', first && first.stroke, spec.stroke);
        if (spec.strokeWidth != null) check(!!first && String(first.strokeWidth) === String(spec.strokeWidth), 'selection strokeWidth', first && first.strokeWidth, spec.strokeWidth);
        if (spec.tag != null)         check(!!first && first.tag === spec.tag, 'selection tag', first && first.tag, spec.tag);
        if (spec.id != null)          check(!!first && first.id === spec.id, 'selection id', first && first.id, spec.id);
        if (spec.bbox != null) {
          var tol = spec.bbox.tol != null ? +spec.bbox.tol : 0.5;
          var b = first && first.bbox;
          ['x', 'y', 'w', 'h'].forEach(function (k) {
            if (spec.bbox[k] == null) return;
            var ok = !!b && Math.abs((+b[k]) - (+spec.bbox[k])) <= tol;
            check(ok, 'selection bbox.' + k, b ? b[k] : null, +spec.bbox[k]);
          });
        }
        var allOk = subs.every(function (s) { return s.ok; });
        return { ok: allOk, msg: 'expectSelection', checks: subs };
      });
    },
    // results() → {ok,passed,failed,checks:[{ok,msg,actual,expected}]}
    // The accumulated oracle. `ok` is true iff every check passed.
    results: function () {
      return run(function () {
        var passed = 0, failed = 0;
        for (var i = 0; i < CHECKS.length; i++) { if (CHECKS[i].ok) passed++; else failed++; }
        return { ok: failed === 0, passed: passed, failed: failed, checks: CHECKS.slice() };
      });
    },
    // clearAsserts() → {ok} — reset the accumulator without touching the document.
    clearAsserts: function () {
      return run(function () { CHECKS = []; return { ok: true }; });
    },

    // ------------------------------------------------------------------- io --
    // getSVGString() → {ok,svg,bytes} — the serialized document markup. THE
    // markup assert primitive: tests grep the returned `svg` for matrix,
    // vector-effect, fill, etc. Also captured verbatim in the do-script output.
    getSVGString: function () {
      return run(function () {
        var svg = cv().getSvgString();
        return { ok: true, svg: svg, bytes: svg ? svg.length : 0 };
      });
    },
    // saveSVG(path) → {ok,path,svg,bytes}
    // Serializes the document and hands the bytes to native for writing (same
    // ai_export bridge Document.exportFile uses). The RETURNED `svg` is the
    // reliable, sandbox-proof channel: redirect the do-script stdout to a file
    // under /tmp/svgeditor-debug/ (see TEST-HOOKS.md — the app is sandboxed and
    // cannot write arbitrary paths from JS).
    saveSVG: function (path) {
      return run(function () {
        var svg = cv().getSvgString();
        try {
          window.webkit.messageHandlers.msg.postMessage({ ai_export: { name: String(path || 'export.svg'), type: 'SVG', path: String(path || ''), svg: svg } });
        } catch (e) {}
        return { ok: true, path: path || null, svg: svg, bytes: svg ? svg.length : 0 };
      });
    },
    // savePNG(path) → {ok,path} — requests a native PNG raster export (async on
    // the native side; JS cannot render+write PNG synchronously in-sandbox).
    savePNG: function (path) {
      return run(function () {
        try { window.webkit.messageHandlers.msg.postMessage({ ai_export: { name: String(path || 'export.png'), type: 'PNG24', path: String(path || ''), svg: cv().getSvgString() } }); } catch (e) {}
        return { ok: true, path: path || null, note: 'PNG raster is produced natively/asynchronously; assert on getSVGString() for deterministic markup checks.' };
      });
    },

    // ---------------------------------------------------- visual capture (#651) --
    // Headless, deterministic visual capture over the do-script channel — no
    // mac-control, no screenshots. All three render the CURRENT document
    // SYNCHRONOUSLY via svgCanvas.rasterizeToCanvasSync (canvg), so each returns its
    // result WITHIN a single `do script` eval (ai-runner cannot await).

    // pixelAt(x, y) -> {ok,x,y,r,g,b,a} — RGBA (0-255) of the RENDERED document at
    // DOCUMENT coordinates (x,y): artboard origin (0,0), 1 doc unit = 1 rendered px.
    // These are DOCUMENT/artboard coords, NOT screen coords. Out-of-bounds -> {ok:false}.
    pixelAt: function (x, y) {
      return run(function () {
        x = +x; y = +y;
        if (!isFinite(x) || !isFinite(y)) return { ok: false, error: 'pixelAt: x,y must be numbers' };
        var r = cv().rasterizeToCanvasSync(1);      // scale 1 -> doc coord maps 1:1 to bitmap px
        var px = Math.round(x), py = Math.round(y);
        if (px < 0 || py < 0 || px >= r.w || py >= r.h)
          return { ok: false, error: 'out of bounds', x: px, y: py, w: r.w, h: r.h };
        var d = r.canvas.getContext('2d').getImageData(px, py, 1, 1).data;
        return { ok: true, x: px, y: py, r: d[0], g: d[1], b: d[2], a: d[3] };
      });
    },

    // snapshotPNG(posixPath [, opts]) -> {ok,path,width,height,bytes}
    // Rasterizes the current artboard to a PNG (canvas.toDataURL) and writes the bytes
    // to `posixPath` NATIVELY (JS in a WKWebView cannot write files) via the `msg`
    // bridge. The native write runs during script-message dispatch, which WebKit
    // orders BEFORE the do-script's evaluateJavaScript completion — so the file is on
    // disk by the time `do script` returns. opts.scale (default 1) multiplies the
    // doc-unit size (a 2x snapshot of an 800×600 artboard is 1600×1200). `bytes` is the
    // PNG byte count computed from the encoded data. The path must be inside the app's
    // sandbox grant (Debug entitlements allow /private/tmp i.e. /tmp).
    snapshotPNG: function (posixPath, opts) {
      return run(function () {
        opts = opts || {};
        var path = String(posixPath || '');
        if (!path) return { ok: false, error: 'snapshotPNG: posixPath required' };
        var scale = (typeof opts.scale === 'number' && opts.scale > 0) ? opts.scale : 1;
        var r = cv().rasterizeToCanvasSync(scale);
        var uri = r.canvas.toDataURL('image/png');
        if (!uri || uri.indexOf('data:image/png') !== 0)
          return { ok: false, error: 'toDataURL did not produce a PNG' };
        var comma = uri.indexOf(',');
        var b64 = comma >= 0 ? uri.slice(comma + 1) : uri;
        var w = writeFileNative(path, b64);
        if (!w || w.ok !== true)
          return { ok: false, error: (w && w.error) || 'native write failed', path: path };
        return { ok: true, path: (w.path || path), width: r.w, height: r.h,
                 bytes: (w.bytes != null ? w.bytes : b64ByteLen(b64)) };
      });
    },

    // snapshotHash() -> {ok,hash,width,height} — a stable FNV-1a (32-bit hex) hash of
    // the rendered RGBA bitmap (scale 1) for quick visual-regression comparison.
    snapshotHash: function () {
      return run(function () {
        var r = cv().rasterizeToCanvasSync(1);
        var data = r.canvas.getContext('2d').getImageData(0, 0, r.w, r.h).data;
        var h = 0x811c9dc5;                          // FNV-1a offset basis
        for (var i = 0; i < data.length; i++) { h ^= data[i]; h = (h * 0x01000193) >>> 0; }
        return { ok: true, hash: ('00000000' + (h >>> 0).toString(16)).slice(-8),
                 width: r.w, height: r.h };
      });
    }
  };

  // Apply optional paint keys (fill / stroke / strokeWidth) to an element,
  // undoably, through ai-dom's change bookkeeping.
  function applyPaint(el, o) {
    if (!o) return;
    var AI = dom().AI;
    if (o.fill != null)        AI._setAttr(el, 'fill', o.fill);
    if (o.stroke != null)      AI._setAttr(el, 'stroke', o.stroke);
    if (o.strokeWidth != null) AI._setAttr(el, 'stroke-width', o.strokeWidth);
  }

  global.SVGETest = SVGETest;

})(this);
