/*!
 * ZipWP MCP - Combined JavaScript
 * Version: 0.0.7
 * Build: 2026-07-13 13:50:01
 */

/**
 * Tool Hooks Registry
 *
 * Minimal tool hooks system for registering and executing JavaScript handlers
 * for MCP tools. This is used by the WordPress bridge host to execute js_hook tools.
 */

(function() {
    'use strict';

    class ToolHooksRegistry {
        constructor() {
            this.hooks = new Map();
            this.listeners = new Map();
        }

        /**
         * Register a JavaScript handler for a tool
         *
         * @param {string}   toolName - The tool name (e.g., 'myplugin/my-action')
         * @param {Function} handler  - The handler function
         * @param {Object}   options  - Additional options
         * @return {boolean} True on success
         */
        registerHandler(toolName, handler, options = {}) {
            if (!toolName || typeof handler !== 'function') {
                return false;
            }

            const config = {
                handler,
                previewMode: options.previewMode || 'client',
                priority: options.priority || 10,
                ...options,
            };

            this.hooks.set(toolName, config);

            return true;
        }

        /**
         * Check if a tool has a registered handler
         *
         * @param {string} toolName - The tool name
         * @return {boolean} True if handler exists
         */
        hasHandler(toolName) {
            return this.hooks.has(toolName);
        }

        /**
         * Execute a tool using its registered JavaScript handler
         *
         * @param {string} toolName  - The tool name
         * @param {Object} args      - Tool arguments
         * @param {Object} context   - Execution context
         * @return {Promise<Object>} Execution result
         */
        async executeToolHook(toolName, args, context = {}) {
            const config = this.hooks.get(toolName);

            if (!config) {
                return {
                    success: false,
                    error: `No JavaScript handler registered for tool: ${toolName}`,
                };
            }

            try {
                // Emit pre-execution event
                this.emitEvent('beforeToolExecution', { toolName, args, context });

                // Execute the handler
                const result = await config.handler(args, context);

                // Ensure result has proper structure — pass through operation fields.
                // The outer `message` reflects actual status so callers don't read
                // "executed successfully" when the inner operation failed.
                const operationSucceeded = result?.success !== false;
                const defaultMessage = operationSucceeded
                    ? `Tool ${toolName} executed successfully`
                    : (result?.error || `Tool ${toolName} failed`);
                const formattedResult = {
                    success: operationSucceeded,
                    data: result?.data || result,
                    message: result?.message || defaultMessage,
                    user_summary: result?.user_summary || null,
                    operation: result?.operation || null,
                    verification: result?.verification || result?.data?.verification || null,
                    error: result?.error || null,
                    toolName,
                    executionMode: 'js_hook',
                };

                // Emit post-execution event
                this.emitEvent('afterToolExecution', { toolName, args, context, result: formattedResult });

                return formattedResult;
            } catch (error) {
                const errorResult = {
                    success: false,
                    error: error.message || 'Tool execution failed',
                    toolName,
                    executionMode: 'js_hook',
                };

                // Emit error event
                this.emitEvent('toolExecutionError', { toolName, args, context, error });

                return errorResult;
            }
        }

        /**
         * Emit an event to all registered listeners
         *
         * @param {string} eventName - Event name
         * @param {any}    data      - Event data
         */
        emitEvent(eventName, data) {
            const callbacks = this.listeners.get(eventName) || [];
            callbacks.forEach((callback) => {
                try {
                    callback(data);
                } catch {
                    // Event listener error - continue with other listeners
                }
            });
        }

    }

    // Create singleton instance
    const toolHooksRegistry = new ToolHooksRegistry();

    // Make available globally
    window.zipwpMcp = window.zipwpMcp || {};
    window.zipwpMcp.toolHooks = toolHooksRegistry;
    window.zipwpMcp.registerTool = function(toolName, handler, options = {}) {
        return toolHooksRegistry.registerHandler(toolName, handler, options);
    };
    window.zipwpMcpHooks = toolHooksRegistry;
})();


/**
 * ZipWP MCP - Spectra Shared Utilities
 *
 * Shared utility functions used across Spectra tool handlers and context providers.
 *
 * @package zip-ai
 */

(function() {
    'use strict';

    /**
     * Text attributes to extract for lightweight serialization.
     */
    const TEXT_ATTRIBUTES = [
        'content', 'text', 'title', 'heading', 'description',
        'label', 'placeholder', 'caption', 'citation', 'value',
        'buttonText', 'linkText', 'question', 'answer',
    ];

    /**
     * Extract only text attributes from block attributes.
     * Truncates long text to save tokens.
     *
     * @param {object} attributes - Block attributes
     * @returns {object} Object with only text attributes
     */
    function extractTextAttributes(attributes) {
        if (!attributes) return {};

        const texts = {};
        for (const attr of TEXT_ATTRIBUTES) {
            if (typeof attributes[attr] === 'string' && attributes[attr].trim()) {
                const text = attributes[attr].trim();
                texts[attr] = text.length > 200 ? text.substring(0, 200) + '...' : text;
            }
        }
        return texts;
    }

    /**
     * Walk a block subtree DFS and return the first non-empty text attribute
     * encountered (the block itself first, then its descendants in document
     * order). Used to derive a user-recognisable label for a selected block
     * whose own attributes may be empty but whose children carry content —
     * e.g. a Spectra/Container that wraps the heading block of a hero.
     *
     * @param {object} block
     * @returns {string} Plain trimmed text, or '' when nothing found.
     */
    function findFirstText(block) {
        if (!block) return '';
        const attrs = block.attributes || {};
        for (const key of TEXT_ATTRIBUTES) {
            const v = attrs[key];
            if (typeof v === 'string' && v.trim()) {
                return v.replace(/<[^>]*>/g, '').trim();
            }
        }
        if (Array.isArray(block.innerBlocks)) {
            for (const child of block.innerBlocks) {
                const nested = findFirstText(child);
                if (nested) return nested;
            }
        }
        return '';
    }

    /**
     * Serialize a block with only essential data (clientId, name, texts).
     * Lightweight version for AI operations - minimizes token usage.
     *
     * Also emits `primary_text`: a single user-recognisable label derived
     * from the block's own text attributes (or its subtree when the block
     * itself carries no text). Consumed by the React chat UI to make the
     * selected-block badge identifiable on pages with many same-type
     * containers. Capped at 120 chars.
     *
     * @param {object} block - WordPress block object
     * @returns {object|null} Lightweight serialized block or null
     */
    function serializeBlockLight(block) {
        if (!block) return null;

        const texts = extractTextAttributes(block.attributes);
        const result = {
            clientId: block.clientId,
            name: block.name,
            texts: texts
        };

        const primary = findFirstText(block);
        if (primary) {
            result.primary_text = primary.length > 120 ? primary.substring(0, 117) + '…' : primary;
        }

        // The HTML tag the user gave this block — Spectra content stores it in
        // `tagName` (h1/h2/p/span), a container in `htmlTag` (section/div/header).
        // Surfaced so the selection chip can show the actual tag instead of the
        // raw block-type slug (falls back to the block name when absent).
        const attrs = block.attributes || {};
        const tag = typeof attrs.tagName === 'string' && attrs.tagName !== ''
            ? attrs.tagName
            : (typeof attrs.htmlTag === 'string' && attrs.htmlTag !== '' ? attrs.htmlTag : '');
        if (tag) {
            result.tag = tag;
        }

        if (block.innerBlocks && block.innerBlocks.length > 0) {
            result.innerBlocks = block.innerBlocks
                .map(innerBlock => serializeBlockLight(innerBlock))
                .filter(b => b !== null);
        }

        return result;
    }

    /**
     * Serialize a block for context/transmission.
     * Recursively includes innerBlocks and HTML representation.
     *
     * @param {object} block - WordPress block object
     * @returns {object|null} Serialized block or null if invalid
     */
    function serializeBlock(block) {
        if (!block) return null;

        // Use WordPress serializer to get HTML representation
        let blocksHtml = '';
        if (window.wp?.blocks?.serialize) {
            try {
                blocksHtml = wp.blocks.serialize([block]);
            } catch (e) {
                console.warn('Failed to serialize block:', e);
            }
        }

        return {
            clientId: block.clientId,
            name: block.name,
            blockName: block.name, // Also include as blockName for backend compatibility
            attributes: block.attributes,
            attrs: block.attributes, // Also include as attrs for backend compatibility
            innerBlocks: (block.innerBlocks || []).map(innerBlock => serializeBlock(innerBlock)),
            blocks_html: blocksHtml, // Pre-serialized HTML for direct use
        };
    }

    /**
     * Screenshot utilities using Screen Capture API
     * Captures exactly what's rendered on screen with persistent stream (one-time permission)
     */

    /**
     * Get block DOM element by clientId.
     *
     * WordPress 6.3+ renders the editor canvas inside an internal iframe
     * (`iframe[name="editor-canvas"]`), so a direct `document.querySelector`
     * on the top document misses every block. Fall through to each
     * same-origin iframe's document before returning null.
     *
     * @param {string} clientId - Block client ID
     * @returns {HTMLElement|null} Block element or null
     */
    function getBlockElement(clientId) {
        if (!clientId) return null;
        const selector = '[data-block="' + clientId + '"]';
        // Top document first (legacy / non-iframe editors).
        const direct = document.querySelector(selector);
        if (direct) return direct;
        // Then every iframe we can reach without a cross-origin error.
        const iframes = document.querySelectorAll('iframe');
        for (let i = 0; i < iframes.length; i++) {
            let doc = null;
            try { doc = iframes[i].contentDocument || (iframes[i].contentWindow && iframes[i].contentWindow.document); } catch (err) { doc = null; }
            if (!doc) continue;
            const nested = doc.querySelector(selector);
            if (nested) return nested;
        }
        return null;
    }

    /**
     * Inject the zip-ai-block-pulse keyframes + class into a document once.
     * Idempotent per-document. Must be called on the owner-document of the
     * element we'll animate — injecting into the top document does nothing
     * when the block lives inside Gutenberg's editor-canvas iframe.
     *
     * @param {Document} [doc] - Target document. Defaults to top document.
     */
    function ensurePulseStyles(doc) {
        const target = doc || document;
        if (!target || !target.head) return;
        if (target.getElementById('zip-ai-block-pulse-styles')) return;
        const style = target.createElement('style');
        style.id = 'zip-ai-block-pulse-styles';
        style.textContent =
            '@keyframes zip-ai-block-pulse {' +
                '0%, 100% { box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.9), 0 0 0 10px rgba(124, 58, 237, 0); }' +
                '50% { box-shadow: 0 0 0 3px rgba(124, 58, 237, 1), 0 0 0 14px rgba(124, 58, 237, 0.18); }' +
            '}' +
            '.zip-ai-block-pulse {' +
                'animation: zip-ai-block-pulse 0.9s ease-in-out 2;' +
                'border-radius: 4px;' +
                'outline: none !important;' +
            '}';
        target.head.appendChild(style);
    }

    /**
     * Scroll the block identified by clientId into view and play a brief
     * pulse highlight so the user can visually locate it. Used by the
     * selected-block badge in the chat UI.
     *
     * Silent no-op if the element isn't in the DOM (block may be in an
     * iframe we can't reach, or was just replaced — don't throw).
     *
     * @param {string} clientId
     * @param {{ durationMs?: number, scroll?: boolean }} [options]
     * @returns {boolean} true when an element was highlighted.
     */
    function highlightBlock(clientId, options) {
        const opts = options || {};
        const el = getBlockElement(clientId);
        if (!el) return false;
        // Inject the animation CSS into the element's owner document so it
        // works inside Gutenberg's editor-canvas iframe as well as on the
        // top document.
        ensurePulseStyles(el.ownerDocument);
        if (opts.scroll !== false) {
            try {
                el.scrollIntoView({ behavior: 'smooth', block: 'center' });
            } catch (err) {
                // Older browsers without smooth scroll — fall back to default.
                try { el.scrollIntoView(); } catch (e) { /* ignore */ }
            }
        }
        el.classList.remove('zip-ai-block-pulse');
        // Force reflow so re-adding the class restarts the animation.
        // eslint-disable-next-line no-unused-expressions
        void el.offsetWidth;
        el.classList.add('zip-ai-block-pulse');
        const duration = typeof opts.durationMs === 'number' ? opts.durationMs : 1800;
        window.setTimeout(() => {
            el.classList.remove('zip-ai-block-pulse');
        }, duration);
        return true;
    }

    /**
     * Persistent screen capture stream (reused to avoid repeated permission prompts)
     */
    let screenCaptureStream = null;
    let screenCaptureVideo = null;
    let screenCapturePermissionDenied = false; // Remember if user cancelled/denied

    /**
     * Initialize or get existing screen capture stream
     * Permission is requested only once per session
     * If user cancels, won't ask again until page reload
     * @returns {Promise<{stream: MediaStream, video: HTMLVideoElement}|null>}
     */
    async function getScreenCaptureStream() {
        // Don't ask again if user already denied/cancelled
        if (screenCapturePermissionDenied) {
            return null;
        }

        // Check if existing stream is still active
        if (screenCaptureStream && screenCaptureVideo) {
            const tracks = screenCaptureStream.getVideoTracks();
            if (tracks.length > 0 && tracks[0].readyState === 'live') {
                return { stream: screenCaptureStream, video: screenCaptureVideo };
            }
            // Stream ended, clean up
            screenCaptureStream = null;
            screenCaptureVideo = null;
        }

        try {
            // Request new stream (prompts user once)
            screenCaptureStream = await navigator.mediaDevices.getDisplayMedia({
                video: {
                    displaySurface: 'browser',
                    preferCurrentTab: true
                },
                preferCurrentTab: true
            });

            // Create persistent video element
            screenCaptureVideo = document.createElement('video');
            screenCaptureVideo.srcObject = screenCaptureStream;
            screenCaptureVideo.muted = true;
            await screenCaptureVideo.play();

            // Listen for stream end (user stops sharing)
            screenCaptureStream.getVideoTracks()[0].addEventListener('ended', () => {
                screenCaptureStream = null;
                screenCaptureVideo = null;
            });

            return { stream: screenCaptureStream, video: screenCaptureVideo };
        } catch (error) {
            console.warn('Screen capture permission denied:', error.message);
            // Remember that user denied/cancelled - don't ask again until page reload
            screenCapturePermissionDenied = true;
            return null;
        }
    }

    /**
     * Capture screenshot of an element using the persistent screen capture stream
     * @param {HTMLElement} element - Element to capture
     * @param {object} options - Capture options
     * @returns {Promise<{base64: string, width: number, height: number}|null>}
     */
    async function captureBlockScreenshot(clientId, options = {}) {
        const { maxWidth = 1200, quality = 0.8 } = options;

        const element = getBlockElement(clientId);
        if (!element) {
            console.warn('Block element not found for clientId:', clientId);
            return null;
        }

        // Scroll element into view
        element.scrollIntoView({ behavior: 'instant', block: 'center' });
        await new Promise(resolve => setTimeout(resolve, 150));

        // Get or initialize screen capture stream
        const capture = await getScreenCaptureStream();
        if (!capture) {
            return null;
        }

        const { video } = capture;

        try {
            // Get element position relative to viewport
            const rect = element.getBoundingClientRect();

            // Draw current video frame to canvas
            const fullCanvas = document.createElement('canvas');
            fullCanvas.width = video.videoWidth;
            fullCanvas.height = video.videoHeight;
            const fullCtx = fullCanvas.getContext('2d');
            fullCtx.drawImage(video, 0, 0);

            // Calculate scale factor (screen capture may be at different DPI)
            const scaleX = video.videoWidth / window.innerWidth;
            const scaleY = video.videoHeight / window.innerHeight;

            // Crop to element bounds
            const cropX = Math.max(0, rect.left * scaleX);
            const cropY = Math.max(0, rect.top * scaleY);
            const cropWidth = Math.min(rect.width * scaleX, fullCanvas.width - cropX);
            const cropHeight = Math.min(rect.height * scaleY, fullCanvas.height - cropY);

            // Create cropped canvas
            const croppedCanvas = document.createElement('canvas');
            croppedCanvas.width = cropWidth;
            croppedCanvas.height = cropHeight;
            const croppedCtx = croppedCanvas.getContext('2d');
            croppedCtx.drawImage(fullCanvas, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight);

            // Optimize if needed
            let finalCanvas = croppedCanvas;
            if (croppedCanvas.width > maxWidth) {
                finalCanvas = document.createElement('canvas');
                const ratio = maxWidth / croppedCanvas.width;
                finalCanvas.width = maxWidth;
                finalCanvas.height = croppedCanvas.height * ratio;
                const ctx = finalCanvas.getContext('2d');
                ctx.drawImage(croppedCanvas, 0, 0, finalCanvas.width, finalCanvas.height);
            }

            return {
                base64: finalCanvas.toDataURL('image/jpeg', quality),
                width: finalCanvas.width,
                height: finalCanvas.height
            };
        } catch (error) {
            console.error('Screenshot capture failed:', error);
            return null;
        }
    }

    // Export utilities to global scope for use by tool handlers and context providers
    window.zipwpMcpSpectraUtils = {
        serializeBlock,
        serializeBlockLight,
        extractTextAttributes,
        // Screenshot utilities
        getScreenCaptureStream,
        getBlockElement,
        highlightBlock,
        captureBlockScreenshot
    };

})();


/**
 * ZipWP MCP — Vibe Editing v2: shared editor utilities.
 *
 * The ONE source of truth for helpers the editor handlers (get-context,
 * apply-change, get/set-scripts, get/set-styles) need identically — so they can
 * never drift:
 *   - currentPostId: the live post id from the editor store, with the same
 *     null-guard. apply-change uses it for the two-tab post_id guard;
 *     get-context returns it in the response.
 *   - editorSelect / editorDispatch: the null-guarded `core/editor` store
 *     select/dispatch accessors. styles read the editing session through these
 *     (getEditedPostAttribute / editPost).
 *   - currentMeta: the live post meta object from the session (or {}), the
 *     read side of the session-scoped styles write.
 *   - blockEditorSelect / blockEditorDispatch / rootClientId: the null-guarded
 *     `core/block-editor` store accessors + the page root container's clientId.
 *     get/set-scripts read/patch a block's `spectraCustomJS` attribute through
 *     these (getBlockAttributes / updateBlockAttributes).
 *   - bannedVisualAttrs / isBannedVisualAttr: the 8 GBS-banned per-block visual
 *     styling props (style, *Color(Hover), boxShadow(Hover), styleAttributes) —
 *     styling lives in `className` ONLY (the Spectra GBS JIT grammar). apply-change
 *     strips them from an incoming attrs write; get-context omits them from the
 *     authorable attr-keys it advertises. ONE list so the two can never drift
 *     (mirrors Laravel's StrictAttrValidator). The 8 props are a sanctioned
 *     constant, not an allowlist — every OTHER attr is decided by the registry.
 *
 * Dual-mode: attaches to window.zipwpEditorShared in the browser; CommonJS
 * export for jest.
 *
 * Load order: enqueued before the editor handlers in source mode
 * (react-manager.php) and concatenated ahead of handler.js in the production
 * grunt bundle (matches the `tools/**\/*-utils.js` glob). The handlers also
 * resolve it lazily at call time, so a missing global degrades consistently
 * (post id -> null = the two-tab guard no-ops, exactly as a null
 * getCurrentPostId() would).
 *
 * @package zip-ai
 */
(function () {
    'use strict';

    function editorSelect() {
        return (typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.select)
            ? window.wp.data.select('core/editor')
            : null;
    }

    function editorDispatch() {
        return (typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.dispatch)
            ? window.wp.data.dispatch('core/editor')
            : null;
    }

    function currentPostId() {
        var editor = editorSelect();
        return editor && typeof editor.getCurrentPostId === 'function'
            ? editor.getCurrentPostId()
            : null;
    }

    // The live post meta object from the editing session (or {} when absent) —
    // the read side of the session-scoped scripts/styles write.
    function currentMeta(sel) {
        var meta = sel && sel.getEditedPostAttribute ? sel.getEditedPostAttribute('meta') : null;
        return meta && typeof meta === 'object' ? meta : {};
    }

    // The null-guarded `core/block-editor` store — the read/write side of a
    // block's attributes (get/set-scripts operate on `spectraCustomJS`, an attr).
    // Edits through it are inherently session-scoped: in-memory now, persisted on
    // Save, discarded with the session (same model apply-change uses).
    function blockEditorSelect() {
        return (typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.select)
            ? window.wp.data.select('core/block-editor')
            : null;
    }
    function blockEditorDispatch() {
        return (typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.dispatch)
            ? window.wp.data.dispatch('core/block-editor')
            : null;
    }

    // The page root container's clientId — the first top-level block — the
    // default owner for page-wide JS when no clientId is passed. null when the
    // tree is empty or the store is absent.
    function rootClientId(sel) {
        var order = sel && sel.getBlockOrder ? sel.getBlockOrder() : null;
        return Array.isArray(order) && order.length > 0 ? order[0] : null;
    }

    // The 8 GBS-banned per-block visual styling props. Per-block styling lives in
    // `className` ONLY (the Spectra GBS JIT grammar); these are never authorable
    // attrs even when a block's registry declares them. A sanctioned constant —
    // every OTHER attribute's validity is decided by the registry (getBlockType).
    var bannedVisualAttrs = [
        'style', 'styleAttributes',
        'backgroundColor', 'backgroundColorHover',
        'boxShadow', 'boxShadowHover',
        'textColor', 'textColorHover',
    ];
    function isBannedVisualAttr(key) {
        return bannedVisualAttrs.indexOf(key) !== -1;
    }

    if (typeof window !== 'undefined') {
        window.zipwpEditorShared = window.zipwpEditorShared || {};
        window.zipwpEditorShared.currentPostId = currentPostId;
        window.zipwpEditorShared.editorSelect = editorSelect;
        window.zipwpEditorShared.editorDispatch = editorDispatch;
        window.zipwpEditorShared.currentMeta = currentMeta;
        window.zipwpEditorShared.blockEditorSelect = blockEditorSelect;
        window.zipwpEditorShared.blockEditorDispatch = blockEditorDispatch;
        window.zipwpEditorShared.rootClientId = rootClientId;
        window.zipwpEditorShared.bannedVisualAttrs = bannedVisualAttrs;
        window.zipwpEditorShared.isBannedVisualAttr = isBannedVisualAttr;
    }
    if (typeof module !== 'undefined' && module.exports) {
        module.exports = {
            currentPostId: currentPostId,
            editorSelect: editorSelect,
            editorDispatch: editorDispatch,
            currentMeta: currentMeta,
            blockEditorSelect: blockEditorSelect,
            blockEditorDispatch: blockEditorDispatch,
            rootClientId: rootClientId,
            bannedVisualAttrs: bannedVisualAttrs,
            isBannedVisualAttr: isBannedVisualAttr,
        };
    }
})();


/**
 * 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,
        };
    }
})();


/**
 * ZipWP MCP — Vibe Editing v2: editor/get-context handler.
 *
 * Reads a slice of the LIVE Gutenberg block tree from wp.data and returns it as
 * structured JSON. Dispatched by the brain's AgentBrowserLoop as a `js_rpc`
 * tool (same eager-execute path as js_hook); the result is POSTed back to the
 * Laravel /agent/rpc-reply route so the loop resolves it IN THE SAME TURN.
 *
 * Input:
 *   scope (optional) — a block clientId. Present → return that block's subtree;
 *   absent → the page's top-level sections (a cheap outline the brain can then
 *   expand by re-calling with a section clientId).
 *
 * Returns { success, data: { scope, postId, blocks: [row] } } where each row is
 * { clientId, blockName, text, className, path, html?, computed? } — a
 * DESIGN-FAITHFUL view: `text` identifies the block (stripped), `html` is the
 * rich content WITH inline markup (the design pattern, when present), `computed`
 * is the rendered-style digest (fontSize/color/background/padding/fontWeight,
 * best-effort) so the brain edits relative to RENDERED reality, not class
 * strings. The clientId is the LIVE targeting handle (re-snapshotted every
 * call); the brain never persists it across turns.
 *
 * @package zip-ai
 */
(function () {
    'use strict';

    // Plain-text snippet for a block, so the brain can identify it by content.
    function textOf(block) {
        var a = block.attributes || {};
        var raw = a.text || a.content || a.label || a.title || '';
        return String(raw).replace(/<[^>]*>/g, '').trim().slice(0, 120);
    }

    // RICH content WITH inline markup intact — the carrier of the page's design
    // PATTERN (per-word colour <span>s, emphasis). `text` above is stripped for
    // identification; this is what the brain must edit so a content rewrite can
    // re-author the SAME inline structure with new words instead of flattening
    // it to a plain string (the headline-flattening defect). Surfaced ONLY when
    // the content actually carries inline markup, so plain blocks stay lean.
    function htmlOf(block) {
        var a = block.attributes || {};
        var raw = a.content != null ? a.content : (a.text != null ? a.text : null);
        if (typeof raw !== 'string' || raw.indexOf('<') === -1) return null;
        return raw.length > 600 ? raw.slice(0, 600) : raw;
    }

    // The live editor canvas document (iframe in the block editor; falls back to
    // the main document for the no-iframe mount). Shared shape with apply-change's
    // canvasCtx — kept minimal here (read-only lookup) to avoid a cross-handler
    // load-order dependency.
    function canvasDoc() {
        var f = document.querySelector('iframe[name="editor-canvas"]');
        return f && f.contentDocument ? f.contentDocument : document;
    }

    // The canvas node for a block (the live rendered element). Resolved ONCE per
    // row by the caller and shared by computedOf + renderedContentOf so the
    // [data-block] lookup runs once, not per-field. null when not mounted
    // (off-screen/virtualized).
    function nodeForBlock(doc, clientId) {
        return doc.querySelector('[data-block="' + clientId + '"]');
    }

    // RENDERED design facts for a block — the load-bearing axes the brain
    // otherwise cannot perceive (it sees authored class tokens, never the
    // rendered px/colour). Lets "bigger" be relative to the real size and lets
    // the brain see when an authored class did NOT move a property (a gs-*
    // !important rule owns it). Best-effort: null when the canvas node isn't
    // mounted (off-screen/virtualized) — the doctrine then falls back to
    // className. Read-only; never throws.
    function computedOf(el, win) {
        try {
            if (!el) return null;
            var cs = win.getComputedStyle(el);
            return {
                fontSize: cs.fontSize,
                color: cs.color,
                backgroundColor: cs.backgroundColor,
                padding: cs.padding,
                fontWeight: cs.fontWeight,
            };
        } catch (e) {
            return null;
        }
    }

    // Collapse whitespace + trim — for the RENDERED DOM text, which the browser
    // already decoded to plain text (no entities/tags). Deliberately NOT routed
    // through an innerHTML round-trip: that would mangle a literal "<" in the text.
    function collapseWs(s) {
        return (s === null || s === undefined ? '' : String(s)).replace(/\s+/g, ' ').trim();
    }

    // Decode HTML entities + strip tags + collapse whitespace — for the AUTHORED
    // attr, which may carry entities/markup. A detached element (cached, plain
    // `document` — decoding is document-agnostic) does the exact decode the browser
    // does; under jest (no DOM) it degrades to a tag strip. Pairs with collapseWs
    // so authored + rendered compare like-for-like — entity/markup/whitespace
    // differences never cause a false divergence.
    var _decodeEl = null;
    function normContent(s) {
        var str = s === null || s === undefined ? '' : String(s);
        try {
            if (_decodeEl === null) _decodeEl = document.createElement('div');
            _decodeEl.innerHTML = str;
            str = _decodeEl.textContent || '';
        } catch (e) {
            str = str.replace(/<[^>]*>/g, '');
        }
        return str.replace(/\s+/g, ' ').trim();
    }

    // CONTENT OWNERSHIP (the content analog of get-styles' styleContext): the
    // RENDERED text of a LEAF content block vs its own authored content attr.
    // When they DIFFER, the block's own attr is a DEAD LEVER — the rendered value
    // is owned UPSTREAM (a parent composite computes it / pushes it via block
    // context: a countdown unit's label comes from the parent's `{unit}sLabel`).
    // Editing the child attr then silently no-ops on render. Surfacing the
    // rendered value (NOT a server-computed boolean — the brain compares `text`
    // vs `rendered` and judges) lets the brain redirect the edit to the owning
    // parent attr, WITHOUT any per-block knowledge — the render is the source of
    // truth, exactly like styleContext's `effective`.
    //
    // Scoped HARD to avoid false positives: LEAF blocks only (a container's
    // textContent is its whole subtree → would always "differ"); only TEXT attrs
    // (content/text/label) — NOT `number`, which leaves self-format/animate
    // (1000→"1,000", count-up mid-flight), so value-inequality there is not
    // ownership; both sides normalized; empty/partial renders (unmounted, or
    // mid-typewriter where rendered is a prefix of authored) are skipped. Takes the
    // pre-resolved canvas node. Returns the normalized rendered value, or null.
    function renderedContentOf(block, el) {
        if ((block.innerBlocks || []).length > 0) return null; // leaf-only — never a container subtree
        var a = block.attributes || {};
        var authoredRaw = a.content != null ? a.content : a.text != null ? a.text : a.label != null ? a.label : null;
        if (authoredRaw === null || authoredRaw === '') return null;
        if (!el) return null;
        try {
            var rendered = collapseWs(el.textContent || '');
            if (!rendered) return null; // unmounted / cleared (mid-stream)
            // indexOf===0 covers BOTH equality (child owns it) and rendered being a
            // strict prefix of authored (mid-typewriter) — either way, not shadowed.
            if (normContent(authoredRaw).indexOf(rendered) === 0) return null;
            return rendered.slice(0, 120);
        } catch (e) {
            return null;
        }
    }

    // Current className (L2 styling). Surfaced so an apply_change.setAttributes
    // can PRESERVE the existing utility/gs classes instead of replacing them
    // wholesale — the brain appends to this string.
    function classOf(block) {
        var a = block.attributes || {};
        return typeof a.className === 'string' ? a.className : '';
    }

    // The 8 GBS-banned visual styling props are NEVER advertised as authorable
    // attrs (styling lives in `className` ONLY, the Spectra GBS JIT grammar). The
    // list is the SHARED SSOT (editor-shared-utils.isBannedVisualAttr) so this +
    // apply-change can never drift. If the shared module isn't resolved we
    // advertise the raw registry keys (the brain's zod still rejects the 8 on
    // write — degrade consistently, never a duplicate list).
    function isBannedVisualAttr(key) {
        var u = sharedEditorUtils();
        return !!(u && typeof u.isBannedVisualAttr === 'function' && u.isBannedVisualAttr(key));
    }

    // The block's VALID structural attr keys — straight from the registry
    // (getBlockType(blockName).attributes, the browser-side SSOT for what
    // Gutenberg accepts) minus the 8 GBS-banned styling attrs. Surfaced so the
    // model authors valid `attrs` first-try (e.g. counter `endNumber`, button
    // `linkURL`, container `isRootBlock`) instead of guessing against a brain
    // allowlist — the registry, not a hand-maintained list, decides validity.
    // null when the type isn't registered (forked/unknown block) → row omits the
    // field rather than advertise an empty/false set.
    function attrKeysOf(name) {
        var t = window.wp && window.wp.blocks && window.wp.blocks.getBlockType
            ? window.wp.blocks.getBlockType(name)
            : null;
        if (!t || !t.attributes) return null;
        var out = [];
        Object.keys(t.attributes).forEach(function (k) {
            if (!isBannedVisualAttr(k)) out.push(k);
        });
        return out;
    }

    // SET (non-default) attribute VALUES — the higher-specificity styling/layout
    // layer that OUTRANKS className (cascade: block attribute > GBS class > block
    // default). Surfaced so the model SEES a pinned `style.*` / `layout` / spacing
    // attr BEFORE it sets a class that would silently no-op against it, and can
    // clear/move it. Uses the PROPER Gutenberg notion of "comment attributes": walk
    // the registered schema (getBlockType), drop attrs SOURCE-d from markup
    // (content/html — those are the text, not a styling override) and attrs still at
    // their declared `default` — exactly the set Gutenberg's serializer persists.
    // FULLY registry-driven — no hardcoded key list: only the schema's own `source`
    // (markup-backed) + `default` + the banned visual props filter it. So whatever
    // is pinned (style.*, layout, spacing, …) surfaces; the model picks the ones
    // relevant to its edit. null when nothing is overridden.
    function setAttrsOf(block) {
        var t = window.wp && window.wp.blocks && window.wp.blocks.getBlockType
            ? window.wp.blocks.getBlockType(block.name)
            : null;
        if (!t || !t.attributes) return null;
        var a = block.attributes || {};
        var schema = t.attributes;
        var out = {};
        Object.keys(schema).forEach(function (k) {
            var def = schema[k] || {};
            if (def.source) return;            // sourced from the markup — it's the content, not a styling override
            if (isBannedVisualAttr(k)) return; // never authorable in attrs anyway
            var v = a[k];
            if (v === undefined || v === null || v === '') return;
            if (Array.isArray(v) && v.length === 0) return;
            if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) return;
            try {
                if (JSON.stringify(v) === JSON.stringify(def.default)) return; // unchanged from default
            } catch (e) { /* unstringifiable → treat as a real override */ }
            out[k] = v;
        });
        return Object.keys(out).length ? out : null;
    }

    function rowOf(block, path) {
        var row = {
            clientId: block.clientId,
            blockName: block.name,
            text: textOf(block),
            className: classOf(block),
            path: path,
        };
        // Registry-declared valid attr keys (minus GBS-banned) so the model
        // authors valid setAttributes.attrs first-try. Omitted for unregistered
        // types — never advertise a guess.
        var attrKeys = attrKeysOf(block.name);
        if (attrKeys !== null) row.attrKeys = attrKeys;
        // Pinned (non-default) attr VALUES — the higher-specificity layer that
        // overrides className. Lets the model reconcile specificity before a class no-ops.
        var setAttrs = setAttrsOf(block);
        if (setAttrs !== null) row.setAttrs = setAttrs;
        // Design-faithful enrichment — only present when meaningful, so the
        // payload stays lean: `html` for blocks carrying inline markup, `computed`
        // when the live canvas node is readable. The canvas node is resolved ONCE
        // here and shared by computedOf + renderedContentOf (one [data-block]
        // lookup per row, not per field).
        var html = htmlOf(block);
        if (html !== null) row.html = html;
        var cdoc = canvasDoc();
        var cel = nodeForBlock(cdoc, block.clientId);
        var computed = computedOf(cel, cdoc.defaultView || window);
        if (computed !== null) row.computed = computed;
        // Content ownership: present ONLY when the rendered text differs from this
        // leaf's own content attr — i.e. the attr is a dead lever and the value is
        // owned upstream (a parent composite). The brain compares `text` vs
        // `rendered` and redirects the edit to the parent's owning attr.
        var rendered = renderedContentOf(block, cel);
        if (rendered !== null) row.rendered = rendered;
        return row;
    }

    // Read bounds. An unbounded subtree dump on a large/whole page can exceed the
    // brain's compaction backstop and clear the active read with NO recovery
    // (the brain holds only the js_rpc envelope, not this payload). Cap rows +
    // depth + total bytes so a single read is always self-contained; a truncated
    // read flags `truncated` and stamps per-parent `childCount`/`hasMore` so the
    // model drills down with a narrower scope instead of getting a silent cut.
    var MAX_CONTEXT_ROWS = 250;
    var MAX_CONTEXT_DEPTH = 8;
    var MAX_CONTEXT_BYTES = 48000;

    // Push a row into the bounded accumulator. Returns false (caller stops) when
    // a cap is hit. Tracks an approximate serialized size so a few heavy rows
    // (long html / many attrKeys) trip the ceiling as readily as many light ones.
    function pushRow(state, row) {
        if (state.rows.length >= MAX_CONTEXT_ROWS) { state.truncated = true; return false; }
        var sz = 200;
        try { sz = JSON.stringify(row).length; } catch (e) { sz = 200; }
        if (state.bytes + sz > MAX_CONTEXT_BYTES && state.rows.length > 0) {
            state.truncated = true;
            return false;
        }
        state.rows.push(row);
        state.bytes += sz;
        return true;
    }

    // Flatten a block + descendants into bounded rows (DFS): clientId (live
    // handle), block name, text snippet, className, dot-path. Stops at the
    // row/byte caps; at the depth cap it emits the node with childCount/hasMore
    // rather than descending, so deeper blocks stay discoverable via a scoped
    // re-read.
    function flatten(block, path, depth, state) {
        var inner = block.innerBlocks || [];
        var row = rowOf(block, path);
        if (inner.length > 0 && depth >= MAX_CONTEXT_DEPTH) {
            row.childCount = inner.length;
            row.hasMore = true;
            pushRow(state, row);
            return;
        }
        if (!pushRow(state, row)) return;
        for (var i = 0; i < inner.length; i++) {
            if (state.truncated) {
                // The caps tripped before this parent's children were exhausted —
                // flag it so the omitted subtree is discoverable.
                row.childCount = inner.length;
                row.hasMore = true;
                return;
            }
            flatten(inner[i], path === '' ? String(i) : path + '.' + i, depth + 1, state);
        }
    }

    // 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 lives there so this handler and apply-change can
    // never drift; a missing module degrades consistently (null).
    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;
    }

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

        // A scope that isn't a real clientId — empty, or a sentinel the model
        // sometimes emits when it means "the whole page" (the literal string
        // "null", "root", "page", …) — degrades to the page outline rather than
        // a hard miss. The outline is the cheap top-level map the model can
        // re-target from; a hard error here just burns a round-trip.
        var scopeRaw = args && args.scope ? String(args.scope) : '';
        var SCOPE_SENTINELS = { '': 1, 'null': 1, 'undefined': 1, 'root': 1, 'page': 1, '0': 1, 'none': 1, 'false': 1 };
        var scope = SCOPE_SENTINELS[scopeRaw.toLowerCase()] ? '' : scopeRaw;
        // Bounded accumulator (rows + running byte size + truncation flag).
        var state = { rows: [], bytes: 0, truncated: false };

        // A stale/rotated clientId — gone after a structural edit (replace/insert
        // mint new ids), a page change, or replayed from older conversation history
        // — must NOT dead-end the turn. A hard `scope_not_found` error makes the
        // model re-ask the SAME dead id (wasted round-trips, duplicate-call blocks,
        // token bloat). Instead DEGRADE to the page outline and FLAG the miss, so the
        // model re-grounds against the live tree and re-targets in the SAME turn. The
        // grounding tool is the model's only map; it must always return a usable one.
        var scopeMissed = '';
        if (scope) {
            var root = sel.getBlock(scope);
            if (root) {
                flatten(root, '', 0, state);
            } else {
                scopeMissed = scope;
                scope = '';
            }
        }
        if (!scope) {
            var top = sel.getBlocks() || [];
            for (var i = 0; i < top.length; i++) {
                // Outline read = top-level sections only (no recurse). Still
                // row/byte-bounded so a page with hundreds of sections can't
                // blow the read.
                if (!pushRow(state, rowOf(top[i], String(i)))) break;
            }
        }
        var blocks = state.rows;

        var data = {
            scope: scope || null,
            postId: currentPostId(),
            blocks: blocks,
        };
        // The read hit a cap (rows/bytes) — tell the model these rows are a
        // PREFIX, so it narrows the scope (re-read a child clientId) rather than
        // assuming it saw the whole tree. Pairs with per-parent childCount/hasMore.
        if (state.truncated) {
            data.truncated = true;
        }
        // Flag a degraded read so the model KNOWS the id it asked for is gone and
        // these rows are the live outline to re-target from (not the requested
        // subtree) — turns a silent substitution into an explicit re-ground signal.
        if (scopeMissed) {
            data.scopeNotFound = scopeMissed;
        }
        return { success: true, data: data };
    }

    // Register once the bridge is ready (same retry pattern as the other
    // editor tools — the bridge mounts asynchronously).
    function initHandler() {
        if (window.zipwpMcp && window.zipwpMcp.registerTool) {
            window.zipwpMcp.registerTool(
                'editor/get-context',
                async (args) => handleGetContext(args),
                { previewMode: 'client' }
            );
        } else {
            setTimeout(initHandler, 100);
        }
    }

    initHandler();
})();


/**
 * editor/get-scripts + editor/set-scripts — typed read/patch over a BLOCK's
 * `spectraCustomJS` attribute (the per-block JS store). `spectraCustomJS` in
 * post_content is the single source of truth for page behaviour — Spectra Pro's
 * BlockJsCompiler renders it once at wp_footer (spectra-blocks-pro #165),
 * superseding the removed per-page `_spectra_blocks_page_scripts` meta.
 *
 * SESSION-SCOPED BY DESIGN (the apply_change philosophy): reads come from
 * `core/block-editor` getBlockAttributes (the live editing session, incl.
 * unsaved edits) and writes go through updateBlockAttributes — the block's JS
 * updates in the session immediately, is DISCARDED if the user discards the
 * session, and persists only on Save. A REST write here would race the open
 * editor's copy and mutate before save — never do that.
 *
 * Target a block by `clientId` (default = the page root container, the first
 * top-level block — page-wide behaviour). The stored code is raw JS; the
 * renderer wraps it in an IIFE and resolves the `_current_block_` token to the
 * block's scope class.
 */
(function () {
    // Block-editor store access comes from the ONE shared source
    // (editor/shared/editor-shared-utils.js): window in the browser, require()
    // under jest — so the editor handlers can't drift.
    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 blockEditorSelect() { var u = sharedEditorUtils(); return u && u.blockEditorSelect ? u.blockEditorSelect() : null; }
    function blockEditorDispatch() { var u = sharedEditorUtils(); return u && u.blockEditorDispatch ? u.blockEditorDispatch() : null; }
    function rootClientId(sel) { var u = sharedEditorUtils(); return u && u.rootClientId ? u.rootClientId(sel) : null; }
    function currentPostId() { var u = sharedEditorUtils(); return u && u.currentPostId ? u.currentPostId() : null; }

    // The target block: an explicit clientId, else the page root container.
    function resolveClientId(sel, args) {
        var cid = args && typeof args.clientId === 'string' && args.clientId !== '' ? args.clientId : null;
        return cid || rootClientId(sel);
    }

    // A block's current spectraCustomJS ('' when unset or the block is gone).
    function customJsOf(sel, clientId) {
        var attrs = sel && sel.getBlockAttributes ? sel.getBlockAttributes(clientId) : null;
        return attrs && typeof attrs.spectraCustomJS === 'string' ? attrs.spectraCustomJS : '';
    }

    function handleGetScripts(args) {
        var sel = blockEditorSelect();
        if (!sel || !sel.getBlockAttributes) {
            return { success: false, error: 'editor_unavailable: core/block-editor store not present (is the block editor open?)' };
        }
        var clientId = resolveClientId(sel, args);
        if (!clientId) {
            return { success: false, error: 'no_block: the page has no blocks to read JS from' };
        }
        return {
            success: true,
            data: {
                post_id: currentPostId(),
                client_id: clientId,
                code: customJsOf(sel, clientId),
            },
        };
    }

    // Throws on code the block renderer would reject — typed and loud beats a
    // silent drop. `<script>` tags never belong in the store (it wraps the raw JS).
    function validateCode(code) {
        if (typeof code !== 'string' || code === '') {
            throw new Error('invalid_input: code is required (the raw JS source, no <script> tags)');
        }
        if (/<\/?script/i.test(code)) {
            throw new Error('invalid_input: code must be the raw JS source only, with no <script> tags (the store wraps it)');
        }
    }

    // `code` REPLACES the block's spectraCustomJS; `append: true` adds to the
    // existing JS instead (read first with editor/get-scripts).
    function handleSetScripts(args) {
        var sel = blockEditorSelect();
        var dis = blockEditorDispatch();
        if (!sel || !sel.getBlockAttributes || !dis || !dis.updateBlockAttributes) {
            return { success: false, error: 'editor_unavailable: core/block-editor store not present (is the block editor open?)' };
        }

        try { validateCode(args ? args.code : undefined); } catch (e) {
            return { success: false, error: String(e && e.message ? e.message : e) };
        }

        var clientId = resolveClientId(sel, args);
        if (!clientId) {
            return { success: false, error: 'no_block: the page has no blocks to attach JS to' };
        }
        var attrs = sel.getBlockAttributes(clientId);
        if (!attrs) {
            return { success: false, error: 'unknown_block: no block with clientId ' + clientId + ' in the live tree (read the outline / get-context first)' };
        }

        var existing = typeof attrs.spectraCustomJS === 'string' ? attrs.spectraCustomJS : '';
        var next = (args.append === true && existing !== '') ? existing + '\n' + args.code : args.code;
        dis.updateBlockAttributes(clientId, { spectraCustomJS: next });

        return {
            success: true,
            data: {
                client_id: clientId,
                code: next,
                note: 'Session-scoped: the block\'s JS updates now; persists when the user saves the page.',
            },
        };
    }

    // Register once the bridge is ready (same retry pattern as get-context /
    // apply-change). The react-manager glob auto-enqueues this file.
    function initHandler() {
        if (window.zipwpMcp && window.zipwpMcp.registerTool) {
            window.zipwpMcp.registerTool(
                'editor/get-scripts',
                async function (args) { return handleGetScripts(args); },
                { previewMode: 'client' }
            );
            window.zipwpMcp.registerTool(
                'editor/set-scripts',
                async function (args) { return handleSetScripts(args); },
                { previewMode: 'client' }
            );
        } else {
            setTimeout(initHandler, 100);
        }
    }

    initHandler();

    // Test-only surface (Node/CommonJS) — inert in the browser bundle.
    if (typeof module !== 'undefined' && module.exports) {
        module.exports = {
            handleGetScripts: handleGetScripts,
            handleSetScripts: handleSetScripts,
        };
    }
})();


/**
 * editor/get-styles + editor/set-styles — typed read/patch over the GBS CSS
 * store (the CSS source-of-truth), symmetric with editor/get-context (HTML) and
 * editor/get-scripts (JS). ONE store key, two scopes:
 *
 *   scope:'page'   → post meta `spectra_blocks_pro_gs_user_css` via the GBS
 *                    `/global-styles/save` route (scope:page) — IMMEDIATE write
 *                    (the same SSOT endpoint the importer uses), then live paint.
 *   scope:'global' → the same-named WP OPTION (header/footer/site-wide chrome),
 *                    read via GET /global-styles/user-css and written via the
 *                    shared /global-styles/sitewide merge route — IMMEDIATE +
 *                    site-wide (not reversible by discard).
 *
 * READ → MERGE only the touched buckets → WRITE (never full-replace, so importer
 * chrome + user classes survive), then RENDER the merged payload via the SSOT
 * GenCssRenderer (REST /global-styles/render — no string hacks) and inject it
 * into the canvas iframe for live paint. Cascade tier: GBS classes sit BELOW
 * block attributes and ABOVE block defaults — the renderer emits that specificity.
 *
 * @package zip-ai
 */
(function () {
    var META_KEY = 'spectra_blocks_pro_gs_user_css'; // page post-meta AND global option key
    var NS = '/spectra-blocks/v1/global-styles';

    // Editor-store access (select/dispatch core/editor + session meta) comes from
    // the ONE shared source (editor/shared/editor-shared-utils.js): window in the
    // browser, require() under jest — so the editor handlers can't drift.
    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 editorSelect() { var u = sharedEditorUtils(); return u && u.editorSelect ? u.editorSelect() : null; }
    function apiFetch(opts) {
        if (!(window.wp && window.wp.apiFetch)) {
            return Promise.reject(new Error('wp.apiFetch unavailable'));
        }
        return window.wp.apiFetch(opts);
    }

    // Merge the touched buckets of `incoming` onto `existing` (read-modify-write).
    // Object buckets (classes/wrapperStyles/rootStyles/scopeVars/presetLock/
    // mediaQuery/object-keyframes) merge PER ENTRY — `null` deletes that entry;
    // a `null` whole bucket deletes the bucket. Array buckets (imports / list
    // keyframes) REPLACE. Never wholesale-clobbers a bucket the caller didn't send.
    function mergePayload(existing, incoming) {
        var out = Object.assign({}, existing || {});
        out.v = '1';
        Object.keys(incoming || {}).forEach(function (bucket) {
            if (bucket === 'v') return;
            var inc = incoming[bucket];
            if (inc === null) { delete out[bucket]; return; }
            if (Array.isArray(inc)) { out[bucket] = inc.slice(); return; }
            if (typeof inc !== 'object') return;
            var base = (out[bucket] && typeof out[bucket] === 'object' && !Array.isArray(out[bucket]))
                ? Object.assign({}, out[bucket])
                : {};
            Object.keys(inc).forEach(function (key) {
                if (inc[key] === null) { delete base[key]; } else { base[key] = inc[key]; }
            });
            out[bucket] = base;
        });
        return out;
    }

    // The block-editor canvas runs in an iframe; styles must be injected THERE.
    function canvasDoc() {
        var ifr = document.querySelector('iframe[name="editor-canvas"]');
        return ifr && ifr.contentDocument ? ifr.contentDocument : document;
    }
    function injectCss(elementId, css) {
        var doc = canvasDoc();
        var el = doc.getElementById(elementId);
        if (!el) {
            el = doc.createElement('style');
            el.id = elementId;
            (doc.head || doc.documentElement).appendChild(el);
        }
        el.textContent = css || '';
    }

    function bucketsOf(payload) {
        return Object.keys(payload || {}).filter(function (k) { return k !== 'v'; });
    }

    // ── STYLE CONTEXT (ownership) ──────────────────────────────────────────────
    // The OWNERSHIP MODEL: a visual property is set by ONE of three layers, in
    // descending CSS specificity — a block ATTRIBUTE, a GBS CLASS body, or the
    // block DEFAULT (DEVELOPER-INSTRUCTIONS §5.1). To change a property you edit
    // its current OWNER (update the existing class, don't stack a new one; clear a
    // block attr that pins it). This resolver answers "who owns each property" so
    // the agent never hand-resolves specificity. It does NOT compute the exact
    // frontend winner (the editor canvas inverts utility-vs-gsClass specificity,
    // and utilities are JIT-compiled, not in the GBS payload) — `effective` is the
    // rendered truth and the agent's verify-iterate loop corrects any mis-guess.
    // The property map is small and explicit ON PURPOSE: it is this tool's job.
    // `prop` is the kebab CSS property — it doubles as the GBS-body key (bodies are
    // kebab, e.g. `font-size`) and the getComputedStyle key. Only `attrs` (the
    // block-attribute path(s) for that property) is non-derivable.
    var STYLE_PROPS = [
        { prop: 'color', attrs: ['style.color.text'] },
        { prop: 'background-color', attrs: ['style.color.background', 'background.color'] },
        { prop: 'padding', attrs: ['style.spacing.padding'] },
        { prop: 'margin', attrs: ['style.spacing.margin'] },
        { prop: 'font-size', attrs: ['style.typography.fontSize'] },
        { prop: 'font-weight', attrs: ['style.typography.fontWeight'] },
        { prop: 'text-align', attrs: ['align'] },
        { prop: 'max-width', attrs: ['maxWidth'] },
    ];
    function deepGet(obj, path) {
        var cur = obj;
        var parts = path.split('.');
        for (var i = 0; i < parts.length; i++) {
            if (cur === null || typeof cur !== 'object') return undefined;
            cur = cur[parts[i]];
        }
        return cur;
    }
    function gsTokensOf(block) {
        var cn = (block && block.attributes && typeof block.attributes.className === 'string')
            ? block.attributes.className : '';
        return cn.trim().split(/\s+/).filter(function (t) { return t && t.indexOf('gs-') === 0; });
    }
    function computedForBlock(clientId) {
        try {
            var doc = canvasDoc();
            var el = doc.querySelector('[data-block="' + clientId + '"]');
            if (!el) return null;
            return (doc.defaultView || window).getComputedStyle(el);
        } catch (e) { return null; }
    }
    // Per-property { effective, owner, availableSources[] } for ONE block, joining
    // its gs- class bodies (from the page GBS payload) + its block attributes +
    // the rendered computed value. owner = the highest-specificity layer that
    // declares the property (attr > gbs > default) — a best-guess the verify loop
    // refines; `effective` is always the rendered truth.
    function buildStyleContext(block, pagePayload) {
        if (!block) return null;
        var classes = (pagePayload && pagePayload.classes && typeof pagePayload.classes === 'object')
            ? pagePayload.classes : {};
        var gsTokens = gsTokensOf(block);
        var cs = computedForBlock(block.clientId);
        var attrs = block.attributes || {};
        var properties = {};
        STYLE_PROPS.forEach(function (def) {
            var sources = [];
            // block_attribute tier (highest specificity)
            def.attrs.forEach(function (path) {
                var v = deepGet(attrs, path);
                if (v !== undefined && v !== null && v !== '') {
                    sources.push({ type: 'block_attribute', path: path, value: v });
                }
            });
            // gbs class tier — only classes ACTUALLY on this block, only if they declare it
            gsTokens.forEach(function (token) {
                var body = classes[token] && classes[token].default;
                if (!body || typeof body !== 'object') return;
                var v = body[def.prop];
                if (v !== undefined && v !== null && v !== '') {
                    sources.push({ type: 'gbs', class: token, value: v });
                }
            });
            var owner = sources.length === 0
                ? 'default'
                : (sources[0].type === 'block_attribute'
                    ? 'block_attribute:' + sources[0].path
                    : 'gbs:' + sources[0].class);
            properties[def.prop] = {
                effective: cs ? cs.getPropertyValue(def.prop) : null,
                owner: owner,
                availableSources: sources,
            };
        });
        return { client_id: block.clientId, gbs_classes: gsTokens, properties: properties };
    }

    // ── get-styles ───────────────────────────────────────────────────────────
    async function handleGetStyles(args) {
        var scope = args && args.scope === 'global' ? 'global' : 'page';
        if (scope === 'page') {
            var sel = editorSelect();
            var postId = sel && sel.getCurrentPostId ? sel.getCurrentPostId() : 0;
            if (!postId) {
                return { success: false, error: 'editor_unavailable: no current post id (is the block editor open?)' };
            }
            try {
                var pres = await apiFetch({ path: NS + '/save?scope=page&post_id=' + postId });
                var pp = (pres && pres.payload && typeof pres.payload === 'object') ? pres.payload : {};
                var out = { scope: 'page', post_id: postId, buckets: bucketsOf(pp), payload: pp };
                // STYLE CONTEXT (ownership) for a target block — the agent reads
                // this BEFORE a styling edit so it updates the existing owner
                // instead of guessing/stacking. Best-effort: a missing block /
                // unmounted node simply omits styleContext (never blocks the read).
                var clientId = args && typeof args.client_id === 'string' ? args.client_id : null;
                if (clientId && sel && sel.getBlock) {
                    var blk = sel.getBlock(clientId);
                    var ctx = buildStyleContext(blk, pp);
                    if (ctx) out.styleContext = ctx;
                }
                return { success: true, data: out };
            } catch (e) {
                return { success: false, error: 'page_read_failed: ' + String(e && e.message ? e.message : e) };
            }
        }
        try {
            var res = await apiFetch({ path: NS + '/user-css' });
            var gp = (res && res.payload && typeof res.payload === 'object') ? res.payload : {};
            return { success: true, data: { scope: 'global', buckets: bucketsOf(gp), payload: gp } };
        } catch (e) {
            return { success: false, error: 'global_read_failed: ' + String(e && e.message ? e.message : e) };
        }
    }

    // ── set-styles ───────────────────────────────────────────────────────────
    async function handleSetStyles(args) {
        var scope = args && (args.scope === 'page' || args.scope === 'global') ? args.scope : null;
        var incoming = args && args.payload;
        if (scope === null) {
            return { success: false, error: 'invalid_input: scope must be "page" or "global"' };
        }
        if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
            return { success: false, error: 'invalid_input: payload must be a schema-v1 object of style buckets' };
        }
        if (!bucketsOf(incoming).length) {
            return { success: false, error: 'invalid_input: payload has no style buckets (classes / wrapperStyles / rootStyles / …)' };
        }

        if (scope === 'page') {
            var sel = editorSelect();
            var postId = sel && sel.getCurrentPostId ? sel.getCurrentPostId() : 0;
            if (!postId) {
                return { success: false, error: 'editor_unavailable: no current post id (is the block editor open?)' };
            }

            // Read-modify-write the per-page GBS store through the SSOT /save route
            // (the same endpoint the importer uses) — IMMEDIATE, no editPost session.
            var existing;
            try {
                var g = await apiFetch({ path: NS + '/save?scope=page&post_id=' + postId });
                existing = (g && g.payload && typeof g.payload === 'object') ? g.payload : {};
            } catch (e) {
                return { success: false, error: 'page_read_failed: ' + String(e && e.message ? e.message : e) };
            }
            var merged = mergePayload(existing, incoming);
            try {
                await apiFetch({ path: NS + '/save', method: 'POST', data: { scope: 'page', post_id: postId, payload: merged, replace: true } });
            } catch (e) {
                return { success: false, error: 'page_write_failed: ' + String(e && e.message ? e.message : e) };
            }

            // Live paint: render the merged payload (SSOT) and replace the page CSS element.
            try {
                var r = await apiFetch({ path: NS + '/render', method: 'POST', data: { payload: merged, post_id: postId, scope: 'page' } });
                injectCss('spectra-gen-custom-css-' + postId + '-inline-css', r && r.css);
            } catch (e) {
                return { success: false, error: 'render_failed: ' + String(e && e.message ? e.message : e) };
            }
            return {
                success: true,
                data: {
                    scope: 'page',
                    post_id: postId,
                    buckets: bucketsOf(incoming),
                    note: 'Written immediately via the GBS /save route; painted live.',
                },
            };
        }

        // scope === 'global' — read-modify-write the option (immediate, site-wide).
        var existingGlobal;
        try {
            var g = await apiFetch({ path: NS + '/user-css' });
            existingGlobal = (g && g.payload && typeof g.payload === 'object') ? g.payload : {};
        } catch (e) {
            return { success: false, error: 'global_read_failed: ' + String(e && e.message ? e.message : e) };
        }
        var mergedGlobal = mergePayload(existingGlobal, incoming);
        try {
            // /sitewide replaces non-class buckets wholesale → send the FULL merged
            // payload so the write equals the merged state (chrome/user classes kept).
            await apiFetch({ path: NS + '/sitewide', method: 'POST', data: { payload: mergedGlobal } });
        } catch (e) {
            return { success: false, error: 'global_write_failed: ' + String(e && e.message ? e.message : e) };
        }
        try {
            var rg = await apiFetch({ path: NS + '/render', method: 'POST', data: { payload: mergedGlobal, post_id: 0, scope: 'global' } });
            // Append-last override so the live global paint wins source-order ties
            // (deletions converge on reload, consistent with the live-JIT model).
            injectCss('zipwp-gbs-live-global', rg && rg.css);
        } catch (e) {
            return { success: false, error: 'render_failed: ' + String(e && e.message ? e.message : e) };
        }
        return {
            success: true,
            data: {
                scope: 'global',
                buckets: bucketsOf(incoming),
                note: 'Site-wide + IMMEDIATE: applied to every page now (not reversible by discarding the editor).',
            },
        };
    }

    function initHandler() {
        if (window.zipwpMcp && window.zipwpMcp.registerTool) {
            window.zipwpMcp.registerTool('editor/get-styles', async function (args) { return handleGetStyles(args); }, { previewMode: 'client' });
            window.zipwpMcp.registerTool('editor/set-styles', async function (args) { return handleSetStyles(args); }, { previewMode: 'client' });
        } else {
            setTimeout(initHandler, 100);
        }
    }
    initHandler();

    // Test-only surface (Node/CommonJS) — inert in the browser bundle.
    if (typeof module !== 'undefined' && module.exports) {
        module.exports = {
            handleGetStyles: handleGetStyles,
            handleSetStyles: handleSetStyles,
            mergePayload: mergePayload,
            // Ownership resolver (per-property effective/owner/availableSources).
            // Exported PURE so a unit test locks the join, not a reimplementation.
            buildStyleContext: buildStyleContext,
        };
    }
})();


/**
 * Block Context Picker
 *
 * Adds a "+" pin button on block hover in Gutenberg editor.
 * Selected blocks are sent as context to the chat assistant.
 * Supports both classic and iframed block editor (WP 6.3+).
 *
 * @since x.x.x
 */

(function() {
    'use strict';

    /** SVG for the "+" (add) icon */
    var ICON_ADD = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';

    /** SVG for the checkmark (pinned) icon */
    var ICON_CHECK = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';

    function BlockContextPicker() {
        /** @type {Map<string, Object>} clientId → serialized block data */
        this.contextBlocks = new Map();

        /** @type {HTMLElement|null} Single floating pin button */
        this.pinButton = null;

        /** @type {string|null} clientId of the block currently being hovered */
        this.hoveredClientId = null;

        /** @type {boolean} Whether the picker is currently active */
        this.active = false;

        /** @type {Function|null} wp.data.subscribe unsubscribe handle */
        this._unsubscribeBlockDeletion = null;

        /** @type {Document} The document where blocks live (parent or iframe) */
        this._editorDocument = document;

        /** @type {number} Last known block count for quick deletion check */
        this._lastBlockCount = 0;

        /** @type {Function|null} Bound handler references for cleanup */
        var self = this;
        this._onMouseOver = function(e) { self._handleMouseOver(e); };
        this._onMouseOut = function(e) { self._handleMouseOut(e); };
        this._onPinClick = function(e) { self._handlePinClick(e); };
    }

    /**
     * Enable the picker — called when sidebar opens.
     *
     * @since x.x.x
     */
    BlockContextPicker.prototype.enable = function() {
        if (this.active) return;

        if (!window.wp || !window.wp.data || !window.wp.data.select('core/block-editor')) {
            return;
        }

        this.active = true;
        this._resolveEditorDocument();
        this._createPinButton();
        this._bindEditorEvents();
        this._watchBlockDeletion();
    };

    /**
     * Disable the picker — called when sidebar closes.
     *
     * @since x.x.x
     */
    BlockContextPicker.prototype.disable = function() {
        if (!this.active) return;

        this.active = false;
        this._removePinButton();
        this._unbindEditorEvents();
        this._unwatchBlockDeletion();
        this.hoveredClientId = null;
    };

    /**
     * Clear all pinned context blocks.
     *
     * @since x.x.x
     */
    BlockContextPicker.prototype.clear = function() {
        this.contextBlocks.clear();
        this.hoveredClientId = null;
        this._removeAllHighlights();
        this._updateBadge();
    };

    /**
     * Get all context blocks as an array for the chat context payload.
     *
     * @since x.x.x
     * @return {Array<Object>}
     */
    BlockContextPicker.prototype.getContextBlocks = function() {
        return Array.from(this.contextBlocks.values());
    };

    /**
     * Check if any blocks are pinned.
     *
     * @since x.x.x
     * @return {boolean}
     */
    BlockContextPicker.prototype.hasContextBlocks = function() {
        return this.contextBlocks.size > 0;
    };

    // ── Editor document resolution (iframe support) ─────────────────

    /**
     * Detect whether the block editor uses an iframe canvas (WP 6.3+).
     *
     * @since x.x.x
     */
    BlockContextPicker.prototype._resolveEditorDocument = function() {
        var editorIframe = document.querySelector('iframe[name="editor-canvas"]');
        if (editorIframe && editorIframe.contentDocument) {
            this._editorDocument = editorIframe.contentDocument;
        } else {
            this._editorDocument = document;
        }
    };

    // ── DOM — pin button ────────────────────────────────────────────

    /**
     * Create the floating pin button element.
     *
     * @since x.x.x
     */
    BlockContextPicker.prototype._createPinButton = function() {
        if (this.pinButton) return;

        var doc = this._editorDocument;
        var btn = doc.createElement('button');
        btn.className = 'zipwp-context-pin';
        btn.type = 'button';
        btn.setAttribute('aria-label', 'Pin block to chat context');
        btn.innerHTML = ICON_ADD;

        btn.addEventListener('click', this._onPinClick);
        doc.body.appendChild(btn);
        this.pinButton = btn;

        // Inject styles into iframe editor if needed
        if (doc !== document) {
            this._injectIframeStyles(doc);
        }
    };

    /**
     * Inject context picker CSS into the editor iframe document.
     *
     * @since x.x.x
     * @param {Document} doc
     */
    BlockContextPicker.prototype._injectIframeStyles = function(doc) {
        if (doc.getElementById('zipwp-context-picker-styles')) return;

        var style = doc.createElement('style');
        style.id = 'zipwp-context-picker-styles';
        style.textContent =
            '.zipwp-context-pin{display:none;position:absolute;z-index:100000;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:none;border-radius:6px;background:#6366f1;color:#fff;cursor:pointer;box-shadow:0 2px 8px rgba(99,102,241,.35);transition:background-color .15s ease,transform .15s ease,box-shadow .15s ease}' +
            '.zipwp-context-pin:hover{background:#4f46e5;transform:scale(1.1);box-shadow:0 4px 12px rgba(99,102,241,.45)}' +
            '.zipwp-context-pin svg{width:16px;height:16px;display:block;pointer-events:none}' +
            '.zipwp-context-pin--active{background:#16a34a}' +
            '.zipwp-context-pin--active:hover{background:#dc2626}' +
            '.zipwp-context-selected{box-shadow:0 0 0 2px #6366f1 !important;border-radius:2px}';
        doc.head.appendChild(style);
    };

    /**
     * Remove the pin button from DOM.
     *
     * @since x.x.x
     */
    BlockContextPicker.prototype._removePinButton = function() {
        if (this.pinButton) {
            this.pinButton.removeEventListener('click', this._onPinClick);
            this.pinButton.remove();
            this.pinButton = null;
        }
    };

    /**
     * Position the pin button next to the hovered block.
     *
     * @since x.x.x
     * @param {HTMLElement} blockEl
     */
    BlockContextPicker.prototype._positionPinButton = function(blockEl) {
        if (!this.pinButton) return;

        var doc = this._editorDocument;
        var rect = blockEl.getBoundingClientRect();
        var scrollTop = doc.defaultView.scrollY || doc.documentElement.scrollTop;
        var scrollLeft = doc.defaultView.scrollX || doc.documentElement.scrollLeft;

        this.pinButton.style.top = (rect.top + scrollTop + 4) + 'px';
        this.pinButton.style.left = (rect.right + scrollLeft - 36) + 'px';
        this.pinButton.style.display = 'flex';

        // Update icon for pinned state
        var clientId = blockEl.dataset.block;
        if (clientId && this.contextBlocks.has(clientId)) {
            this.pinButton.classList.add('zipwp-context-pin--active');
            this.pinButton.innerHTML = ICON_CHECK;
            this.pinButton.setAttribute('aria-label', 'Unpin block from chat context');
        } else {
            this.pinButton.classList.remove('zipwp-context-pin--active');
            this.pinButton.innerHTML = ICON_ADD;
            this.pinButton.setAttribute('aria-label', 'Pin block to chat context');
        }
    };

    /**
     * Hide the pin button.
     *
     * @since x.x.x
     */
    BlockContextPicker.prototype._hidePinButton = function() {
        if (this.pinButton) {
            this.pinButton.style.display = 'none';
        }
    };

    // ── Events — hover delegation ───────────────────────────────────

    /**
     * Bind mouseover/mouseout on the editor canvas via delegation.
     *
     * @since x.x.x
     */
    BlockContextPicker.prototype._bindEditorEvents = function() {
        var doc = this._editorDocument;
        doc.addEventListener('mouseover', this._onMouseOver, true);
        doc.addEventListener('mouseout', this._onMouseOut, true);
    };

    /**
     * Unbind editor hover events.
     *
     * @since x.x.x
     */
    BlockContextPicker.prototype._unbindEditorEvents = function() {
        var doc = this._editorDocument;
        doc.removeEventListener('mouseover', this._onMouseOver, true);
        doc.removeEventListener('mouseout', this._onMouseOut, true);
    };

    /**
     * Handle mouseover — find the closest block wrapper.
     *
     * @since x.x.x
     * @param {MouseEvent} e
     */
    BlockContextPicker.prototype._handleMouseOver = function(e) {
        if (!this.active) return;

        if (this.pinButton && this.pinButton.contains(e.target)) return;

        var blockEl = e.target.closest('[data-block]');
        if (!blockEl) return;

        var clientId = blockEl.dataset.block;
        if (!clientId || clientId === this.hoveredClientId) return;

        this.hoveredClientId = clientId;
        this._positionPinButton(blockEl);
    };

    /**
     * Handle mouseout — hide pin when cursor leaves all blocks.
     *
     * @since x.x.x
     * @param {MouseEvent} e
     */
    BlockContextPicker.prototype._handleMouseOut = function(e) {
        if (!this.active) return;

        if (this.pinButton && this.pinButton.contains(e.relatedTarget)) return;

        var relatedBlock = e.relatedTarget && e.relatedTarget.closest
            ? e.relatedTarget.closest('[data-block]')
            : null;
        if (relatedBlock) return;

        this.hoveredClientId = null;
        this._hidePinButton();
    };

    /**
     * Handle click on the pin button — toggle block in context.
     *
     * @since x.x.x
     * @param {MouseEvent} e
     */
    BlockContextPicker.prototype._handlePinClick = function(e) {
        e.preventDefault();
        e.stopPropagation();

        var clientId = this.hoveredClientId;
        if (!clientId) return;

        if (this.contextBlocks.has(clientId)) {
            // Unpin
            this.contextBlocks.delete(clientId);
            this._removeHighlight(clientId);
            if (this.pinButton) {
                this.pinButton.classList.remove('zipwp-context-pin--active');
                this.pinButton.innerHTML = ICON_ADD;
                this.pinButton.setAttribute('aria-label', 'Pin block to chat context');
            }
        } else {
            // Pin
            var blockData = this._serializeBlockForContext(clientId);
            if (blockData) {
                this.contextBlocks.set(clientId, blockData);
                this._addHighlight(clientId);
                if (this.pinButton) {
                    this.pinButton.classList.add('zipwp-context-pin--active');
                    this.pinButton.innerHTML = ICON_CHECK;
                    this.pinButton.setAttribute('aria-label', 'Unpin block from chat context');
                }
            }
        }

        this._updateBadge();
        this._notifyContextChanged();
    };

    // ── Bridge notification ─────────────────────────────────────────

    /**
     * Notify the React app that context blocks changed.
     * Emits event on the React DirectBridge so components can react.
     *
     * @since x.x.x
     */
    BlockContextPicker.prototype._notifyContextChanged = function() {
        var appBridge = window.zipwpMcpAppBridge;
        if (appBridge && appBridge.emit) {
            appBridge.emit('context_blocks_changed', {
                context_blocks: this.getContextBlocks(),
                count: this.contextBlocks.size,
            });
        }
    };

    // ── Block serialization ─────────────────────────────────────────

    /**
     * Serialize a block for chat context.
     *
     * @since x.x.x
     * @param {string} clientId
     * @return {Object|null}
     */
    BlockContextPicker.prototype._serializeBlockForContext = function(clientId) {
        if (!window.wp || !window.wp.data || !window.wp.blocks) return null;

        var blockEditorSelect = window.wp.data.select('core/block-editor');
        var block = blockEditorSelect.getBlock(clientId);
        if (!block) return null;

        var blocksHtml = '';
        try {
            blocksHtml = window.wp.blocks.serialize([block]);
            var bridge = window.zipwpMcpBridge;
            if (bridge && bridge.fixUnicodeEscapes) {
                blocksHtml = bridge.fixUnicodeEscapes(blocksHtml);
            }
        } catch (e) {
            console.warn('BlockContextPicker: Failed to serialize block', e);
        }

        var rootClientId = blockEditorSelect.getBlockRootClientId(clientId);
        var blockIndex = blockEditorSelect.getBlockIndex(clientId);

        return {
            clientId: block.clientId,
            name: block.name,
            blocks_html: blocksHtml,
            block_index: blockIndex,
            root_client_id: rootClientId || null,
        };
    };

    // ── Visual feedback — block highlights ──────────────────────────

    /**
     * @since x.x.x
     */
    BlockContextPicker.prototype._addHighlight = function(clientId) {
        var blockEl = this._editorDocument.querySelector('[data-block="' + clientId + '"]');
        if (blockEl) blockEl.classList.add('zipwp-context-selected');
    };

    /**
     * @since x.x.x
     */
    BlockContextPicker.prototype._removeHighlight = function(clientId) {
        var blockEl = this._editorDocument.querySelector('[data-block="' + clientId + '"]');
        if (blockEl) blockEl.classList.remove('zipwp-context-selected');
    };

    /**
     * @since x.x.x
     */
    BlockContextPicker.prototype._removeAllHighlights = function() {
        var els = this._editorDocument.querySelectorAll('.zipwp-context-selected');
        for (var i = 0; i < els.length; i++) {
            els[i].classList.remove('zipwp-context-selected');
        }
    };

    // ── Badge on FAB trigger button ─────────────────────────────────

    /**
     * @since x.x.x
     */
    BlockContextPicker.prototype._updateBadge = function() {
        var trigger = document.getElementById('zip-ai-floating-trigger');
        if (!trigger) return;

        var badge = trigger.querySelector('.zipwp-context-badge');
        var count = this.contextBlocks.size;

        if (count === 0) {
            if (badge) badge.remove();
            return;
        }

        if (!badge) {
            badge = document.createElement('span');
            badge.className = 'zipwp-context-badge';
            trigger.appendChild(badge);
        }

        badge.textContent = count;
    };

    // ── Block deletion watcher ──────────────────────────────────────

    /**
     * @since x.x.x
     */
    BlockContextPicker.prototype._watchBlockDeletion = function() {
        if (!window.wp || !window.wp.data) return;

        var self = this;
        var subscribe = window.wp.data.subscribe;
        var blockEditorSelect = window.wp.data.select('core/block-editor');

        this._lastBlockCount = blockEditorSelect.getGlobalBlockCount
            ? blockEditorSelect.getGlobalBlockCount()
            : blockEditorSelect.getBlocks().length;

        this._unsubscribeBlockDeletion = subscribe(function() {
            if (self.contextBlocks.size === 0) return;

            var currentCount = blockEditorSelect.getGlobalBlockCount
                ? blockEditorSelect.getGlobalBlockCount()
                : blockEditorSelect.getBlocks().length;

            if (currentCount >= self._lastBlockCount) {
                self._lastBlockCount = currentCount;
                return;
            }

            self._lastBlockCount = currentCount;

            var currentBlockIds = self._getAllBlockClientIds();
            var changed = false;

            // Use Array.from to avoid iterator issues in older environments
            var pinnedIds = Array.from(self.contextBlocks.keys());
            for (var i = 0; i < pinnedIds.length; i++) {
                if (!currentBlockIds.has(pinnedIds[i])) {
                    self.contextBlocks.delete(pinnedIds[i]);
                    self._removeHighlight(pinnedIds[i]);
                    changed = true;
                }
            }

            if (changed) {
                self._updateBadge();
                self._notifyContextChanged();
            }
        });
    };

    /**
     * @since x.x.x
     */
    BlockContextPicker.prototype._unwatchBlockDeletion = function() {
        if (this._unsubscribeBlockDeletion) {
            this._unsubscribeBlockDeletion();
            this._unsubscribeBlockDeletion = null;
        }
    };

    /**
     * Get a Set of all block clientIds in the editor.
     *
     * @since x.x.x
     * @return {Set<string>}
     */
    BlockContextPicker.prototype._getAllBlockClientIds = function() {
        var ids = new Set();
        if (!window.wp || !window.wp.data) return ids;

        var blockEditorSelect = window.wp.data.select('core/block-editor');
        if (!blockEditorSelect) return ids;

        var collectIds = function(blocks) {
            for (var i = 0; i < blocks.length; i++) {
                ids.add(blocks[i].clientId);
                if (blocks[i].innerBlocks && blocks[i].innerBlocks.length > 0) {
                    collectIds(blocks[i].innerBlocks);
                }
            }
        };

        collectIds(blockEditorSelect.getBlocks());
        return ids;
    };

    // Expose singleton on window
    window.zipwpMcpContextPicker = new BlockContextPicker();

})();


/**
 * Popover Drag Manager
 *
 * Handles drag-to-reposition for the popover panel mode.
 * Position persists in localStorage so it stays where the user left it.
 *
 * Used by WPBridgeHost — not standalone.
 */

/* global zipwpMcpBridge */

(function() {
    'use strict';

    var STORAGE_KEY = 'zipwp-popover-position';
    var SIZE_STORAGE_KEY = 'zipwp-popover-size';
    var CONTAINER_ID = 'zip-ai-assistant-container';
    var DRAG_HANDLE_SELECTOR = '[data-popover-drag]';
    var RESIZE_HANDLE_SELECTOR = '[data-popover-resize]';
    var INTERACTIVE_SELECTOR = 'button, input, textarea, a, [role="menuitem"], [data-radix-collection-item]';
    var DRAG_THRESHOLD = 4; // px — ignore tiny accidental drags
    var MIN_WIDTH = 360;
    var MIN_HEIGHT = 400;
    var MAX_WIDTH_MARGIN = 40; // min margin from viewport edge
    var MAX_HEIGHT_MARGIN = 60;

    /**
     * Sets up drag listeners on the container.
     * Called once from WPBridgeHost.setup().
     */
    function setupPopoverDrag(getBridgeLayout) {
        var container = document.getElementById(CONTAINER_ID);
        if (!container) return;

        injectResizeHandles(container);

        var isDragging = false;
        var didMove = false;
        var startX, startY, startLeft, startTop;
        var isResizing = false;
        var resizeEdges = null;
        var startWidth, startHeight;

        function onMouseMove(e) {
            var dx = e.clientX - startX;
            var dy = e.clientY - startY;

            // Ignore until threshold is met
            if (!didMove && Math.abs(dx) + Math.abs(dy) < DRAG_THRESHOLD) return;
            didMove = true;

            var newLeft = startLeft + dx;
            var newTop = startTop + dy;

            // Clamp to viewport
            var cw = container.offsetWidth;
            var ch = container.offsetHeight;
            newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - cw));
            newTop = Math.max(0, Math.min(newTop, window.innerHeight - ch));

            container.style.left = newLeft + 'px';
            container.style.top = newTop + 'px';
            container.style.right = 'auto';
            container.style.bottom = 'auto';
        }

        function onMouseUp() {
            if (!isDragging) return;
            isDragging = false;
            document.body.style.userSelect = '';
            container.style.transition = '';
            document.removeEventListener('mousemove', onMouseMove);
            document.removeEventListener('mouseup', onMouseUp);
            // Persist position only if the user actually moved
            if (didMove) {
                savePosition();
            }
        }

        container.addEventListener('mousedown', function(e) {
            if (getBridgeLayout() !== 'popover') return;

            // Resize takes priority over drag
            var resizeHandle = e.target.closest(RESIZE_HANDLE_SELECTOR);
            if (resizeHandle) {
                isResizing = true;
                resizeEdges = resizeHandle.getAttribute('data-popover-resize');
                var rect = container.getBoundingClientRect();
                startX = e.clientX;
                startY = e.clientY;
                startLeft = rect.left;
                startTop = rect.top;
                startWidth = rect.width;
                startHeight = rect.height;

                document.body.style.userSelect = 'none';
                container.style.transition = 'none';
                e.preventDefault();
                e.stopPropagation();

                document.addEventListener('mousemove', onResizeMove);
                document.addEventListener('mouseup', onResizeEnd);
                return;
            }

            // Only drag from header area
            var dragHandle = e.target.closest(DRAG_HANDLE_SELECTOR);
            if (!dragHandle) return;
            // Don't drag when clicking interactive elements
            if (e.target.closest(INTERACTIVE_SELECTOR)) return;

            isDragging = true;
            didMove = false;
            var dragRect = container.getBoundingClientRect();
            startX = e.clientX;
            startY = e.clientY;
            startLeft = dragRect.left;
            startTop = dragRect.top;

            document.body.style.userSelect = 'none';
            container.style.transition = 'none';
            e.preventDefault();

            document.addEventListener('mousemove', onMouseMove);
            document.addEventListener('mouseup', onMouseUp);
        });

        function onResizeMove(e) {
            var dx = e.clientX - startX;
            var dy = e.clientY - startY;
            var maxW = window.innerWidth - MAX_WIDTH_MARGIN;
            var maxH = window.innerHeight - MAX_HEIGHT_MARGIN;
            var newWidth = startWidth;
            var newHeight = startHeight;
            var newLeft = startLeft;
            var newTop = startTop;

            if (resizeEdges.indexOf('e') !== -1) {
                newWidth = clamp(startWidth + dx, MIN_WIDTH, maxW);
            }
            if (resizeEdges.indexOf('w') !== -1) {
                newWidth = clamp(startWidth - dx, MIN_WIDTH, maxW);
                newLeft = startLeft + (startWidth - newWidth);
            }
            if (resizeEdges.indexOf('s') !== -1) {
                newHeight = clamp(startHeight + dy, MIN_HEIGHT, maxH);
            }
            if (resizeEdges.indexOf('n') !== -1) {
                newHeight = clamp(startHeight - dy, MIN_HEIGHT, maxH);
                newTop = startTop + (startHeight - newHeight);
            }

            container.style.setProperty('--zipwp-popover-w', newWidth + 'px');
            container.style.setProperty('--zipwp-popover-h', newHeight + 'px');
            container.style.setProperty('--zipwp-popover-max-h', 'none');
            if (resizeEdges.indexOf('w') !== -1 || resizeEdges.indexOf('n') !== -1 || container.style.left) {
                container.style.left = newLeft + 'px';
                container.style.top = newTop + 'px';
                container.style.right = 'auto';
                container.style.bottom = 'auto';
            }
        }

        function onResizeEnd() {
            if (!isResizing) return;
            isResizing = false;
            resizeEdges = null;
            document.body.style.userSelect = '';
            container.style.transition = '';
            document.removeEventListener('mousemove', onResizeMove);
            document.removeEventListener('mouseup', onResizeEnd);
            saveSize();
            if (container.style.left) savePosition();
        }
    }

    function clamp(v, lo, hi) {
        return Math.max(lo, Math.min(hi, v));
    }

    /**
     * Inject resize handle DOM elements — 4 edges + 4 corners.
     * Only visible in popover mode (CSS-gated by body class).
     */
    function injectResizeHandles(container) {
        if (container.querySelector(RESIZE_HANDLE_SELECTOR)) return;
        var edges = ['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se'];
        edges.forEach(function(edge) {
            var el = document.createElement('div');
            el.setAttribute('data-popover-resize', edge);
            el.className = 'zipwp-popover-resize zipwp-popover-resize--' + edge;
            container.appendChild(el);
        });
    }

    function saveSize() {
        var container = document.getElementById(CONTAINER_ID);
        if (!container) return;
        try {
            localStorage.setItem(SIZE_STORAGE_KEY, JSON.stringify({
                width: container.offsetWidth,
                height: container.offsetHeight
            }));
        } catch (e) {}
    }

    function restoreSize() {
        var container = document.getElementById(CONTAINER_ID);
        if (!container) return;
        try {
            var raw = localStorage.getItem(SIZE_STORAGE_KEY);
            if (!raw) return;
            var size = JSON.parse(raw);
            if (!size || typeof size.width !== 'number' || typeof size.height !== 'number') return;
            var maxW = window.innerWidth - MAX_WIDTH_MARGIN;
            var maxH = window.innerHeight - MAX_HEIGHT_MARGIN;
            var w = clamp(size.width, MIN_WIDTH, maxW);
            var h = clamp(size.height, MIN_HEIGHT, maxH);
            container.style.setProperty('--zipwp-popover-w', w + 'px');
            container.style.setProperty('--zipwp-popover-h', h + 'px');
            container.style.setProperty('--zipwp-popover-max-h', 'none');
        } catch (e) {}
    }

    function clearSize() {
        var container = document.getElementById(CONTAINER_ID);
        if (!container) return;
        container.style.removeProperty('--zipwp-popover-w');
        container.style.removeProperty('--zipwp-popover-h');
        container.style.removeProperty('--zipwp-popover-max-h');
        try { localStorage.removeItem(SIZE_STORAGE_KEY); } catch (e) {}
    }

    /**
     * Save current container position to localStorage.
     */
    function savePosition() {
        var container = document.getElementById(CONTAINER_ID);
        if (!container || !container.style.left) return;
        try {
            localStorage.setItem(STORAGE_KEY, JSON.stringify({
                left: parseInt(container.style.left, 10),
                top: parseInt(container.style.top, 10)
            }));
        } catch (e) {}
    }

    /**
     * Restore saved position from localStorage, clamped to current viewport.
     */
    function restorePosition() {
        var container = document.getElementById(CONTAINER_ID);
        if (!container) return;
        // Restore size first so clamping uses the restored dimensions
        restoreSize();
        try {
            var raw = localStorage.getItem(STORAGE_KEY);
            if (!raw) return;
            var pos = JSON.parse(raw);
            if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number') return;

            var left = pos.left;
            var top = pos.top;
            var cw = container.offsetWidth || 420;
            var ch = container.offsetHeight || 620;

            // Clamp to viewport
            if (left + cw > window.innerWidth) left = window.innerWidth - cw;
            if (top + ch > window.innerHeight) top = window.innerHeight - ch;
            if (left < 0) left = 0;
            if (top < 0) top = 0;

            container.style.left = left + 'px';
            container.style.top = top + 'px';
            container.style.right = 'auto';
            container.style.bottom = 'auto';
        } catch (e) {}
    }

    /**
     * Clear saved position and inline styles from the container.
     */
    function clearPosition() {
        var container = document.getElementById(CONTAINER_ID);
        if (!container) return;
        container.style.left = '';
        container.style.top = '';
        container.style.right = '';
        container.style.bottom = '';
        try { localStorage.removeItem(STORAGE_KEY); } catch (e) {}
        // Note: size intentionally NOT cleared here — user's custom popover
        // size persists across mode switches. Only resetLayout() wipes size.
    }

    /**
     * Reset popover to default size and position.
     * Removes CSS vars + inline position styles + clears storage.
     */
    function resetLayout() {
        var container = document.getElementById(CONTAINER_ID);
        if (!container) return;
        container.style.left = '';
        container.style.top = '';
        container.style.right = '';
        container.style.bottom = '';
        container.style.removeProperty('--zipwp-popover-w');
        container.style.removeProperty('--zipwp-popover-h');
        container.style.removeProperty('--zipwp-popover-max-h');
        try {
            localStorage.removeItem(STORAGE_KEY);
            localStorage.removeItem(SIZE_STORAGE_KEY);
        } catch (e) {}
    }

    // Expose for WPBridgeHost
    window.zipwpPopoverDrag = {
        setup: setupPopoverDrag,
        restorePosition: restorePosition,
        clearPosition: clearPosition,
        resetLayout: resetLayout
    };

})();


/**
 * ZipWP MCP — pure js_rpc dispatch-dedup decision (B-1 / P5).
 *
 * The bridge's executeTools loop owns the seen-map I/O (sessionStorage-backed,
 * FIFO-bounded). THIS module is the pure DECISION over a prior seen entry, so the
 * idempotency logic — the silent-until-it-bites duplicate-content path — is
 * unit-tested instead of living only inline in the browser IIFE.
 *
 * Decisions, given the prior _rpcSeen entry for a js_rpc call_id (or undefined):
 *   - a prior entry (IN-FLIGHT or COMPLETED) → repost_cached: NEVER re-dispatch
 *     (re-running the handler double-applies the mutation); re-POST the stored
 *     reply so a still-waiting brain BRPOP resolves.
 *   - no prior entry → run: the caller records IN_FLIGHT_MARKER BEFORE the handler
 *     mutates the tree, so a crash BETWEEN the mutation and the completion-record
 *     still leaves a marker. A replay then reposts an uncertain (ok:false) reply
 *     and the brain verifies with get_context before any retry — never a blind
 *     re-dispatch.
 *
 * Dual-mode: window.zipwpRpcDedup in the browser; CommonJS for jest.
 *
 * @package zip-ai
 */
(function () {
    'use strict';

    // The reply a replay receives while the first run is mid-apply, or after a
    // crash before the completion-record. ok:false → the brain treats it as an
    // unconfirmed apply (verify-before-retry), never a blind re-apply.
    var IN_FLIGHT_MARKER = { ok: false, data: undefined, error: 'apply_pending_no_confirmation' };

    function decideJsRpcDispatch(seenEntry) {
        if (seenEntry) {
            return { action: 'repost_cached', reply: seenEntry };
        }
        return { action: 'run', inFlightMarker: IN_FLIGHT_MARKER };
    }

    if (typeof window !== 'undefined') {
        window.zipwpRpcDedup = window.zipwpRpcDedup || {};
        window.zipwpRpcDedup.decideJsRpcDispatch = decideJsRpcDispatch;
        window.zipwpRpcDedup.IN_FLIGHT_MARKER = IN_FLIGHT_MARKER;
    }
    if (typeof module !== 'undefined' && module.exports) {
        module.exports = {
            decideJsRpcDispatch: decideJsRpcDispatch,
            IN_FLIGHT_MARKER: IN_FLIGHT_MARKER,
        };
    }
})();


/**
 * WordPress Bridge Host
 *
 * Same-window host bridge for the mounted React assistant.
 * Exposes window.zipwpMcpBridge for the React DirectBridge to call.
 */

(function() {
    'use strict';

    // Block attribute keys that hold human-readable text (used by buildPageOutline
    // to separate copy from config/style attrs).
    var TEXT_KEYS = ['content', 'text', 'title', 'label', 'heading', 'question'];

    // B1 capability handshake — this bundle's browser-RPC protocol version,
    // stamped into every editor context snapshot (getEditorContext). Version 1 =
    // executes `js_rpc` envelopes for the editor/* handlers and POSTs replies to
    // /agent/rpc-reply with {call_id, session_id, ok, data?, error?}. The brain
    // refuses to route a turn to its AgentBrowserLoop unless it sees rpc >= 1, so
    // bump this ONLY when the dispatch/reply contract changes incompatibly.
    var RPC_PROTOCOL_VERSION = 1;

    // B-1 idempotency — a js_rpc apply mutates the live block tree BEFORE it
    // POSTs its reply, so a SECOND dispatch of the SAME tool_use call_id (an SSE
    // replay, a brain turn-replay after a mid-turn restart, OR hydrateSession's
    // replay_events after a page reload) would apply the mutation twice and
    // silently duplicate content. We record every js_rpc call_id we have already
    // executed, with the reply we produced; an exact replay skips the handler
    // entirely and just re-POSTs the cached reply (the brain may still be
    // BRPOP-waiting on it). Bounded FIFO so it can't grow unbounded.
    //
    // M3 — backed by sessionStorage (per-tab, survives a reload) because the
    // duplicate-apply hazard's worst case is exactly a mid-turn reload: the user
    // saves, reloads, hydrateSession replays the turn's tool_call_result events
    // through the SAME handlers, and a memory-only map is gone. Degrades to the
    // in-memory map when sessionStorage is unavailable (quota / privacy mode) —
    // never throws into the dispatch path.
    var RPC_DEDUP_MAX = 64;
    var RPC_DEDUP_STORAGE_KEY = 'zipwpRpcSeen.v1';
    var _rpcSeen = loadRpcSeen(); // call_id -> { ok, data, error }
    function loadRpcSeen() {
        try {
            var raw = window.sessionStorage && window.sessionStorage.getItem(RPC_DEDUP_STORAGE_KEY);
            if (raw) {
                var entries = JSON.parse(raw);
                if (Array.isArray(entries)) return new Map(entries);
            }
        } catch (e) { /* corrupt/unavailable storage → start fresh */ }
        return new Map();
    }
    function saveRpcSeen() {
        try {
            if (!window.sessionStorage) return;
            window.sessionStorage.setItem(
                RPC_DEDUP_STORAGE_KEY,
                JSON.stringify(Array.from(_rpcSeen.entries()))
            );
        } catch (e) { /* quota/unavailable → memory-only behaviour, same as before */ }
    }
    function rpcSeenGet(callId) {
        return callId ? _rpcSeen.get(callId) : undefined;
    }
    function rpcSeenRemember(callId, ok, data, error) {
        if (!callId) return;
        if (_rpcSeen.has(callId)) _rpcSeen.delete(callId);
        _rpcSeen.set(callId, { ok: ok, data: data, error: error });
        while (_rpcSeen.size > RPC_DEDUP_MAX) {
            _rpcSeen.delete(_rpcSeen.keys().next().value);
        }
        saveRpcSeen();
    }

    // Map WP editor getSettings() colors/fontSizes (`[{name, slug, color|size}]`)
    // into the brain's wire shape `{ colors: [{slug, hex}], font_sizes: [{slug, size}] }`.
    // Drops malformed entries (blank slug/value). `size` is coerced to a string
    // (theme.json may give numbers). Returns null when no valid tokens exist, so
    // the caller omits `theme_tokens` entirely rather than send an empty object.
    function buildThemeTokens(settings) {
        var tokens = {};

        var srcColors = Array.isArray(settings.colors) ? settings.colors : [];
        var colors = [];
        for (var i = 0; i < srcColors.length; i++) {
            var c = srcColors[i] || {};
            var cslug = typeof c.slug === 'string' ? c.slug : '';
            var hex = typeof c.color === 'string' ? c.color : '';
            if (cslug && hex) colors.push({ slug: cslug, hex: hex });
        }
        if (colors.length) tokens.colors = colors;

        var srcSizes = Array.isArray(settings.fontSizes) ? settings.fontSizes : [];
        var fontSizes = [];
        for (var j = 0; j < srcSizes.length; j++) {
            var f = srcSizes[j] || {};
            var fslug = typeof f.slug === 'string' ? f.slug : '';
            var size = f.size;
            var hasSize = typeof size === 'string' ? size !== '' : typeof size === 'number';
            if (fslug && hasSize) fontSizes.push({ slug: fslug, size: String(size) });
        }
        if (fontSizes.length) tokens.font_sizes = fontSizes;

        return (tokens.colors || tokens.font_sizes) ? tokens : null;
    }

    // The dominant repeated DIRECT-child block type — the block name occurring
    // 3+ times among `children`, with the highest count — or null when none
    // qualifies (fewer than 3 children, or no single type reaches 3). One
    // detection shared by the page outline (repeated_children flag) and the
    // selected-block repeater signal (flag + count + clone target), so the two
    // can never drift. Iframe path mirrors this in src/utils/selectedBlockDto.js.
    function dominantRepeatedChildType(children) {
        if (!Array.isArray(children) || children.length < 3) return null;
        var counts = {};
        for (var i = 0; i < children.length; i++) {
            var name = children[i] && children[i].name;
            if (typeof name === 'string') counts[name] = (counts[name] || 0) + 1;
        }
        var best = null;
        var names = Object.keys(counts);
        for (var k = 0; k < names.length; k++) {
            var n = counts[names[k]];
            if (n >= 3 && (best === null || n > best.count)) best = { name: names[k], count: n };
        }
        return best;
    }

    // A grid nested ONE level down: when the DIRECT children aren't a repeater
    // but EXACTLY ONE direct child is a container whose children ARE a repeater,
    // that inner container is the grid (the Spectra `section > content >
    // [card×N]` layout). "Exactly one" keeps it unambiguous. Iframe path mirrors
    // this in src/utils/selectedBlockDto.js::nestedGrid — keep both in lockstep.
    function nestedGrid(children) {
        var found = null;
        for (var i = 0; i < children.length; i++) {
            var c = children[i];
            var inner = c && Array.isArray(c.innerBlocks) ? c.innerBlocks : null;
            if (!inner) continue;
            var d = dominantRepeatedChildType(inner);
            if (d === null) continue;
            if (found !== null) return null; // 2+ candidate grids — ambiguous, bail
            found = { dominant: d, children: inner };
        }
        return found;
    }

    class WPBridgeHost {
        constructor() {
            this.config = window.zipwpIframeConfig || {};
            this._snapshotCounter = 0;
            this._selectionRevision = 0;
            this._lastSelectedId = null;
            this.init();
        }

        init() {
            if (document.readyState === 'loading') {
                document.addEventListener('DOMContentLoaded', () => this.setup());
            } else {
                this.setup();
            }
        }

        setup() {
            this.setupAdminBarToggle();
            this.setupResizeHandle();
            this.loadSavedSidebarWidth();
            this.setupInlineEditShortcut();
            this.restorePanelLayout();
            if (window.zipwpPopoverDrag) {
                var bridge = this;
                window.zipwpPopoverDrag.setup(function() { return bridge._panelLayout || 'sidebar'; });
            }
            this.checkAutoOpen();
            this.restorePanelState();
        }

        // ── Panel Toggle ──────────────────────────────────────────────

        setupAdminBarToggle() {
            var self = this;
            document.addEventListener('click', function(e) {
                var trigger = e.target.closest('#zip-ai-floating-trigger');
                if (trigger) {
                    e.preventDefault();
                    self.togglePanel();
                }
            });

            // Escape closes the panel when focus is inside it.
            document.addEventListener('keydown', function(e) {
                if (e.key !== 'Escape') return;
                var container = document.getElementById('zip-ai-assistant-container');
                if (!container || !container.classList.contains('zip-ai-iframe-visible')) return;
                // Only close if focus is inside the panel (or on body — e.g. after clicking inside)
                if (container.contains(document.activeElement) || document.activeElement === document.body) {
                    e.preventDefault();
                    self.togglePanel();
                }
            });

            // Global keyboard shortcut:
            // - Primary: Cmd+E / Ctrl+E (more reliable across OS/browser combos)
            // - Legacy: Cmd/Ctrl+Shift+Space (kept for backward compatibility)
            // Registered on the parent page so it works even when the panel is closed.
            document.addEventListener('keydown', function(e) {
                var modKey = e.metaKey || e.ctrlKey;

                // Avoid intercepting typing inside form fields or contenteditable regions.
                var target = e.target;
                var tagName = target && target.tagName;
                var isTextInput =
                    tagName === 'INPUT' ||
                    tagName === 'TEXTAREA' ||
                    (target && target.isContentEditable);

                if (isTextInput || !modKey) {
                    return;
                }

                // Primary shortcut: Cmd/Ctrl + E
                if (String(e.key).toLowerCase() === 'e') {
                    e.preventDefault();
                    self.togglePanel();
                    return;
                }

                // Legacy shortcut: Cmd/Ctrl + Shift + Space
                if (!e.shiftKey) {
                    return;
                }

                var isSpaceKey =
                    e.code === 'Space' ||
                    e.key === ' ' ||
                    e.key === 'Spacebar';

                if (!isSpaceKey) {
                    return;
                }

                e.preventDefault();
                self.togglePanel();
            });
        }

        togglePanel() {
            var container = document.getElementById('zip-ai-assistant-container');
            if (!container) {
                // Full-page mode has no sidebar container. "Close" navigates back to admin home.
                if (this.config.displayMode === 'fullpage') {
                    window.location.href = this.config.adminHomeUrl || '/wp-admin/';
                }
                return;
            }

            var isVisible = container.classList.contains('zip-ai-iframe-visible');

            if (isVisible) {
                container.classList.remove('zip-ai-iframe-visible');
                document.body.classList.remove('zip-ai-assistant-open');
                this.toggleFullscreen(false);
                // Make hidden container fully non-interactive (prevents click interception).
                container.setAttribute('inert', '');

                // Disable block context picker when sidebar closes
                if (window.zipwpMcpContextPicker) {
                    window.zipwpMcpContextPicker.disable();
                }

                try { localStorage.setItem('zipwp-panel-open', '0'); } catch (e) {}
            } else {
                container.removeAttribute('inert');
                container.classList.add('zip-ai-iframe-visible');
                document.body.classList.add('zip-ai-assistant-open');
                this.emitFullscreenChanged(container.classList.contains('zip-ai-iframe-fullscreen'));

                // Enable block context picker when sidebar opens
                if (window.zipwpMcpContextPicker) {
                    window.zipwpMcpContextPicker.enable();
                }

                try { localStorage.setItem('zipwp-panel-open', '1'); } catch (e) {}
            }
        }

        checkAutoOpen() {
            var urlParams = new URLSearchParams(window.location.search);

            // Legacy param: ?zipwp_open_assistant
            if (urlParams.has('zipwp_open_assistant')) {
                this._autoOpened = true;
                urlParams.delete('zipwp_open_assistant');
                var newUrl = window.location.pathname +
                    (urlParams.toString() ? '?' + urlParams.toString() : '') +
                    window.location.hash;
                window.history.replaceState({}, '', newUrl);

                var self = this;
                setTimeout(function() { self.togglePanel(); }, 300);
                return;
            }

            // New param: ?auto_open=true&prompt=...
            if (urlParams.get('auto_open') === 'true') {
                var prompt = urlParams.get('prompt') || '';

                // Clean URL params
                urlParams.delete('auto_open');
                urlParams.delete('prompt');
                urlParams.delete('mode');
                var cleanUrl = window.location.pathname +
                    (urlParams.toString() ? '?' + urlParams.toString() : '') +
                    window.location.hash;
                window.history.replaceState({}, '', cleanUrl);

                // Stash prompt for React to pick up after mount
                if (prompt) {
                    window.ZIPAI_AUTO_PROMPT = prompt;
                }

                this._autoOpened = true;

                // Open sidebar panel if not already on fullpage (fullpage is always open)
                if (this.config.displayMode !== 'fullpage') {
                    var self = this;
                    setTimeout(function() { self.togglePanel(); }, 300);
                }
            }
        }

        restorePanelState() {
            // Skip in full-page mode — panel is always visible there.
            if (this.config.displayMode === 'fullpage') return;
            // Skip if checkAutoOpen already scheduled a deferred toggle — avoid double-toggle race.
            if (this._autoOpened) return;

            var container = document.getElementById('zip-ai-assistant-container');
            if (!container) return;

            var alreadyOpen = container.classList.contains('zip-ai-iframe-visible');
            var wasOpen = false;
            try { wasOpen = localStorage.getItem('zipwp-panel-open') === '1'; } catch (e) {}

            if (wasOpen && !alreadyOpen) {
                this.togglePanel();
            }
        }

        // ── Panel Layout (sidebar vs popover) ─────────────────────────
        // 'sidebar' = pushes WP content left (default)
        // 'popover' = floating card, no viewport shrink

        restorePanelLayout() {
            var layout = 'sidebar';
            try { layout = localStorage.getItem('zipwp-panel-layout') || 'sidebar'; } catch (e) {}
            // Only the two valid layouts are honored; anything else falls back to
            // the default (mirrors setPanelLayout's guard).
            if (layout !== 'sidebar' && layout !== 'popover') layout = 'sidebar';
            this._panelLayout = layout;
            this._applyPanelLayoutClasses(layout);
        }

        setPanelLayout(layout) {
            if (layout !== 'sidebar' && layout !== 'popover') return;
            this._panelLayout = layout;
            try { localStorage.setItem('zipwp-panel-layout', layout); } catch (e) {}
            // _applyPanelLayoutClasses already calls drag.clearPosition()
            // on the non-popover branch — no additional cleanup needed.
            this._applyPanelLayoutClasses(layout);
        }

        _applyPanelLayoutClasses(layout) {
            var drag = window.zipwpPopoverDrag;
            document.body.classList.remove('zip-ai-overlay-mode', 'zip-ai-popover-mode');
            if (layout === 'popover') {
                document.body.classList.add('zip-ai-popover-mode');
                if (drag) drag.restorePosition();
            } else {
                if (drag) drag.clearPosition();
            }
        }

        resetPopoverLayout() {
            var drag = window.zipwpPopoverDrag;
            if (drag && drag.resetLayout) drag.resetLayout();
        }

        reloadWithAutoOpen() {
            // In full-page mode, just reload — the assistant is already the whole page.
            if (this.config.displayMode === 'fullpage') {
                window.location.reload();
                return;
            }
            var url = new URL(window.location.href);
            url.searchParams.set('zipwp_open_assistant', '1');
            window.location.href = url.toString();
        }

        // ── Context ───────────────────────────────────────────────────

        getContext() {
            return {
                wp_user_id: this.config.userId || null,
                editor_context: this.getEditorContext(),
                page_context: this.getPageContext(),
                website_context: {
                    current_url: window.location.href,
                    ...(this.config.websiteContext || {}),
                },
                theme_context: this.config.themeContext || {},
                installed_plugins: this.config.installedPlugins || {},
                admin_screen: this.getAdminScreenContext(),
            };
        }

        getEditorContext() {
            var snapshotId = ++this._snapshotCounter;

            // PHP-authoritative flag from `get_current_screen()->is_block_editor()`,
            // already narrowed server-side to the native post/page editor
            // (screen base 'post'; see React_Manager::is_block_editor_screen).
            // Used as a fallback when `wp.data` / `core/block-editor` haven't
            // initialized yet at iframe boot — so the brain doesn't see
            // is_block_editor=false on the first turn and route to dashboard mode.
            var phpIsBlockEditor = !!(this.config && this.config.isBlockEditor);

            // Native post/page editor URL gate. The DOM/store heuristic below
            // (block-list layout, .editor-styles-wrapper, #editor) matches ANY
            // screen rendering @wordpress/block-editor — including SureCart's
            // page editor and the Site Editor — so without this gate it would
            // re-flag those as the block editor even after PHP is narrowed.
            // The host bridge runs in the parent admin window, so location is
            // the real wp-admin URL: only post.php / post-new.php may qualify.
            var editorPath = (window.location && window.location.pathname) || '';
            var isNativePostEditorUrl = /\/(post|post-new)\.php$/.test(editorPath);

            var editorContext = {
                is_block_editor: phpIsBlockEditor,
                // B1 capability handshake — this bundle's browser-RPC protocol
                // version. Declares that THIS host bridge executes js_rpc
                // editor/* dispatches and POSTs replies to /agent/rpc-reply.
                // The brain routes to its AgentBrowserLoop ONLY when it sees
                // rpc >= 1; a bundle without the stamp (v1) degrades to the
                // dashboard flow instead of timing out on every editor tool.
                // Bump ONLY when the dispatch/reply contract changes shape.
                rpc: RPC_PROTOCOL_VERSION,
                selected_block: null,
            };

            if (!window.wp || !window.wp.data) return editorContext;

            var select = window.wp.data.select;
            var blockEditorSelect = select('core/block-editor');
            if (!blockEditorSelect) return editorContext;

            var currentSelectedId = blockEditorSelect.getSelectedBlockClientId();
            if (currentSelectedId !== this._lastSelectedId) {
                this._lastSelectedId = currentSelectedId;
                this._selectionRevision = (this._selectionRevision || 0) + 1;
            }

            var blocks = blockEditorSelect.getBlocks();
            var hasEditorDOM = document.body.classList.contains('block-editor-page') ||
                document.querySelector('.block-editor-block-list__layout') ||
                document.querySelector('.editor-styles-wrapper') ||
                document.querySelector('#editor');

            // Fallback: core/editor.getEditingMode() returns 'visual' in block editor context
            // Guards against DOM selector drift across WP versions
            var coreEditorSelectEarly = select('core/editor');
            var hasEditorMode = coreEditorSelectEarly
                ? (coreEditorSelectEarly.getEditingMode?.() === 'visual' || coreEditorSelectEarly.getCurrentPostId?.() > 0)
                : false;

            editorContext.is_block_editor = phpIsBlockEditor || (isNativePostEditorUrl && Array.isArray(blocks) && (hasEditorDOM || hasEditorMode));

            // Include post_id from the core/editor store so the brain knows which page is open
            var coreEditorSelect = coreEditorSelectEarly;
            if (coreEditorSelect) {
                var postId = coreEditorSelect.getCurrentPostId();
                var postTitle = coreEditorSelect.getEditedPostAttribute('title');
                var postType = coreEditorSelect.getCurrentPostType();
                if (postId) editorContext.post_id = postId;
                if (postTitle) editorContext.post_title = postTitle;
                if (postType) editorContext.post_type = postType;
            }

            // Build the compact page outline (top-level sections) for AI navigation.
            // Block detail is pulled on demand by the brain via editor__get_context
            // (fresh clientIds + classNames), never dumped here — so no live block index.
            if (editorContext.is_block_editor && blocks.length > 0) {
                editorContext.page_outline = this.buildPageOutline(blocks);
            }

            editorContext.snapshot_id = snapshotId;
            editorContext.selection_revision = this._selectionRevision || 0;

            var selectedBlock = blockEditorSelect.getSelectedBlock();
            var hasLiveSelection = !!(selectedBlock && selectedBlock.clientId && selectedBlock.name);
            // Explicit scope intent — replaces the brain's page-wide TEXT regex
            // (computeSelectionBinding). A live block selection means the user is
            // acting on THAT element, so confine the edit to it (page_wide:false);
            // no selection means a page-wide edit is fine (page_wide:true).
            // Deselecting is the explicit affordance to widen scope. Driven by
            // REAL editor state, never the user's words — so a coincidental "all
            // sections" phrase can't silently disable the selection scope lock.
            // Keep in lockstep with the iframe path (src/hooks/useMessageSubmit.js).
            editorContext.page_wide = !hasLiveSelection;
            if (hasLiveSelection) {
                // Build the brain's selected_block DTO DIRECTLY in snake_case — the
                // exact shape the brain's zod reads ({ client_id, block_name }). NO
                // camelCase intermediate (serializeBlockLight emitted clientId/name +
                // texts, all of which the brain strips) so there is no two-spelling
                // drift surface to fall out of sync — the original "client_id: Required"
                // turn-drop bug. Mirrors src/utils/selectedBlockDto.js, the iframe path's
                // builder; keep the two in lockstep. The guard above omits the selection
                // entirely when ids are absent, never a partial.
                editorContext.selected_block = {
                    client_id: selectedBlock.clientId,
                    block_name: selectedBlock.name,
                };
                // Repeater signal — so the brain sees "the user selected a card
                // grid" and routes "add one more" to a CHILD clone (duplicateBlocks
                // the last child) instead of authoring a brand-new section. Mirrors
                // the page-outline heuristic (selectedBlockRepeatInfo). Iframe path
                // computes the same in src/utils/selectedBlockDto.js — keep both in
                // lockstep. getSelectedBlock() carries innerBlocks live this turn.
                var repeatInfo = this.selectedBlockRepeatInfo(selectedBlock);
                editorContext.selected_block.repeated_children = repeatInfo.repeated_children;
                editorContext.selected_block.repeated_child_count = repeatInfo.repeated_child_count;
                if (repeatInfo.last_child_client_id) {
                    editorContext.selected_block.last_child_client_id = repeatInfo.last_child_client_id;
                }
                // NOTE: selected_block carries ONLY {client_id, block_name}. The
                // brain's wire schema declares {client_id, block_name, text} and
                // STRIPS anything else (wireToDomain). Two attr bundles were computed
                // here every turn and silently dropped: a parent_* bundle
                // (parent_client_id / parent_block_name / parent_attributes_schema /
                // parent_config_attrs) and selected_block.config_attrs (pickConfigAttrs)
                // — pure wasted per-turn compute + a boundary smear (plugin-side attr-
                // schema interpretation that never reached the model). Exact attribute
                // names are available on demand via editor__get_context. Removed (L-8).
            }

            // Live theme.json design tokens (editor getSettings()) — the
            // deterministic, always-present palette/type SSOT the brain styles
            // against. Emitted directly in the BRAIN WIRE SHAPE
            // (`theme_tokens: { colors: [{slug, hex}], font_sizes: [{slug, size}] }`)
            // so Laravel relays it VERBATIM with no reshaping. The WP getSettings()
            // shape is `[{name, slug, color|size}]`; we map + drop malformed entries
            // here at the source.
            var editorSettings = blockEditorSelect.getSettings();
            if (editorSettings) {
                var themeTokens = buildThemeTokens(editorSettings);
                if (themeTokens) editorContext.theme_tokens = themeTokens;
            }

            // Inject and clear results from the previous turn's js_hook executions
            if (window.__zipwpLastToolResults && window.__zipwpLastToolResults.length > 0) {
                editorContext.last_tool_results = window.__zipwpLastToolResults;
                window.__zipwpLastToolResults = null;
            }

            return editorContext;
        }


        buildPageOutline(blocks) {
            function countDeep(innerBlocks) {
                if (!innerBlocks || !innerBlocks.length) return 0;
                var count = 0;
                var queue = innerBlocks.slice();
                while (queue.length > 0) {
                    var b = queue.shift();
                    count++;
                    if (b.innerBlocks && b.innerBlocks.length > 0) {
                        for (var i = 0; i < b.innerBlocks.length; i++) queue.push(b.innerBlocks[i]);
                    }
                }
                return count;
            }

            function clipLabel(s) {
                return s.length > 80 ? s.substring(0, 77) + '...' : s;
            }
            // A block's OWN representative text — LIVE TEXT_KEYS attributes ONLY.
            // We deliberately do NOT fall back to block.originalContent: that is
            // the parse-time serialized HTML, which goes STALE vs the live
            // attributes after an in-editor edit. A stale label can steer the
            // model to match the WRONG section → wrong clientId (select-X →
            // operate-Y). A block with no live text attr returns null, and
            // extractHeading then DESCENDS to a child that has one — so a section
            // CONTAINER (no own text) is always labelled by its real heading.
            function ownText(block) {
                var attrs = block && block.attributes;
                if (attrs) {
                    for (var i = 0; i < TEXT_KEYS.length; i++) {
                        var val = attrs[TEXT_KEYS[i]];
                        if (typeof val === 'string' && val.trim()) {
                            var stripped = val.replace(/<[^>]*>/g, '').trim();
                            if (stripped) return stripped;
                        }
                    }
                }
                return null;
            }
            // A heading-ish block: a heading block type, or a content block whose
            // tag is h1-h6. Preferred as a section's label over body copy.
            function isHeadingBlock(block) {
                if (!block || !block.name) return false;
                if (block.name.indexOf('heading') !== -1) return true;
                var tag = block.attributes && (block.attributes.tagName || block.attributes.htmlTag);
                return typeof tag === 'string' && /^h[1-6]$/i.test(tag);
            }
            // A section's label. A section CONTAINER carries no text of its own —
            // its identifying heading lives in a NESTED child — so when the block
            // itself has no text we DESCEND (document order) and surface the first
            // nested heading (h1-h6 / heading block), falling back to the first
            // nested text. Without this every Spectra/FSE section reads as an
            // anonymous `spectra/container` (heading: null) and the agent cannot
            // tell the hero from the CTA — forcing a get_context drill per section
            // (or a fabricated answer). With it, the outline is a real section map.
            function extractHeading(block) {
                var own = ownText(block);
                if (own) return clipLabel(own);
                var firstText = null;
                function walk(b) {
                    if (!b) return null;
                    var t = ownText(b);
                    if (t) {
                        if (isHeadingBlock(b)) return t; // best: a real heading
                        if (firstText === null) firstText = t; // fallback: first text in order
                    }
                    if (b.innerBlocks) {
                        for (var i = 0; i < b.innerBlocks.length; i++) {
                            var r = walk(b.innerBlocks[i]);
                            if (r) return r;
                        }
                    }
                    return null;
                }
                var inner = block.innerBlocks || [];
                for (var j = 0; j < inner.length; j++) {
                    var found = walk(inner[j]);
                    if (found) return clipLabel(found);
                }
                return firstText !== null ? clipLabel(firstText) : null;
            }

            function hasButtons(block) {
                // DFS scan for button blocks
                var stack = [block];
                while (stack.length > 0) {
                    var b = stack.pop();
                    if (b.name && (b.name.indexOf('button') !== -1 || b.name.indexOf('cta') !== -1)) return true;
                    if (b.innerBlocks) {
                        for (var i = 0; i < b.innerBlocks.length; i++) stack.push(b.innerBlocks[i]);
                    }
                }
                return false;
            }

            function hasFormFields(block) {
                var stack = [block];
                while (stack.length > 0) {
                    var b = stack.pop();
                    if (b.name && (b.name.indexOf('form') !== -1 || b.name.indexOf('input') !== -1 || b.name.indexOf('field') !== -1)) return true;
                    if (b.innerBlocks) {
                        for (var i = 0; i < b.innerBlocks.length; i++) stack.push(b.innerBlocks[i]);
                    }
                }
                return false;
            }

            // --- Section surfacing ------------------------------------------
            // Spectra/FSE pages nest every visual section under one (or a chain
            // of) WRAPPER container(s) whose own class is empty or purely
            // layout/alignment. getBlocks() therefore hands us the wrapper, not
            // the sections. Descend through such a single wrapper until we reach
            // the level where the sections actually live — multiple siblings, a
            // single SEMANTICALLY-classed section, or a non-container. This makes
            // the outline a real section map the agent can target directly.
            function blockHasText(b) {
                var a = (b && b.attributes) || {};
                var t = typeof a.content === 'string' ? a.content : (typeof a.text === 'string' ? a.text : '');
                return t.replace(/<[^>]*>/g, '').trim().length > 0;
            }
            function isContainerBlock(b) {
                return !!(b && b.name && (b.name.indexOf('container') !== -1 || b.name === 'core/group' || b.name === 'core/columns'));
            }
            // A class string is "wrapper-generic" when every token is a layout /
            // alignment / auto class (alignfull, is-layout-*, wp-block-*, has-*)
            // — nothing SEMANTIC like zip-ai-hero. Such a container is a pass-through
            // wrapper we descend past; a semantic class marks a real section.
            function isGenericWrapperClass(cls) {
                if (!cls) return true;
                var tokens = cls.split(/\s+/);
                for (var k = 0; k < tokens.length; k++) {
                    var t = tokens[k];
                    if (t && !/^align(full|wide|left|right|center|none)$|^is-layout-|^wp-block-|^has-/.test(t)) return false;
                }
                return true;
            }
            function sectionLevel(rootBlocks) {
                var level = rootBlocks || [];
                for (var hop = 0; hop < 6; hop++) {
                    // meaningful siblings = drop trailing freeform / empty spacers
                    // so a lone wrapper isn't masked by decorative neighbours.
                    var meaningful = [];
                    for (var j = 0; j < level.length; j++) {
                        var b = level[j];
                        if (!b || !b.name || b.name.indexOf('freeform') !== -1) continue;
                        if ((b.innerBlocks && b.innerBlocks.length > 0) || blockHasText(b)) meaningful.push(b);
                    }
                    if (meaningful.length !== 1) break; // 0 or 2+ siblings → this IS the section level
                    var only = meaningful[0];
                    var cls = (only.attributes && typeof only.attributes.className === 'string' ? only.attributes.className : '').trim();
                    var canDescend = isContainerBlock(only) && isGenericWrapperClass(cls) && only.innerBlocks && only.innerBlocks.length > 0;
                    if (!canDescend) break; // semantic-classed container = a section; non-container = stop
                    level = only.innerBlocks;
                }
                return level.length > 0 ? level : (rootBlocks || []);
            }

            // Emits the BRAIN WIRE SHAPE for each outline row directly (snake_case
            // `client_id`, optional fields OMITTED rather than null). Laravel relays
            // page_outline VERBATIM — it does no renaming, hand-picking, or default
            // injection. The brain's EditorSchema (wireSchemas.ts) is the sole
            // validator: `heading_excerpt` is z.string().min(1).optional() so it must
            // be ABSENT (not null) when there's no heading.
            return sectionLevel(blocks).map(function(block) {
                // Repeated card-like children (3+ of one block type) — shared
                // detection with the selected-block repeater signal.
                var repeated = dominantRepeatedChildType(block.innerBlocks || []) !== null;

                var row = {
                    client_id: block.clientId,
                    block_name: block.name,
                    // Section's current class string — so the brain can restyle a
                    // section (className REPLACES wholesale, so it needs the
                    // existing classes to keep) DIRECTLY from the outline, without
                    // a get_context round-trip.
                    class_name: (block.attributes && typeof block.attributes.className === 'string')
                        ? block.attributes.className : '',
                    inner_block_count: countDeep(block.innerBlocks),
                    has_buttons: hasButtons(block),
                    has_form_fields: hasFormFields(block),
                    repeated_children: repeated,
                };

                // Optional hint — present only when the section actually carries a
                // heading. Omitted (never null) to satisfy the brain's optional() schema.
                var heading = extractHeading(block);
                if (heading) row.heading_excerpt = heading;

                return row;
            });
        }

        // Repeater shape of the SELECTED block, in the brain wire shape, via the
        // shared dominantRepeatedChildType detection. The clone target is the
        // last child OF THE DOMINANT type (never a trailing CTA / spacer of a
        // different type), and the count is that type's occurrence count (the
        // number of cards), not the deep descendant total. Kept in lockstep with
        // the iframe builder src/utils/selectedBlockDto.js::repeatInfoFromInnerBlocks.
        selectedBlockRepeatInfo(block) {
            var children = (block && block.innerBlocks) || [];
            var dominant = dominantRepeatedChildType(children);
            var gridChildren = children;
            if (dominant === null) {
                var nested = nestedGrid(children);
                if (nested !== null) {
                    dominant = nested.dominant;
                    gridChildren = nested.children;
                }
            }
            if (dominant === null) return { repeated_children: false, repeated_child_count: 0 };

            var info = { repeated_children: true, repeated_child_count: dominant.count };
            for (var i = gridChildren.length - 1; i >= 0; i--) {
                var c = gridChildren[i];
                if (c && c.name === dominant.name && typeof c.clientId === 'string' && c.clientId !== '') {
                    info.last_child_client_id = c.clientId;
                    break;
                }
            }
            return info;
        }

        getPageContext() {
            var phpPageContext = this.config.pageContext || {};
            return {
                post_id: phpPageContext.post_id || null,
                post_type: phpPageContext.post_type || null,
                post_title: phpPageContext.post_title || null,
                post_status: phpPageContext.post_status || null,
            };
        }

        getAdminScreenContext() {
            var phpContext = window.zipwpMcpContext || {};
            var currentScreen = phpContext.currentScreen || {};
            return {
                id: currentScreen.id || null,
                base: currentScreen.base || null,
                post_type: currentScreen.post_type || null,
                action: currentScreen.action || null,
                parent_base: currentScreen.parent_base || null,
                is_admin: phpContext.isAdmin || false,
            };
        }

        // ── Block Serialization ───────────────────────────────────────

        serializeBlock(block) {
            if (!block) return null;

            var blocksHtml = '';
            if (window.wp && window.wp.blocks && window.wp.blocks.serialize) {
                try {
                    blocksHtml = window.wp.blocks.serialize([block]);
                    blocksHtml = this.fixUnicodeEscapes(blocksHtml);
                } catch (e) {
                    console.warn('Failed to serialize block:', e);
                }
            }

            var self = this;
            return {
                clientId: block.clientId,
                name: block.name,
                blockName: block.name,
                attributes: block.attributes,
                attrs: block.attributes,
                innerBlocks: (block.innerBlocks || []).map(function(innerBlock) { return self.serializeBlock(innerBlock); }),
                blocks_html: blocksHtml,
            };
        }

        fixUnicodeEscapes(html) {
            if (!html || typeof html !== 'string') return html;
            return html
                .replace(/\\\\u002d/gi, '-')
                .replace(/\\u002d/gi, '-')
                .replace(/u002d/gi, '-');
        }

        // ── Block Editor Operations ───────────────────────────────────

        captureBlockScreenshot(clientId, options) {
            if (!clientId) return Promise.reject(new Error('Block clientId is required'));

            var utils = window.zipwpMcpSpectraUtils;
            if (!utils || !utils.captureBlockScreenshot) {
                return Promise.reject(new Error('Screenshot utility not available'));
            }

            return utils.captureBlockScreenshot(clientId, options || {}).then(function(screenshot) {
                if (!screenshot) throw new Error('Failed to capture screenshot');
                return {
                    success: true,
                    base64: screenshot.base64,
                    width: screenshot.width,
                    height: screenshot.height
                };
            });
        }

        /**
         * Capture a screenshot of the editor content area.
         *
         * Uses html2canvas to render the editor styles wrapper (or fallback targets)
         * and returns a base64 JPEG data URL (quality 0.7, max width 1280px).
         *
         * @since x.x.x
         * @return {Promise<Object>} { success, dataUrl, width, height } or { success: false, error }
         */
        async captureScreenshot() {
            try {
                // Find the best element to capture
                var target = document.querySelector('.editor-styles-wrapper')
                    || document.querySelector('.block-editor-block-list__layout')
                    || document.querySelector('#wpwrap');

                if (!target) {
                    return { success: false, error: 'No editor content found' };
                }

                // html2canvas is available via the WP plugin
                var html2canvas = window.html2canvas;
                if (!html2canvas) {
                    // Try dynamic import
                    try {
                        var mod = await import('html2canvas');
                        html2canvas = mod.default || mod;
                    } catch(e) {
                        return { success: false, error: 'html2canvas not available' };
                    }
                }

                var canvas = await html2canvas(target, {
                    useCORS: true,
                    allowTaint: true,
                    scale: 1,
                    logging: false,
                    windowWidth: target.scrollWidth,
                    windowHeight: target.scrollHeight,
                });

                // Resize if too large
                var maxWidth = 1280;
                var finalCanvas = canvas;
                if (canvas.width > maxWidth) {
                    finalCanvas = document.createElement('canvas');
                    var ratio = maxWidth / canvas.width;
                    finalCanvas.width = maxWidth;
                    finalCanvas.height = Math.round(canvas.height * ratio);
                    var ctx = finalCanvas.getContext('2d');
                    ctx.drawImage(canvas, 0, 0, finalCanvas.width, finalCanvas.height);
                }

                return {
                    success: true,
                    dataUrl: finalCanvas.toDataURL('image/jpeg', 0.7),
                    width: finalCanvas.width,
                    height: finalCanvas.height,
                };
            } catch(e) {
                return { success: false, error: e.message || 'Screenshot capture failed' };
            }
        }

        // ── Tool Execution ────────────────────────────────────────────

        // `sessionId` (M1) — the chat session that dispatched these calls; the
        // React SSE layer passes it so js_rpc replies can carry it to Laravel,
        // which verifies it against the brain's owner key for the call_id.
        async executeTools(executionResults, sessionId) {
            console.log('[ZIP AI:wp-bridge-host] executeTools called count=%d tools=%s',
                executionResults.length,
                executionResults.map(r => r.js_handler || r.tool_name).join(', '));

            for (var i = 0; i < executionResults.length; i++) {
                    var result = executionResults[i];
                    if (result.execution_mode === 'js_hook' || result.execution_mode === 'hybrid' || result.execution_mode === 'js_rpc') {
                        try {
                            var toolName = result.js_handler || result.tool_name;
                            var toolArguments = result.arguments || {};
                            // Anthropic-assigned tool_use_id, stamped by the
                            // SSE handler before this dispatch. Carried onto
                            // window.__zipwpLastToolResults so the brain's
                            // jsHookReconciler can attribute the next-turn
                            // result deterministically to the originating
                            // todo via todo.dispatched_call_ids.
                            var toolCallId = result.call_id || null;

                            // B-1 / P5 idempotency: decide BEFORE running the handler.
                            // The pure decision (core/rpc-dedup.js, unit-tested) reads
                            // the prior _rpcSeen entry: a prior entry (in-flight OR
                            // completed) → NEVER re-dispatch (that double-applies) —
                            // re-POST it so a waiting brain BRPOP resolves; a first run
                            // → record the IN-FLIGHT marker NOW (before the handler
                            // mutates the tree) so a crash mid-apply still blocks a
                            // replay (the replay reposts an uncertain ok:false reply →
                            // the brain verifies before retrying). Degrades to the
                            // pre-P5 seen-check if the helper module isn't loaded (no
                            // crash-window marker) — never a duplicate of the logic.
                            if (result.execution_mode === 'js_rpc') {
                                var seenReply = rpcSeenGet(toolCallId);
                                var dedupApi = (typeof window !== 'undefined') ? window.zipwpRpcDedup : null;
                                var dedup = (dedupApi && dedupApi.decideJsRpcDispatch)
                                    ? dedupApi.decideJsRpcDispatch(seenReply)
                                    : { action: seenReply ? 'repost_cached' : 'run', reply: seenReply, inFlightMarker: null };
                                if (dedup.action === 'repost_cached') {
                                    console.warn('[ZIP AI:wp-bridge-host] js_rpc duplicate/in-flight call_id=%s — skipping re-dispatch, re-POSTing reply', toolCallId);
                                    await this.postRpcReply(toolCallId, dedup.reply.ok, dedup.reply.data, dedup.reply.error, sessionId);
                                    continue;
                                }
                                if (dedup.inFlightMarker) {
                                    var inflight = dedup.inFlightMarker;
                                    rpcSeenRemember(toolCallId, inflight.ok, inflight.data, inflight.error);
                                }
                            }

                            var hasHandler = window.zipwpMcp && window.zipwpMcp.toolHooks &&
                                window.zipwpMcp.toolHooks.hasHandler(toolName);

                            console.log('[ZIP AI:wp-bridge-host] executing tool=%s hasHandler=%s zipwpMcp=%s',
                                toolName, hasHandler, !!(window.zipwpMcp));

                            // [RTRACE] BRIDGE-IN — the js_rpc envelope arrives at the browser bridge
                            // and the registered handler is about to be invoked with the wire args.
                            if (result.execution_mode === 'js_rpc') {
                                try {
                                    // attr_keys is the load-bearing signal: a key the
                                    // target block doesn't define (e.g. tagName on a
                                    // spectra/container, which uses htmlTag) is silently
                                    // dropped by the registry → the model never sees the
                                    // edit "complete" and re-tries. Surface it at the JS
                                    // hop so the loop is visible from the browser too.
                                    var _ops = (toolArguments && Array.isArray(toolArguments.operations)) ? toolArguments.operations : [];
                                    var _attrKeys = _ops.reduce(function (acc, o) {
                                        if (o && o.attributes && typeof o.attributes === 'object') {
                                            acc.push.apply(acc, Object.keys(o.attributes));
                                        }
                                        return acc;
                                    }, []);
                                    (window.__zipwpTrace = window.__zipwpTrace || []).push({
                                        hop: 'bridge:receive+invoke', ts: Date.now(),
                                        tool_name: toolName, call_id: toolCallId,
                                        execution_mode: result.execution_mode, has_handler: !!hasHandler,
                                        version: toolArguments && toolArguments.version,
                                        post_id: toolArguments && toolArguments.post_id,
                                        scope: toolArguments && toolArguments.scope,
                                        op_count: _ops.length,
                                        functions: _ops.map(function (o) { return o && o.function; }),
                                        attr_keys: _attrKeys.length ? _attrKeys : null,
                                    });
                                    console.log('[RTRACE] bridge:receive+invoke tool=%s call_id=%s ops=%d attr_keys=%s', toolName, toolCallId, _ops.length, JSON.stringify(_attrKeys));
                                } catch (e) { /* trace never breaks dispatch */ }
                            }

                            if (!hasHandler) {
                                throw new Error('No handler available for tool: ' + toolName);
                            }

                            // L-16: the full-page getContext() re-walk was merged
                            // into every envelope as `_wordpress_context` — but NO
                            // editor handler reads it (the brain strips it at the
                            // wire). Dropped the per-envelope re-walk; pass the wire
                            // args through directly.
                            var toolArgs = Object.assign({}, toolArguments);

                            var hookResult = await window.zipwpMcp.toolHooks.executeToolHook(
                                toolName,
                                toolArgs,
                                { jsExecutionResult: executionResults }
                            );

                            // Capture result for next-turn context injection
                            console.log('[ZIP AI:wp-bridge-host] handler finished tool=%s hookResult=%s', toolName, JSON.stringify(hookResult));

                            // Vibe Editing v2 — synchronous RPC: POST the reply so the brain's
                            // AgentBrowserLoop (blocked on BRPOP for this call_id) resolves the
                            // call IN THE SAME TURN, then skip the next-turn buffer below.
                            if (result.execution_mode === 'js_rpc') {
                                var rpcOk = !!(hookResult && hookResult.success !== false);
                                var rpcData = hookResult ? hookResult.data : undefined;
                                var rpcErr = hookResult ? (hookResult.error || null) : 'js_rpc handler returned no result';
                                // [RTRACE] BRIDGE-OUT — what wp.data actually produced,
                                // before the reply goes back to the brain. The reply
                                // summary (applied / failed / unknown_attrs) is what the
                                // model reacts to next step; unknown_attrs here is the
                                // browser-side proof of a silent registry drop.
                                try {
                                    var _ap = (rpcData && Array.isArray(rpcData.applied)) ? rpcData.applied : [];
                                    var _ua = _ap.reduce(function (acc, a) {
                                        if (a && Array.isArray(a.unknown_attrs)) acc.push.apply(acc, a.unknown_attrs);
                                        return acc;
                                    }, []);
                                    (window.__zipwpTrace = window.__zipwpTrace || []).push({
                                        hop: 'bridge:reply', ts: Date.now(),
                                        tool_name: toolName, call_id: toolCallId, ok: rpcOk,
                                        applied: _ap.length,
                                        failed: (rpcData && Array.isArray(rpcData.failed)) ? rpcData.failed.length : null,
                                        unknown_attrs: _ua.length ? _ua : null,
                                        blocks: (rpcData && Array.isArray(rpcData.blocks)) ? rpcData.blocks.length : null,
                                        error: rpcErr,
                                    });
                                    console.log('[RTRACE] bridge:reply tool=%s call_id=%s ok=%s applied=%d unknown_attrs=%s', toolName, toolCallId, rpcOk, _ap.length, JSON.stringify(_ua));
                                } catch (e) { /* trace never breaks dispatch */ }
                                // UPGRADE the in-flight marker to the REAL reply, and
                                // before posting so a replay during the POST round-trip
                                // still de-dups against the final reply (B-1 / P5).
                                rpcSeenRemember(toolCallId, rpcOk, rpcData, rpcErr);
                                await this.postRpcReply(toolCallId, rpcOk, rpcData, rpcErr, sessionId);
                                continue;
                            }

                            if (!window.__zipwpLastToolResults) {
                                window.__zipwpLastToolResults = [];
                            }
                            if (hookResult && typeof hookResult === 'object') {
                                window.__zipwpLastToolResults.push({
                                    tool: toolName,
                                    // Primary correlation: Anthropic tool_use_id assigned by the LLM,
                                    // plumbed end-to-end. The brain's jsHookReconciler matches this
                                    // against todo.dispatched_call_ids — deterministic, identity-based.
                                    call_id: toolCallId,
                                    // Fallback correlation: original tool arguments. Used when
                                    // call_id is missing (legacy bundle or non-LLM-initiated paths)
                                    // — the reconciler subset-matches against todo.tool_call.arguments.
                                    args: toolArgs || null,
                                    success: hookResult.success !== false,
                                    message: hookResult.message || null,
                                    user_summary: hookResult.user_summary || null,
                                    operation: hookResult.operation || null,
                                    verification: hookResult.verification || hookResult.data?.verification || null,
                                    error: hookResult.error || null,
                                });
                            } else {
                                console.warn('[ZIP AI:wp-bridge-host] js_hook returned null/undefined for tool=%s — result NOT captured in last_tool_results', toolName);
                                window.__zipwpLastToolResults.push({
                                    tool: toolName,
                                    call_id: toolCallId,
                                    args: toolArgs || null,
                                    success: false,
                                    error: 'js_hook handler returned no result',
                                });
                            }
                        } catch (error) {
                            console.error('[ZIP AI:wp-bridge-host] handler threw for tool=%s error=%s', toolName, error?.message, error);
                            if (result.execution_mode === 'js_rpc') {
                                // A thrown handler may still have partially mutated
                                // the tree, so de-dup this call_id too (B-1).
                                var thrownErr = error?.message || 'js_rpc handler threw';
                                rpcSeenRemember(toolCallId, false, undefined, thrownErr);
                                await this.postRpcReply(toolCallId, false, undefined, thrownErr, sessionId);
                                continue;
                            }
                            if (!window.__zipwpLastToolResults) window.__zipwpLastToolResults = [];
                            window.__zipwpLastToolResults.push({
                                tool: toolName,
                                call_id: toolCallId,
                                args: toolArgs || null,
                                success: false,
                                error: error?.message || 'js_hook threw an exception',
                            });
                        }
                    }
                }
        }

        // Vibe Editing v2 — POST a synchronous editor-RPC reply to the SaaS so
        // the brain's AgentBrowserLoop (blocked on BRPOP for this call_id)
        // resolves the tool in the SAME turn. Auth + base URL come from
        // window.ZIPAI_CONFIG (the same source the React app's api client uses).
        //
        // `sessionId` (M1) — included as session_id so Laravel can verify the
        // reply against the brain-claimed owner of this call_id (the owner key's
        // VALUE is the dispatching session). Without it any authenticated tenant
        // holding a call_id could inject a reply into a foreign turn.
        //
        // H1 — bounded retry. The reply is the ONLY confirmation the brain gets
        // and there is no later resend, so a transient failure (network blip,
        // 5xx, or a 409 from a not-yet-claimed owner key) must not drop it
        // permanently: every dropped reply costs the brain a full BRPOP window
        // plus a verify round-trip. Retries are safe by construction — Laravel
        // RPUSHes at most one consumable copy per POST and the brain releases
        // the owner key after consuming, so a duplicate late POST is 409-rejected,
        // never double-folded; re-execution is impossible (the handler already
        // ran; _rpcSeen replays the cached reply).
        async postRpcReply(callId, ok, data, error, sessionId) {
            // The editor turn's executeTools loop AWAITS this POST before moving
            // to the next call, so a hung request would stall the whole turn.
            // Bound it with an AbortController timeout — the brain's BRPOP times
            // out independently on its side, so dropping a slow reply is safe.
            //
            // B-1 INVARIANT: total time here (attempts × timeout + backoff) MUST
            // stay BELOW the brain's BRAIN_EDITOR_RPC_TIMEOUT_MS (default 20s).
            // The plugin applies the change BEFORE replying, so if the brain
            // gives up first it reads a false "nothing applied" and may nudge a
            // retry → duplicate content. Per-attempt timeout 6s × 3 + backoff
            // ≈ 19s worst case, inside the 20s window.
            var cfg = window.ZIPAI_CONFIG || {};
            var attempts = 3;
            var backoffMs = 300;
            var perAttemptDefault = 6000;
            var timeoutMs = Number(cfg.rpcReplyTimeoutMs) > 0 ? Number(cfg.rpcReplyTimeoutMs) : perAttemptDefault;
            var apiUrl = (cfg.apiUrl || '/api').replace(/\/+$/, '');
            // Route the reply DIRECT to the brain when configured (same switch
            // as the React api client's brainPath); else fall back to Laravel.
            var rpcReplyUrl = (cfg.brainUrl ? cfg.brainUrl.replace(/\/+$/, '') : apiUrl) + '/agent/rpc-reply';
            var body = { call_id: callId, ok: !!ok };
            if (sessionId) { body.session_id = String(sessionId); }
            if (data !== undefined && data !== null) { body.data = data; }
            if (error) { body.error = String(error); }
            var headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
            if (cfg.token) { headers['Authorization'] = 'Bearer ' + cfg.token; }
            var payload = JSON.stringify(body);

            // [RTRACE] BRIDGE-OUT — the Gutenberg execution reply leaves the browser back to
            // Laravel /agent/rpc-reply, which RPUSHes it onto the brain's BRPOP key (call_id).
            try {
                (window.__zipwpTrace = window.__zipwpTrace || []).push({
                    hop: 'bridge:postRpcReply', ts: Date.now(),
                    call_id: callId, ok: !!ok,
                    endpoint: rpcReplyUrl,
                    applied: (data && data.applied) ? data.applied.length : null,
                    failed: (data && data.failed) ? data.failed.length : null,
                    refused: (data && data.refused) ? data.refused : null,
                    error: error ? String(error) : null,
                });
                console.log('[RTRACE] bridge:postRpcReply call_id=%s ok=%s', callId, !!ok);
            } catch (e) { /* trace never breaks reply */ }

            for (var attempt = 1; attempt <= attempts; attempt++) {
                var controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
                var timer = controller ? setTimeout(function () { controller.abort(); }, timeoutMs) : null;
                try {
                    var opts = { method: 'POST', headers: headers, body: payload };
                    if (controller) opts.signal = controller.signal;
                    var res = await fetch(rpcReplyUrl, opts);
                    if (res.ok) return;
                    // 4xx other than 409 won't improve on retry (bad payload /
                    // auth); 409 can be the claim race or an already-consumed
                    // reply — retry covers the former, the latter stays 409 and
                    // we stop after the budget.
                    var retryable = res.status === 409 || res.status >= 500;
                    console.error('[ZIP AI:wp-bridge-host] rpc-reply POST failed status=%d call_id=%s attempt=%d/%d',
                        res.status, callId, attempt, attempts);
                    if (!retryable) return;
                } catch (e) {
                    var reason = (e && e.name === 'AbortError') ? ('timeout after ' + timeoutMs + 'ms') : (e && e.message);
                    console.error('[ZIP AI:wp-bridge-host] rpc-reply POST threw call_id=%s attempt=%d/%d err=%s',
                        callId, attempt, attempts, reason);
                } finally {
                    if (timer) clearTimeout(timer);
                }
                if (attempt < attempts) {
                    await new Promise(function (r) { setTimeout(r, backoffMs * attempt); });
                }
            }
        }

        // ── Canvas Loader Protocol ────────────────────────────────────

        applyCanvasLoader(toolName, target) {
            if (!target) return;

            switch (target.type) {
                case 'block':
                    this._applyBlockLoader(target.id);
                    break;
                case 'selector':
                    this._applySelectorLoader(target.selector);
                    break;
                case 'global':
                    this._applyGlobalLoader(toolName);
                    break;
                case 'fullpage':
                    this._applyFullPageLoader();
                    break;
            }
        }

        removeCanvasLoader(toolName, target, success) {
            if (!target) return;

            switch (target.type) {
                case 'block':
                    this._removeBlockLoader(target.id);
                    break;
                case 'selector':
                    this._removeSelectorLoader(target.selector);
                    break;
                case 'global':
                    this._removeGlobalLoader();
                    break;
                case 'fullpage':
                    this._removeFullPageLoader();
                    break;
            }
        }

        _applyBlockLoader(clientId) {
            if (!clientId) return;
            var blockNode = document.querySelector('[data-block="' + clientId + '"]');
            if (blockNode) blockNode.classList.add('is-ai-processing');

            if (window.wp && window.wp.data) {
                try {
                    window.wp.data.dispatch('core/block-editor')
                        .updateBlockAttributes(clientId, { lock: { move: true, remove: true } });
                } catch (e) { /* block may not support lock */ }
            }
        }

        _removeBlockLoader(clientId) {
            if (!clientId) return;
            var blockNode = document.querySelector('[data-block="' + clientId + '"]');
            if (blockNode) blockNode.classList.remove('is-ai-processing');

            if (window.wp && window.wp.data) {
                try {
                    window.wp.data.dispatch('core/block-editor')
                        .updateBlockAttributes(clientId, { lock: undefined });
                } catch (e) { /* silent */ }
            }
        }

        _applySelectorLoader(selector) {
            try {
                var el = document.querySelector(selector);
                if (el) el.classList.add('is-ai-processing');
            } catch (e) { /* invalid selector */ }
        }

        _removeSelectorLoader(selector) {
            try {
                var el = document.querySelector(selector);
                if (el) el.classList.remove('is-ai-processing');
            } catch (e) { /* silent */ }
        }

        _applyGlobalLoader() {
            if (document.getElementById('ai-global-loader')) return;
            var toast = document.createElement('div');
            toast.id = 'ai-global-loader';
            toast.className = 'ai-global-processing-toast';
            toast.textContent = 'AI is updating design settings\u2026';
            document.body.appendChild(toast);
        }

        _removeGlobalLoader() {
            var toast = document.getElementById('ai-global-loader');
            if (toast) toast.remove();
        }

        _applyFullPageLoader() {
            if (document.getElementById('ai-fullpage-overlay')) return;
            var overlay = document.createElement('div');
            overlay.id = 'ai-fullpage-overlay';
            overlay.className = 'ai-fullpage-processing-overlay';
            var text = document.createElement('div');
            text.className = 'ai-fullpage-text';
            text.textContent = 'AI is building your page\u2026';
            overlay.appendChild(text);
            document.body.appendChild(overlay);
        }

        _removeFullPageLoader() {
            var overlay = document.getElementById('ai-fullpage-overlay');
            if (overlay) overlay.remove();
        }

        // ── Color Palette Preview ─────────────────────────────────────

        previewPalette(colors) {
            if (!colors || !Array.isArray(colors)) return;

            var styleEl = document.getElementById('zipwp-palette-preview');
            if (!styleEl) {
                styleEl = document.createElement('style');
                styleEl.id = 'zipwp-palette-preview';
                document.head.appendChild(styleEl);
            }

            var vars = colors.map(function(c, i) {
                var slug = (typeof c === 'object' && c.slug) || ('ast-global-color-' + i);
                var color = typeof c === 'string' ? c : (c.color || c.hex || '');
                return '--' + slug + ':' + color;
            }).join(';');

            styleEl.textContent = ':root{' + vars + '}';
        }

        // ── Auth ──────────────────────────────────────────────────────

        checkAuthStatus() {
            var self = this;
            var formData = new URLSearchParams();
            formData.append('action', 'zipwp_verify_auth_status');
            formData.append('nonce', self.config.nonce);
            return fetch(self.config.ajaxUrl, {
                method: 'POST',
                credentials: 'same-origin',
                body: formData
            })
                .then(function(response) { return response.json(); })
                .then(function(result) {
                    return !!(result && result.success && result.data && result.data.is_authorized);
                })
                .catch(function() { return false; });
        }

        openAuthPopup() {
            var self = this;
            return new Promise(function(resolve, reject) {
                var url = self.config.authUrl;
                if (!url || !self.config.ajaxUrl || !self.config.nonce) {
                    reject(new Error('Assistant auth configuration is missing. Reload the page and try again.'));
                    return;
                }

                var width = 600;
                var height = 700;
                var left = (screen.width - width) / 2;
                var top = (screen.height - height) / 2;

                var popupWindow = window.open(
                    url,
                    'ZipWP Login',
                    'width=' + width + ',height=' + height + ',top=' + top + ',left=' + left + ',popup=yes,toolbar=no,location=no,menubar=no'
                );

                if (!popupWindow || popupWindow.closed) {
                    reject(new Error('Popup was blocked. Please allow popups for this site and try again.'));
                    return;
                }

                var iterations = 0;
                var maxIterations = 300;

                var authPollingInterval = setInterval(async function() {
                    if (popupWindow.closed || iterations >= maxIterations) {
                        clearInterval(authPollingInterval);
                        if (!popupWindow.closed) popupWindow.close();
                        // The popup can close (ZipWP auto-closes on success, or the
                        // user closes it) between two 500ms polls — before a tick
                        // catches is_authorized. Do one final check so a completed
                        // login still reloads into the authed app instead of
                        // stranding the user on the auth screen.
                        if (popupWindow.closed && await self.checkAuthStatus()) {
                            self.reloadWithAutoOpen();
                            return;
                        }
                        if (iterations >= maxIterations) {
                            reject(new Error('Authentication timeout'));
                        }
                        return;
                    }

                    if (await self.checkAuthStatus()) {
                        if (!popupWindow.closed) popupWindow.close();
                        clearInterval(authPollingInterval);
                        self.reloadWithAutoOpen();
                    }

                    iterations++;
                }, 500);
            });
        }

        // ── Inline Edit Shortcut (Cmd+J / Ctrl+J) ────────────────────

        /**
         * Register Cmd+J / Ctrl+J keyboard shortcut to open sidebar and focus chat input.
         *
         * @since x.x.x
         */
        setupInlineEditShortcut() {
            var self = this;
            var handler = function(e) {
                var isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
                var modKey = isMac ? e.metaKey : e.ctrlKey;

                if (modKey && e.key === 'j') {
                    e.preventDefault();
                    e.stopPropagation();

                    // Open sidebar if not visible
                    var container = document.getElementById('zip-ai-assistant-container');
                    if (container && !container.classList.contains('zip-ai-iframe-visible')) {
                        self.togglePanel();
                    }

                    // Emit event for React to focus input and lock context
                    if (window.zipwpMcpAppBridge) {
                        window.zipwpMcpAppBridge.emit('inline_edit_shortcut', {});
                    }
                }
            };

            // Listen on main document
            document.addEventListener('keydown', handler, true);

            // Also listen inside the block editor iframe (WP 6.3+)
            var attachToEditorIframe = function() {
                var iframes = document.querySelectorAll('iframe[name="editor-canvas"]');
                iframes.forEach(function(iframe) {
                    try {
                        var iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
                        if (iframeDoc && !iframeDoc._zipwpShortcutAttached) {
                            iframeDoc.addEventListener('keydown', handler, true);
                            iframeDoc._zipwpShortcutAttached = true;
                        }
                    } catch (e) { /* cross-origin iframe, skip */ }
                });
            };

            // Retry attaching since editor iframe loads asynchronously
            setTimeout(attachToEditorIframe, 1000);
            setTimeout(attachToEditorIframe, 3000);
        }

        // ── Fullscreen ────────────────────────────────────────────────

        emitFullscreenChanged(isFullscreen) {
            if (window.zipwpMcpAppBridge && typeof window.zipwpMcpAppBridge.emit === 'function') {
                window.zipwpMcpAppBridge.emit('fullscreen_changed', { fullscreen: !!isFullscreen });
            }
        }

        toggleFullscreen(isFullscreen) {
            var container = document.getElementById('zip-ai-assistant-container');
            if (!container) return;

            if (isFullscreen) {
                container.classList.add('zip-ai-iframe-fullscreen');
                document.body.classList.add('zip-ai-assistant-fullscreen');
                // Legacy alias for older selectors/scripts.
                document.body.classList.add('zip-ai-iframe-fullscreen');
            } else {
                container.classList.remove('zip-ai-iframe-fullscreen');
                document.body.classList.remove('zip-ai-assistant-fullscreen');
                document.body.classList.remove('zip-ai-iframe-fullscreen');
            }

            this.emitFullscreenChanged(isFullscreen);
        }

        // ── Resize Handle ─────────────────────────────────────────────

        setupResizeHandle() {
            var resizeHandle = document.getElementById('zip-ai-resize-handle');
            if (!resizeHandle) return;

            var isDragging = false;
            var startX = 0;
            var startWidth = 0;
            var self = this;

            var onMouseDown = function(e) {
                e.preventDefault();
                isDragging = true;
                startX = e.clientX;
                var container = document.getElementById('zip-ai-assistant-container');
                startWidth = container ? container.offsetWidth : 550;

                resizeHandle.classList.add('dragging');
                document.body.classList.add('zip-ai-resizing');

                document.addEventListener('mousemove', onMouseMove);
                document.addEventListener('mouseup', onMouseUp);
            };

            var onMouseMove = function(e) {
                if (!isDragging) return;
                var delta = startX - e.clientX;
                var newWidth = startWidth + delta;
                self.resizeSidebar(newWidth);
            };

            var onMouseUp = function() {
                if (!isDragging) return;

                isDragging = false;
                resizeHandle.classList.remove('dragging');
                document.body.classList.remove('zip-ai-resizing');

                document.removeEventListener('mousemove', onMouseMove);
                document.removeEventListener('mouseup', onMouseUp);

                var container = document.getElementById('zip-ai-assistant-container');
                if (container) {
                    self.saveSidebarWidth(container.offsetWidth);
                }
            };

            resizeHandle.addEventListener('mousedown', onMouseDown);

            resizeHandle.addEventListener('touchstart', function(e) {
                var touch = e.touches[0];
                onMouseDown({ clientX: touch.clientX, preventDefault: function() { e.preventDefault(); } });
            });

            document.addEventListener('touchmove', function(e) {
                if (!isDragging) return;
                var touch = e.touches[0];
                onMouseMove({ clientX: touch.clientX });
            });

            document.addEventListener('touchend', onMouseUp);
        }

        resizeSidebar(width) {
            var minWidth = 360;
            var maxWidth = Math.round(window.innerWidth * 0.8);
            var clampedWidth = Math.min(maxWidth, Math.max(minWidth, width));

            document.documentElement.style.setProperty('--zipwp-sidebar-width', clampedWidth + 'px');

            var container = document.getElementById('zip-ai-assistant-container');
            if (container) {
                container.style.width = clampedWidth + 'px';
            }
        }

        saveSidebarWidth(width) {
            var minWidth = 360;
            var maxWidth = Math.round(window.innerWidth * 0.8);
            var clampedWidth = Math.min(maxWidth, Math.max(minWidth, width));

            localStorage.setItem('zipwp-sidebar-width', clampedWidth.toString());
            this.resizeSidebar(clampedWidth);
        }

        loadSavedSidebarWidth() {
            var savedWidth = localStorage.getItem('zipwp-sidebar-width');
            if (savedWidth) {
                this.resizeSidebar(parseInt(savedWidth, 10));
            }
        }

        // ── Generic Message Handler ───────────────────────────────────

        handleMessage(type, data) {
            // Extensibility point for future message types
        }
    }

    // Initialize and expose globally
    var bridgeHost = new WPBridgeHost();
    window.zipwpMcpBridge = bridgeHost;

})();
