<?php
/**
 * Rank Booster Breakdance Support
 *
 * Breakdance stores its layout as a JSON tree in the post meta `_breakdance_data`.
 * The visible post_content holds only a launcher stub
 * (<!-- wp:breakdance/block-breakdance-launcher /-->), so the generic
 * post_content pipeline sees almost no text. That is why a Breakdance page
 * produced a tiny, wrong "extracted content".
 *
 * This class mirrors the Elementor handler: it extracts editable text into
 * stable-ID slots, serialises them as <div id="..."> HTML for the LLM,
 * parses the reply back, and re-injects the improved text into the JSON tree.
 *
 * Schema-defensive: instead of hard-coding Breakdance widget/field names
 * (which change between versions), we walk the whole node `data` subtree and
 * treat any *prose* string leaf as editable (same heuristic the Elementor
 * walker uses as a fallback: >= 30 chars, >= 3 spaces, not a URL/CSS token).
 * Each slot key encodes the node id + JSON path so re-import is deterministic.
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class Rank_Booster_Breakdance {

    /** Meta key Breakdance stores its tree in. */
    const DATA_META = '_breakdance_data';

    /** Meta key for storing the AI draft tree (not yet live). */
    const META_DRAFT = '_rank_booster_breakdance_draft';

    /** Meta key: set when an AI draft was loaded into the Breakdance builder. */
    const META_DRAFT_PENDING = '_rank_booster_breakdance_draft_pending';

    /** Flag: draft data was injected this request. */
    private static $draft_injected = false;

    /* ═══════════════════════════════════════════
       Bootstrap
       ═══════════════════════════════════════════ */

    public static function init() {
        // When the Breakdance builder requests the tree, swap in the AI draft
        // (one-shot). Breakdance reads `_breakdance_data` via get_post_meta(),
        // so we intercept it with the get_post_metadata filter.
        add_filter( 'get_post_metadata', array( __CLASS__, 'maybe_inject_draft' ), 10, 4 );

        // Best-effort: when the post is saved (Breakdance persists the tree to
        // the same meta key on save) auto-mark the job as pushed_live.
        add_action( 'updated_post_meta', array( __CLASS__, 'on_breakdance_save' ), 10, 4 );
        add_action( 'added_post_meta', array( __CLASS__, 'on_breakdance_save' ), 10, 4 );
    }

    /**
     * Is this request the Breakdance builder loading a document?
     * Builder URLs carry ?breakdance=builder (and an id).
     */
    private static function is_builder_request() {
        return isset( $_GET['breakdance'] ) || isset( $_GET['breakdance_iframe'] );
    }

    /**
     * Swap `_breakdance_data` with the stored AI draft when the builder loads.
     * One-shot: the draft meta is deleted after the first read so a refresh
     * shows the original (un-applied) tree.
     *
     * @param mixed  $value     Short-circuit value (null = let WP read normally).
     * @param int    $object_id Post ID.
     * @param string $meta_key  Meta key being read.
     * @param bool   $single    Whether a single value was requested.
     * @return mixed
     */
    public static function maybe_inject_draft( $value, $object_id, $meta_key, $single ) {
        if ( $meta_key !== self::DATA_META || ! self::is_builder_request() ) {
            return $value;
        }

        // Avoid infinite recursion: this filter fires again for our own reads.
        static $reading = false;
        if ( $reading ) {
            return $value;
        }

        $reading = true;
        $draft   = get_post_meta( $object_id, self::META_DRAFT, true );
        $reading = false;

        if ( empty( $draft ) ) {
            return $value;
        }

        // One-shot consume.
        delete_post_meta( $object_id, self::META_DRAFT );
        update_post_meta( $object_id, self::META_DRAFT_PENDING, '1' );
        self::$draft_injected = true;

        // get_post_meta() with $single=true expects the value wrapped in an array.
        return $single ? array( $draft ) : array( $draft );
    }

    /**
     * Auto-mark the latest completed job pushed_live when Breakdance saves the
     * tree after we injected a draft.
     */
    public static function on_breakdance_save( $meta_id, $object_id, $meta_key, $_meta_value ) {
        if ( $meta_key !== self::DATA_META ) {
            return;
        }
        $pending = get_post_meta( $object_id, self::META_DRAFT_PENDING, true );
        if ( empty( $pending ) ) {
            return;
        }
        delete_post_meta( $object_id, self::META_DRAFT_PENDING );

        if ( ! class_exists( 'Rank_Booster_DB' ) ) {
            return;
        }
        global $wpdb;
        $table = Rank_Booster_DB::table_name();
        $job   = $wpdb->get_row( $wpdb->prepare(
            "SELECT id FROM $table WHERE post_id = %d AND status = 'completed' ORDER BY updated_at DESC LIMIT 1",
            $object_id
        ) );
        if ( $job ) {
            Rank_Booster_DB::update_job( $job->id, array(
                'status'         => 'pushed_live',
                'pushed_live_at' => current_time( 'mysql' ),
            ) );
            Rank_Booster_DB::add_log_entry( $job->id, '🚀 Published from Breakdance builder.' );
        }
    }

    /* ═══════════════════════════════════════════
       Tree helpers (decode / encode / locate)
       ═══════════════════════════════════════════ */

    /**
     * Read and decode the Breakdance tree. Returns null on failure.
     *
     * Breakdance wraps the real tree inside a JSON envelope:
     *   {"tree_json_string":"<escaped JSON of the tree>"}
     * (older versions may store the tree directly). We always return the inner
     * tree (the array containing `root`); save_tree() re-wraps it correctly.
     */
    public static function get_tree( $post_id ) {
        $raw = get_post_meta( $post_id, self::DATA_META, true );
        if ( empty( $raw ) ) {
            return null;
        }
        $outer = is_array( $raw ) ? $raw : json_decode( $raw, true );
        if ( ! is_array( $outer ) ) {
            return null;
        }
        // Envelope form: decode the inner tree string.
        if ( isset( $outer['tree_json_string'] ) ) {
            $inner = json_decode( (string) $outer['tree_json_string'], true );
            return is_array( $inner ) ? $inner : null;
        }
        // Direct form: the decoded data already is the tree.
        return $outer;
    }

    /**
     * Build the stored-format JSON string for an inner tree, preserving the
     * envelope shape (and sibling keys) the post already uses.
     *
     * @param int   $post_id
     * @param array $tree
     * @return string
     */
    public static function wrap_tree( $post_id, array $tree ) {
        $raw   = get_post_meta( $post_id, self::DATA_META, true );
        $outer = is_string( $raw ) ? json_decode( $raw, true ) : ( is_array( $raw ) ? $raw : null );

        // Match Breakdance's own encoding (unescaped slashes/unicode).
        $flags     = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
        $tree_json = wp_json_encode( $tree, $flags );

        if ( is_array( $outer ) && array_key_exists( 'tree_json_string', $outer ) ) {
            $outer['tree_json_string'] = $tree_json;
            return wp_json_encode( $outer, $flags );
        }
        return $tree_json;
    }

    /**
     * Persist the inner tree back to `_breakdance_data` in stored format.
     */
    private static function save_tree( $post_id, array $tree ) {
        update_post_meta( $post_id, self::DATA_META, wp_slash( self::wrap_tree( $post_id, $tree ) ) );
    }

    /* ═══════════════════════════════════════════
       1. Extract editable prose slots
       ═══════════════════════════════════════════ */

    /**
     * Walk the Breakdance tree and return a flat map of slot_key => html text.
     * slot_key encodes the owning node id + the JSON path to the leaf, so the
     * value can be written back deterministically.
     *
     * @param int $post_id
     * @return array
     */
    public static function extract_slots( $post_id ) {
        $tree = self::get_tree( $post_id );
        if ( $tree === null ) {
            return array();
        }
        return self::extract_slots_from_tree( $tree );
    }

    /**
     * Extract the ordered slot map from an in-memory tree.
     * Returns internalKey => html, in stable document order. The same walk is
     * used at extract time and apply time, so positional matching is exact.
     */
    public static function extract_slots_from_tree( $tree ) {
        $slots = array();
        self::walk_nodes( $tree, $slots );
        return $slots;
    }

    /**
     * Recursively find every Breakdance node (object carrying an `id`), then
     * scan that node's own data for prose string leaves.
     */
    private static function walk_nodes( $branch, &$slots ) {
        if ( ! is_array( $branch ) ) {
            return;
        }

        // A "node" is an associative array with an id + a data/properties bag.
        $node_id = self::node_id( $branch );
        if ( $node_id !== null ) {
            // Scan this node's own data, excluding the children subtree.
            foreach ( $branch as $key => $value ) {
                if ( $key === 'children' ) {
                    continue;
                }
                self::scan_leaves( $value, array( $key ), $node_id, $slots );
            }
        }

        // Recurse into children (and any nested arrays that may hold nodes).
        if ( isset( $branch['children'] ) && is_array( $branch['children'] ) ) {
            foreach ( $branch['children'] as $child ) {
                self::walk_nodes( $child, $slots );
            }
        }
        // Some Breakdance versions nest the document under `root` / `tree`.
        foreach ( array( 'root', 'tree' ) as $wrapper ) {
            if ( isset( $branch[ $wrapper ] ) && is_array( $branch[ $wrapper ] ) ) {
                self::walk_nodes( $branch[ $wrapper ], $slots );
            }
        }
    }

    /**
     * Return the node id if $branch looks like a Breakdance node, else null.
     */
    private static function node_id( $branch ) {
        if ( ! is_array( $branch ) || ! isset( $branch['id'] ) ) {
            return null;
        }
        if ( is_scalar( $branch['id'] ) ) {
            return (string) $branch['id'];
        }
        return null;
    }

    /**
     * Recursively scan a value for prose string leaves, recording each as a slot.
     *
     * @param mixed  $value
     * @param array  $path     Path segments from the node root to this value.
     * @param string $node_id
     * @param array  $slots    By reference.
     */
    private static function scan_leaves( $value, $path, $node_id, &$slots ) {
        if ( is_string( $value ) ) {
            if ( self::is_prose( $value ) ) {
                $key = self::make_slot_key( $node_id, $path );
                $slots[ $key ] = $value;
            }
            return;
        }
        if ( is_array( $value ) ) {
            // Don't descend into a nested node here — walk_nodes handles those.
            if ( self::node_id( $value ) !== null ) {
                return;
            }
            foreach ( $value as $k => $v ) {
                self::scan_leaves( $v, array_merge( $path, array( $k ) ), $node_id, $slots );
            }
        }
    }

    /**
     * Prose heuristic: human-readable text worth optimizing.
     * Rejects URLs, CSS tokens, hex colors, IDs/classes and short fragments.
     */
    public static function is_prose( $value ) {
        if ( ! is_string( $value ) || $value === '' ) {
            return false;
        }
        // Never touch embeds.
        if ( preg_match( '/<\s*(iframe|script|embed|object|video|audio|form|svg|canvas)\b/i', $value ) ) {
            return false;
        }
        $plain = trim( wp_strip_all_tags( $value ) );
        if ( $plain === '' ) {
            return false;
        }
        // URLs / file paths / CSS-ish tokens.
        if ( preg_match( '#^(https?:)?//#i', $plain ) || preg_match( '/^#[0-9a-f]{3,8}$/i', $plain ) ) {
            return false;
        }
        if ( strpos( $plain, '{' ) !== false && strpos( $plain, '}' ) !== false ) {
            return false; // looks like CSS / JSON
        }
        // Require real words: >= 30 chars and >= 3 spaces (multi-word sentence).
        return strlen( $plain ) >= 30 && substr_count( $plain, ' ' ) >= 3;
    }

    /* ═══════════════════════════════════════════
       2. Slot keys (reversible, id-safe)
       ═══════════════════════════════════════════ */

    /**
     * Build an HTML-id-safe, reversible slot key from node id + path.
     * Format: bd-<base64url(json([nodeId, path]))>
     */
    private static function make_slot_key( $node_id, array $path ) {
        $payload = wp_json_encode( array( $node_id, $path ) );
        return 'bd-' . rtrim( strtr( base64_encode( $payload ), '+/', '-_' ), '=' );
    }

    /**
     * Decode a slot key back to [ node_id, path[] ] or null.
     */
    private static function parse_slot_key( $key ) {
        if ( strpos( $key, 'bd-' ) !== 0 ) {
            return null;
        }
        $b64 = strtr( substr( $key, 3 ), '-_', '+/' );
        $b64 .= str_repeat( '=', ( 4 - strlen( $b64 ) % 4 ) % 4 );
        $json = base64_decode( $b64, true );
        if ( $json === false ) {
            return null;
        }
        $decoded = json_decode( $json, true );
        if ( ! is_array( $decoded ) || count( $decoded ) !== 2 || ! is_array( $decoded[1] ) ) {
            return null;
        }
        return array( (string) $decoded[0], $decoded[1] );
    }

    /* ═══════════════════════════════════════════
       3. Serialize slots → HTML for the API
       ═══════════════════════════════════════════ */

    /**
     * Convert the ordered slot map into <div id="bd-IDX">text</div> HTML.
     *
     * We use a short positional id (bd-0, bd-1, …) rather than the long
     * base64 internal key, because LLMs reliably preserve short ids but often
     * mangle long opaque ones. The real tree path for each index is recovered
     * later by re-extracting the slots in the same deterministic order.
     */
    public static function slots_to_html( $slots ) {
        $parts = array();
        $i     = 0;
        foreach ( $slots as $text ) {
            $parts[] = '<div id="bd-' . $i . '">' . $text . '</div>';
            $i++;
        }
        return implode( "\n", $parts );
    }

    /**
     * Parse API-returned HTML back into an ordered list of improved texts,
     * indexed by slot position. Robust to the model altering or dropping the
     * id: prefer the numeric suffix of a bd-N id, otherwise fall back to the
     * div's document position.
     *
     * @param string $html
     * @return array  [ int index => improved html ]
     */
    public static function html_to_slots( $html ) {
        if ( empty( $html ) ) {
            return array();
        }
        $dom = new DOMDocument();
        libxml_use_internal_errors( true );
        $dom->loadHTML(
            '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head><body>' . $html . '</body></html>',
            LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
        );
        libxml_clear_errors();

        $xpath = new DOMXPath( $dom );
        $nodes = $xpath->query( '//div[@id]' );

        $texts = array();
        $pos   = 0;
        foreach ( $nodes as $node ) {
            $id = $node->getAttribute( 'id' );
            if ( preg_match( '/^bd-(\d+)$/', $id, $m ) ) {
                $idx = (int) $m[1];
            } else {
                $idx = $pos;
            }
            $texts[ $idx ] = trim( self::inner_html( $node ) );
            $pos++;
        }

        // Fallback: if no ided divs were found at all, treat every top-level
        // div in order as a positional slot (covers a model that stripped ids).
        if ( empty( $texts ) ) {
            $pos = 0;
            foreach ( $xpath->query( '//body/div' ) as $node ) {
                $texts[ $pos ] = trim( self::inner_html( $node ) );
                $pos++;
            }
        }

        ksort( $texts );
        return $texts;
    }

    private static function inner_html( DOMNode $node ) {
        $html = '';
        foreach ( $node->childNodes as $child ) {
            $html .= $node->ownerDocument->saveHTML( $child );
        }
        return $html;
    }

    /**
     * Return any top-level HTML in the draft that is NOT a bd- slot div — i.e.
     * standalone appended content such as an FAQ or summary section.
     *
     * @param string $html
     * @return string
     */
    public static function extract_non_slot_html( $html ) {
        if ( empty( $html ) ) {
            return '';
        }
        $dom = new DOMDocument();
        libxml_use_internal_errors( true );
        $dom->loadHTML(
            '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head><body>' . $html . '</body></html>',
            LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
        );
        libxml_clear_errors();

        $body = $dom->getElementsByTagName( 'body' )->item( 0 );
        if ( ! $body ) {
            return '';
        }

        $extra = '';
        foreach ( $body->childNodes as $node ) {
            if ( $node->nodeType === XML_ELEMENT_NODE
                && strtolower( $node->nodeName ) === 'div'
                && $node->hasAttribute( 'id' )
                && strpos( $node->getAttribute( 'id' ), 'bd-' ) === 0
            ) {
                continue; // a slot div — not appended content
            }
            $extra .= $dom->saveHTML( $node );
        }
        return trim( $extra );
    }

    /* ═══════════════════════════════════════════
       5. Append standalone content as a new node
       ═══════════════════════════════════════════ */

    /**
     * Append (or prepend) a new Breakdance Text node carrying $html.
     * Attaches inside the first/last Section when available, else at root.
     *
     * @param array  $tree      By reference (inner tree with `root`).
     * @param string $html
     * @param string $position  'append' | 'prepend'
     * @return bool
     */
    private static function append_text_node( &$tree, $html, $position = 'append' ) {
        if ( ! isset( $tree['root'] ) || ! is_array( $tree['root'] ) ) {
            return false;
        }

        $new_id = self::next_node_id( $tree );

        $node = array(
            'id'   => $new_id,
            'data' => array(
                'type'       => 'EssentialElements\\Text',
                'properties' => array(
                    'content' => array(
                        'content' => array( 'text' => $html ),
                    ),
                ),
            ),
            'children' => array(),
        );

        $root      =& $tree['root'];
        $root_id   = isset( $root['id'] ) ? $root['id'] : 1;
        if ( ! isset( $root['children'] ) || ! is_array( $root['children'] ) ) {
            $root['children'] = array();
        }

        // Find a Section/Container child to host the node.
        $host_idx = null;
        $indices  = array_keys( $root['children'] );
        if ( $position === 'prepend' ) {
            foreach ( $indices as $i ) {
                if ( self::is_container( $root['children'][ $i ] ) ) { $host_idx = $i; break; }
            }
        } else {
            foreach ( array_reverse( $indices ) as $i ) {
                if ( self::is_container( $root['children'][ $i ] ) ) { $host_idx = $i; break; }
            }
        }

        if ( $host_idx !== null ) {
            $host =& $root['children'][ $host_idx ];
            if ( ! isset( $host['children'] ) || ! is_array( $host['children'] ) ) {
                $host['children'] = array();
            }
            $node['_parentId'] = isset( $host['id'] ) ? $host['id'] : $root_id;
            if ( $position === 'prepend' ) {
                array_unshift( $host['children'], $node );
            } else {
                $host['children'][] = $node;
            }
            unset( $host );
        } else {
            $node['_parentId'] = $root_id;
            if ( $position === 'prepend' ) {
                array_unshift( $root['children'], $node );
            } else {
                $root['children'][] = $node;
            }
        }

        return true;
    }

    /**
     * Is this node a layout container that can hold child elements?
     */
    private static function is_container( $node ) {
        $type = isset( $node['data']['type'] ) ? $node['data']['type'] : '';
        return (bool) preg_match( '/(Section|Container|Columns|Column|Div|Group)/i', $type );
    }

    /**
     * Allocate the next node id, honouring Breakdance's _nextNodeId counter
     * when present and bumping it; otherwise max existing id + 1.
     */
    private static function next_node_id( &$tree ) {
        if ( isset( $tree['_nextNodeId'] ) && is_numeric( $tree['_nextNodeId'] ) ) {
            $id = (int) $tree['_nextNodeId'];
            $tree['_nextNodeId'] = $id + 1;
            return $id;
        }
        $max = 1;
        $stack = isset( $tree['root'] ) ? array( $tree['root'] ) : array();
        while ( $stack ) {
            $n = array_pop( $stack );
            if ( is_array( $n ) ) {
                if ( isset( $n['id'] ) && is_numeric( $n['id'] ) ) {
                    $max = max( $max, (int) $n['id'] );
                }
                if ( isset( $n['children'] ) && is_array( $n['children'] ) ) {
                    foreach ( $n['children'] as $c ) { $stack[] = $c; }
                }
            }
        }
        return $max + 1;
    }

    /* ═══════════════════════════════════════════
       4. Apply slots back into the tree
       ═══════════════════════════════════════════ */

    /**
     * Apply an AI draft to the live `_breakdance_data`. Handles both slot
     * rewrites (<div id="bd-N">) and standalone appended content (FAQ /
     * summary HTML with no slots), which is added as a new Breakdance node.
     *
     * @param int    $post_id
     * @param string $draft_html  Raw draft content returned by the API.
     * @return int|false  Number of changes written, or false on failure.
     */
    public static function apply_draft( $post_id, $draft_html, $position = 'auto' ) {
        $tree = self::get_tree( $post_id );
        if ( $tree === null ) {
            return false;
        }
        $applied = self::apply_draft_to_tree( $tree, $draft_html, $position );
        if ( $applied > 0 ) {
            self::save_tree( $post_id, $tree );
        }
        return $applied;
    }

    /**
     * Back-compat shim: apply a parsed positional slot list only.
     */
    public static function apply_slots( $post_id, $idx_texts ) {
        if ( empty( $idx_texts ) ) {
            return false;
        }
        $tree = self::get_tree( $post_id );
        if ( $tree === null ) {
            return false;
        }
        $applied = self::apply_indexed_to_tree( $tree, $idx_texts );
        if ( $applied > 0 ) {
            self::save_tree( $post_id, $tree );
        }
        return $applied;
    }

    /**
     * Compute the modified tree in stored (envelope) format in memory — used
     * for the preview filter and the builder draft injection, so the value can
     * be served directly as `_breakdance_data`. Returns null only when there is
     * no tree at all; otherwise returns the (possibly unchanged) tree so the
     * caller never hard-fails.
     */
    public static function build_preview_data( $post_id, $draft_content, $position = 'auto' ) {
        $tree = self::get_tree( $post_id );
        if ( $tree === null ) {
            return null;
        }
        self::apply_draft_to_tree( $tree, $draft_content, $position );
        return self::wrap_tree( $post_id, $tree );
    }

    /**
     * Apply a draft (slots + appended content) to an in-memory tree.
     *
     * @param array  $tree        By reference.
     * @param string $draft_html
     * @param string $position    'append' | 'prepend' | 'auto'
     * @return int  Number of changes made.
     */
    private static function apply_draft_to_tree( &$tree, $draft_html, $position = 'auto' ) {
        $changes = 0;

        // 1. Slot rewrites.
        $idx_texts = self::html_to_slots( $draft_html );
        $changes  += self::apply_indexed_to_tree( $tree, $idx_texts );

        // 2. Standalone content (FAQ / summary) that isn't a slot div → add as
        //    a new Text node. FAQ goes to the bottom, summary to the top.
        $non_slot = self::extract_non_slot_html( $draft_html );
        if ( $non_slot !== '' ) {
            $append = ( $position !== 'prepend' );
            if ( self::append_text_node( $tree, $non_slot, $append ? 'append' : 'prepend' ) ) {
                $changes++;
            }
        }

        return $changes;
    }

    /**
     * Map positional indexes back to real tree paths (by re-extracting the slot
     * order from the tree) and write each improved text in place.
     *
     * @param array $tree       By reference.
     * @param array $idx_texts  [ index => improved html ]
     * @return int  Number of leaves written.
     */
    private static function apply_indexed_to_tree( &$tree, $idx_texts ) {
        if ( empty( $idx_texts ) ) {
            return 0;
        }

        // Re-extract the ordered internal keys from THIS tree — identical walk
        // order to extraction, so index N maps to the same leaf we sent as bd-N.
        $ordered_keys = array_keys( self::extract_slots_from_tree( $tree ) );
        if ( empty( $ordered_keys ) ) {
            return 0;
        }

        $by_node = array();
        foreach ( $idx_texts as $idx => $value ) {
            if ( ! isset( $ordered_keys[ $idx ] ) ) {
                continue; // model returned more slots than we sent — ignore extras
            }
            $parsed = self::parse_slot_key( $ordered_keys[ $idx ] );
            if ( $parsed === null ) {
                continue;
            }
            list( $node_id, $path ) = $parsed;
            $by_node[ $node_id ][] = array( 'path' => $path, 'value' => $value );
        }
        if ( empty( $by_node ) ) {
            return 0;
        }
        return self::apply_to_nodes( $tree, $by_node );
    }

    /**
     * Recursively find nodes by id and set the leaf at each recorded path.
     * Returns the number of leaves written.
     */
    private static function apply_to_nodes( &$branch, $by_node ) {
        $count = 0;
        if ( ! is_array( $branch ) ) {
            return 0;
        }

        $node_id = self::node_id( $branch );
        if ( $node_id !== null && isset( $by_node[ $node_id ] ) ) {
            foreach ( $by_node[ $node_id ] as $target ) {
                if ( self::set_path( $branch, $target['path'], $target['value'] ) ) {
                    $count++;
                }
            }
        }

        if ( isset( $branch['children'] ) && is_array( $branch['children'] ) ) {
            foreach ( $branch['children'] as &$child ) {
                $count += self::apply_to_nodes( $child, $by_node );
            }
            unset( $child );
        }
        foreach ( array( 'root', 'tree' ) as $wrapper ) {
            if ( isset( $branch[ $wrapper ] ) && is_array( $branch[ $wrapper ] ) ) {
                $count += self::apply_to_nodes( $branch[ $wrapper ], $by_node );
            }
        }
        return $count;
    }

    /**
     * Set a nested array value at $path. Only overwrites an existing string
     * leaf (never creates new structure) — protects the tree from corruption.
     */
    private static function set_path( &$node, array $path, $value ) {
        $ref =& $node;
        foreach ( $path as $segment ) {
            if ( ! is_array( $ref ) || ! array_key_exists( $segment, $ref ) ) {
                return false;
            }
            $ref =& $ref[ $segment ];
        }
        if ( ! is_string( $ref ) ) {
            return false;
        }
        $ref = $value;
        return true;
    }
}
