<?php
defined( 'ABSPATH' ) || exit;

/**
 * Converts stored JSON page data into clean, minimal HTML + CSS.
 * No wrapper bloat, no inline JS, output close to hand-written HTML.
 */
class Jalpi_Renderer {

    // Split Text config builder — shared with Jalpi_Element_Split_Text so the
    // whitelists, clamps and the st_enabled gate have exactly one definition.
    use Jalpi_Split_Text;

    /**
     * Cache-busting version for assets/css/frontend.css. Uses the file's
     * modification time so any edit to the stylesheet invalidates browser caches
     * immediately (JALPI_VERSION alone does not change across CSS edits, so themed
     * WooCommerce/animation rules would otherwise be served stale).
     */
    public static function frontend_css_ver(): string {
        $path  = JALPI_DIR . 'assets/css/frontend.css';
        $mtime = file_exists( $path ) ? filemtime( $path ) : false;
        return $mtime ? ( JALPI_VERSION . '.' . $mtime ) : JALPI_VERSION;
    }

    /**
     * Cache-busting version for the frontend script.
     *
     * Mirrors frontend_css_ver(): versioning the JS by the static
     * JALPI_VERSION means edits to jalpi-frontend.js are never picked up by
     * browsers holding a cached copy, so new interaction triggers silently
     * appear dead until a hard refresh.
     */
    public static function frontend_js_ver(): string {
        $path  = JALPI_DIR . 'assets/js/jalpi-frontend.js';
        $mtime = file_exists( $path ) ? filemtime( $path ) : false;
        return $mtime ? ( JALPI_VERSION . '.' . $mtime ) : JALPI_VERSION;
    }

    private static $instance = null;
    private $css_rules        = [];
    private $tablet_css_rules = [];
    private $mobile_css_rules = [];
    private $used_fonts       = [];
    private $used_icon_libs   = [];

    // ── Class-Based Styling System ──────────────────────────────────────────
    /** @var array<string,true> Set of class keys referenced on the current page. */
    private array $used_class_keys       = [];
    /** Separate accumulators so class CSS can be forced first regardless of DOM traversal order. */
    private array $class_css_rules        = [];
    private array $class_tablet_css_rules = [];
    private array $class_mobile_css_rules = [];
    /** Site-wide Design System token set for the current render pass. */
    private array $design_system = [];
    private ?Jalpi_Dynamic_Tags $tags = null;
    private int $tags_post_id      = 0;
    private int $current_post_id   = 0;
    private bool $needs_carousel_js  = false;
    private bool $needs_tabs_js      = false;
    private bool $needs_countdown_js = false;
    private bool $needs_counter_js   = false;
    private bool $needs_progress_js  = false;
    private bool $needs_faq_js             = false;
    private bool $needs_image_carousel_js  = false;
    private bool $needs_gallery_js        = false;
    private bool $needs_interaction_js    = false;
    private bool $needs_any_js             = false;

    /**
     * True while rendering a design preview (Template Library / kit preview)
     * rather than a real page.
     *
     * Elements that resolve site data use it to show the template's own demo
     * content instead, so a preview represents the DESIGN rather than whatever
     * the current site happens to contain. Only the Menu element consults it
     * today — see Jalpi_Element_Menu::get_items().
     */
    private bool $preview_mode = false;

    /** Pre-rendered HTML cached during wp_enqueue_scripts for use in the_content. */
    private ?string $prerendered_html = null;

    /** @var array<string, Jalpi_Element> Element registry: type → instance. */
    private array $elements = [];

    // =========================================================================
    // Public API for element classes
    // =========================================================================

    public function add_css( string $rule ): void       { $this->css_rules[] = $rule; }
    public function add_tablet_css( string $rule ): void { $this->tablet_css_rules[] = $rule; }
    public function add_mobile_css( string $rule ): void { $this->mobile_css_rules[] = $rule; }
    public function add_icon_lib( string $lib ): void    { $this->used_icon_libs[] = $lib; }
    public function add_font( string $font ): void       { $this->used_fonts[] = $font; }

    public function set_needs_js( string $type ): void {
        $prop = 'needs_' . $type . '_js';
        if ( property_exists( $this, $prop ) ) {
            $this->$prop = true;
        }
        $this->needs_any_js = true;
    }

    public function get_current_post_id(): int { return $this->current_post_id; }

    /**
     * Preview mode — see the $preview_mode declaration for what it means.
     * Set around a render_to_string() call; reset_state() deliberately leaves it
     * alone so it survives the per-render state wipe.
     */
    public function set_preview_mode( bool $on ): void { $this->preview_mode = $on; }
    public function is_preview_mode(): bool           { return $this->preview_mode; }

    public static function instance() {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct() {
        $this->register_elements();

        add_filter( 'the_content',    [ $this, 'maybe_render_content' ], 99 );
        add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_frontend_assets' ] );
        // Register the frontend stylesheet early so we can always use wp_add_inline_style()
        add_action( 'wp_enqueue_scripts', [ $this, 'register_frontend_stylesheet' ], 1 );
        // Invalidate the render cache whenever a post or its meta is saved.
        add_action( 'save_post',          [ $this, 'invalidate_cache' ], 10, 1 );
        add_action( 'acf/save_post',      [ $this, 'invalidate_cache' ], 20, 1 );
        add_action( 'rwmb_after_save_post', function( $object_id ) {
            $this->invalidate_cache( $object_id );
        } );
        // Admin notice when a Jalpi page failed to render on the frontend.
        add_action( 'admin_notices', [ $this, 'render_error_notice' ] );
    }

    /**
     * Register the frontend stylesheet early (priority 1) so it's always available
     * for wp_add_inline_style(). This runs before enqueue_frontend_assets (priority 10).
     */
    public function register_frontend_stylesheet(): void {
        wp_register_style(
            'jalpi-frontend',
            JALPI_ASSETS_URL . 'css/frontend.css',
            [],
            self::frontend_css_ver()
        );
    }

    /**
     * Register all element renderers in the type → instance registry.
     */
    private function register_elements(): void {
        $element_classes = [
            Jalpi_Element_Section::class,
            Jalpi_Element_Container::class,
            Jalpi_Element_Column::class,
            Jalpi_Element_Heading::class,
            Jalpi_Element_Text::class,
            Jalpi_Element_Image::class,
            Jalpi_Element_Button::class,
            Jalpi_Element_Spacer::class,
            Jalpi_Element_Divider::class,
            Jalpi_Element_Video::class,
            Jalpi_Element_Maps::class,
            Jalpi_Element_Html::class,
            Jalpi_Element_Icon::class,
            Jalpi_Element_Shortcode::class,
            Jalpi_Element_Form::class,
            Jalpi_Element_Logo::class,
            Jalpi_Element_Alert::class,
            Jalpi_Element_Badge::class,
            Jalpi_Element_Counter::class,
            Jalpi_Element_Progress::class,
            Jalpi_Element_Testimonial::class,
            Jalpi_Element_Accordion::class,
            Jalpi_Element_Icon_List::class,
            Jalpi_Element_Pricing_Table::class,
            Jalpi_Element_Team_Member::class,
            Jalpi_Element_Faq::class,
            Jalpi_Element_Table::class,
            Jalpi_Element_Feature_Card::class,
            Jalpi_Element_Cta::class,
            Jalpi_Element_Stats_Card::class,
            Jalpi_Element_Timeline::class,
            Jalpi_Element_Testimonials::class,
            Jalpi_Element_Tabs::class,
            Jalpi_Element_Tabs_Adv::class,
            Jalpi_Element_Split_Text::class,
            Jalpi_Element_Menu::class,
            Jalpi_Element_Adv_Menu::class,
            Jalpi_Element_Breadcrumbs::class,
            Jalpi_Element_Image_Box::class,
            Jalpi_Element_Social_Icons::class,
            Jalpi_Element_Gallery::class,
            Jalpi_Element_Image_Carousel::class,
            Jalpi_Element_Countdown::class,
            Jalpi_Element_Flip_Box::class,
            Jalpi_Element_Star_Rating::class,
            Jalpi_Element_Products_Grid::class,
            Jalpi_Element_Product_Categories::class,
            Jalpi_Element_Single_Product::class,
        ];

        foreach ( $element_classes as $class ) {
            $el = new $class();
            $this->elements[ $class::type() ] = $el;
        }

        // Schema-driven widgets: one Jalpi_Element_Generic instance per schema
        // type, appended AFTER the explicit list above so it can never shadow a
        // bespoke element. Each instance is told its concrete type via
        // set_type(). See includes/widgets-schema.php + class-jalpi-element-generic.php.
        if ( function_exists( 'jalpi_widget_schema_types' ) ) {
            foreach ( jalpi_widget_schema_types() as $schema_type ) {
                if ( isset( $this->elements[ $schema_type ] ) ) {
                    continue; // never override a bespoke element
                }
                $generic = new Jalpi_Element_Generic();
                $generic->set_type( $schema_type );
                $this->elements[ $schema_type ] = $generic;
            }
        }
    }

    /**
     * Show an admin notice on the post edit screen when the last render failed.
     */
    public function render_error_notice(): void {
        $screen = get_current_screen();
        if ( ! $screen || 'post' !== $screen->base ) {
            return;
        }
        $post_id = absint( $_GET['post'] ?? 0 ); // phpcs:ignore WordPress.Security.NonceVerification
        if ( ! $post_id ) {
            return;
        }
        $error = get_post_meta( $post_id, '_jalpi_render_error', true );
        if ( ! $error ) {
            return;
        }
        printf(
            '<div class="notice notice-error"><p><strong>%s</strong> %s</p></div>',
            esc_html__( 'Jalpi Page Builder render error:', 'jalpi-page-builder' ),
            esc_html( $error )
        );
    }

    /**
     * Reset all per-render state before a new render pass.
     */
    private function reset_state( int $post_id ): void {
        $this->css_rules          = [];
        $this->tablet_css_rules   = [];
        $this->mobile_css_rules   = [];
        $this->used_class_keys        = [];
        $this->class_css_rules        = [];
        $this->class_tablet_css_rules = [];
        $this->class_mobile_css_rules = [];
        $this->used_icon_libs     = [];
        $this->needs_carousel_js  = false;
        $this->needs_tabs_js      = false;
        $this->needs_countdown_js = false;
        $this->needs_counter_js   = false;
        $this->needs_progress_js  = false;
        $this->needs_faq_js             = false;
        $this->needs_image_carousel_js  = false;
        $this->needs_gallery_js        = false;
        $this->needs_interaction_js    = false;
        $this->current_post_id    = $post_id;
        $this->tags               = new Jalpi_Dynamic_Tags( $post_id );
        $this->tags_post_id       = $post_id;
        $this->design_system      = Jalpi_Settings::get_design_system();
    }

    /**
     * Style properties resolved for each composite preset kind, mirroring
     * (and keeping in sync with) the editor's CanvasNode.tsx getPresetStyles().
     */
    private const COMPOSITE_PRESET_FIELDS = [
        'typo' => [ 'fontSize', 'lineHeight', 'letterSpacing', 'color', 'fontFamily', 'fontWeight', 'textTransform', 'textDecoration' ],
        'btn'  => [ 'backgroundColor', 'color', 'border', 'boxShadow', 'borderRadius', 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight', 'fontSize', 'fontWeight', 'textTransform', 'fontFamily' ],
    ];

    /**
     * Resolve a node's Design System links (globalStyleRef.tokens for atomic
     * tokens, globalStyleRef.typographyId/buttonId for composite presets) into
     * var()-based values merged into node.styles, before the node reaches its
     * element's render(). This is the single choke point every element class
     * already goes through via render_node() — no element file needs to know
     * about globalStyleRef at all.
     *
     * Precedence (matches the editor's existing preset-resolution behavior):
     * a literal value already present in node.styles always wins over a link.
     * Missing/dangling token or preset ids are skipped silently, never fatal —
     * the node falls through to whatever literal value (if any) already exists.
     *
     * @param array $node
     * @return array Node with styles merged with resolved var() references.
     */
    private function resolve_token_refs( array $node ): array {
        $ref = $node['globalStyleRef'] ?? null;
        if ( ! is_array( $ref ) ) {
            return $node;
        }

        $styles = (array) ( $node['styles'] ?? [] );

        // Atomic token links: styleProp -> {category, id}.
        foreach ( (array) ( $ref['tokens'] ?? [] ) as $prop => $t ) {
            if ( ! is_array( $t ) ) {
                continue;
            }
            if ( array_key_exists( $prop, $styles ) && '' !== (string) $styles[ $prop ] ) {
                continue; // Local literal override always wins.
            }
            $category = $t['category'] ?? '';
            $id       = $t['id'] ?? '';
            $prefix   = Jalpi_Settings::TOKEN_VAR_PREFIX[ $category ] ?? null;
            if ( ! $prefix || '' === $id ) {
                continue; // Unknown category or malformed ref — tolerate, skip.
            }
            $var_name = "--jalpi-{$prefix}-{$id}";
            $fallback = $this->lookup_token_literal( $category, $id );
            $styles[ $prop ] = '' !== $fallback ? "var({$var_name}, {$fallback})" : "var({$var_name})";
        }

        // Composite preset links: typographyId / buttonId -> every field of that preset.
        $composite_map = [
            'typo' => [ 'ref_key' => 'typographyId', 'list_key' => 'typography' ],
            'btn'  => [ 'ref_key' => 'buttonId',      'list_key' => 'buttons' ],
        ];
        foreach ( $composite_map as $kind => $cfg ) {
            $preset_id = $ref[ $cfg['ref_key'] ] ?? '';
            if ( '' === $preset_id ) {
                continue;
            }
            $preset = $this->find_by_id( $this->design_system[ $cfg['list_key'] ] ?? [], $preset_id );
            foreach ( self::COMPOSITE_PRESET_FIELDS[ $kind ] as $field ) {
                if ( array_key_exists( $field, $styles ) && '' !== (string) $styles[ $field ] ) {
                    continue; // Local literal override always wins.
                }
                if ( ! $preset || empty( $preset[ $field ] ) ) {
                    continue; // Dangling preset ref or field not set on the preset — tolerate, skip.
                }
                $var_name       = '--jalpi-' . $kind . '-' . $preset_id . '-' . self::camel_to_kebab( $field );
                $styles[ $field ] = 'var(' . $var_name . ', ' . $preset[ $field ] . ')';
            }
        }

        $node['styles'] = $styles;
        return $node;
    }

    /** Find an item by 'id' inside a Design System token/preset list. */
    private function find_by_id( array $list, string $id ): ?array {
        foreach ( $list as $item ) {
            if ( is_array( $item ) && ( $item['id'] ?? '' ) === $id ) {
                return $item;
            }
        }
        return null;
    }

    /** Look up an atomic token's literal value, used as the var() fallback. */
    private function lookup_token_literal( string $category, string $id ): string {
        $list_key = Jalpi_Settings::TOKEN_CATEGORIES[ $category ] ?? '';
        if ( '' === $list_key ) {
            return '';
        }
        $token = $this->find_by_id( $this->design_system[ $list_key ] ?? [], $id );
        return $token ? (string) ( $token['value'] ?? '' ) : '';
    }

    /** camelCase -> kebab-case, mirrors Jalpi_Settings::camel_to_kebab() for var names. */
    private static function camel_to_kebab( string $s ): string {
        return strtolower( preg_replace( '/(?<!^)[A-Z]/', '-$0', $s ) );
    }

    public function invalidate_cache( $post_id ): void {
        if ( is_numeric( $post_id ) ) {
            delete_transient( $this->cache_key( (int) $post_id ) );
        }
    }

    /** Lazy-init the tag resolver, keyed by post_id. */
    public function tags( int $post_id = 0 ): Jalpi_Dynamic_Tags {
        if ( null === $this->tags || ( $post_id > 0 && $this->tags_post_id !== $post_id ) ) {
            $this->tags         = new Jalpi_Dynamic_Tags( $post_id );
            $this->tags_post_id = $post_id;
        }
        return $this->tags;
    }

    public function enqueue_frontend_assets() {
        if ( ! is_singular() ) {
            return;
        }
        $post_id = get_the_ID();
        if ( ! get_post_meta( $post_id, '_jalpi_has_canvas_data', true ) ) {
            return;
        }
        // Stylesheet is pre-registered in register_frontend_stylesheet()
        wp_enqueue_style( 'jalpi-frontend' );

        wp_register_script(
            'jalpi-frontend',
            JALPI_ASSETS_URL . 'js/jalpi-frontend.js',
            [],
            self::frontend_js_ver(),
            true
        );

        // ── Pre-render the page so CSS can be enqueued properly ────────
        $api       = Jalpi_API::instance();
        $page_data = $api->load_page_data( $post_id );

        if ( empty( $page_data['sections'] ) ) {
            return;
        }

        $this->reset_state( $post_id );

        try {
            $html = $this->render_page( $page_data );
        } catch ( \Throwable $e ) {
            // Render failed — fall back to legacy inline rendering in the_content.
            return;
        }

        // Cache the HTML-only output for maybe_render_content().
        $this->prerendered_html = $html;

        // Attach page-specific CSS to the frontend stylesheet via wp_add_inline_style.
        $css = $this->compile_css();
        if ( $css ) {
            wp_add_inline_style( 'jalpi-frontend', $css );
        }

        // Google Fonts: enqueue via wp_enqueue_style.
        if ( ! empty( $this->used_fonts ) ) {
            $families = implode( '&family=', array_map( 'rawurlencode', $this->used_fonts ) );
            $url      = 'https://fonts.googleapis.com/css2?family=' . $families . ':wght@400;500;600;700&display=swap';
            wp_enqueue_style( 'jalpi-google-fonts', $url, [], JALPI_VERSION );
        }

        // Icon library CDN CSS: enqueue each library.
        $fa_emitted = false;
        foreach ( array_unique( $this->used_icon_libs ) as $lib_id ) {
            $url = self::$ICON_LIB_CDNS[ $lib_id ] ?? '';
            if ( ! $url ) continue;
            if ( in_array( $lib_id, [ 'fa-solid', 'fa-regular', 'fa-brands' ], true ) ) {
                if ( $fa_emitted ) continue;
                $fa_emitted = true;
            }
            $handle = 'jalpi-icons-' . sanitize_key( $lib_id );
            if ( ! wp_style_is( $handle, 'enqueued' ) ) {
                wp_enqueue_style( $handle, $url, [], JALPI_VERSION );
            }
        }

        // Enqueue frontend JS only when interactive widgets are present.
        if ( $this->needs_any_js ) {
            // AJAX config for the Products Grid (pagination + add-to-cart). The
            // handler rebuilds the query from the saved node, so only the post
            // id, node id and page number ever travel over the wire.
            wp_localize_script( 'jalpi-frontend', 'jalpiPG', [
                'ajaxUrl'  => admin_url( 'admin-ajax.php' ),
                'nonce'    => wp_create_nonce( Jalpi_Woo_Ajax::NONCE ),
                'cartUrl'  => function_exists( 'wc_get_cart_url' ) ? wc_get_cart_url() : '',
                'i18n'     => [
                    'added'   => __( 'Added',  'jalpi-page-builder' ),
                    'error'   => __( 'Error',  'jalpi-page-builder' ),
                    'loading' => __( 'Loading…', 'jalpi-page-builder' ),
                ],
            ] );

            wp_enqueue_script( 'jalpi-frontend' );
        }
    }

    // -----------------------------------------------------------------
    // WordPress hooks
    // -----------------------------------------------------------------

    public function maybe_render_content( $content ) {
        if ( ! is_singular() ) {
            return $content;
        }

        $post_id              = get_the_ID();
        $this->current_post_id = $post_id;

        // Check if this post was built with Jalpi Page Builder (uses meta flag — no DB query).
        if ( ! get_post_meta( $post_id, '_jalpi_has_canvas_data', true ) ) {
            return $content;
        }

        // If the page was pre-rendered during wp_enqueue_scripts, return cached
        // HTML directly — CSS/fonts/icons were already enqueued properly.
        if ( null !== $this->prerendered_html ) {
            // Prevent wpautop and wptexturize from mangling our structured HTML
            remove_filter( 'the_content', 'wpautop' );
            remove_filter( 'the_content', 'wptexturize' );

            // Clear any previous render error on successful render.
            delete_post_meta( $post_id, '_jalpi_render_error' );

            return wp_kses( $this->prerendered_html, self::allowed_html() );
        }

        // ── Fallback: render inline (backward compatibility) ──────────
        $api       = Jalpi_API::instance();
        $page_data = $api->load_page_data( $post_id );

        if ( empty( $page_data['sections'] ) ) {
            return $content;
        }

        // Prevent wpautop and wptexturize from mangling our structured HTML
        remove_filter( 'the_content', 'wpautop' );
        remove_filter( 'the_content', 'wptexturize' );

        // Skip the transient cache when the page contains form elements — form
        // plugins embed per-request nonces that become stale in cached HTML.
        $has_forms = class_exists( 'Jalpi_Forms' ) && Jalpi_Forms::has_form_node( $page_data['sections'] );

        // Detect user-specific dynamic tags that must never be cached.
        $serialised_data  = wp_json_encode( $page_data['sections'] ?? [] );
        $has_user_tags    = (bool) preg_match( '/\{\{(current_user|user_meta)/', $serialised_data );
        $use_cache        = ! $has_forms && ! $has_user_tags;

        if ( $use_cache ) {
            $cache_key = $this->cache_key( $post_id );
            $cached    = get_transient( $cache_key );
            if ( false !== $cached ) {
                return wp_kses( $cached, self::allowed_html() );
            }
        }

        try {
            // Ensure the stylesheet is registered so we can attach CSS via wp_add_inline_style.
            if ( ! wp_style_is( 'jalpi-frontend', 'registered' ) ) {
                wp_register_style( 'jalpi-frontend', JALPI_ASSETS_URL . 'css/frontend.css', [], self::frontend_css_ver() );
            }

            $this->reset_state( $post_id );
            $html = $this->render_page( $page_data );
            $css  = $this->compile_css();

            wp_enqueue_style( 'jalpi-frontend' );
            if ( $css ) {
                wp_add_inline_style( 'jalpi-frontend', $css );
            }

            // Enqueue Google Fonts via wp_enqueue_style.
            if ( ! empty( $this->used_fonts ) ) {
                $families = implode( '&family=', array_map( 'rawurlencode', $this->used_fonts ) );
                $url      = 'https://fonts.googleapis.com/css2?family=' . $families . ':wght@400;500;600;700&display=swap';
                wp_enqueue_style( 'jalpi-google-fonts-fallback', $url, [], JALPI_VERSION );
            }

            // Enqueue icon library CDN CSS.
            $fa_emitted = false;
            foreach ( array_unique( $this->used_icon_libs ) as $lib_id ) {
                $url = self::$ICON_LIB_CDNS[ $lib_id ] ?? '';
                if ( ! $url ) continue;
                if ( in_array( $lib_id, [ 'fa-solid', 'fa-regular', 'fa-brands' ], true ) ) {
                    if ( $fa_emitted ) continue;
                    $fa_emitted = true;
                }
                $handle = 'jalpi-icons-' . sanitize_key( $lib_id );
                if ( ! wp_style_is( $handle, 'enqueued' ) ) {
                    wp_enqueue_style( $handle, $url, [], JALPI_VERSION );
                }
            }

            if ( $this->needs_any_js ) {
                wp_enqueue_script( 'jalpi-frontend' );
            }

            $rendered = $html;
        } catch ( \Throwable $e ) {
            // Log the error for administrators (only when WP_DEBUG_LOG is enabled).
            if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                error_log( 'Jalpi Page Builder render error on post ' . $post_id . ': ' . $e->getMessage() );
            }

            // Store the error so an admin notice can be shown on the edit screen.
            update_post_meta( $post_id, '_jalpi_render_error', $e->getMessage() );

            // Fall back to the original post_content so the page is not blank.
            return $content;
        }

        // Clear any previous render error on successful render.
        delete_post_meta( $post_id, '_jalpi_render_error' );

        // Escape before caching so the cached value is already safe.
        $rendered = wp_kses( $rendered, self::allowed_html() );

        if ( $use_cache ) {
            set_transient( $cache_key, $rendered, HOUR_IN_SECONDS );
        }

        return $rendered;
    }

    /**
     * Return the allowed HTML tags for builder output escaping.
     *
     * Extends wp_kses_post defaults with SVG elements and data-* attributes
     * used by the page builder's interactive widgets (gallery, carousel, lightbox).
     */
    public static function allowed_html(): array {
        $allowed = wp_kses_allowed_html( 'post' );

        // Global attributes allowed on all elements.
        $global_attrs = [
            'id'             => true,
            'class'          => true,
            'style'          => true,
            'role'           => true,
            'aria-hidden'    => true,
            'aria-label'     => true,
            'aria-labelledby'=> true,
            'aria-current'   => true,
            'data-*'         => true,
            'title'          => true,
            'tabindex'       => true,
            // schema.org microdata. Without these wp_kses silently strips the
            // structured data from breadcrumbs and any other element that
            // emits it, so search engines see plain markup.
            'itemscope'      => true,
            'itemtype'       => true,
            'itemprop'       => true,
            'itemid'         => true,
            'itemref'        => true,
        ];

        // Ensure common elements have the global attributes.
        $common_tags = [ 'div', 'section', 'header', 'footer', 'main', 'article', 'aside', 'nav',
                         'span', 'figure', 'figcaption', 'button', 'a', 'img', 'video', 'source',
                         'ul', 'ol', 'li', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'iframe',
                         // <meta> carries the itemprop="position" value inside
                         // each breadcrumb list item.
                         'meta' ];
        foreach ( $common_tags as $tag ) {
            if ( ! isset( $allowed[ $tag ] ) ) {
                $allowed[ $tag ] = [];
            }
            $allowed[ $tag ] = array_merge( $allowed[ $tag ], $global_attrs );
        }

        // The microdata value itself lives in <meta content="…">; kses drops
        // the attribute otherwise and the position data is lost.
        $allowed['meta']['content'] = true;

        // Specific element attributes.
        $allowed['a']      = array_merge( $allowed['a'] ?? [], $global_attrs, [
            'href' => true, 'target' => true, 'rel' => true,
        ] );
        $allowed['img']    = array_merge( $allowed['img'] ?? [], $global_attrs, [
            'src' => true, 'alt' => true, 'loading' => true, 'width' => true, 'height' => true,
            'srcset' => true, 'sizes' => true,
        ] );
        $allowed['video']  = array_merge( $allowed['video'] ?? [], $global_attrs, [
            'src' => true, 'autoplay' => true, 'muted' => true, 'loop' => true,
            'playsinline' => true, 'poster' => true, 'preload' => true,
        ] );
        $allowed['source'] = array_merge( $allowed['source'] ?? [], [
            'src' => true, 'type' => true,
        ] );
        $allowed['iframe'] = array_merge( $allowed['iframe'] ?? [], $global_attrs, [
            'src' => true, 'width' => true, 'height' => true, 'frameborder' => true,
            'allow' => true, 'allowfullscreen' => true, 'loading' => true,
        ] );
        $allowed['input']  = array_merge( $allowed['input'] ?? [], $global_attrs, [
            'type' => true, 'name' => true, 'value' => true, 'placeholder' => true,
            'required' => true, 'checked' => true, 'min' => true, 'max' => true,
            'step' => true, 'maxlength' => true, 'pattern' => true, 'disabled' => true,
        ] );
        $allowed['select']   = array_merge( $allowed['select'] ?? [], $global_attrs, [
            'name' => true, 'required' => true, 'multiple' => true,
        ] );
        $allowed['option']   = [ 'value' => true, 'selected' => true ];
        $allowed['textarea'] = array_merge( $allowed['textarea'] ?? [], $global_attrs, [
            'name' => true, 'rows' => true, 'cols' => true, 'placeholder' => true,
            'required' => true, 'maxlength' => true,
        ] );
        $allowed['label']    = array_merge( $allowed['label'] ?? [], $global_attrs, [
            'for' => true,
        ] );
        $allowed['form']     = array_merge( $allowed['form'] ?? [], $global_attrs, [
            'action' => true, 'method' => true, 'enctype' => true, 'novalidate' => true,
        ] );
        $allowed['button']   = array_merge( $allowed['button'] ?? [], $global_attrs, [
            'type' => true, 'disabled' => true, 'name' => true, 'value' => true,
        ] );

        // SVG elements used by icon rendering.
        $svg_attrs = [
            'xmlns' => true, 'viewbox' => true, 'width' => true, 'height' => true,
            'fill' => true, 'stroke' => true, 'stroke-width' => true,
            'stroke-linecap' => true, 'stroke-linejoin' => true,
            'class' => true, 'style' => true, 'aria-hidden' => true,
        ];
        $allowed['svg']      = $svg_attrs;
        $allowed['path']     = [ 'd' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true ];
        $allowed['circle']   = [ 'cx' => true, 'cy' => true, 'r' => true, 'fill' => true, 'stroke' => true ];
        $allowed['rect']     = [ 'x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'ry' => true, 'fill' => true, 'stroke' => true, 'opacity' => true ];
        $allowed['line']     = [ 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, 'stroke' => true, 'stroke-width' => true ];
        $allowed['polyline'] = [ 'points' => true, 'fill' => true, 'stroke' => true ];
        $allowed['polygon']  = [ 'points' => true, 'fill' => true ];
        $allowed['g']        = [ 'fill' => true, 'stroke' => true, 'transform' => true ];

        // Allow <i> for font icon classes (Font Awesome, Bootstrap Icons).
        $allowed['i'] = array_merge( $allowed['i'] ?? [], $global_attrs );

        return $allowed;
    }

    // CDN URLs for icon libraries (keyed by lib id)
    // Note: Tabler Icons removed - users should include locally or use Font Awesome/Bootstrap icons
    private static $ICON_LIB_CDNS = [
        'fa-solid'   => 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css',
        'fa-regular' => 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css',
        'fa-brands'  => 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css',
        'bootstrap'  => 'https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.11.3/font/bootstrap-icons.min.css',
        'material'   => 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block',
    ];

    /**
     * No-op. Page styles are now enqueued via wp_add_inline_style() during
     * wp_enqueue_scripts. Retained for backward compatibility with any code
     * that may call this method directly.
     */
    public function output_page_styles() {
        // Styles are handled by enqueue_frontend_assets() and render_to_string().
    }

    // -----------------------------------------------------------------
    // Public render entry point
    // -----------------------------------------------------------------

    /**
     * Render a page to an HTML string.
     *
     * @param array $page_data   Page data with 'sections' key.
     * @param int   $post_id     Context post ID for dynamic tags.
     * @param bool  $include_styles When true (default), enqueue CSS styles and fonts
     *                              via the WP enqueue system. Set false when styles
     *                              are handled separately by the caller.
     */
    public function render_to_string( $page_data, int $post_id = 0, bool $include_styles = true ): string {
        $this->reset_state( $post_id );
        $html = $this->render_page( $page_data );

        if ( ! $include_styles ) {
            return $html;
        }

        // Ensure the frontend stylesheet is always registered so we can use
        // wp_add_inline_style() — no inline <style> tags needed.
        if ( ! wp_style_is( 'jalpi-frontend', 'registered' ) ) {
            wp_register_style( 'jalpi-frontend', JALPI_ASSETS_URL . 'css/frontend.css', [], self::frontend_css_ver() );
        }
        wp_enqueue_style( 'jalpi-frontend' );

        // Attach page-specific CSS via wp_add_inline_style.
        $css = $this->compile_css();
        if ( $css ) {
            wp_add_inline_style( 'jalpi-frontend', $css );
        }

        // Google Fonts: enqueue via wp_enqueue_style.
        if ( ! empty( $this->used_fonts ) ) {
            $families = implode( '&family=', array_map( 'rawurlencode', $this->used_fonts ) );
            $url      = 'https://fonts.googleapis.com/css2?family=' . esc_attr( $families ) . ':wght@400;500;600;700&display=swap';
            wp_enqueue_style( 'jalpi-google-fonts-tpl', $url, [], JALPI_VERSION );
        }

        // Enqueue icon library CDN CSS.
        $fa_emitted = false;
        foreach ( array_unique( $this->used_icon_libs ) as $lib_id ) {
            $url = self::$ICON_LIB_CDNS[ $lib_id ] ?? '';
            if ( ! $url ) continue;
            if ( in_array( $lib_id, [ 'fa-solid', 'fa-regular', 'fa-brands' ], true ) ) {
                if ( $fa_emitted ) continue;
                $fa_emitted = true;
            }
            $handle = 'jalpi-icons-' . sanitize_key( $lib_id );
            if ( ! wp_style_is( $handle, 'enqueued' ) ) {
                wp_enqueue_style( $handle, $url, [], JALPI_VERSION );
            }
        }

        // Enqueue frontend JS when interactive widgets are present.
        if ( $this->needs_any_js ) {
            wp_enqueue_script( 'jalpi-frontend' );
        }

        return $html;
    }

    // -----------------------------------------------------------------
    // Page rendering
    // -----------------------------------------------------------------

    /**
     * Render sections to HTML only. CSS/font/icon output is handled by the
     * caller (enqueue_frontend_assets or render_to_string).
     */
    private function render_page( $page_data ) {
        $html = '';
        foreach ( $page_data['sections'] as $section ) {
            $html .= $this->render_node( $section );
        }
        return $html;
    }


    public function render_node( $node ) {
        $node = $this->resolve_token_refs( (array) $node );
        $type = $node['type'] ?? '';

        // Dispatch to registered element class or fall back to legacy methods.
        if ( isset( $this->elements[ $type ] ) ) {
            $html = $this->elements[ $type ]->render( (array) $node, $this );
        } else {
            $html = '';
        }

        $this->maybe_add_responsive_rules( $node );
        $this->maybe_add_custom_css( $node );
        $this->maybe_add_interaction_css( $node );
        $html = $this->apply_interactions( $node, $html );

        return $html;
    }

    public function render_children( $node ) {
        if ( empty( $node['children'] ) ) {
            return '';
        }
        $html = '';
        foreach ( $node['children'] as $child ) {
            $html .= $this->render_node( $child );
        }
        return $html;
    }

    // -----------------------------------------------------------------
    // Shared helpers used by element classes
    // -----------------------------------------------------------------

    private static function sanitize_color_val( string $val ): string {
        // Allow hex, rgb(), rgba(), hsl(), hsla(), and named colours
        if ( preg_match( '/^#[0-9a-fA-F]{3,8}$/', $val ) ) return $val;
        if ( preg_match( '/^rgba?\([\d,.\s%]+\)$/', $val )  ) return $val;
        if ( preg_match( '/^hsla?\([\d,.\s%]+\)$/', $val )  ) return $val;
        if ( preg_match( '/^[a-z]{2,30}$/', $val )           ) return $val;
        return '#f0f0f0';
    }

    /**
     * Render an icon value (either legacy string key or IconValue array) to HTML.
     * Tracks CDN library usage in $this->used_icon_libs.
     */
    public function render_icon_value( $icon ): string {
        // Legacy string key → look up builtin SVG
        if ( is_string( $icon ) ) {
            return $this->get_icon_svg( $icon );
        }

        if ( ! is_array( $icon ) ) return '';

        $type = $icon['type'] ?? '';
        $lib  = $icon['lib']  ?? '';
        $name = $icon['name'] ?? '';

        if ( $type === 'svg' ) {
            // Use cached SVG stored at pick time
            $svg = $icon['svg'] ?? '';
            if ( $svg ) {
                // Strip script tags for safety then output
                // 'viewbox' lowercase is the key that matches — wp_kses lowercases
                // attribute names before lookup, so a camelCase-only entry strips
                // the viewBox and the icon collapses to its intrinsic size.
                return wp_kses( $svg, [ 'svg' => array_fill_keys( [ 'xmlns','viewbox','viewBox','width','height','fill','stroke','stroke-width','stroke-linecap','stroke-linejoin','class','style' ], true ), 'path' => array_fill_keys( [ 'd','fill','stroke','stroke-width','stroke-linecap','stroke-linejoin' ], true ), 'circle' => array_fill_keys( [ 'cx','cy','r','fill','stroke' ], true ), 'rect' => array_fill_keys( [ 'x','y','width','height','rx','ry','fill','stroke' ], true ), 'line' => array_fill_keys( [ 'x1','y1','x2','y2','stroke','stroke-width' ], true ), 'polyline' => array_fill_keys( [ 'points','fill','stroke' ], true ), 'polygon' => array_fill_keys( [ 'points','fill' ], true ), 'g' => [ 'fill' => true, 'stroke' => true ], 'defs' => [], 'title' => [] ] );
            }
            // Fallback: try builtin lookup by name
            return $this->get_icon_svg( $name );
        }

        if ( $type === 'font' ) {
            // Track this lib for CDN injection
            if ( $lib && ! in_array( $lib, $this->used_icon_libs, true ) ) {
                $this->used_icon_libs[] = $lib;
            }
            $cls = sanitize_text_field( $icon['cls'] ?? '' );
            if ( $lib === 'material' ) {
                $cls = $cls ?: 'material-symbols-outlined';
                return '<span class="' . esc_attr( $cls ) . '">' . esc_html( $name ) . '</span>';
            }
            if ( $cls ) {
                return '<i class="' . esc_attr( $cls ) . '" aria-hidden="true"></i>';
            }
        }

        if ( $type === 'img' ) {
            $url = esc_url( $icon['url'] ?? '' );
            $alt = esc_attr( $icon['alt'] ?? '' );
            if ( $url ) {
                return '<img src="' . $url . '" alt="' . $alt . '" loading="lazy" />';
            }
        }

        return '';
    }

    private function get_icon_svg( $key ) {
        $icons = [
            'star'     => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>',
            'heart'    => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
            'check'    => '<svg xmlns="http://www.w3.org/2000/svg" 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>',
            'arrow'    => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>',
            'phone'    => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 13a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L7.91 9.91a16 16 0 0 0 6.18 6.18l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>',
            'email'    => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>',
            'location' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>',
            'user'     => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
            'calendar' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
            'clock'    => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
            'globe'    => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
            'info'     => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>',
        ];
        return $icons[ $key ] ?? $icons['star'];
    }

    // -----------------------------------------------------------------
    // Responsive styles + hideOn rules
    // -----------------------------------------------------------------
    private function maybe_add_responsive_rules( $node ): void {
        $id            = sanitize_html_class( $node['id'] ?? '' );
        if ( ! $id ) return;

        $type          = $node['type'] ?? '';
        $tablet_styles = $node['tabletStyles'] ?? [];
        $mobile_styles = $node['mobileStyles'] ?? [];
        $hide_on       = $node['hideOn']        ?? [];

        $selector = match ( $type ) {
            'section'   => '.jalpi-section.jalpi-' . $id,
            'container' => '.jalpi-container.jalpi-' . $id,
            'column'    => '.jalpi-column.jalpi-' . $id,
            default     => '.jalpi-el.jalpi-' . $id,
        };

        // Tablet overrides
        if ( ! empty( $tablet_styles ) ) {
            $css = $this->styles_to_css( $tablet_styles );
            if ( $css ) {
                $this->tablet_css_rules[] = "{$selector}{{$css}}";
            }

            // Button: outerPadding* → padding-* on the outer wrapper div (.jalpi-outer-{id}).
            // outerPadding is in the composite_keys skip-list so styles_to_css ignores it;
            // we need to emit it separately against the wrapper selector.
            if ( 'button' === $type ) {
                $outer_css = $this->outer_padding_css( $tablet_styles );
                if ( $outer_css ) {
                    $this->tablet_css_rules[] = ".jalpi-outer-{$id}{{$outer_css}}";
                }
            }
        }

        // Mobile overrides
        if ( ! empty( $mobile_styles ) ) {
            $css = $this->styles_to_css( $mobile_styles );
            if ( $css ) {
                $this->mobile_css_rules[] = "{$selector}{{$css}}";
            }

            if ( 'button' === $type ) {
                $outer_css = $this->outer_padding_css( $mobile_styles );
                if ( $outer_css ) {
                    $this->mobile_css_rules[] = ".jalpi-outer-{$id}{{$outer_css}}";
                }
            }
        }

        // Container: when responsive flexDirection stacks children vertically, force column
        // children to full width.  Column inline styles use explicit width values that prevent
        // responsive CSS from overriding them without !important.
        if ( 'container' === $type ) {
            $children = $node['children'] ?? [];
            foreach ( [ 'tablet' => $tablet_styles, 'mobile' => $mobile_styles ] as $bp => $resp ) {
                $resp_dir = sanitize_key( $resp['flexDirection'] ?? '' );
                if ( in_array( $resp_dir, [ 'column', 'column-reverse' ], true ) ) {
                    foreach ( $children as $child ) {
                        if ( ( $child['type'] ?? '' ) === 'column' ) {
                            $cid = sanitize_html_class( $child['id'] ?? '' );
                            if ( ! $cid ) continue;
                            $rule = ".jalpi-column.jalpi-{$cid}{width:100%!important;max-width:100%!important;flex-basis:100%!important;}";
                            if ( 'tablet' === $bp ) {
                                $this->tablet_css_rules[] = $rule;
                            } else {
                                $this->mobile_css_rules[] = $rule;
                            }
                        }
                    }
                }
            }
        }

        // Hide on desktop (no media query)
        if ( ! empty( $hide_on['desktop'] ) ) {
            $this->css_rules[] = "{$selector}{display:none;}";
        }

        // Hide on tablet
        if ( ! empty( $hide_on['tablet'] ) ) {
            $this->tablet_css_rules[] = "{$selector}{display:none !important;}";
        }

        // Hide on mobile
        if ( ! empty( $hide_on['mobile'] ) ) {
            $this->mobile_css_rules[] = "{$selector}{display:none !important;}";
        }
    }

    /**
     * Emit a node's raw Custom CSS (settings.customCss), replacing the
     * `selector` placeholder token with this node's own scoped selector.
     * Called after the element's own render() so it lands after that node's
     * local styles in $css_rules — the final override tier in the cascade.
     */
    private function maybe_add_custom_css( array $node ): void {
        $css = trim( (string) ( $node['settings']['customCss'] ?? '' ) );
        if ( '' === $css ) {
            return;
        }
        $id = sanitize_html_class( $node['id'] ?? '' );
        if ( ! $id ) {
            return;
        }
        $resolved = str_replace( 'selector', '.jalpi-' . $id, $css );
        $this->add_css( $resolved );
    }

    // -----------------------------------------------------------------
    // Interaction Engine — see includes/class-jalpi-sanitizer.php's
    // sanitize_interactions() for the data shape this trusts (already
    // sanitized at save time), and assets/js/jalpi-frontend.js's
    // initInteractions() / assets/css/frontend.css for the runtime half.
    // -----------------------------------------------------------------

    /**
     * Inject the sanitized interactions payload into the node's outermost tag
     * as a single data-jalpi-interact='{JSON}' attribute, and flag that the
     * shared frontend interaction script is needed. No-op (zero overhead) for
     * the vast majority of nodes that carry no interactions.
     *
     * Safe against every element type without touching any of them: each
     * element's render() already returns exactly one root wrapper tag — the
     * same assumption the .jalpi-el.jalpi-{id} CSS selector scoping already
     * depends on — so future widgets inherit this automatically.
     */
    private function apply_interactions( array $node, string $html ): string {
        $rules = $node['interactions'] ?? [];
        if ( empty( $rules ) || ! is_array( $rules ) || '' === $html ) {
            return $html;
        }

        $enabled = array_values( array_filter( $rules, fn( $r ) => is_array( $r ) && ( ! isset( $r['enabled'] ) || $r['enabled'] ) ) );
        if ( empty( $enabled ) ) {
            return $html;
        }

        $this->set_needs_js( 'interaction' );

        $payload = wp_json_encode( $enabled );
        if ( false === $payload ) {
            return $html;
        }

        $injected = preg_replace(
            '/^(<[a-zA-Z][\w-]*)/',
            '$1 data-jalpi-interact=\'' . esc_attr( $payload ) . '\'',
            $html,
            1
        );

        return $injected ?? $html;
    }

    /**
     * Emit the per-node CSS for every enabled interaction rule. Continuous
     * triggers (mouseMove, scrollProgress) are fully JS-driven (rAF writes
     * straight to inline `transform`) and emit no CSS here. The `hover`
     * trigger emits two rule variants (in/out) since it needs distinct
     * enter/leave visuals (see add_interaction_keyframe_css()'s $reverse
     * param); every other discrete trigger emits one.
     */
    private function maybe_add_interaction_css( array $node ): void {
        $rules = $node['interactions'] ?? [];
        if ( empty( $rules ) || ! is_array( $rules ) ) {
            return;
        }
        $id = sanitize_html_class( $node['id'] ?? '' );
        if ( ! $id ) {
            return;
        }
        $type = $node['type'] ?? '';
        $selector = match ( $type ) {
            'section'   => '.jalpi-section.jalpi-' . $id,
            'container' => '.jalpi-container.jalpi-' . $id,
            'column'    => '.jalpi-column.jalpi-' . $id,
            default     => '.jalpi-el.jalpi-' . $id,
        };

        foreach ( $rules as $rule ) {
            if ( ! is_array( $rule ) ) continue;
            $rule_id = sanitize_html_class( $rule['id'] ?? '' );
            $action  = $rule['action']  ?? '';
            $trigger = $rule['trigger'] ?? '';
            if ( ! $rule_id || ! $action || ! $trigger ) continue;
            if ( isset( $rule['enabled'] ) && ! $rule['enabled'] ) continue;
            $s = (array) ( $rule['settings'] ?? [] );

            // mouseMove/scrollProgress write directly to inline style every
            // animation frame — there is no fixed from/to state to express
            // as a CSS @keyframes pair, so nothing to emit here.
            if ( in_array( $trigger, [ 'mouseMove', 'scrollProgress' ], true ) ) continue;

            // parentHover reuses the exact same in/out keyframe-rule pair as
            // hover — the only difference is which DOM element the runtime
            // listens to (the nearest structural ancestor instead of this
            // node itself, see assets/js/jalpi-frontend.js's 'parentHover'
            // case), which is a JS-only concern, not a CSS one.
            if ( in_array( $trigger, [ 'hover', 'parentHover' ], true ) ) {
                $in_action  = ! empty( $s['hoverInAction'] ) ? $s['hoverInAction'] : $action;
                $dur        = $s['transitionDuration'] ?? ( $s['duration'] ?? '0.3s' );
                $peek_mode  = ! empty( $s['hideAfterPlay'] );
                $this->add_interaction_keyframe_css( $selector, $rule_id . '-in', $in_action, array_merge( $s, [ 'duration' => $dur ] ), false, $peek_mode );

                if ( $peek_mode ) {
                    // "Peek on hover": the element is hidden at rest, plays
                    // its reveal on mouseenter, then jalpi-frontend.js's
                    // 'hover' case clears the "-in" marker itself once that
                    // playback finishes (via animationend) — at which point
                    // this plain, lower-specificity base rule takes back over
                    // and hides it again until the next hover. No "-out"
                    // reverse animation is emitted at all in this mode; it's
                    // never used (see jalpi-frontend.js's hideAfterPlay branch).
                    $this->add_css( "{$selector}{opacity:0;}" );
                } else {
                    $out_action = ! empty( $s['hoverOutAction'] ) ? $s['hoverOutAction'] : $action;
                    $this->add_interaction_keyframe_css( $selector, $rule_id . '-out', $out_action, array_merge( $s, [ 'duration' => $dur ] ), true );
                }
            } else {
                $this->add_interaction_keyframe_css( $selector, $rule_id, $action, $s, false );
            }

            $this->maybe_add_interaction_responsive_overrides( $selector, $rule_id, $rule['responsive'] ?? null );
        }
    }

    /**
     * Per-breakpoint enable/disable for one interaction rule. Reuses the same
     * $this->tablet_css_rules / $this->mobile_css_rules / $this->css_rules
     * accumulators (and therefore the same configured breakpoints) that
     * maybe_add_responsive_rules() already uses for hideOn — there is only
     * ever one breakpoint source of truth in this renderer. Absent/true for a
     * tier means "no override," matching every rule saved before this field
     * existed.
     */
    private function maybe_add_interaction_responsive_overrides( string $selector, string $rule_id, $responsive ): void {
        if ( ! is_array( $responsive ) ) return;

        // Matches every marker this rule can produce: the plain marker for
        // discrete/scroll triggers, or the -in/-out pair for hover/parentHover.
        $kill = "{$selector}[data-jalpi-ia-active~=\"{$rule_id}\"],{$selector}[data-jalpi-ia-active~=\"{$rule_id}-in\"],{$selector}[data-jalpi-ia-active~=\"{$rule_id}-out\"]{animation:none!important;}";

        if ( isset( $responsive['desktop'] ) && ! $responsive['desktop'] ) {
            $this->css_rules[] = $kill;
        }
        if ( isset( $responsive['tablet'] ) && ! $responsive['tablet'] ) {
            $this->tablet_css_rules[] = $kill;
        }
        if ( isset( $responsive['mobile'] ) && ! $responsive['mobile'] ) {
            $this->mobile_css_rules[] = $kill;
        }
    }

    /**
     * Emit one `{selector}[data-jalpi-ia-active~="{marker}"]{...}` rule that
     * plays the shared, parametrized @keyframes for $action (assets/css/
     * frontend.css), customized via --jalpi-ia-* custom properties built from
     * $s. $reverse flips animation-direction — used for the hover "out"
     * variant so it animates back toward the resting state using the same
     * keyframe pair instead of needing a second one.
     */
    private function add_interaction_keyframe_css( string $selector, string $marker, string $action, array $s, bool $reverse, bool $force_visible = false ): void {
        $vars     = $this->interaction_css_vars( $action, $s );
        $duration = $s['duration'] ?? '0.6s';
        $delay    = $reverse ? '0s' : ( $s['delay'] ?? '0s' );
        $easing   = $s['easing'] ?? 'ease';
        $iter     = ! empty( $s['infinite'] ) ? 'infinite' : ( $s['repeat'] ?? '1' );
        $base_dir = in_array( $s['direction'] ?? '', [ 'normal', 'reverse', 'alternate', 'alternate-reverse' ], true ) ? $s['direction'] : 'normal';
        $anim_dir = $reverse ? ( 'reverse' === $base_dir ? 'normal' : 'reverse' ) : $base_dir;

        // 'bounce' and 'elastic' each have two keyframe implementations — the
        // original vertical one (up/down) and a horizontal counterpart
        // (left/right), chosen by direction. Every other action has exactly
        // one keyframe named after itself, so this is the one place action
        // !== keyframe name.
        $keyframe = $action;
        if ( in_array( $action, [ 'bounce', 'elastic' ], true ) && in_array( $s['direction'] ?? '', [ 'left', 'right' ], true ) ) {
            $keyframe = $action . 'X';
        }

        $decls  = $vars;
        $decls .= 'animation-name:jalpi-ia-' . sanitize_html_class( $keyframe ) . ';';
        $decls .= 'animation-duration:' . esc_attr( (string) $duration ) . ';';
        $decls .= 'animation-delay:' . esc_attr( (string) $delay ) . ';';
        $decls .= 'animation-timing-function:' . esc_attr( (string) $easing ) . ';';
        $decls .= 'animation-iteration-count:' . esc_attr( (string) $iter ) . ';';
        $decls .= 'animation-direction:' . esc_attr( $anim_dir ) . ';';
        $decls .= 'animation-fill-mode:both;';
        // "Peek on hover" needs the reveal to actually become visible even
        // when the chosen action never touches opacity itself (Translate,
        // Blur) — a CSS animation always wins over a plain property value for
        // whatever it animates, so this is a harmless no-op for actions that
        // already fade opacity via their own @keyframes, and the only thing
        // that makes Translate/Blur actually reveal anything in that mode.
        if ( $force_visible ) {
            $decls .= 'opacity:1;';
        }

        $this->add_css( "{$selector}[data-jalpi-ia-active~=\"{$marker}\"]{{$decls}}" );
    }

    /**
     * Build the --jalpi-ia-* custom-property declarations for one action from
     * the rule's sanitized settings. The shared @keyframes definitions in
     * assets/css/frontend.css read these with their own built-in fallbacks,
     * so an unset property here simply falls back to that keyframe's default
     * — every action has a reasonable look with zero configured settings.
     */
    private function interaction_css_vars( string $action, array $s ): string {
        $vars = [];

        if ( ! empty( $s['opacityValue'] ) ) $vars['--jalpi-ia-op-to'] = $s['opacityValue'];
        if ( ! empty( $s['transformOrigin'] ) ) $vars['--jalpi-ia-origin'] = $s['transformOrigin'];

        switch ( $action ) {
            case 'slide':
            case 'translate':
                $dist = $s['distance'] ?? '30px';
                $dir  = $s['direction'] ?? 'up';
                switch ( $dir ) {
                    case 'left':  $vars['--jalpi-ia-tx-from'] = "-{$dist}"; $vars['--jalpi-ia-ty-from'] = '0'; break;
                    case 'right': $vars['--jalpi-ia-tx-from'] = $dist;      $vars['--jalpi-ia-ty-from'] = '0'; break;
                    case 'down':  $vars['--jalpi-ia-tx-from'] = '0'; $vars['--jalpi-ia-ty-from'] = "-{$dist}"; break;
                    default:      $vars['--jalpi-ia-tx-from'] = '0'; $vars['--jalpi-ia-ty-from'] = $dist; // 'up': starts below, moves up
                }
                break;
            case 'scale':
            case 'zoom':
                if ( ! empty( $s['scaleValue'] ) ) $vars['--jalpi-ia-scale-from'] = $s['scaleValue'];
                break;
            case 'rotate':
                if ( ! empty( $s['rotationDeg'] ) ) $vars['--jalpi-ia-rot-from'] = $s['rotationDeg'] . ( is_numeric( $s['rotationDeg'] ) ? 'deg' : '' );
                break;
            case 'skew':
                if ( ! empty( $s['rotationDeg'] ) ) $vars['--jalpi-ia-skew-from'] = $s['rotationDeg'] . ( is_numeric( $s['rotationDeg'] ) ? 'deg' : '' );
                break;
            case 'blur':
                if ( ! empty( $s['blurAmount'] ) ) $vars['--jalpi-ia-blur-from'] = $s['blurAmount'];
                break;
            case 'flip':
                $dir = $s['direction'] ?? 'left';
                if ( in_array( $dir, [ 'up', 'down' ], true ) ) {
                    $vars['--jalpi-ia-flip-x-from'] = ( 'up' === $dir ? '-' : '' ) . '90deg';
                } else {
                    $vars['--jalpi-ia-flip-y-from'] = ( 'left' === $dir ? '-' : '' ) . '90deg';
                }
                break;
            case 'bounce':
                // Direction support added for the named preset library (Bounce
                // Up/Down/Left/Right). Default (no direction, or 'down') keeps
                // the exact original behavior: only emit a var when an explicit
                // distance is set, otherwise defer entirely to the keyframe's
                // own -30px fallback — unchanged for any rule saved before this.
                $dir = $s['direction'] ?? 'down';
                if ( ! empty( $s['distance'] ) ) {
                    $dist = $s['distance'];
                    switch ( $dir ) {
                        case 'left':  $vars['--jalpi-ia-tx-from'] = "-{$dist}"; break;
                        case 'right': $vars['--jalpi-ia-tx-from'] = $dist; break;
                        case 'up':    $vars['--jalpi-ia-ty-from'] = $dist; break;
                        default:      $vars['--jalpi-ia-ty-from'] = '-' . $dist; break;
                    }
                } elseif ( in_array( $dir, [ 'left', 'right', 'up' ], true ) ) {
                    // Non-default direction with no explicit distance — still
                    // move the correct axis, using the same 30px magnitude the
                    // keyframe itself defaults to.
                    switch ( $dir ) {
                        case 'left':  $vars['--jalpi-ia-tx-from'] = '-30px'; break;
                        case 'right': $vars['--jalpi-ia-tx-from'] = '30px'; break;
                        case 'up':    $vars['--jalpi-ia-ty-from'] = '30px'; break;
                    }
                }
                break;
            case 'customTransform':
                if ( ! empty( $s['customTransform'] ) ) $vars['--jalpi-ia-custom'] = $s['customTransform'];
                // Companion filter for the directional Blur presets (Blur Up/
                // Down/Left/Right) — the dedicated 'blur' action's own keyframe
                // has no translate component, so those presets combine a
                // translate customTransform with this instead of a new action.
                if ( ! empty( $s['customFilter'] ) ) $vars['--jalpi-ia-custom-filter'] = $s['customFilter'];
                break;
            case 'jackInTheBox':
                if ( ! empty( $s['scaleValue'] ) ) $vars['--jalpi-ia-scale-from'] = $s['scaleValue'];
                break;
            case 'elastic':
                // Same direction convention as slide/translate/bounce: 'up' =
                // starts below moving up, 'down' (default) = starts above
                // moving down, 'left'/'right' move along X instead (resolved
                // to the elasticX keyframe in add_interaction_keyframe_css()).
                $dir  = $s['direction'] ?? 'down';
                $dist = $s['distance'] ?? '50px';
                switch ( $dir ) {
                    case 'left':  $vars['--jalpi-ia-tx-from'] = "-{$dist}"; break;
                    case 'right': $vars['--jalpi-ia-tx-from'] = $dist; break;
                    case 'up':    $vars['--jalpi-ia-ty-from'] = $dist; break;
                    default:      $vars['--jalpi-ia-ty-from'] = '-' . $dist; break;
                }
                break;
            case 'reveal':
            case 'wipe':
                // Both actions share the exact same clip-path computation —
                // they only differ in which @keyframes they reference ('wipe'
                // has no opacity component, 'reveal' does; see frontend.css).
                // clip-path interpolates smoothly only between two values of
                // the SAME basic-shape function, so "to" must match "from"'s
                // shape, not always be a bare inset(0 0 0 0).
                $shape = $s['clipShape'] ?? 'edge';
                switch ( $shape ) {
                    case 'circle':
                        $vars['--jalpi-ia-clip-from'] = 'circle(0% at 50% 50%)';
                        $vars['--jalpi-ia-clip-to']   = 'circle(150% at 50% 50%)';
                        break;
                    case 'diamond':
                        $vars['--jalpi-ia-clip-from'] = 'polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%)';
                        $vars['--jalpi-ia-clip-to']   = 'polygon(50% -50%, 150% 50%, 50% 150%, -50% 50%)';
                        break;
                    case 'square':
                        $vars['--jalpi-ia-clip-from'] = 'inset(50% 50% 50% 50%)';
                        $vars['--jalpi-ia-clip-to']   = 'inset(0 0 0 0)';
                        break;
                    default: // 'edge': directional wipe, reveals from the named side
                        $dir = $s['direction'] ?? 'left';
                        switch ( $dir ) {
                            case 'right': $vars['--jalpi-ia-clip-from'] = 'inset(0 0 0 100%)'; break;
                            case 'up':    $vars['--jalpi-ia-clip-from'] = 'inset(100% 0 0 0)'; break;
                            case 'down':  $vars['--jalpi-ia-clip-from'] = 'inset(0 0 100% 0)'; break;
                            default:      $vars['--jalpi-ia-clip-from'] = 'inset(0 100% 0 0)'; break; // 'left'
                        }
                        $vars['--jalpi-ia-clip-to'] = 'inset(0 0 0 0)';
                }
                break;
        }

        $css = '';
        foreach ( $vars as $k => $v ) { $css .= $k . ':' . esc_attr( (string) $v ) . ';'; }
        return $css;
    }

    // Convert outerPadding* keys to padding-* CSS string (used for button wrapper div).
    public function outer_padding_css( array $styles ): string {
        $css  = '';
        $map  = [ 'outerPaddingTop' => 'padding-top', 'outerPaddingRight' => 'padding-right',
                  'outerPaddingBottom' => 'padding-bottom', 'outerPaddingLeft' => 'padding-left' ];
        foreach ( $map as $key => $prop ) {
            $val = trim( (string) ( $styles[ $key ] ?? '' ) );
            if ( '' === $val ) continue;
            // Add px to bare numbers (same normalisation as styles_to_css).
            if ( preg_match( '/^-?[\d.]+$/', $val ) ) $val .= 'px';
            $css .= $prop . ':' . sanitize_text_field( $val ) . ';';
        }
        return $css;
    }

    // -----------------------------------------------------------------
    // Helper: extract CSS ID / Class from node settings
    // -----------------------------------------------------------------
    public function get_css_attrs( $node ) {
        $settings  = $node['settings'] ?? [];
        $css_id    = sanitize_html_class( $settings['cssId']    ?? '' );
        $css_class = esc_attr( sanitize_text_field( $settings['cssClass'] ?? '' ) );

        // Reusable classes (Class-Based Styling System). Orphaned references
        // (deleted class) are skipped silently — no cross-page cascade needed.
        $resolved_names = [];
        foreach ( (array) ( $settings['cssClasses'] ?? [] ) as $key ) {
            $key = sanitize_key( $key );
            if ( ! $key ) continue;
            $cls = Jalpi_Class_Manager::instance()->get_by_key( $key );
            if ( ! $cls ) continue;
            $name = sanitize_html_class( $cls['name'] );
            if ( ! $name ) continue;
            $resolved_names[]                 = $name;
            $this->used_class_keys[ $key ]    = true;
        }

        $all_classes = array_filter( array_merge( $resolved_names, '' !== $css_class ? [ $css_class ] : [] ) );

        // NB: keep the ternary parenthesised — without it the concatenation
        // binds to the else branch only, and the split-text attribute would
        // vanish on any node that also sets a CSS ID.
        return [
            'id_attr'   => ( $css_id ? ' id="' . $css_id . '"' : '' )
                           . $this->split_text_attr( $settings ),
            'extra_cls' => $all_classes ? ' ' . implode( ' ', $all_classes ) : '',
        ];
    }

    /**
     * Build the `data-jalpi-st` attribute that drives per-character/word/line/
     * paragraph split text (see initSplitText() in assets/js/jalpi-frontend.js).
     *
     * Emitted from get_css_attrs() so EVERY element type can opt in without
     * per-widget changes — one Split Text group in the Animation tab reaches
     * Heading, Text Editor, Button, CTA, FAQ, Pricing, Testimonials and the
     * rest at once.
     *
     * The whitelists, clamps and gate all live in the Jalpi_Split_Text trait so
     * this and Jalpi_Element_Split_Text can never drift apart. The gate is
     * settings.st_enabled: absent (as in all pre-existing content) means this
     * returns '' and the node renders exactly as it did before.
     *
     * @param array $settings Node settings.
     * @return string Attribute markup, or '' when not enabled.
     */
    private function split_text_attr( array $settings ): string {
        $attr = $this->build_split_text_attr( $settings );

        // Flag the shared frontend script here rather than in each element:
        // the retrofit reaches widgets (Heading, Text, Button, …) whose render()
        // never calls set_needs_js(), so without this the attribute would ship
        // with no engine to act on it. Gated on $attr, so pages that do not use
        // split text still enqueue nothing.
        if ( '' !== $attr ) {
            $this->set_needs_js( 'generic' );
        }

        return $attr;
    }

    // Wraps $html in a <div> with outer padding when outerPadding* keys are set.
    // $id is the node's canvas ID — when provided the wrapper gets class="jalpi-outer-{id}"
    // so that responsive CSS rules generated in maybe_add_responsive_rules can target it.
    public function wrap_outer_padding( string $html, array $styles, string $id = '' ): string {
        $parts = [];
        foreach ( [ 'Top' => 'top', 'Right' => 'right', 'Bottom' => 'bottom', 'Left' => 'left' ] as $cap => $prop ) {
            $val = trim( $styles[ "outerPadding{$cap}" ] ?? '' );
            if ( '' !== $val ) {
                $safe = Jalpi_Sanitizer::sanitize_dimension( $val );
                if ( '' !== $safe ) {
                    $parts[] = "padding-{$prop}:{$safe}";
                }
            }
        }
        $class_attr = $id ? ' class="jalpi-outer-' . esc_attr( $id ) . '"' : '';
        if ( empty( $parts ) ) {
            // Still emit the wrapper div (with class only) if an id is given so that
            // responsive CSS rules from maybe_add_responsive_rules have a DOM target.
            return $id ? '<div' . $class_attr . '>' . $html . '</div>' : $html;
        }
        return '<div' . $class_attr . ' style="' . esc_attr( implode( ';', $parts ) ) . '">' . $html . '</div>';
    }

    // -----------------------------------------------------------------
    // CSS helpers
    // -----------------------------------------------------------------

    public function section_css( $id, $settings, $styles = [] ) {
        $sel = ".jalpi-section.jalpi-{$id}";
        $css = "{$sel}{display:block;box-sizing:border-box;";

        // ── Content Width ──────────────────────────────────────────
        $cw = sanitize_text_field( $settings['contentWidth'] ?? 'full' );
        $bw = sanitize_text_field( $settings['boxedWidth']   ?? '' );
        if ( $cw === 'full' ) {
            // Break out of the WordPress theme content container to true viewport width.
            $css .= 'width:100vw;max-width:none;margin-left:calc(50% - 50vw);margin-right:0;';
        } elseif ( $cw === 'boxed' && $bw ) {
            $css .= 'max-width:' . esc_attr( $bw ) . ';margin-left:auto;margin-right:auto;';
        }

        // ── Align Items (turns section into flex column) ───────────
        $align = sanitize_text_field( $settings['alignItems'] ?? '' );
        if ( $align ) {
            $css .= "display:flex;flex-direction:column;align-items:{$align};";
        }

        // ── Background — new model (bgNormal) with fallback to legacy ─
        $bg = $settings['bgNormal'] ?? $settings['background'] ?? [];
        $css .= $this->bg_data_to_css( $bg );

        // ── Old-model padding (backwards compat) ───────────────────
        $pad = $settings['padding'] ?? [];
        if ( $pad ) {
            $css .= 'padding:'
                . sanitize_text_field( $pad['top']    ?? '0' ) . ' '
                . sanitize_text_field( $pad['right']  ?? '0' ) . ' '
                . sanitize_text_field( $pad['bottom'] ?? '0' ) . ' '
                . sanitize_text_field( $pad['left']   ?? '0' ) . ';';
        }

        // ── Border (borderNormal) ──────────────────────────────────
        $bd = $settings['borderNormal'] ?? [];
        $css .= $this->border_data_to_css( $bd );

        // ── Overlay: needs position:relative ──────────────────────
        $ov = $settings['overlayNormal'] ?? [];
        if ( ! empty( $ov['color'] ) || ! empty( $ov['imageUrl'] ) || ! empty( $ov['gradient'] ) ) {
            $css .= 'position:relative;';
        }

        // ── Styles from the style/advanced tabs (last so they win) ─
        $css .= $this->styles_to_css( $styles );
        $css .= '}';

        // ── Hover state ────────────────────────────────────────────
        $hover_parts = '';
        $bg_h = $settings['bgHover'] ?? [];
        if ( ! empty( $bg_h ) ) {
            $hover_parts .= $this->bg_data_to_css( $bg_h );
        }
        $bd_h = $settings['borderHover'] ?? [];
        if ( ! empty( $bd_h ) ) {
            $hover_parts .= $this->border_data_to_css( $bd_h );
        }
        if ( $hover_parts ) {
            $css .= "{$sel}:hover{{$hover_parts}}";
        }

        // ── Overlay CSS ────────────────────────────────────────────
        if ( ! empty( $ov['color'] ) || ! empty( $ov['imageUrl'] ) || ! empty( $ov['gradient'] ) ) {
            $ov_css = "{$sel} .jalpi-section-overlay{position:absolute;inset:0;pointer-events:none;z-index:0;";
            $ov_css .= $this->bg_data_to_css( $ov );
            $opacity = floatval( $ov['opacity'] ?? 0.5 );
            $ov_css .= 'opacity:' . esc_attr( $opacity ) . ';';
            $ov_css .= '}';
            $css .= $ov_css;
            // Lift section children above the overlay so content remains visible
            $css .= "{$sel}>:not(.jalpi-section-overlay){position:relative;z-index:1;}";

            $ov_h = $settings['overlayHover'] ?? [];
            if ( ! empty( $ov_h ) ) {
                $ov_h_css = "{$sel}:hover .jalpi-section-overlay{";
                $ov_h_css .= $this->bg_data_to_css( $ov_h );
                $opacity_h = floatval( $ov_h['opacity'] ?? 0.5 );
                $ov_h_css .= 'opacity:' . esc_attr( $opacity_h ) . ';';
                $ov_h_css .= '}';
                $css .= $ov_h_css;
            }
        }

        return $css;
    }

    public function bg_data_to_css( $bg ) {
        if ( empty( $bg ) ) return '';
        $css  = '';
        $type = sanitize_text_field( $bg['type'] ?? 'classic' );

        // Legacy single-type check
        if ( isset( $bg['value'] ) || isset( $bg['url'] ) ) {
            switch ( $bg['type'] ?? 'color' ) {
                case 'color':
                    $color = Jalpi_Sanitizer::sanitize_color( (string) ( $bg['value'] ?? '' ) );
                    if ( $color ) $css .= "background-color:{$color};";
                    break;
                case 'image':
                    $url  = esc_url( $bg['url'] ?? '' );
                    $pos  = sanitize_text_field( $bg['position'] ?? 'center center' );
                    $size = sanitize_text_field( $bg['size'] ?? 'cover' );
                    if ( $url ) $css .= "background-image:url({$url});background-position:{$pos};background-size:{$size};";
                    break;
                case 'gradient':
                    $css .= 'background:' . Jalpi_Sanitizer::sanitize_css_value( $bg['value'] ?? '' ) . ';';
                    break;
            }
            return $css;
        }

        // New model
        if ( $type === 'classic' ) {
            if ( ! empty( $bg['color'] ) ) {
                $color = Jalpi_Sanitizer::sanitize_color( (string) $bg['color'] );
                if ( $color ) $css .= "background-color:{$color};";
            }
            if ( ! empty( $bg['imageUrl'] ) ) {
                $url  = esc_url( $bg['imageUrl'] );
                $pos  = sanitize_text_field( $bg['imagePosition'] ?? 'center center' );
                $size = sanitize_text_field( $bg['imageSize']     ?? 'cover' );
                $css .= "background-image:url({$url});background-position:{$pos};background-size:{$size};background-repeat:no-repeat;";
            }
        } elseif ( $type === 'gradient' && ! empty( $bg['gradient'] ) ) {
            $css .= 'background:' . Jalpi_Sanitizer::sanitize_css_value( $bg['gradient'] ) . ';';
        } elseif ( $type === 'video' && ! empty( $bg['videoUrl'] ) ) {
            // Video backgrounds are rendered as a <video> element — no CSS bg needed.
        } elseif ( $type === 'slideshow' && ! empty( $bg['slideshowUrls'] ) ) {
            $urls  = array_values( array_filter( array_map( 'trim', explode( "\n", $bg['slideshowUrls'] ) ) ) );
            $first = ! empty( $urls[0] ) ? esc_url( $urls[0] ) : '';
            if ( $first ) {
                $css .= "background-image:url({$first});background-position:center center;background-size:cover;background-repeat:no-repeat;";
            }
        }

        return $css;
    }

    public function border_data_to_css( $bd ) {
        if ( empty( $bd ) ) return '';
        $css  = '';
        $type = sanitize_text_field( $bd['borderType'] ?? 'none' );
        if ( $type && $type !== 'none' ) {
            $color        = sanitize_hex_color( $bd['borderColor'] ?? '#000000' ) ?: '#000000';
            $has_per_side = ! empty( $bd['borderTopWidth'] ) || ! empty( $bd['borderRightWidth'] )
                         || ! empty( $bd['borderBottomWidth'] ) || ! empty( $bd['borderLeftWidth'] );
            if ( $has_per_side ) {
                $css .= "border-style:{$type};border-color:{$color};";
                $css .= 'border-top-width:'    . Jalpi_Sanitizer::sanitize_dimension( $bd['borderTopWidth']    ?? '0' ) . ';';
                $css .= 'border-right-width:'  . Jalpi_Sanitizer::sanitize_dimension( $bd['borderRightWidth']  ?? '0' ) . ';';
                $css .= 'border-bottom-width:' . Jalpi_Sanitizer::sanitize_dimension( $bd['borderBottomWidth'] ?? '0' ) . ';';
                $css .= 'border-left-width:'   . Jalpi_Sanitizer::sanitize_dimension( $bd['borderLeftWidth']   ?? '0' ) . ';';
            } else {
                $width = sanitize_text_field( $bd['borderWidth'] ?? '1px' );
                $css  .= "border:{$width} {$type} {$color};";
            }
        }
        $has_per_corner = ! empty( $bd['borderTopLeftRadius'] ) || ! empty( $bd['borderTopRightRadius'] )
                       || ! empty( $bd['borderBottomRightRadius'] ) || ! empty( $bd['borderBottomLeftRadius'] );
        if ( $has_per_corner ) {
            $css .= 'border-top-left-radius:'     . Jalpi_Sanitizer::sanitize_dimension( $bd['borderTopLeftRadius']     ?? '0' ) . ';';
            $css .= 'border-top-right-radius:'    . Jalpi_Sanitizer::sanitize_dimension( $bd['borderTopRightRadius']    ?? '0' ) . ';';
            $css .= 'border-bottom-right-radius:' . Jalpi_Sanitizer::sanitize_dimension( $bd['borderBottomRightRadius'] ?? '0' ) . ';';
            $css .= 'border-bottom-left-radius:'  . Jalpi_Sanitizer::sanitize_dimension( $bd['borderBottomLeftRadius']  ?? '0' ) . ';';
        } elseif ( ! empty( $bd['borderRadius'] ) ) {
            $css .= 'border-radius:' . Jalpi_Sanitizer::sanitize_dimension( $bd['borderRadius'] ) . ';';
        }
        if ( ! empty( $bd['boxShadow'] ) ) {
            $css .= 'box-shadow:' . Jalpi_Sanitizer::sanitize_css_value( $bd['boxShadow'] ) . ';';
        }
        return $css;
    }

    public function styles_to_css( $styles ) {
        $map = [
            'fontSize'       => 'font-size',
            'fontWeight'     => 'font-weight',
            'fontFamily'     => 'font-family',
            'fontStyle'      => 'font-style',
            'color'          => 'color',
            'backgroundColor'=> 'background-color',
            'textAlign'      => 'text-align',
            'lineHeight'     => 'line-height',
            'letterSpacing'  => 'letter-spacing',
            'wordSpacing'    => 'word-spacing',
            'textDecoration' => 'text-decoration',
            'textTransform'  => 'text-transform',
            'marginTop'      => 'margin-top',
            'marginBottom'   => 'margin-bottom',
            'marginLeft'     => 'margin-left',
            'marginRight'    => 'margin-right',
            'paddingTop'     => 'padding-top',
            'paddingBottom'  => 'padding-bottom',
            'paddingLeft'    => 'padding-left',
            'paddingRight'   => 'padding-right',
            'width'          => 'width',
            'height'         => 'height',
            'minHeight'      => 'min-height',
            'borderRadius'          => 'border-radius',
            'borderTopLeftRadius'   => 'border-top-left-radius',
            'borderTopRightRadius'  => 'border-top-right-radius',
            'borderBottomRightRadius' => 'border-bottom-right-radius',
            'borderBottomLeftRadius'  => 'border-bottom-left-radius',
            'border'                => 'border',
            'borderStyle'           => 'border-style',
            'borderColor'           => 'border-color',
            'borderTopWidth'        => 'border-top-width',
            'borderRightWidth'      => 'border-right-width',
            'borderBottomWidth'     => 'border-bottom-width',
            'borderLeftWidth'       => 'border-left-width',
            'boxShadow'             => 'box-shadow',
            'opacity'        => 'opacity',
            'display'        => 'display',
            'gap'            => 'gap',
            'rowGap'         => 'row-gap',
            'columnGap'      => 'column-gap',
            'position'       => 'position',
            'top'            => 'top',
            'right'          => 'right',
            'bottom'         => 'bottom',
            'left'           => 'left',
            'zIndex'         => 'z-index',
            // Flexbox layout
            'flexDirection'  => 'flex-direction',
            'justifyContent' => 'justify-content',
            'alignItems'     => 'align-items',
            'alignSelf'      => 'align-self',
            'alignContent'   => 'align-content',
            'flexWrap'       => 'flex-wrap',
            'flex'           => 'flex',
            'flexGrow'       => 'flex-grow',
            'flexShrink'     => 'flex-shrink',
            'flexBasis'      => 'flex-basis',
            // Sizing
            'maxWidth'       => 'max-width',
            'minWidth'       => 'min-width',
            'maxHeight'      => 'max-height',
            'aspectRatio'    => 'aspect-ratio',
            // Media
            'objectFit'      => 'object-fit',
            'objectPosition' => 'object-position',
            // Overflow & text
            'overflow'       => 'overflow',
            'overflowX'      => 'overflow-x',
            'overflowY'      => 'overflow-y',
            'whiteSpace'     => 'white-space',
            'textOverflow'   => 'text-overflow',
            'verticalAlign'  => 'vertical-align',
            // Interaction
            'cursor'         => 'cursor',
            'pointerEvents'  => 'pointer-events',
            // Background shorthand (used for gradient-text CSS trick)
            'background'              => 'background',
            'backgroundClip'          => 'background-clip',
            'WebkitBackgroundClip'    => '-webkit-background-clip',
            'WebkitTextFillColor'     => '-webkit-text-fill-color',
            // Animation & transforms
            'transition'     => 'transition',
            'transform'      => 'transform',
            'filter'         => 'filter',
            'backdropFilter' => 'backdrop-filter',
            'willChange'     => 'will-change',
            // Misc
            'listStyle'      => 'list-style',
            'appearance'     => 'appearance',
            'resize'         => 'resize',
            'userSelect'     => 'user-select',
        ];

        // Properties handled via composition below — skip direct output.
        $composite_keys = [
            'textStrokeWidth', 'textStrokeColor',
            'textShadowH', 'textShadowV', 'textShadowBlur', 'textShadowColor',
            // Handled as extra CSS rules in heading/text render methods.
            'colorHover', 'linkColor', 'linkColorHover', 'paragraphSpacing',
            // Background — composed below so gradient/solid are mutually exclusive.
            'backgroundColor', 'backgroundType',
            'gradientColor1', 'gradientColor2', 'gradientAngle',
            // Hover-only overrides — not applied in static CSS.
            'backgroundColorHover', 'gradientColor1Hover', 'gradientColor2Hover',
            'borderColorHover', 'boxShadowHover',
            // Outer wrapper padding — applied to a wrapping <div>, not the element itself.
            'outerPaddingTop', 'outerPaddingRight', 'outerPaddingBottom', 'outerPaddingLeft',
        ];

        // CSS properties that are color values
        $color_props = [ 'color', 'backgroundColor', 'WebkitTextFillColor' ];
        // CSS properties that are compound values requiring sanitize_css_value
        $compound_props = [ 'boxShadow', 'border', 'background', 'transition', 'transform', 'filter', 'backdropFilter' ];

        $css = '';
        foreach ( $styles as $key => $value ) {
            if ( in_array( $key, $composite_keys, true ) ) {
                continue;
            }
            if ( ! isset( $map[ $key ] ) || '' === (string) $value ) {
                continue;
            }

            if ( in_array( $key, $color_props, true ) ) {
                $safe = Jalpi_Sanitizer::sanitize_color( (string) $value );
            } elseif ( in_array( $key, $compound_props, true ) ) {
                $safe = Jalpi_Sanitizer::sanitize_css_value( (string) $value );
            } else {
                $safe = sanitize_text_field( (string) $value );
            }

            if ( '' === $safe ) {
                continue;
            }

            // Auto-append 'px' to bare numeric values for dimension properties.
            // BoxSpacingControl stores values as typed ("20" not "20px"); bare numbers
            // are invalid CSS — the browser silently ignores them.
            static $dimension_props = [
                'marginTop','marginRight','marginBottom','marginLeft',
                'paddingTop','paddingRight','paddingBottom','paddingLeft',
                'top','right','bottom','left',
                'width','height','maxWidth','minWidth','maxHeight','minHeight',
                'gap','rowGap','columnGap',
                'fontSize','letterSpacing','wordSpacing','textIndent',
                'borderWidth',
                'borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth',
                'borderRadius',
                'borderTopLeftRadius','borderTopRightRadius',
                'borderBottomLeftRadius','borderBottomRightRadius',
                'outlineWidth','outlineOffset',
            ];
            if ( in_array( $key, $dimension_props, true ) && preg_match( '/^-?[\d.]+$/', $safe ) ) {
                $safe .= 'px';
            }

            $css .= $map[ $key ] . ':' . $safe . ';';

            // A Design System-linked fontFamily resolves to var(--jalpi-font-{id}, "Family", ...)
            // (see resolve_token_refs()) — skip the Google-Fonts-URL heuristic for those, the
            // literal family name isn't parseable from the var() wrapper and doesn't need to be:
            // fonts for linked fontFamily tokens are enqueued centrally by
            // Jalpi_Settings::output_design_system_css() from the token's own literal value.
            if ( 'fontFamily' === $key && ! str_starts_with( $safe, 'var(' ) ) {
                $font = trim( explode( ',', $safe )[0], "\"' " );
                if ( $font && ! in_array( $font, $this->used_fonts, true ) ) {
                    $this->used_fonts[] = $font;
                }
            }
        }

        // Compose -webkit-text-stroke from width + color parts.
        $stroke_w = trim( $styles['textStrokeWidth'] ?? '' );
        $stroke_c = trim( $styles['textStrokeColor'] ?? '' );
        if ( $stroke_w && $stroke_w !== '0' && $stroke_w !== '0px' ) {
            $css .= '-webkit-text-stroke:' . sanitize_text_field( $stroke_w ) . ' ' . sanitize_text_field( $stroke_c ?: '#000000' ) . ';';
        }

        // Compose text-shadow from individual offset/blur/color parts.
        $sh = trim( $styles['textShadowH']     ?? '' );
        $sv = trim( $styles['textShadowV']     ?? '' );
        $sb = trim( $styles['textShadowBlur']  ?? '' );
        $sc = trim( $styles['textShadowColor'] ?? '' );
        if ( $sh || $sv || $sc ) {
            $h_val = $sh ?: '0px';
            $v_val = $sv ?: '0px';
            $b_val = $sb ?: '0px';
            $c_val = $sc ?: '#000000';
            // Ensure px suffix on numeric-only values.
            if ( is_numeric( $h_val ) ) $h_val .= 'px';
            if ( is_numeric( $v_val ) ) $v_val .= 'px';
            if ( is_numeric( $b_val ) ) $b_val .= 'px';
            $css .= 'text-shadow:' . sanitize_text_field( "$h_val $v_val $b_val $c_val" ) . ';';
        }

        // Compose background: gradient or solid color.
        $bg_type = trim( $styles['backgroundType'] ?? '' );
        if ( 'gradient' === $bg_type ) {
            $c1    = Jalpi_Sanitizer::sanitize_color( trim( $styles['gradientColor1'] ?? '' ) ) ?: '#000000';
            $c2    = Jalpi_Sanitizer::sanitize_color( trim( $styles['gradientColor2'] ?? '' ) ) ?: '#ffffff';
            $angle = sanitize_text_field( trim( $styles['gradientAngle'] ?? '90deg' ) ) ?: '90deg';
            $css  .= 'background-image:linear-gradient(' . $angle . ',' . $c1 . ',' . $c2 . ');';
        } elseif ( isset( $styles['backgroundColor'] ) && '' !== (string) $styles['backgroundColor'] ) {
            $bg_color = Jalpi_Sanitizer::sanitize_color( (string) $styles['backgroundColor'] );
            if ( '' !== $bg_color ) {
                $css .= 'background-color:' . $bg_color . ';';
            }
        }

        return $css;
    }

    /**
     * Generic hover-state compiler for the Class-Based Styling System: scans
     * for the existing `{prop}Hover` key convention (already used ad hoc per
     * widget for colorHover/backgroundColorHover/etc.) and maps the resulting
     * base-keyed map through the same property table as styles_to_css(). Used
     * only for class CSS — the 40 widgets' own hand-rolled hover logic is
     * untouched.
     */
    public function hover_styles_to_css( array $styles ): string {
        if ( empty( $styles ) ) {
            return '';
        }
        $hover = [];
        foreach ( $styles as $key => $value ) {
            if ( '' === (string) $value || ! str_ends_with( $key, 'Hover' ) ) {
                continue;
            }
            $hover[ substr( $key, 0, -5 ) ] = $value;
        }
        return $hover ? $this->styles_to_css( $hover ) : '';
    }

    /**
     * Compile CSS for every reusable class referenced on the current page,
     * exactly once each regardless of how many elements use it. Cascade order
     * = the class manager's own sort order (multi-class precedence), and the
     * whole block is emitted before per-node local styles so local overrides
     * always win. A class's own Design System link (globalStyleRef) is
     * resolved through the same resolve_token_refs() used for nodes, so
     * classes inherit site-wide tokens/presets identically.
     */
    private function compile_class_css(): void {
        if ( empty( $this->used_class_keys ) ) {
            return;
        }

        $manager = Jalpi_Class_Manager::instance();
        $classes = array_values( array_filter( array_map(
            static fn( $key ) => $manager->get_by_key( $key ),
            array_keys( $this->used_class_keys )
        ) ) );
        usort( $classes, static fn( $a, $b ) => $a['sortOrder'] <=> $b['sortOrder'] );

        $rules = $tablet_rules = $mobile_rules = [];

        foreach ( $classes as $cls ) {
            $name = sanitize_html_class( $cls['name'] );
            if ( ! $name ) {
                continue;
            }
            $sel = '.' . $name;

            $resolved = $this->resolve_token_refs( [
                'styles'         => $cls['styles'] ?? [],
                'globalStyleRef' => $cls['globalStyleRef'] ?? null,
            ] );
            $styles   = $resolved['styles'] ?? [];
            $settings = $cls['settings'] ?? [];

            $body  = $this->bg_data_to_css( $settings['bgNormal'] ?? [] );
            $body .= $this->border_data_to_css( $settings['borderNormal'] ?? [] );
            $body .= $this->styles_to_css( $styles );
            if ( $body ) {
                $rules[] = "{$sel}{{$body}}";
            }

            $hover  = $this->bg_data_to_css( $settings['bgHover'] ?? [] );
            $hover .= $this->border_data_to_css( $settings['borderHover'] ?? [] );
            $hover .= $this->hover_styles_to_css( $styles );
            if ( $hover ) {
                $rules[] = "{$sel}:hover{{$hover}}";
            }

            $tablet_styles = $cls['tabletStyles'] ?? [];
            $tablet        = $this->styles_to_css( $tablet_styles );
            if ( $tablet ) {
                $tablet_rules[] = "{$sel}{{$tablet}}";
            }
            $tablet_hover = $this->hover_styles_to_css( $tablet_styles );
            if ( $tablet_hover ) {
                $tablet_rules[] = "{$sel}:hover{{$tablet_hover}}";
            }

            $mobile_styles = $cls['mobileStyles'] ?? [];
            $mobile        = $this->styles_to_css( $mobile_styles );
            if ( $mobile ) {
                $mobile_rules[] = "{$sel}{{$mobile}}";
            }
            $mobile_hover = $this->hover_styles_to_css( $mobile_styles );
            if ( $mobile_hover ) {
                $mobile_rules[] = "{$sel}:hover{{$mobile_hover}}";
            }
        }

        $this->class_css_rules        = [ apply_filters( 'jalpi_before_compile_class_css', implode( '', $rules ), $this->used_class_keys ) ];
        $this->class_tablet_css_rules = $tablet_rules;
        $this->class_mobile_css_rules = $mobile_rules;
    }

    /**
     * Render transient cache key, folding in the class-system version so
     * editing any class invalidates every page's cached render without a
     * cross-page usage index — old transients simply become unreachable and
     * expire naturally within their normal TTL.
     */
    public function cache_key( int $post_id ): string {
        return 'jalpi_render_' . $post_id . '_' . JALPI_VERSION . '_c' . Jalpi_Class_Manager::instance()->version();
    }

    private function compile_css() {
        $tablet_bp = (int) Jalpi_Settings::get( 'breakpoints', 'tablet_breakpoint', 1024 );
        $mobile_bp = (int) Jalpi_Settings::get( 'breakpoints', 'mobile_breakpoint', 767 );

        $this->compile_class_css();

        $css  = implode( '', array_unique( $this->class_css_rules ) );
        $css .= implode( '', array_unique( $this->css_rules ) );

        $tablet_css = implode( '', array_unique( $this->class_tablet_css_rules ) )
                    . implode( '', array_unique( $this->tablet_css_rules ) );
        if ( $tablet_css ) {
            $css .= "@media(max-width:{$tablet_bp}px){{$tablet_css}}";
        }

        $mobile_css = implode( '', array_unique( $this->class_mobile_css_rules ) )
                    . implode( '', array_unique( $this->mobile_css_rules ) );
        if ( $mobile_css ) {
            $css .= "@media(max-width:{$mobile_bp}px){{$mobile_css}}";
        }

        return $css;
    }
}

