<?php
/**
 * Rank Booster Elementor Support
 *
 * Extracts text content from Elementor's _elementor_data JSON,
 * serializes it as plain HTML for the API, and re-injects improved
 * text back into the JSON after the LLM returns results.
 *
 * The API uses editor_type='elementor' to instruct the LLM:
 * "Preserve all <div id="..."> wrapper tags exactly. Only improve text inside them."
 */

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

class Rank_Booster_Elementor {

    /** Meta key for storing draft Elementor data (not yet live). */
    const META_DRAFT = '_rank_booster_elementor_draft';

    /** Meta key: set when AI draft was loaded into the Elementor editor. */
    const META_DRAFT_PENDING = '_rank_booster_draft_pending';

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

    /**
     * Register hooks. Called once from the bootstrap (class-rank-booster.php).
     */
    public static function init() {
        // When the Elementor editor loads a document, inject stored draft data.
        add_filter( 'elementor/document/load/data', array( __CLASS__, 'filter_editor_draft_data' ), 10, 2 );

        // After the editor loads, mark the document dirty so the Publish button enables.
        add_action( 'elementor/editor/footer', array( __CLASS__, 'mark_editor_dirty' ) );

        // When user saves from Elementor editor, auto-mark the job as pushed_live.
        add_action( 'elementor/document/after_save', array( __CLASS__, 'on_elementor_save' ), 10, 2 );
    }

    /**
     * Elementor filter: swap in AI-modified elements when the editor opens a
     * post that has pending draft data stored by "Edit Draft".
     *
     * The draft meta is deleted after injection (one-shot), so:
     *  - If the user saves in Elementor → AI changes become the live version.
     *  - If the user closes without saving → live data is untouched.
     *  - If the user refreshes → original data loads (draft meta was deleted).
     *
     * @param array                                $data     Elements array.
     * @param \Elementor\Core\Base\Document        $document Document instance.
     * @return array
     */
    public static function filter_editor_draft_data( $data, $document ) {
        // Only act inside the Elementor editor, never on the frontend.
        if ( ! class_exists( '\Elementor\Plugin' ) || ! \Elementor\Plugin::$instance->editor->is_edit_mode() ) {
            return $data;
        }

        $post_id = $document->get_main_id();
        if ( ! $post_id ) {
            return $data;
        }

        $draft_json = get_post_meta( $post_id, self::META_DRAFT, true );
        if ( empty( $draft_json ) ) {
            return $data;
        }

        $draft_data = json_decode( $draft_json, true );
        if ( ! is_array( $draft_data ) ) {
            return $data;
        }

        // Delete immediately — one-shot injection into the editor.
        delete_post_meta( $post_id, self::META_DRAFT );

        // Mark that we injected a draft — persists so the save handler can detect it.
        update_post_meta( $post_id, self::META_DRAFT_PENDING, '1' );

        self::$draft_injected = true;

        return $draft_data;
    }

    /**
     * Inject a tiny JS snippet that marks the document as modified ("dirty")
     * so the Publish / Update button becomes active.
     * Only fires when we actually injected draft data above.
     */
    public static function mark_editor_dirty() {
        if ( ! self::$draft_injected ) {
            return;
        }
        ?>
        <script>
        /* Rank Booster: mark document dirty so user can save the AI draft */
        jQuery(window).on('elementor:init', function() {
            elementor.on('document:loaded', function() {
                if (typeof elementor.saver !== 'undefined') {
                    elementor.saver.setFlagEditorChange(true);
                }
            });
        });
        </script>
        <?php
    }

    /**
     * When the user publishes/updates from Elementor after our draft was injected,
     * auto-mark the corresponding rank booster job as pushed_live.
     *
     * @param \Elementor\Core\Base\Document $document
     * @param array                         $data  Save data (may contain settings/elements).
     */
    public static function on_elementor_save( $document, $data ) {
        // Skip autosaves — only act on real publish/update.
        if ( $document->is_autosave() ) {
            return;
        }

        $post_id = $document->get_main_id();
        if ( ! $post_id ) {
            return;
        }

        // Check if we previously injected a draft into this post's editor.
        $pending = get_post_meta( $post_id, self::META_DRAFT_PENDING, true );
        if ( empty( $pending ) ) {
            return;
        }

        // Clean up the flag.
        delete_post_meta( $post_id, self::META_DRAFT_PENDING );

        // Find the latest completed job for this post and mark it pushed_live.
        if ( class_exists( 'Rank_Booster_DB' ) ) {
            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",
                $post_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 Elementor editor.' );
            }
        }
    }

    /**
     * Widget types and the settings fields that contain editable text.
     * Only these fields are extracted and sent to the LLM.
     */
    const TEXT_FIELDS = array(
        'heading'          => array( 'title' ),
        'text-editor'      => array( 'editor' ),
        'button'           => array( 'text' ),
        'icon-box'         => array( 'title_text', 'description_text' ),
        'image-box'        => array( 'title_text', 'description_text' ),
        'alert'            => array( 'alert_title', 'alert_description' ),
        'text'             => array( 'text' ),
        'toggle'           => array(),  // handled via repeater below
        'accordion'        => array(),  // handled via repeater below
        'tabs'             => array(),  // handled via repeater below
    );

    /**
     * Repeater-based widgets: widget_type => [ repeater_field => [title_field, content_field] ]
     */
    const REPEATER_FIELDS = array(
        'accordion' => array( 'tabs'  => array( 'tab_title', 'tab_content' ) ),
        'toggle'    => array( 'tabs'  => array( 'tab_title', 'tab_content' ) ),
        'tabs'      => array( 'tabs'  => array( 'tab_title', 'tab_content' ) ),
    );

    /* ═══════════════════════════════════════════
       0. Shared helpers (embed safety + cleanup)
       ═══════════════════════════════════════════ */

    /**
     * Does this HTML contain a non-prose embed that must never be sent to the
     * LLM or overwritten? Covers iframes, scripts, and other embed/media tags.
     *
     * @param string $html
     * @return bool
     */
    public static function contains_embed( $html ) {
        if ( ! is_string( $html ) || $html === '' ) {
            return false;
        }
        return (bool) preg_match( '/<\s*(iframe|script|embed|object|video|audio|form|svg|canvas|map)\b/i', $html );
    }

    /**
     * Strip transient TinyMCE editor artifacts and zero-width/BOM characters
     * that should never be persisted (e.g. selection bookmark spans).
     *
     * @param string $html
     * @return string
     */
    public static function strip_editor_artifacts( $html ) {
        if ( ! is_string( $html ) || $html === '' ) {
            return $html;
        }
        // Remove TinyMCE selection bookmark spans (mce_SELRES_* / data-mce-type="bookmark").
        $html = preg_replace( '#<span[^>]*data-mce-type=("|\')bookmark\1[^>]*>.*?</span>#is', '', $html );
        // Remove any stray BOM / zero-width no-break space characters.
        $html = str_replace( "\xEF\xBB\xBF", '', $html );
        $html = preg_replace( '/\x{FEFF}/u', '', $html );
        return $html;
    }

    /**
     * Add a slot value to the map, skipping embeds and cleaning editor artifacts.
     * Embed-bearing fields (iframes etc.) are never optimized.
     *
     * @param array  $slots  By reference.
     * @param string $key    Slot key.
     * @param string $value  Raw field value.
     */
    private static function maybe_add_slot( &$slots, $key, $value ) {
        if ( ! is_string( $value ) || $value === '' ) {
            return;
        }
        // Never extract embed-only/non-prose widgets — they have no text to improve
        // and round-tripping them through the LLM/DOM corrupts the embed.
        if ( self::contains_embed( $value ) ) {
            return;
        }
        $clean = self::strip_editor_artifacts( $value );
        if ( trim( wp_strip_all_tags( $clean ) ) === '' ) {
            return;
        }
        $slots[ $key ] = $clean;
    }

    /* ═══════════════════════════════════════════
       1. Extract text slots from _elementor_data
       ═══════════════════════════════════════════ */

    /**
     * Find the first Heading widget with header_size = 'h1' in _elementor_data.
     * Returns [ 'slot_key' => 'widgetId.title', 'text' => '...' ] or null.
     *
     * Used to send the actual visual H1 to the API instead of the WP post title.
     *
     * @param int $post_id
     * @return array|null
     */
    public static function get_h1_slot( $post_id ) {
        $raw = get_post_meta( $post_id, '_elementor_data', true );
        if ( empty( $raw ) ) {
            return null;
        }
        $data = is_array( $raw ) ? $raw : json_decode( $raw, true );
        if ( ! is_array( $data ) ) {
            return null;
        }
        return self::find_h1_widget( $data );
    }

    /**
     * Recursively search elements for the first h1 heading widget.
     */
    private static function find_h1_widget( $elements ) {
        foreach ( $elements as $el ) {
            if ( ! empty( $el['elType'] ) && $el['elType'] === 'widget' && ( $el['widgetType'] ?? '' ) === 'heading' ) {
                $settings    = $el['settings'] ?? array();
                $header_size = $settings['header_size'] ?? 'h2';
                if ( $header_size === 'h1' && ! empty( $settings['title'] ) ) {
                    return array(
                        'slot_key' => $el['id'] . '.title',
                        'text'     => $settings['title'],
                    );
                }
            }
            if ( ! empty( $el['elements'] ) && is_array( $el['elements'] ) ) {
                $found = self::find_h1_widget( $el['elements'] );
                if ( $found ) {
                    return $found;
                }
            }
        }
        return null;
    }

    /**
     * Read _elementor_data for a post and return a flat map of slot_key => text.
     *
     * @param int $post_id
     * @return array  e.g. [ 'abc123.title' => 'Heading text', ... ]
     */
    public static function extract_text_slots( $post_id ) {
        $raw = get_post_meta( $post_id, '_elementor_data', true );
        if ( empty( $raw ) ) {
            return array();
        }

        $data = is_array( $raw ) ? $raw : json_decode( $raw, true );
        if ( ! is_array( $data ) ) {
            return array();
        }

        $slots = array();
        self::walk_elements( $data, $slots );
        return $slots;
    }

    /**
     * Recursively walk Elementor elements and populate $slots.
     */
    private static function walk_elements( $elements, &$slots ) {
        foreach ( $elements as $el ) {
            if ( empty( $el['elType'] ) ) {
                continue;
            }

            if ( $el['elType'] === 'widget' && ! empty( $el['widgetType'] ) ) {
                $widget_type = $el['widgetType'];
                $id          = $el['id'];
                $settings    = isset( $el['settings'] ) ? $el['settings'] : array();

                // Standard text fields for known widget types
                if ( isset( self::TEXT_FIELDS[ $widget_type ] ) ) {
                    foreach ( self::TEXT_FIELDS[ $widget_type ] as $field ) {
                        if ( ! empty( $settings[ $field ] ) ) {
                            self::maybe_add_slot( $slots, $id . '.' . $field, $settings[ $field ] );
                        }
                    }
                }

                // Repeater fields for known widget types (accordion, tabs, toggle)
                if ( isset( self::REPEATER_FIELDS[ $widget_type ] ) ) {
                    foreach ( self::REPEATER_FIELDS[ $widget_type ] as $repeater_key => $sub_fields ) {
                        if ( empty( $settings[ $repeater_key ] ) || ! is_array( $settings[ $repeater_key ] ) ) {
                            continue;
                        }
                        foreach ( $settings[ $repeater_key ] as $item_index => $item ) {
                            foreach ( $sub_fields as $field ) {
                                if ( ! empty( $item[ $field ] ) ) {
                                    // Key: widgetId.repeaterKey.index.field
                                    $slot_key = $id . '.' . $repeater_key . '.' . $item_index . '.' . $field;
                                    self::maybe_add_slot( $slots, $slot_key, $item[ $field ] );
                                }
                            }
                        }
                    }
                }

                // Heuristic fallback for unknown/custom widget types:
                // include any settings value that looks like human-readable text.
                // Rule: string, >= 30 chars, at least 3 spaces (indicates multiple words).
                if ( ! isset( self::TEXT_FIELDS[ $widget_type ] ) && ! isset( self::REPEATER_FIELDS[ $widget_type ] ) ) {
                    foreach ( $settings as $field => $value ) {
                        if ( ! is_string( $value ) ) {
                            continue;
                        }
                        $plain = wp_strip_all_tags( $value );
                        if ( strlen( $plain ) >= 30 && substr_count( $plain, ' ' ) >= 3 ) {
                            self::maybe_add_slot( $slots, $id . '.' . $field, $value );
                        }
                    }
                }
            }

            // Recurse into child elements (containers, sections, columns)
            if ( ! empty( $el['elements'] ) && is_array( $el['elements'] ) ) {
                self::walk_elements( $el['elements'], $slots );
            }
        }
    }

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

    /**
     * Convert flat slot map to HTML string for sending to the LLM API.
     * Each slot becomes: <div id="SLOT_KEY">TEXT</div>
     *
     * The API uses editor_type='elementor' to tell the LLM:
     * preserve the <div id="..."> wrappers, only improve the inner text.
     *
     * @param array $slots
     * @return string HTML
     */
    public static function slots_to_html( $slots ) {
        $parts = array();
        foreach ( $slots as $key => $text ) {
            // Sanitize key for use as HTML id (dots are valid in HTML5 id attributes)
            $parts[] = '<div id="' . esc_attr( $key ) . '">' . $text . '</div>';
        }
        return implode( "\n", $parts );
    }

    /* ═══════════════════════════════════════════
       3. Parse API-returned HTML → slot map
       ═══════════════════════════════════════════ */

    /**
     * Parse the HTML returned by the API back into a flat slot map.
     * Looks for <div id="SLOT_KEY">...</div> elements.
     *
     * @param string $html
     * @return array  Flat map of slot_key => improved text
     */
    public static function html_to_slots( $html ) {
        if ( empty( $html ) ) {
            return array();
        }

        $slots = array();

        // Use DOMDocument to parse — suppress warnings for HTML5 quirks
        $dom = new DOMDocument();
        libxml_use_internal_errors( true );
        // Declare UTF-8 explicitly — without this, libxml assumes ISO-8859-1 and
        // mangles multibyte characters (e.g. Danish æ/ø/å) on every round-trip.
        $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 );
        // Find all div elements with an id that looks like a slot key (contains a dot)
        $nodes = $xpath->query( '//div[@id]' );

        foreach ( $nodes as $node ) {
            $id = $node->getAttribute( 'id' );
            // Valid slot keys contain at least one dot
            if ( strpos( $id, '.' ) === false ) {
                continue;
            }
            // Get inner HTML of the node
            $inner = self::strip_editor_artifacts( self::inner_html( $node ) );
            $slots[ $id ] = $inner;
        }

        return $slots;
    }

    /**
     * Extract any HTML that is NOT inside a slot <div id="widget.field"> wrapper.
     * Used to detect add_faq / quick_summary content returned for Elementor pages.
     *
     * @param string $html
     * @return string  The non-slot HTML, or empty string.
     */
    public static function extract_non_slot_html( $html ) {
        if ( empty( $html ) ) {
            return '';
        }

        $dom = new DOMDocument();
        libxml_use_internal_errors( true );
        // Declare UTF-8 explicitly (see html_to_slots) to avoid charset corruption.
        $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 );
        $extra = '';

        foreach ( $body->childNodes as $node ) {
            if ( $node->nodeType === XML_ELEMENT_NODE
                && strtolower( $node->nodeName ) === 'div'
                && $node->hasAttribute( 'id' )
                && strpos( $node->getAttribute( 'id' ), '.' ) !== false
            ) {
                // This is a slot div — skip
                continue;
            }
            $extra .= $dom->saveHTML( $node );
        }

        return trim( $extra );
    }

    /**
     * Add a new text-editor widget at the end (or start) of the first/last
     * top-level section/container in _elementor_data.
     *
     * @param int    $post_id
     * @param string $html        HTML content for the new widget.
     * @param string $position    'append' or 'prepend'.
     * @return bool
     */
    public static function inject_html_widget( $post_id, $html, $position = 'append' ) {
        if ( empty( $html ) ) {
            return false;
        }

        $raw = get_post_meta( $post_id, '_elementor_data', true );
        if ( empty( $raw ) ) {
            return false;
        }

        $data = is_array( $raw ) ? $raw : json_decode( $raw, true );
        if ( ! is_array( $data ) ) {
            return false;
        }

        // Build a minimal text-editor widget
        $widget = array(
            'id'         => substr( md5( uniqid( '', true ) ), 0, 7 ),
            'elType'     => 'widget',
            'widgetType' => 'text-editor',
            'settings'   => array( 'editor' => $html ),
            'elements'   => array(),
        );

        if ( $position === 'prepend' ) {
            // Add to the start of the first top-level element's children
            if ( ! empty( $data[0]['elements'] ) ) {
                array_unshift( $data[0]['elements'], $widget );
            } else {
                array_unshift( $data, $widget );
            }
        } else {
            // Append to the last top-level element's children
            $last = count( $data ) - 1;
            if ( isset( $data[ $last ]['elements'] ) ) {
                $data[ $last ]['elements'][] = $widget;
            } else {
                $data[] = $widget;
            }
        }

        update_post_meta( $post_id, '_elementor_data', wp_slash( wp_json_encode( $data ) ) );
        self::regenerate_post_content( $post_id );
        self::clear_cache( $post_id );

        return true;
    }

    /**
     * Get the inner HTML of a DOMNode.
     */
    private static function inner_html( DOMNode $node ) {
        $html = '';
        foreach ( $node->childNodes as $child ) {
            $html .= $node->ownerDocument->saveHTML( $child );
        }
        return trim( $html );
    }

    /* ═══════════════════════════════════════════
       4. Re-inject slot map → _elementor_data
       ═══════════════════════════════════════════ */

    /**
     * Apply an improved slot map back into _elementor_data for a post.
     * Only updates the specific settings fields — layout, IDs, colors etc. untouched.
     *
     * @param int   $post_id
     * @param array $slots  Flat map of slot_key => improved text
     * @return bool True on success.
     */
    public static function apply_text_slots( $post_id, $slots ) {
        if ( empty( $slots ) ) {
            return false;
        }

        $raw = get_post_meta( $post_id, '_elementor_data', true );
        if ( empty( $raw ) ) {
            return false;
        }

        $data = is_array( $raw ) ? $raw : json_decode( $raw, true );
        if ( ! is_array( $data ) ) {
            return false;
        }

        // Build lookup: widget_id => array of field => value
        $updates = array();
        foreach ( $slots as $key => $value ) {
            $parts     = explode( '.', $key, 2 );
            $widget_id = $parts[0];
            $field_key = isset( $parts[1] ) ? $parts[1] : '';
            if ( $widget_id && $field_key ) {
                $updates[ $widget_id ][ $field_key ] = $value;
            }
        }

        self::apply_updates( $data, $updates );

        // Save back — Elementor expects a JSON string in post meta
        update_post_meta( $post_id, '_elementor_data', wp_slash( wp_json_encode( $data ) ) );

        // Also update post_content (Elementor stores a plain-text copy there)
        // Regenerate via Elementor if available, otherwise leave as-is
        self::regenerate_post_content( $post_id );

        self::clear_cache( $post_id );

        return true;
    }

    /**
     * Recursively apply widget updates to the elements tree.
     */
    public static function apply_updates( &$elements, $updates ) {
        foreach ( $elements as &$el ) {
            if ( empty( $el['elType'] ) ) {
                continue;
            }

            if ( $el['elType'] === 'widget' && ! empty( $el['id'] ) ) {
                $widget_id   = $el['id'];
                $widget_type = $el['widgetType'] ?? '';

                if ( isset( $updates[ $widget_id ] ) ) {
                    foreach ( $updates[ $widget_id ] as $field_key => $value ) {
                        // Check if this is a repeater field (format: repeaterKey.index.field)
                        $sub = explode( '.', $field_key, 3 );
                        if ( count( $sub ) === 3 && isset( self::REPEATER_FIELDS[ $widget_type ][ $sub[0] ] ) ) {
                            // $sub[0] = repeater key, $sub[1] = index, $sub[2] = field
                            $rep_key   = $sub[0];
                            $item_idx  = (int) $sub[1];
                            $item_field = $sub[2];
                            if ( isset( $el['settings'][ $rep_key ][ $item_idx ] ) ) {
                                $el['settings'][ $rep_key ][ $item_idx ][ $item_field ] = $value;
                            }
                        } else {
                            // Simple field. Safety guard: never replace a field that
                            // contains an embed (iframe/script/etc.) with a value that
                            // has dropped it — protects embeds from LLM/DOM mangling.
                            $existing = isset( $el['settings'][ $field_key ] ) ? $el['settings'][ $field_key ] : '';
                            if ( self::contains_embed( $existing ) && ! self::contains_embed( $value ) ) {
                                continue;
                            }
                            $el['settings'][ $field_key ] = $value;
                        }
                    }
                }
            }

            if ( ! empty( $el['elements'] ) && is_array( $el['elements'] ) ) {
                self::apply_updates( $el['elements'], $updates );
            }
        }
        unset( $el );
    }

    /* ═══════════════════════════════════════════
       5. Build preview data (in-memory, no DB write)
       ═══════════════════════════════════════════ */

    /**
     * Build a modified _elementor_data JSON string with AI slots applied.
     * Used for preview: computes the result in memory without touching the DB.
     *
     * @param int    $post_id
     * @param string $draft_content  Slot HTML from the API (div wrappers).
     * @param string $draft_title    Improved H1 text (optional).
     * @return string|null  Modified JSON string, or null on failure.
     */
    public static function build_preview_data( $post_id, $draft_content, $draft_title = '' ) {
        $raw = get_post_meta( $post_id, '_elementor_data', true );
        if ( empty( $raw ) ) {
            return null;
        }

        $data = is_array( $raw ) ? $raw : json_decode( $raw, true );
        if ( ! is_array( $data ) ) {
            return null;
        }

        // Parse API slot HTML back to slot map
        $slots = self::html_to_slots( $draft_content );

        // Inject improved H1 into the correct heading widget slot
        if ( ! empty( $draft_title ) ) {
            $h1 = self::find_h1_widget( $data );
            if ( $h1 ) {
                $slots[ $h1['slot_key'] ] = $draft_title;
            }
        }

        // Apply slot updates in memory
        if ( ! empty( $slots ) ) {
            $updates = array();
            foreach ( $slots as $key => $value ) {
                $parts     = explode( '.', $key, 2 );
                $widget_id = $parts[0];
                $field_key = isset( $parts[1] ) ? $parts[1] : '';
                if ( $widget_id && $field_key ) {
                    $updates[ $widget_id ][ $field_key ] = $value;
                }
            }
            self::apply_updates( $data, $updates );
        }

        // Inject non-slot HTML (FAQ / summary) as a new text-editor widget.
        // Position heuristic: FAQ → append (bottom), summary/key takeaways → prepend (top).
        $non_slot = self::extract_non_slot_html( $draft_content );
        if ( ! empty( $non_slot ) ) {
            $widget = array(
                'id'         => substr( md5( uniqid( '', true ) ), 0, 7 ),
                'elType'     => 'widget',
                'widgetType' => 'text-editor',
                'settings'   => array( 'editor' => $non_slot ),
                'elements'   => array(),
            );

            // Same heuristic as apply_draft() in class-rank-booster-preview.php
            $lower   = strtolower( $non_slot );
            $is_faq  = strpos( $lower, 'faq' ) !== false
                    || strpos( $lower, 'frequently asked' ) !== false;

            if ( $is_faq ) {
                // Append to the last top-level element
                $last = count( $data ) - 1;
                if ( $last >= 0 && isset( $data[ $last ]['elements'] ) ) {
                    $data[ $last ]['elements'][] = $widget;
                } else {
                    $data[] = $widget;
                }
            } else {
                // Prepend to the first top-level element
                if ( ! empty( $data[0]['elements'] ) ) {
                    array_unshift( $data[0]['elements'], $widget );
                } else {
                    array_unshift( $data, $widget );
                }
            }
        }

        return wp_json_encode( $data );
    }

    /* ═══════════════════════════════════════════
       6. Cache clearing
       ═══════════════════════════════════════════ */

    /**
     * Clear Elementor's CSS/HTML file cache for a post.
     * Required after updating _elementor_data so changes appear on the frontend.
     *
     * @param int $post_id
     */
    public static function clear_cache( $post_id ) {
        if ( ! class_exists( '\Elementor\Plugin' ) ) {
            return;
        }

        try {
            \Elementor\Plugin::$instance->files_manager->clear_cache();
        } catch ( \Exception $e ) {
            error_log( 'Rank Booster Elementor: cache clear failed — ' . $e->getMessage() );
        }
    }

    /**
     * Ask Elementor to regenerate post_content from the updated _elementor_data.
     * Falls back gracefully if Elementor is not loaded.
     *
     * @param int $post_id
     */
    private static function regenerate_post_content( $post_id ) {
        if ( ! class_exists( '\Elementor\Plugin' ) ) {
            return;
        }

        try {
            $document = \Elementor\Plugin::$instance->documents->get( $post_id );
            if ( $document ) {
                $document->save( array() );
            }
        } catch ( \Exception $e ) {
            error_log( 'Rank Booster Elementor: post_content regeneration failed — ' . $e->getMessage() );
        }
    }
}
