/**
 * ZipWP MCP — Vibe Editing v2: editor/apply-change handler.
 *
 * The WRITE sibling of editor/get-context. The brain's AgentBrowserLoop dispatches
 * a `js_rpc` envelope carrying a serialized Gutenberg EXECUTION PLAN; this handler
 * runs each operation against the LIVE Gutenberg tree via wp.data dispatch, then
 * returns a structured reply that the bridge POSTs to /agent/rpc-reply so the loop
 * folds it IN THE SAME TURN.
 *
 * Contract:
 *   args = { version, post_id, operations:[ { function, ...named args } ] }
 *   Each operation maps to ONE-OR-MORE real wp.data.dispatch('core/block-editor')[function](...)
 *   calls — one per op, except moveBlocksToPosition's reorder ('order') and
 *   multi-parent forms, which fan out to N native moveBlocksToPosition dispatches.
 *   Allowed functions: updateBlockAttributes | insertBlocks | removeBlocks |
 *   moveBlocksToPosition | replaceBlocks | replaceInnerBlocks | duplicateBlocks |
 *   selectBlock. The brain drives them with its native Gutenberg API knowledge.
 *   - updateBlockAttributes PRESERVES clientId (read-merge-dispatch; className
 *     verbatim — the GBS JIT renders it; NO style translation).
 *   - insertBlocks / replace* MINT clientIds (createBlock stamps them synchronously)
 *     → returned in applied[].new_client_ids so the brain can target them next turn.
 *   - moveBlocksToPosition PRESERVES clientIds (a splice). insert/move accept a
 *     before|after anchor that resolves to root+index here.
 *   post_id two-tab guard: abort ALL on mismatch (returned as a `refused` reply,
 *   ok:true, so the LLM sees the real reason — not "browser unreachable").
 *   Partial-apply: a per-op failure (stale clientId) records into failed[] by index
 *   and continues; earlier ops stay applied. Targeting is liveness-checked here;
 *   the brain holds no durable handle.
 *
 * @package zip-ai
 */
(function () {
    'use strict';

    function isPlainObject(v) {
        return v !== null && typeof v === 'object' && !Array.isArray(v);
    }

    // L3 boundary — per-block visual styling lives in `className` ONLY (the
    // Spectra GBS JIT grammar); these flat visual attrs are NEVER allowed on a
    // block. The brain's zod denylist is the contract owner; we re-enforce
    // client-side (defense in depth) so a stale/forked brain bundle can't paint
    // banned attrs into the live tree. The 8-prop list is the SHARED SSOT
    // (editor-shared-utils.isBannedVisualAttr) so this + get-context can never
    // drift. If the shared module isn't resolved we don't strip — the brain's zod
    // already rejected the 8 upstream, so we degrade consistently (never a 3rd copy).
    function isBannedVisualAttr(key) {
        var u = sharedEditorUtils();
        return !!(u && typeof u.isBannedVisualAttr === 'function' && u.isBannedVisualAttr(key));
    }
    function stripBannedAttrs(attrs) {
        if (!isPlainObject(attrs)) return attrs;
        var clean = {};
        Object.keys(attrs).forEach(function (k) {
            if (!isBannedVisualAttr(k)) clean[k] = attrs[k];
            else console.warn('[ZIP AI:apply-change] dropped banned visual attr "%s" — styling belongs in className', k);
        });
        return clean;
    }

    // Shared editor utilities — resolved at call time from the ONE source
    // (editor/shared/editor-shared-utils.js): window in the browser, require()
    // under jest. currentPostId (post_id guard) lives there so this handler and
    // get-context can never drift. A missing module degrades consistently (null)
    // — never a spurious post_id refusal.
    function sharedEditorUtils() {
        if (typeof window !== 'undefined' && window.zipwpEditorShared) return window.zipwpEditorShared;
        if (typeof require === 'function') {
            try { return require('../shared/editor-shared-utils.js'); } catch (e) { return null; }
        }
        return null;
    }
    function currentPostId() {
        var u = sharedEditorUtils();
        return u && u.currentPostId ? u.currentPostId() : null;
    }

    // Liveness (GAP-C / G34): the clientId must resolve against the live tree.
    // Throws a typed miss so the caller records it in failed[] and never mutates
    // the wrong block (a stale id must NEVER default to "top").
    function liveness(sel, id) {
        var block = sel.getBlock(id);
        if (!block) throw new Error('stale_client_id:' + id);
        return block;
    }

    // Block-safety guard (SCENARIO-003): a MUTATING op must not touch a block the
    // editor has LOCKED, nor escape THIS page by editing synced-pattern
    // (core/block) inner content or template-locked content. Gutenberg's own lock
    // selectors are authoritative — canEditBlock / canRemoveBlock / canMoveBlock
    // already fold in template lock, content lock, and synced-pattern instance
    // locks (the instance's inner blocks are content-locked). A blocked op throws
    // a typed miss → failed[], so the brain surfaces it (and can tell the user)
    // instead of silently clobbering shared/locked content. Degrades to ALLOW when
    // a selector is absent (older Gutenberg) — never a false block.
    var MUTATION_KIND = {
        updateBlockAttributes: 'edit',
        removeBlocks: 'remove',
        replaceBlocks: 'remove',
        replaceInnerBlocks: 'edit',
        moveBlocksToPosition: 'move',
    };
    function assertMutable(sel, id, kind) {
        if (!id || !sel.getBlock(id)) return; // stale / missing — liveness owns that miss
        var can = kind === 'remove' ? sel.canRemoveBlock
            : kind === 'move' ? sel.canMoveBlock
                : sel.canEditBlock;
        if (typeof can === 'function' && can.call(sel, id) === false) {
            throw new Error('locked_block:' + id);
        }
    }

    // Insert-time lock guard (GBR-2): insertBlocks / duplicateBlocks ADD a block to
    // a CONTAINER — a case canEdit/Remove/Move (assertMutable) does NOT cover, so
    // those two ops previously bypassed the lock check. getTemplateLock on the
    // destination root is authoritative: 'all' (fully locked) and 'insert' (no
    // add/remove/move) both forbid the insertion. Throws a typed miss → failed[] so
    // the brain surfaces it instead of relying on the dispatch to reject. Degrades
    // to ALLOW when the selector is absent (older Gutenberg) — never a false block.
    function assertInsertable(sel, root) {
        if (typeof sel.getTemplateLock !== 'function') return;
        var lock = sel.getTemplateLock(root == null ? '' : root);
        if (lock === 'all' || lock === 'insert') {
            throw new Error('locked_block:' + (root || 'root'));
        }
    }

    // M2 — move-DESTINATION lock guard. canMoveBlock (assertMutable 'move')
    // folds in the SOURCE parent's lock only; moving INTO a fully-locked
    // container bypassed every check. templateLock semantics: 'all' forbids any
    // structural change inside the container; 'insert' forbids add/remove but
    // PERMITS moving existing children, so only 'all' blocks a move-in here.
    // Same degrade-to-ALLOW posture as assertInsertable.
    function assertMoveDestination(sel, root) {
        if (typeof sel.getTemplateLock !== 'function') return;
        if (sel.getTemplateLock(root == null ? '' : root) === 'all') {
            throw new Error('locked_block:' + (root || 'root'));
        }
    }

    // ── Subtree scope-lock (selection confinement — SaaS remediation ①) ───────
    // The brain stamps `scope_lock` (the selected container's clientId) on the
    // envelope ONLY when the turn bound a SUBTREE scope (a CONTAINER is selected
    // and the request is NOT page-wide). Every MUTATING op must then act on the
    // locked container or a DESCENDANT of it — editing / inserting / moving into
    // a DIFFERENT section is refused (→ failed[], so the brain surfaces it and
    // can tell the user). This is the tree-aware AIRTIGHT half of "stay inside
    // the selected element"; the brain gate is the soft (outline-only) half.
    // selectBlock is exempt (navigation, not a mutation). Degrades to ALLOW when
    // getBlockParents is absent (older Gutenberg) — never a false block; the
    // caller also drops a stale (non-live) lock id before enforcing.
    var SCOPE_EXEMPT_FUNCTIONS = { selectBlock: true };
    function isWithinScope(sel, id, lockId) {
        if (!id || id === lockId) return true;
        if (typeof sel.getBlockParents !== 'function') return true; // can't tell → allow
        var parents = sel.getBlockParents(id) || [];
        return parents.indexOf(lockId) !== -1;
    }
    // Every clientId a mutating op acts ON or places relative to (the surface a
    // "roam" would land on): clientIds[], order[], rootClientId, toRootClientId,
    // and the before/after placement anchors.
    function scopeGoverningIds(op) {
        var ids = [];
        if (Array.isArray(op.clientIds)) ids = ids.concat(op.clientIds);
        if (Array.isArray(op.order)) ids = ids.concat(op.order);
        ['rootClientId', 'toRootClientId', 'before', 'after', 'clientId'].forEach(function (k) {
            if (op[k] != null) ids.push(op[k]);
        });
        return ids.filter(function (x) { return typeof x === 'string' && x !== ''; });
    }
    function assertWithinScope(sel, op, lockId) {
        if (!lockId || SCOPE_EXEMPT_FUNCTIONS[op.function]) return;
        var ids = scopeGoverningIds(op);
        if (ids.length === 0) {
            // A mutating op with no resolved target = a page-root op (e.g. insert
            // at root). That lands OUTSIDE the selected container.
            throw new Error('out_of_scope: this edit has no in-section target (it would land at the page root), but you are scoped to the selected container ' + lockId + ' — insert/edit INSIDE it, or tell the user if a different section is intended.');
        }
        for (var i = 0; i < ids.length; i++) {
            if (!isWithinScope(sel, ids[i], lockId)) {
                throw new Error('out_of_scope: target ' + ids[i] + ' is outside the selected container ' + lockId + ' (and its children). You are scoped to that section — edit inside it, or tell the user if a different section is intended.');
            }
        }
    }

    // The block's editable-content attribute(s), from the block REGISTRY — no
    // hardcoded per-type table. Core blocks declare content via a `source` of
    // 'rich-text'/'html' (core/heading→content, core/button→text). Spectra blocks
    // store content in a CUSTOM attribute with no standard source, so we fall
    // back to a declared `content`/`text` attribute (the Spectra convention,
    // e.g. spectra/content→text). Block types with neither (image, spacer) get
    // an empty list and are left untouched.
    function richTextAttrsOf(name) {
        var t = window.wp.blocks && window.wp.blocks.getBlockType
            ? window.wp.blocks.getBlockType(name)
            : null;
        var attrs = t && t.attributes ? t.attributes : {};
        var out = [];
        for (var k in attrs) {
            if (!Object.prototype.hasOwnProperty.call(attrs, k)) continue;
            var src = attrs[k] && attrs[k].source;
            if (src === 'rich-text' || src === 'html') out.push(k);
        }
        if (out.length === 0) {
            ['content', 'text'].forEach(function (k) {
                if (Object.prototype.hasOwnProperty.call(attrs, k)) out.push(k);
            });
        }
        return out;
    }

    // The block's REGISTRY attribute schema — the SSOT for which attrs are valid
    // on a given block type. getBlockType(name).attributes is the exact set
    // Gutenberg itself validates against; an attr absent from this set is one
    // updateBlockAttributes would silently ignore. Returns null when the type
    // isn't registered (a forked/unknown block) — caller then can't partition, so
    // it applies everything (degrade open, never block a write on a missing type).
    function registeredAttrKeysOf(name) {
        var t = window.wp.blocks && window.wp.blocks.getBlockType
            ? window.wp.blocks.getBlockType(name)
            : null;
        if (!t || !t.attributes) return null;
        return Object.keys(t.attributes);
    }

    // Partition an incoming attrs object against the block's REGISTRY schema:
    // { valid, unknown }. `valid` is the subset whose keys exist in
    // getBlockType(name).attributes (the keys Gutenberg will actually accept);
    // `unknown` is the keys with no schema entry (Gutenberg would silently drop
    // them). `className` is always valid (Gutenberg's universal block-support
    // attr, not always re-declared per type). When the type isn't registered we
    // can't partition — return everything as valid (degrade open). Surfacing
    // `unknown` in the reply lets the brain LEARN that attr X isn't valid for
    // block Y, instead of inferring success from a silent ignore.
    function partitionAttrs(name, attrs) {
        var valid = {};
        var unknown = [];
        if (!isPlainObject(attrs)) return { valid: valid, unknown: unknown };
        var registered = registeredAttrKeysOf(name);
        Object.keys(attrs).forEach(function (k) {
            if (registered === null || k === 'className' || registered.indexOf(k) !== -1) {
                valid[k] = attrs[k];
            } else {
                unknown.push(k);
            }
        });
        return { valid: valid, unknown: unknown };
    }

    // Route a generic content/text value onto the block's real rich-text attr(s).
    // `content`/`text` are universal authoring ALIASES — the model writes one and
    // we copy it onto whatever rich-text attr the block actually declares (e.g.
    // spectra/content -> `text`, core/heading -> `content`). After routing, DROP
    // any consumed alias the block does NOT itself declare, so a `content` the
    // model sent for a `text`-block isn't later flagged as an unknown attr (it was
    // applied, just under the real key) — a false-positive that would mis-fire the
    // unknown_attrs nudge.
    function placeContent(name, attributes) {
        var a = Object.assign({}, attributes || {});
        var body = typeof a.content === 'string' && a.content !== '' ? a.content
            : typeof a.text === 'string' && a.text !== '' ? a.text : undefined;
        if (body === undefined) return a;
        var targets = richTextAttrsOf(name);
        targets.forEach(function (k) {
            if (a[k] === undefined || a[k] === '') a[k] = body;
        });
        ['content', 'text'].forEach(function (alias) {
            if (targets.indexOf(alias) === -1) delete a[alias];
        });
        return a;
    }

    // Plain text the block renders — its rich-text attrs concatenated, tags
    // stripped. Used for the effectiveness check + the result_text the reply
    // surfaces so the brain SEES what actually landed.
    function renderedTextOf(block) {
        var attrs = (block && block.attributes) || {};
        var s = '';
        richTextAttrsOf(block && block.name).forEach(function (k) {
            // Core rich-text attrs become RichTextData OBJECTS after createBlock
            // (not strings); String() yields their text. Spectra stores plain
            // strings. A `typeof === "string"` check misses the core case and
            // falsely fires assertEffectiveContent's empty_content (live-found).
            var v = attrs[k];
            if (v !== null && v !== undefined && v !== '') s += ' ' + String(v);
        });
        return s.replace(/<[^>]*>/g, '').trim();
    }

    function intendedContent(spec) {
        var a = (spec && spec.attributes) || {};
        return (typeof a.content === 'string' && a.content !== '')
            || (typeof a.text === 'string' && a.text !== '');
    }

    // A spec that ASKED for text content but produced an EMPTY block is a silent
    // content loss — fail it LOUD (before dispatch) so the brain never reports a
    // false success on an empty insert/replace. Generalized: it compares INTENT
    // (the spec carried content) against RESULT (the built block renders no text
    // and has no children) — never a per-block-type rule.
    function assertEffectiveContent(specs, blocks) {
        (specs || []).forEach(function (spec, i) {
            var blk = blocks[i];
            if (!intendedContent(spec) || !blk) return;
            var hasChildren = blk.innerBlocks && blk.innerBlocks.length > 0;
            if (renderedTextOf(blk) === '' && !hasChildren) {
                throw new Error('empty_content:' + (spec.name || 'block'));
            }
        });
    }

    // JSON block-spec → Gutenberg block (recursive). NOT the markup-string path.
    // Applies ONLY the attrs the block's registry declares (partitionAttrs, the
    // getBlockType SSOT) and pushes the keys it doesn't into `unknownSink` (when
    // provided) — recursively across innerBlocks. The brain folds every attr
    // through with no allowlist; this is where insert/replace decide validity and
    // report back what they dropped, exactly like the setAttributes path (real
    // Gutenberg's createBlock already sanitizes undeclared attrs away — silently;
    // collecting them here is what makes the drop visible to the brain).
    function toBlock(spec, unknownSink) {
        // Insert-time block-name gate (SaaS remediation plan E2E-F1,
        // live-observed 2026-06-11): an UNREGISTERED name must fail the op
        // typed-and-loud — createBlock would otherwise mint a missing-block
        // placeholder ("Unsupported block") the user has to delete by hand
        // (the model invented `core/input`/`core/textarea` and the page
        // collected junk until read-back). Same feedback philosophy as
        // unknown attrs: structured, model-actionable, never silent.
        if (!(window.wp.blocks
            && window.wp.blocks.getBlockType
            && window.wp.blocks.getBlockType(spec.name))) {
            throw new Error(
                'unregistered_block_type: "' + spec.name + '" is not a registered block on this site — '
                + 'never invent block names. Use registered blocks (spectra/container, spectra/content, '
                + 'core/paragraph, …) or embed a real form via { "name": "srfm/form", "attrs": { "id": <formId> } }.'
            );
        }
        var inner = (spec.innerBlocks || []).map(function (s) { return toBlock(s, unknownSink); });
        // S4 (PR #282): strip the 8 GBS-banned visual attrs FIRST, exactly like the
        // updateBlockAttributes path (normalizePlanAttrs) — so insert/replace can't
        // paint a banned visual attr into a freshly-minted block even if a stale/
        // forked brain bundle folds one through. Defense-in-depth behind the brain
        // blockSpec zod; symmetric with the update path.
        var parts = partitionAttrs(spec.name, placeContent(spec.name, stripBannedAttrs(spec.attributes)));
        if (unknownSink && parts.unknown.length) {
            Array.prototype.push.apply(unknownSink, parts.unknown);
        }
        return window.wp.blocks.createBlock(spec.name, parts.valid, inner);
    }

    // updateBlockAttributes shallow-merges at the TOP key only, so deep-merge
    // plain-object values (layout, responsiveControls) to keep sibling nested keys.
    function deepMerge(cur, partial) {
        var out = Object.assign({}, cur);
        for (var k in partial) {
            if (!Object.prototype.hasOwnProperty.call(partial, k)) continue;
            var pv = partial[k];
            var cv = cur ? cur[k] : undefined;
            out[k] = isPlainObject(pv) && isPlainObject(cv) ? deepMerge(cv, pv) : pv;
        }
        return out;
    }

    // INVARIANT — gs-* identity is immutable on a className write. updateBlockAttributes
    // replaces className wholesale, so an incoming string keeps the block's CURRENT gs-*
    // tokens verbatim and contributes only its NON-gs (utility) tokens; foreign gs-* are
    // dropped. Prevents the Edit-5 clobber (a paragraph's `gs-0a6168-text` overwritten by
    // a heading's `gs-ae2ba6-h1`) — the executor owns identity, the model owns utilities.
    function reconcileClassName(currentClassName, incomingClassName) {
        var isGs = function (t) { return t.indexOf('gs-') === 0; };
        var toTokens = function (s) {
            return String(s == null ? '' : s).trim().split(/\s+/).filter(Boolean);
        };
        var curGs = toTokens(currentClassName).filter(isGs);
        var incUtil = toTokens(incomingClassName).filter(function (t) { return !isGs(t); });
        var seen = Object.create(null);
        return curGs.concat(incUtil).filter(function (t) {
            if (seen[t]) return false;
            seen[t] = true;
            return true;
        }).join(' ');
    }

    // Realize an explicit child order within `parent` (reorder). Place each id at
    // its target index in sequence — earlier indices are already correct, so each
    // move lands the next block at position i.
    function reorderChildren(sel, dis, parent, order) {
        // M2 — the order form previously bypassed EVERY lock check (its targets
        // ride `order`, not `clientIds`, so the runOp pre-check resolved no
        // targets). canMoveBlock per child folds in the parent's template/content
        // locks — a locked container's reorder now fails typed instead of
        // silently applying.
        order.forEach(function (id) {
            liveness(sel, id);
            assertMutable(sel, id, 'move');
        });
        var root = parent == null ? '' : parent;
        for (var i = 0; i < order.length; i++) {
            // Undo coalescing: each move after the first merges into the same
            // undo level so the whole reorder reverts in one ⌘Z. (The caller
            // already marked the first dispatch when this isn't the batch's
            // first change.)
            if (i > 0 && typeof dis.__unstableMarkNextChangeAsNotPersistent === 'function') {
                dis.__unstableMarkNextChangeAsNotPersistent();
            }
            dis.moveBlocksToPosition([order[i]], root, root, i);
        }
    }

    // L5 — THE single documented use of a private Gutenberg API in this
    // codebase. `__unstableMarkNextChangeAsNotPersistent` coalesces undo levels
    // (one ⌘Z per plan) and keeps typewriter ticks out of the undo stack.
    // DEGRADATION CONTRACT: it is feature-checked everywhere; if a future
    // Gutenberg removes/renames it, every call becomes a no-op and behaviour
    // degrades to MORE undo levels (one per dispatch) — correct, just noisier.
    // Nothing may ever depend on it for correctness, only for undo ergonomics.
    function markNonPersistent(dis) {
        if (dis && typeof dis.__unstableMarkNextChangeAsNotPersistent === 'function') {
            dis.__unstableMarkNextChangeAsNotPersistent();
        }
    }

    // H4 — saving is LOCKED while a typewriter stream is live. The persistent
    // full text is applied by runOp BEFORE the stream starts, but the stream
    // then clears the attr and re-types it word-by-word — so for words×28ms the
    // LIVE edited attribute is partial. A manual Ctrl+S or the 60s autosave in
    // that window serializes the truncated text into the post (non-persistent
    // suppresses the undo level, NOT what getEditedPostContent reads). Lock
    // via the core/editor store while ANY stream is active; unlock on drain.
    // Feature-checked: an editor without lockPostSaving keeps today's window
    // (no false lock, no throw).
    var TYPEWRITER_SAVE_LOCK = 'zipwp-vibe-typewriter';
    function lockSavingForTypewriter() {
        try {
            var ed = window.wp && window.wp.data && window.wp.data.dispatch('core/editor');
            if (ed && typeof ed.lockPostSaving === 'function') ed.lockPostSaving(TYPEWRITER_SAVE_LOCK);
        } catch (e) { /* never break the apply on a lock failure */ }
    }
    function unlockSavingForTypewriter() {
        try {
            var ed = window.wp && window.wp.data && window.wp.data.dispatch('core/editor');
            if (ed && typeof ed.unlockPostSaving === 'function') ed.unlockPostSaving(TYPEWRITER_SAVE_LOCK);
        } catch (e) { /* unlock is best-effort; the lock name is idempotent */ }
    }

    // --- Typewriter (streaming text effect) -------------------------------
    // ANY text the agent writes — a rewrite (updateBlockAttributes) OR new copy in
    // an inserted/replaced block — STREAMS in word-by-word instead of snapping.
    // Ms between words; named single cadence source.
    var TYPEWRITER_WORD_MS = 28;

    // Animation off when the host can't animate (no matchMedia — e.g. jsdom under
    // jest) or the user prefers reduced motion. Keeps the persistent full content.
    function typewriterDisabled() {
        try {
            return !window.matchMedia ||
                window.matchMedia('(prefers-reduced-motion: reduce)').matches;
        } catch (e) { return true; }
    }

    // Current rich-text content of a block (the first content attr that HAS text),
    // '' if none. The op-agnostic text identity we diff before/after an op.
    function contentOf(blk) {
        var cattr = firstRichTextWithContent(blk);
        return cattr ? String(blk.attributes[cattr]) : '';
    }

    // Every clientId an op NAMES as an operand — generic id-bearing fields ONLY, no
    // switch on op.function. The before/after text diff over these (+ newly minted
    // ids) is what decides what streams, so no operation is ever special-cased.
    function opOperandIds(op) {
        var ids = [];
        if (op) {
            if (Array.isArray(op.clientIds)) ids = ids.concat(op.clientIds);
            if (op.rootClientId != null) ids.push(op.rootClientId);
            if (op.clientId != null) ids.push(op.clientId);
        }
        return ids;
    }

    // First rich-text content attr of a block that actually HAS text — used to find
    // what to stream in a freshly inserted/replaced block.
    function firstRichTextWithContent(blk) {
        var cattrs = richTextAttrsOf(blk.name);
        for (var i = 0; i < cattrs.length; i++) {
            var k = cattrs[i];
            var v = (blk.attributes || {})[k];
            if (typeof v === 'string' && v.trim()) return k;
        }
        return null;
    }

    // Active streams + the SINGLE shared ticker that advances them all together, so
    // a BULK operation (many blocks at once) types CONCURRENTLY off one timer.
    var _twStreams = [];
    var _twTimer = null;
    function _twTick() {
        var typing = false;
        for (var s = 0; s < _twStreams.length; s++) {
            var st = _twStreams[s];
            if (st.i >= st.tokens.length) continue;
            // Liveness re-check: the block can vanish mid-stream (a later op in the
            // same plan removed/replaced it, or the user deleted it). Writing to a
            // gone block is a wasted dispatch that can warn — drop the stream.
            if (st.sel && !st.sel.getBlock(st.id)) { st.i = st.tokens.length; continue; }
            st.acc += st.tokens[st.i];
            st.i++;
            markNonPersistent(st.dis); // VISUAL only — never an undo level
            var a = {};
            a[st.contentAttr] = st.acc;
            st.dis.updateBlockAttributes(st.id, a);
            if (st.i < st.tokens.length) typing = true;
        }
        if (typing) { _twTimer = setTimeout(_twTick, TYPEWRITER_WORD_MS); return; }
        _twStreams = [];
        _twTimer = null;
        // H4 — every stream drained: the live attrs hold full text again, so
        // saving is safe. (Unlock here, the ONLY drain point.)
        unlockSavingForTypewriter();
    }

    // VISUAL typewriter overlay for block `id`'s content attr. The REAL change (the
    // full `target` content) is ALREADY applied PERSISTENTLY by the caller; this
    // only adds a non-persistent type-in, so it NEVER touches undo and works the
    // same for a rewrite or a freshly-inserted block. Clears the content
    // SYNCHRONOUSLY (same frame as the op → no flash of the full text) then types
    // it back word-by-word off the shared ticker. No-op when animation is disabled
    // or the text is too short (leaves the persistent full content).
    function typewriterStream(dis, sel, id, contentAttr, target) {
        if (typewriterDisabled()) return;
        var tokens = String(target != null ? target : '').split(/(\s+)/);
        if (tokens.length <= 2) return;
        // Same-block dedup: if a stream for this block+attr is already active (an
        // earlier op in the plan touched the same block), DROP it — two tickers
        // writing one block's content fight and garble. Last write wins.
        _twStreams = _twStreams.filter(function (st) {
            return !(st.id === id && st.contentAttr === contentAttr);
        });
        // H4 — lock saving BEFORE the clear: from here until the drain in
        // _twTick the live attribute is partial. Idempotent per lock name, so
        // a bulk plan's many streams lock once.
        lockSavingForTypewriter();
        markNonPersistent(dis);
        var clear = {};
        clear[contentAttr] = '';
        dis.updateBlockAttributes(id, clear);
        _twStreams.push({ dis: dis, sel: sel, id: id, contentAttr: contentAttr, tokens: tokens, i: 0, acc: '' });
        if (!_twTimer) _twTimer = setTimeout(_twTick, TYPEWRITER_WORD_MS);
    }

    // Snapshot the rich-text content of block subtrees by clientId (BEFORE an op),
    // into `out`. Recurses innerBlocks so a container op also captures its children.
    function snapshotContent(sel, ids, out) {
        ids.forEach(function (cid) {
            var root = sel.getBlock(cid);
            if (!root) return;
            (function walk(b) {
                out[b.clientId] = contentOf(b);
                if (b.innerBlocks) b.innerBlocks.forEach(walk);
            })(root);
        });
    }

    // SSOT — the ONLY place streaming is decided, AFTER an op: stream every
    // rich-text block (within the op's operand subtrees OR newly minted) whose text
    // CHANGED vs the pre-op snapshot, or is NEW (no snapshot). It's a pure text
    // diff — op.function is NEVER inspected, so a rewrite, an insert, a replace, or
    // any future operation all get the effect identically; a move / remove / style-
    // only change leaves the text equal and streams nothing.
    function streamChangedText(dis, sel, ids, before) {
        if (typewriterDisabled()) return;
        var seen = {};
        ids.forEach(function (cid) {
            var root = sel.getBlock(cid);
            if (!root) return;
            (function walk(b) {
                if (seen[b.clientId]) return;
                seen[b.clientId] = true;
                var cattr = firstRichTextWithContent(b);
                if (cattr) {
                    var now = String(b.attributes[cattr]);
                    if (now && now !== (before[b.clientId] || '')) {
                        typewriterStream(dis, sel, b.clientId, cattr, now);
                    }
                }
                if (b.innerBlocks) b.innerBlocks.forEach(walk);
            })(root);
        });
    }

    // Apply ONE change. Returns { new_client_ids?, result_text?, deferRebase? }
    // or throws a typed Error. `isAnchor` (batch's first applied change) controls
    // the streaming-text undo anchor; it's irrelevant to the non-animated kinds.
    // Resolve an anchor-relative placement (insert/move "before"/"after" a
    // sibling block) into the {parent, index} the dispatch API needs — using the
    // LIVE store (getBlockRootClientId + getBlockIndex). This is why the MODEL no
    // longer computes parent/index: it just names the neighbour and the layer that
    // OWNS the tree resolves the coordinates (closing the parent:null move-to-page-
    // root failure). A stale anchor throws the typed stale_client_id miss via
    // liveness → failed[]. parent is '' for page root (matches getBlockRootClientId
    // + the move toRoot convention). `position` is 'before' | 'after'.
    function resolveAnchor(sel, anchorId, position) {
        liveness(sel, anchorId);
        return {
            parent: sel.getBlockRootClientId(anchorId) || '',
            index: sel.getBlockIndex(anchorId) + (position === 'after' ? 1 : 0),
        };
    }

    // Pull the anchor (before/after) off a change, if present. before/after are
    // mutually exclusive (the brain superRefine enforces it); after wins only if
    // both somehow arrive.
    function anchorOf(c) {
        if (c.after !== undefined) return { id: c.after, position: 'after' };
        if (c.before !== undefined) return { id: c.before, position: 'before' };
        return null;
    }

    // --- Computed-effect verification --------------------------------------
    // A className edit can be SET yet paint NOTHING — a phantom token
    // (bg-neutral-900), or a gs- !important rule winning the cascade. Rather than
    // classify the className, we ask the only question that matters: did the
    // PAINTED style move? Snapshot the target's FULL computed style before +
    // after dispatch; a setAttributes that changed the class string but moved
    // ZERO computed values is a silent no-op. The brain nudges on it instead of
    // reporting a false success. (No token list / regex — the rendered result is
    // the source of truth; this also catches specificity loss, not just phantoms.)
    function canvasCtx() {
        var iframe = document.querySelector('iframe[name="editor-canvas"]');
        return iframe && iframe.contentDocument
            ? { doc: iframe.contentDocument, win: iframe.contentWindow }
            : { doc: document, win: window };
    }
    function computedSnapshot(clientId) {
        var ctx = canvasCtx();
        var el = ctx.doc.querySelector('[data-block="' + clientId + '"]');
        if (!el) return null;
        var cs = ctx.win.getComputedStyle(el);
        var out = '';
        for (var i = 0; i < cs.length; i++) out += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
        return out;
    }
    // True when the computed style moved (or we can't compare — never a false noop).
    function computedChanged(before, after) {
        return !before || !after ? true : before !== after;
    }
    // The GBS utility stylesheet text (~1MB). L6 — memoized per source element,
    // invalidated by the parsed sheet's RULE COUNT (cheap — no string
    // materialization to compare): the live JIT only ever APPENDS rules, so a
    // changed count is the exact "stylesheet grew" signal. When the CSSOM sheet
    // is unreadable (detached element) we fall through to a fresh read. The
    // rare fallback path (no id'd element) stays unmemoized.
    var _utilCssCache = null; // { el, ruleCount, css }
    function utilSheetRuleCount(el) {
        try {
            return el.sheet && el.sheet.cssRules ? el.sheet.cssRules.length : -1;
        } catch (e) { return -1; }
    }
    // CSS injected by ensureLiveUtilityCss (tokens compiled server-side after a
    // live insert). Appended to every readUtilityCss() result so the no-op /
    // hasLiveUtility checks see the just-injected rules as real utilities.
    function liveJitCssText(doc) {
        var el = doc.getElementById('zipwp-gbs-live-jit');
        return el ? (el.textContent || '') : '';
    }
    function readUtilityCss() {
        var ctx = canvasCtx();
        var el = ctx.doc.getElementById('spectra-gs-utility-classes-inline-css');
        if (el) {
            var count = utilSheetRuleCount(el);
            if (_utilCssCache && _utilCssCache.el === el && count !== -1 && _utilCssCache.ruleCount === count) {
                return _utilCssCache.css + liveJitCssText(ctx.doc);
            }
            var text = el.textContent || '';
            _utilCssCache = { el: el, ruleCount: count, css: text };
            return text + liveJitCssText(ctx.doc);
        }
        var css = '';
        var styles = ctx.doc.querySelectorAll('style');
        for (var s = 0; s < styles.length; s++) css += styles[s].textContent || '';
        return css;
    }
    // A recognized GBS utility (bg-base-800, text-primary-500, p-10, …) is NOT a
    // no-op even when the live editor canvas doesn't move. The editor's live JIT
    // emits the utility at low specificity (`:root .token`, 0,2,0), which LOSES to
    // a per-block gs- style's editor selector (`.editor-styles-wrapper
    // [class*="wp-block"].gs-…`, 0,3,0) — so the canvas preview stays put. But the
    // SERVER JIT re-emits the SAME utility at `:root .token×5` (0,5,0) on save +
    // frontend, where it WINS and paints. So a className whose tokens are real
    // utilities (their `.token` rule exists in the GBS stylesheet) WILL render on
    // save; flagging it `noop` would nudge the brain into a false "couldn't style
    // it" retry loop. Only a token with NO matching rule (a typo / phantom like
    // bg-neutral-900) is a true no-op. Substring match (cheap; the JIT writes
    // `.token{…}`); gs-/semantic classes carry no `.token` utility rule so they
    // don't false-trigger.
    function classNameHasLiveUtility(className, css) {
        if (typeof className !== 'string' || className.trim() === '' || !css) return false;
        var tokens = className.trim().split(/\s+/);
        for (var t = 0; t < tokens.length; t++) {
            var tok = tokens[t];
            if (!tok || tok.indexOf('gs-') === 0) continue; // gs- = per-block style
            if (css.indexOf('.' + tok) !== -1) return true;
        }
        return false;
    }

    // boostUtilitySpecificity + utilityRuleBody REMOVED — their own stated
    // REMOVAL CONDITION ("once editor-side utilities reach parity with the
    // server JIT") is met by ensureLiveUtilityCss below, which injects the
    // server compiler's OWN output (×5 base + media-wrapped responsive
    // variants) for every token a plan touches. The boost was also the root
    // cause of the stacked-grid-until-reload bug: it re-emitted BASE tokens
    // (e.g. grid-cols-1) at ×5 specificity WITHOUT their media context, so a
    // boosted base beat its md:/lg: variant siblings at every viewport width
    // until a reload dropped the session-scoped boost element. Any lingering
    // `zipwp-gbs-editor-boost` element from an older session dies on reload.

    // ── LIVE-JIT for tokens the canvas has never seen ─────────────────────────
    // ROOT CAUSE this closes: the per-post JIT stylesheet is compiled from SAVED
    // content at editor load (spectra-blocks Engine::enqueue_jit_for_current_post
    // → JitCache::get_for_post). Blocks inserted programmatically AFTER load
    // (Vibe Editing section inserts) can carry utility tokens with NO rule
    // anywhere in the canvas — they render unstyled (grids stack) until the next
    // save + reload. Fix at the source of truth: ask Spectra's OWN compiler
    // (REST /spectra-blocks/v1/global-styles/jit-compile — the same JitCompiler
    // that runs at save) for the missing tokens' CSS and inject it into the
    // canvas BEFORE the settle/no-op check reads computed styles, so the live
    // preview and the verification both converge to saved-page truth instantly.
    // Session-deduped per token; network-tolerant (the rpc reply must never
    // hang on this — hard 1500ms cap, failure = canvas converges on save).
    var _liveJitRequested = {}; // token → 1 (session-scoped request dedupe)
    function collectPlanClassNames(ops) {
        var out = [];
        function fromBlocks(blocks) {
            if (!blocks) return;
            for (var b = 0; b < blocks.length; b++) {
                var blk = blocks[b];
                if (!blk || typeof blk !== 'object') continue;
                var cn = blk.attributes && blk.attributes.className;
                if (typeof cn === 'string' && cn.trim() !== '') out.push(cn);
                fromBlocks(blk.innerBlocks);
            }
        }
        for (var i = 0; i < ops.length; i++) {
            var op = ops[i] || {};
            if (op.attributes && typeof op.attributes.className === 'string') out.push(op.attributes.className);
            fromBlocks(op.blocks);
        }
        return out;
    }
    function ensureLiveUtilityCss(classNames) {
        try {
            var ctx = canvasCtx();
            // Compile EVERY touched token (session-deduped) — not only the ones
            // with no live rule. The live/static sheets carry tokens at MIXED
            // specificities (static 0,2,0; persisted dynamic ×5), and partial
            // injection breaks the responsive cascade: a base token already
            // present at ×5 (e.g. grid-cols-1) beats a freshly-injected
            // md:/lg: variant unless the variant arrives at the SAME server
            // parity alongside it. Server output is the SSOT cascade — inject
            // it wholesale.
            var missing = [];
            for (var c = 0; c < classNames.length; c++) {
                var toks = String(classNames[c]).trim().split(/\s+/);
                for (var t = 0; t < toks.length; t++) {
                    var tok = toks[t];
                    if (!tok || tok.indexOf('gs-') === 0 || _liveJitRequested[tok]) continue;
                    _liveJitRequested[tok] = 1;
                    missing.push(tok);
                }
            }
            if (!missing.length) return Promise.resolve();
            var apiFetch = window.wp && window.wp.apiFetch;
            if (!apiFetch) return Promise.resolve();
            var fetchP = apiFetch({
                path: '/spectra-blocks/v1/global-styles/jit-compile',
                method: 'POST',
                data: { class_strings: [missing.join(' ')] },
            }).then(function (res) {
                var css = res && typeof res.css === 'string' ? res.css : '';
                if (!css) return;
                var styleEl = ctx.doc.getElementById('zipwp-gbs-live-jit');
                if (!styleEl) {
                    styleEl = ctx.doc.createElement('style');
                    styleEl.id = 'zipwp-gbs-live-jit';
                    (ctx.doc.head || ctx.doc.documentElement).appendChild(styleEl);
                }
                styleEl.textContent = (styleEl.textContent || '') + '\n' + css;
            }).catch(function () { /* converges on save/reload; never fail the apply */ });
            var capP = new Promise(function (resolve) { setTimeout(resolve, 1500); });
            return Promise.race([fetchP, capP]);
        } catch (e) {
            return Promise.resolve();
        }
    }

    // ── Serialized Gutenberg execution plan ({ version, operations[] }). One
    // operation = ONE real wp.data.dispatch('core/block-editor') function + named
    // args, keyed on op.function. Each op reuses the shared guards
    // (createBlock/placeContent/partitionAttrs/stripBannedAttrs/resolveAnchor/
    // liveness/deepMerge). The brain zod already coerced grid + ran the GBS
    // denylist; we re-run them (defense-in-depth) so a direct/forked caller can't
    // crash the canvas or paint banned props.

    function coerceGridLayoutLengthAttrs(attrs) {
        if (!isPlainObject(attrs)) return attrs;
        function coerce(layout) {
            if (!isPlainObject(layout) || typeof layout.minimumColumnWidth !== 'number') return layout;
            var c = {};
            for (var k in layout) if (Object.prototype.hasOwnProperty.call(layout, k)) c[k] = layout[k];
            c.minimumColumnWidth = layout.minimumColumnWidth + 'px';
            return c;
        }
        var out = {};
        for (var k in attrs) if (Object.prototype.hasOwnProperty.call(attrs, k)) out[k] = attrs[k];
        if ('layout' in out) out.layout = coerce(out.layout);
        if (isPlainObject(out.responsiveControls)) {
            var rc = {};
            for (var bp in out.responsiveControls) {
                if (!Object.prototype.hasOwnProperty.call(out.responsiveControls, bp)) continue;
                var v = out.responsiveControls[bp];
                rc[bp] = (isPlainObject(v) && 'layout' in v)
                    ? Object.assign({}, v, { layout: coerce(v.layout) })
                    : v;
            }
            out.responsiveControls = rc;
        }
        return out;
    }

    // Image-attribute routing: a source tool (find-assets / import-media /
    // ai_generate_image) yields { id, url } and the agent sets a UNIFORM
    // { url, id, alt } on the target block. Map that to the block's REAL image
    // attribute so it lands regardless of block type — most importantly a Spectra
    // container BACKGROUND, which lives in `background:{ type:'image', media:{
    // id, url, type:'image' } }` (NOT a bare `url`, and NOT the `overlay*` keys —
    // those are a separate tint ON TOP of the background). This mirrors exactly
    // what Spectra's own Background control writes (components/background onSelect).
    // Keyed off the block's REGISTERED schema (not a hardcoded name): a native
    // image block (has `url`) keeps {url,id,alt}; a block exposing a `background`
    // object gets the background-media shape. deepMerge at apply time preserves
    // the container's other background keys (position/size/repeat). Conservative:
    // only fires on a bare image url/id with no agent-supplied `background` object.
    // Pull an { id, url } image ref out of a candidate object (any of the
    // url/id-bearing shapes weak models emit).
    function pickImageRef(o) {
        if (!isPlainObject(o)) return null;
        var url = typeof o.url === 'string' && o.url !== '' ? o.url : undefined;
        var id = typeof o.id === 'number'
            ? o.id
            : (typeof o.id === 'string' && o.id !== '' ? o.id : undefined);
        return url !== undefined || id !== undefined ? { url: url, id: id } : null;
    }

    // Find the image the agent intends, from EITHER a bare { url, id } (the
    // contract) OR a mis-built `background` object — models routinely invent the
    // old UAGB shape (`background.backgroundImageDesktop`) or guess `media`/`image`
    // sub-keys. We extract the ref from any of them so the SHAPE the model used
    // can't break the write. Overlay keys are intentionally NOT consulted — an
    // overlay is a separate tint, not the background.
    function extractImageRef(attrs) {
        var bare = pickImageRef(attrs);
        if (bare) return bare;
        var bg = attrs.background;
        if (isPlainObject(bg)) {
            var keys = ['media', 'backgroundImageDesktop', 'image', 'imageDesktop', 'backgroundImage'];
            for (var i = 0; i < keys.length; i++) {
                var ref = pickImageRef(bg[keys[i]]);
                if (ref) return ref;
            }
        }
        return null;
    }

    function normalizeImageAttrs(name, attrs, currentAttrs) {
        if (!isPlainObject(attrs)) return attrs;
        var registered = registeredAttrKeysOf(name);
        if (registered === null) return attrs; // unknown block — degrade open
        // Native image block (core/image, spectra/image) takes {url,id,alt} as-is.
        if (registered.indexOf('url') !== -1) return attrs;
        // Background-capable container (Spectra): the bg image lives in
        // background.{type:'image', media:{id,url,type:'image'}} — mirror exactly
        // what Spectra's Background control writes. We REBUILD it from the
        // extracted ref (ignoring the model's invented sub-keys), so any input
        // shape lands. deepMerge at apply preserves the block's other background
        // keys (position/size/repeat).
        if (registered.indexOf('background') === -1) return attrs;
        var ref = extractImageRef(attrs);
        if (ref === null) return attrs; // no image intent (pure style/overlay edit)
        var out = {};
        Object.keys(attrs).forEach(function (k) {
            if (k !== 'url' && k !== 'id' && k !== 'alt' && k !== 'background') out[k] = attrs[k];
        });
        function freshMedia() {
            var m = { type: 'image' };
            if (ref.id !== undefined) m.id = ref.id;
            if (ref.url !== undefined) m.url = ref.url;
            return m;
        }
        out.background = { type: 'image', media: freshMedia() };
        // A Spectra container ALSO holds per-breakpoint background overrides under
        // responsiveControls.{lg,md,sm}.background. The renderer reads the active
        // device's override FIRST, so a base-only update leaves the old image
        // showing at any breakpoint that has its own image background. Mirror the
        // new image into every responsive breakpoint that CURRENTLY has an image
        // background (don't create one where there isn't, and don't touch a
        // color/gradient/video override). deepMerge at apply preserves each
        // breakpoint's other keys (layout/height).
        var rc = currentAttrs && currentAttrs.responsiveControls;
        if (isPlainObject(rc)) {
            // Merge the mirror ON TOP of any responsiveControls the agent set in
            // THIS op (out already carries it from the copy above) — replacing it
            // wholesale would drop a same-op RC edit (e.g. responsiveControls.md.layout).
            var rcOut = isPlainObject(out.responsiveControls) ? Object.assign({}, out.responsiveControls) : {};
            var mirrored = false;
            Object.keys(rc).forEach(function (device) {
                var dbg = rc[device] && rc[device].background;
                if (isPlainObject(dbg) && dbg.type === 'image') {
                    // Per-device shallow merge: keep the agent's other per-device
                    // keys (layout/height) for this breakpoint, swap the background.
                    rcOut[device] = Object.assign({}, rcOut[device], {
                        background: { type: 'image', media: freshMedia() },
                    });
                    mirrored = true;
                }
            });
            if (mirrored) out.responsiveControls = rcOut;
        }
        return out;
    }

    // Normalize a bare attributes object for updateBlockAttributes: GBS strip +
    // grid coerce + content routing + image routing + registry partition
    // (collects unknown_attrs). `currentAttrs` (the live block's attributes) lets
    // image routing mirror a container background into its responsive overrides.
    function normalizePlanAttrs(name, attributes, sink, currentAttrs) {
        var a = stripBannedAttrs(attributes || {});
        a = coerceGridLayoutLengthAttrs(a);
        a = placeContent(name, a);
        a = normalizeImageAttrs(name, a, currentAttrs);
        var parts = partitionAttrs(name, a);
        if (sink) Array.prototype.push.apply(sink, parts.unknown);
        return parts.valid;
    }

    // Move targets grouped by source root (multi-parent safe; preserves input order
    // at the destination by advancing insertAt per group). Shared shape with the
    // 5-kind move case.
    function moveTargetsGrouped(sel, dis, targets, toRoot, insertAt) {
        var groups = {};
        var groupOrder = [];
        (targets || []).forEach(function (id) {
            var fromRoot = sel.getBlockRootClientId(id) || '';
            if (!Object.prototype.hasOwnProperty.call(groups, fromRoot)) {
                groups[fromRoot] = [];
                groupOrder.push(fromRoot);
            }
            groups[fromRoot].push(id);
        });
        var moveIdx = 0;
        groupOrder.forEach(function (fromRoot) {
            if (moveIdx > 0 && typeof dis.__unstableMarkNextChangeAsNotPersistent === 'function') {
                dis.__unstableMarkNextChangeAsNotPersistent();
            }
            moveIdx++;
            var grp = groups[fromRoot];
            dis.moveBlocksToPosition(grp, fromRoot, toRoot, insertAt);
            insertAt += grp.length;
        });
    }

    // Apply ONE operation. Returns { new_client_ids?, result_text?, unknown_attrs? }
    // or throws a typed Error (→ failed[]). The allowlist gate (defense-in-depth;
    // the brain zod already gated) is the default case.
    function runOp(sel, dis, op) {
        // Block-safety pre-check for mutating ops (locked / synced / template).
        var mutKind = MUTATION_KIND[op.function];
        if (mutKind) {
            var mutTargets = op.clientIds || (op.rootClientId != null ? [op.rootClientId] : []);
            mutTargets.forEach(function (id) { assertMutable(sel, id, mutKind); });
        }
        switch (op.function) {
            case 'updateBlockAttributes': {
                var ids = op.clientIds || [];
                ids.forEach(function (id) { liveness(sel, id); });
                var sinkU = [];
                // Apply the final attrs (persistent). The typewriter flourish is
                // added uniformly afterwards by the runPlan before/after text diff
                // (streamChangedText, SSOT) — never per-op here.
                ids.forEach(function (id, j) {
                    // L2 — coalesce a BULK restyle into one undo level: only the
                    // first target's dispatch is persistent (whether THAT one is
                    // persistent is the caller's op-level decision); the rest
                    // merge into it, matching reorderChildren/moveTargetsGrouped.
                    if (j > 0) markNonPersistent(dis);
                    var blk = sel.getBlock(id);
                    var norm = normalizePlanAttrs(blk.name, op.attributes, sinkU, blk.attributes);
                    // gs-* identity is immutable on a className write (anti-clobber):
                    // keep the block's own gs- tokens, apply only the model's utilities.
                    if (typeof norm.className === 'string') {
                        norm.className = reconcileClassName(
                            blk.attributes && blk.attributes.className,
                            norm.className,
                        );
                    }
                    dis.updateBlockAttributes(id, deepMerge(blk.attributes || {}, norm));
                });
                return sinkU.length ? { unknown_attrs: sinkU } : {};
            }
            case 'insertBlocks': {
                var insAnchor = anchorOf(op);
                var insRoot = op.rootClientId;
                var insIndex = op.index;
                if (insAnchor) {
                    var ra = resolveAnchor(sel, insAnchor.id, insAnchor.position);
                    insRoot = ra.parent === '' ? null : ra.parent;
                    insIndex = ra.index;
                } else if (op.rootClientId != null) {
                    liveness(sel, op.rootClientId);
                }
                assertInsertable(sel, insRoot); // GBR-2 — destination container lock
                var sinkI = [];
                var insBlocks = (op.blocks || []).map(function (s) { return toBlock(s, sinkI); });
                assertEffectiveContent(op.blocks, insBlocks);
                dis.insertBlocks(insBlocks, insIndex, insRoot == null ? undefined : insRoot);
                return {
                    new_client_ids: insBlocks.map(function (b) { return b.clientId; }),
                    result_text: insBlocks.map(renderedTextOf),
                    unknown_attrs: sinkI.length ? sinkI : undefined,
                };
            }
            case 'removeBlocks': {
                (op.clientIds || []).forEach(function (id) { liveness(sel, id); });
                dis.removeBlocks(op.clientIds);
                return {};
            }
            case 'moveBlocksToPosition': {
                if (op.order) { reorderChildren(sel, dis, op.toRootClientId, op.order); return {}; }
                (op.clientIds || []).forEach(function (id) { liveness(sel, id); });
                var mvAnchor = anchorOf(op);
                var toRoot, insertAt;
                if (mvAnchor) {
                    var rm = resolveAnchor(sel, mvAnchor.id, mvAnchor.position);
                    toRoot = rm.parent;
                    insertAt = rm.index;
                } else {
                    toRoot = op.toRootClientId == null ? '' : op.toRootClientId;
                    insertAt = op.index;
                }
                // M2 — the destination container must accept a move-in
                // (canMoveBlock above only folds in the SOURCE parent's lock).
                assertMoveDestination(sel, toRoot);
                moveTargetsGrouped(sel, dis, op.clientIds, toRoot, insertAt);
                return {};
            }
            case 'replaceBlocks': {
                (op.clientIds || []).forEach(function (id) { liveness(sel, id); });
                var sinkR = [];
                var repBlocks = (op.blocks || []).map(function (s) { return toBlock(s, sinkR); });
                assertEffectiveContent(op.blocks, repBlocks);
                dis.replaceBlocks(op.clientIds, repBlocks);
                return {
                    new_client_ids: repBlocks.map(function (b) { return b.clientId; }),
                    result_text: repBlocks.map(renderedTextOf),
                    unknown_attrs: sinkR.length ? sinkR : undefined,
                };
            }
            case 'replaceInnerBlocks': {
                liveness(sel, op.rootClientId);
                var sinkN = [];
                var innerBlocks = (op.blocks || []).map(function (s) { return toBlock(s, sinkN); });
                dis.replaceInnerBlocks(op.rootClientId, innerBlocks);
                return {
                    new_client_ids: innerBlocks.map(function (b) { return b.clientId; }),
                    unknown_attrs: sinkN.length ? sinkN : undefined,
                };
            }
            case 'duplicateBlocks': {
                (op.clientIds || []).forEach(function (id) {
                    liveness(sel, id);
                    // GBR-2 — a duplicate lands as a sibling, so the PARENT container
                    // must allow insertion (template-locked parent → refuse).
                    assertInsertable(sel, sel.getBlockRootClientId(id) || '');
                });
                dis.duplicateBlocks(op.clientIds);
                return {};
            }
            case 'selectBlock': {
                liveness(sel, op.clientId);
                dis.selectBlock(op.clientId);
                return {};
            }
            default:
                throw new Error('forbidden_function:' + op.function);
        }
    }

    // A style op whose computed paint we verify (updateBlockAttributes w/ className).
    function isPlanStyleOp(op) {
        return op && op.function === 'updateBlockAttributes' &&
            op.attributes && typeof op.attributes.className === 'string' &&
            op.attributes.className.trim() !== '' &&
            Array.isArray(op.clientIds) && op.clientIds.length > 0;
    }

    // [RTRACE] browser-side round-trip tracer. Always pushes structured entries
    // into window.__zipwpTrace (read via agent-browser eval); the console mirror is
    // GATED behind window.ZIPAI_CONFIG.debug so it doesn't spam every shopper's
    // devtools in production (PR #282 S5). Diagnostic only — wrapped so it can
    // never break an apply.
    function rtrace(hop, data) {
        try {
            var entry = Object.assign({ hop: hop, ts: Date.now() }, data || {});
            (window.__zipwpTrace = window.__zipwpTrace || []).push(entry);
            if (window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug) {
                console.log('[RTRACE] gutenberg ' + hop, entry);
            }
        } catch (e) { /* never break the apply on a trace failure */ }
    }

    function runPlan(args) {
        if (!window.wp || !window.wp.data || !window.wp.blocks) {
            return { success: false, error: 'block_editor_unavailable' };
        }
        var sel = window.wp.data.select('core/block-editor');
        var dis = window.wp.data.dispatch('core/block-editor');
        if (!sel || !dis) return { success: false, error: 'block_editor_unavailable' };

        var livePostId = currentPostId();
        var expectedPostId = args && args.post_id;
        // SEC-2 — FAIL CLOSED. This guard is the SOLE protection against applying a
        // plan to the WRONG open post (a second editor tab). If the brain stamped an
        // expected post_id but the live post id can't be resolved, we cannot verify
        // we are on the right post — refuse rather than apply blind. (Previously this
        // fell open when livePostId was null, skipping the check entirely.)
        if (expectedPostId != null && livePostId == null) {
            return {
                success: true,
                data: {
                    post_id: null, applied: [], failed: [],
                    refused: { reason: 'post_id_unresolvable', detail: 'expected=' + expectedPostId + ',live=null' },
                },
            };
        }
        if (expectedPostId != null && livePostId != null && Number(livePostId) !== Number(expectedPostId)) {
            return {
                success: true,
                data: {
                    post_id: livePostId, applied: [], failed: [],
                    refused: { reason: 'post_id_mismatch', detail: 'expected=' + expectedPostId + ',live=' + livePostId },
                },
            };
        }

        // Subtree scope-lock — enforce ONLY when the brain bound one AND the
        // locked container is still live this turn. A stale lock (selection
        // changed / block gone) degrades OPEN — never a false block.
        var scopeLock = args && typeof args.scope_lock === 'string' && args.scope_lock !== ''
            ? args.scope_lock
            : null;
        if (scopeLock && !sel.getBlock(scopeLock)) scopeLock = null;

        var ops = (args && args.operations) || [];
        var applied = [];
        var failed = [];
        var beforeFx = {};
        for (var b = 0; b < ops.length; b++) {
            // EXEC-2: snapshot EVERY target of a (possibly bulk) restyle, not just
            // clientIds[0] — else idB/idC painting nothing slips through as success.
            if (isPlanStyleOp(ops[b])) {
                beforeFx[b] = ops[b].clientIds.map(function (id) { return computedSnapshot(id); });
            }
        }

        // [RTRACE] EXEC #1 — the whole plan the brain handed to live Gutenberg.
        rtrace('runPlan:entry', {
            post_id: args && args.post_id, version: args && args.version,
            livePostId: livePostId, op_count: ops.length,
            functions: ops.map(function (o) { return o && o.function; }),
        });

        for (var i = 0; i < ops.length; i++) {
            try {
                var op = ops[i] || {};
                // Subtree confinement: refuse a mutating op that reaches OUTSIDE
                // the selected container (→ failed[]); in-scope ops still apply.
                if (scopeLock) assertWithinScope(sel, op, scopeLock);
                // [RTRACE] EXEC #2 — the exact wp.data.dispatch('core/block-editor') call about
                // to fire, with its resolved args + the LIVE target block(s) it will hit.
                var __tids = op.clientIds || (op.clientId != null ? [op.clientId] : (op.rootClientId != null ? [op.rootClientId] : []));
                rtrace('runOp:dispatch', {
                    index: i, fn: op.function,
                    dispatch: "wp.data.dispatch('core/block-editor')." + op.function + '(...)',
                    clientIds: op.clientIds, clientId: op.clientId,
                    rootClientId: op.rootClientId, toRootClientId: op.toRootClientId,
                    index_arg: op.index, order: op.order, before: op.before, after: op.after,
                    blocks: (op.blocks || []).map(function (s) { return s && s.name; }),
                    attr_keys: op.attributes ? Object.keys(op.attributes) : undefined,
                    className: op.attributes && op.attributes.className,
                    targets: __tids.map(function (id) {
                        var blk = sel.getBlock(id);
                        return { clientId: id, live: !!blk, name: blk && blk.name };
                    }),
                });
                if (applied.length > 0) markNonPersistent(dis); // undo coalescing → one ⌘Z
                // SSOT streaming: snapshot operand text BEFORE the op, then stream
                // whatever text CHANGED or APPEARED after — a pure diff, no check on
                // op.function. Works for any operation, present or future.
                var _before = {};
                snapshotContent(sel, opOperandIds(op), _before);
                var res = runOp(sel, dis, op);
                streamChangedText(
                    dis, sel,
                    opOperandIds(op).concat((res && res.new_client_ids) || []),
                    _before
                );
                var entry = { index: i };
                if (res && res.new_client_ids && res.new_client_ids.length) entry.new_client_ids = res.new_client_ids;
                if (res && res.result_text) entry.result_text = res.result_text;
                if (res && res.unknown_attrs && res.unknown_attrs.length) entry.unknown_attrs = res.unknown_attrs;
                applied.push(entry);
                // [RTRACE] EXEC #3 — what the dispatch returned (minted clientIds / rendered text / dropped attrs).
                rtrace('runOp:result', {
                    index: i, fn: op.function,
                    new_client_ids: res && res.new_client_ids, result_text: res && res.result_text,
                    unknown_attrs: res && res.unknown_attrs,
                });
            } catch (e) {
                failed.push({ index: i, error: (e && e.message) || 'apply_failed' });
                // [RTRACE] EXEC #3b — typed failure (stale_client_id / empty_content / forbidden_function).
                rtrace('runOp:failed', { index: i, fn: (ops[i] || {}).function, error: (e && e.message) || 'apply_failed' });
            }
        }

        // LIVE-JIT: compile + inject CSS for utility tokens this plan introduced
        // (inserted blocks included — the className collector below only covers
        // updateBlockAttributes). Kicked off NOW so the network round-trip runs
        // in parallel with the 120ms paint settle; the settle body awaits it so
        // the no-op check reads computed styles WITH the new rules present.
        var liveCssReady = ensureLiveUtilityCss(collectPlanClassNames(ops));

        // Auto-focus: honour an explicit trailing selectBlock (model-driven), else leave selection.
        var postId = livePostId != null ? livePostId : expectedPostId;
        return new Promise(function (resolve) {
            // M5 — TWO-PHASE settle. The 120ms snapshot is a heuristic: a slow
            // re-render (large page, busy main thread) can leave the computed
            // style untouched at 120ms and repaint later — a FALSE noop that
            // nudges the brain into a pointless restyle retry. So a noop is only
            // CONFIRMED by a second unchanged reading 160ms later; ops whose
            // style already moved at phase 1 (the common case) pay nothing extra
            // and the reply leaves at 120ms exactly as before.
            function finishReply(classNames) {
                // [RTRACE] EXEC #4 — the reply Gutenberg sends back (after the
                // paint-settle no-op check — 120ms, +160ms confirm when needed).
                // This is what the bridge POSTs to /agent/rpc-reply → brain folds it.
                rtrace('runPlan:reply', {
                    post_id: postId, applied_count: applied.length, failed_count: failed.length,
                    noop_indices: applied.filter(function (a) { return a.noop; }).map(function (a) { return a.index; }),
                    boosted_classNames: classNames,
                    applied: JSON.parse(JSON.stringify(applied)),
                    failed: JSON.parse(JSON.stringify(failed)),
                });
                resolve({ success: true, data: { post_id: postId, applied: applied, failed: failed } });
            }
            setTimeout(function () {
              liveCssReady.then(function () {
                var classNames = [];
                for (var a = 0; a < applied.length; a++) {
                    var o = ops[applied[a].index];
                    if (o && o.function === 'updateBlockAttributes' && o.attributes && typeof o.attributes.className === 'string') {
                        classNames.push(o.attributes.className);
                    }
                }
                // boostUtilitySpecificity REMOVED (its own stated REMOVAL
                // CONDITION is met): ensureLiveUtilityCss now injects server-
                // parity rules (×5 base + media-wrapped variants) for every
                // touched token. The boost actively BROKE responsive variants:
                // it re-emitted base tokens (grid-cols-1) at ×5 UNCONDITIONALLY,
                // which beat md:/lg: siblings at every width until reload.
                var utilCss = readUtilityCss();
                // Phase 1 — collect noop CANDIDATES (unchanged at 120ms).
                // candidates[i] = { k (applied index), targets: clientIds still unchanged }
                var candidates = [];
                for (var k = 0; k < applied.length; k++) {
                    var idx = applied[k].index;
                    if (Object.prototype.hasOwnProperty.call(beforeFx, idx)) {
                        var o2 = ops[idx];
                        // The className applies to EVERY target in the op; if it is a
                        // recognized live utility it renders post-save → never a no-op.
                        var hasLiveUtil = classNameHasLiveUtility(o2.attributes && o2.attributes.className, utilCss);
                        if (hasLiveUtil) continue;
                        var beforeArr = beforeFx[idx];
                        for (var t = 0; t < o2.clientIds.length; t++) {
                            // EXEC-2: ANY target that painted nothing marks the op a no-op.
                            if (!computedChanged(beforeArr[t], computedSnapshot(o2.clientIds[t]))) {
                                candidates.push({ k: k, id: o2.clientIds[t], before: beforeArr[t] });
                                break; // one unchanged target is enough to re-check this op
                            }
                        }
                    }
                }
                if (candidates.length === 0) { finishReply(classNames); return; }
                // Phase 2 — confirm: still unchanged 160ms later = a real noop;
                // moved = a late paint, NOT a noop.
                setTimeout(function () {
                    for (var c = 0; c < candidates.length; c++) {
                        if (!computedChanged(candidates[c].before, computedSnapshot(candidates[c].id))) {
                            applied[candidates[c].k].noop = true;
                        }
                    }
                    finishReply(classNames);
                }, 160);
              });
            }, 120);
        });
    }

    function handleApplyChange(args) {
        // Vibe Editing v2: every apply_change is a serialized execution plan
        // ({ post_id?, operations[] }). runPlan validates the live editor, enforces
        // the post_id two-tab guard, applies each operation in order, and returns
        // per-index applied[] / failed[] with computed-effect no-op detection. There
        // is no other shape — the brain zod emits operations[] only.
        return runPlan(args);
    }

    // Register once the bridge is ready (same retry pattern as get-context).
    function initHandler() {
        if (window.zipwpMcp && window.zipwpMcp.registerTool) {
            window.zipwpMcp.registerTool(
                'editor/apply-change',
                async function (args) { return handleApplyChange(args); },
                { previewMode: 'client' }
            );
        } else {
            setTimeout(initHandler, 100);
        }
    }

    initHandler();

    // Test-only surface (Node/CommonJS). The browser bundle has no `module`, so
    // this block is inert there and the IIFE registration path above is untouched.
    // Exposes the PURE functions so unit tests exercise the REAL logic (no
    // reimplementation) against a mocked window.wp.data / window.wp.blocks.
    if (typeof module !== 'undefined' && module.exports) {
        module.exports = {
            handleApplyChange: handleApplyChange,
            runPlan: runPlan,
            runOp: runOp,
            normalizePlanAttrs: normalizePlanAttrs,
            coerceGridLayoutLengthAttrs: coerceGridLayoutLengthAttrs,
            placeContent: placeContent,
            renderedTextOf: renderedTextOf,
            assertEffectiveContent: assertEffectiveContent,
            deepMerge: deepMerge,
            // gs-* identity is immutable on a className write (anti-clobber).
            // Exported PURE so a unit test locks the reconcile rule.
            reconcileClassName: reconcileClassName,
            toBlock: toBlock,
            // Computed-effect no-op decision logic — the highest-risk path
            // (gs-utility specificity inversion has regressed before). Exported
            // PURE so unit tests lock the real logic, not a reimplementation.
            computedChanged: computedChanged,
            classNameHasLiveUtility: classNameHasLiveUtility,
            stripBannedAttrs: stripBannedAttrs,
            // Registry-driven attr validation — getBlockType(name).attributes is
            // the SSOT for attr validity (mirrors what Gutenberg accepts). Exported
            // PURE so unit tests lock the partition, not a reimplementation.
            partitionAttrs: partitionAttrs,
            registeredAttrKeysOf: registeredAttrKeysOf,
            // Image-attribute routing — maps the agent's uniform {url,id,alt} to a
            // block's real image attr (incl. container background overlay). Exported
            // PURE so unit tests lock the routing per block type.
            normalizeImageAttrs: normalizeImageAttrs,
            // Subtree scope-lock (selection confinement) — the tree-aware
            // airtight half of "stay inside the selected element". Exported PURE
            // so unit tests lock the in/out-of-scope decision against a mock sel.
            isWithinScope: isWithinScope,
            scopeGoverningIds: scopeGoverningIds,
            assertWithinScope: assertWithinScope,
        };
    }
})();
