<?php
/**
 * Markup token interpolation (flat, nested, repeaters, content stack).
 *
 * @package JOCBM
 *
 * Copyright (C) 2026 JayOnCode
 * License: GPL-2.0-or-later
 */

namespace JOCBM\Services;

use JOCBM\Database\Schema;

defined( 'ABSPATH' ) || exit;

/**
 * Class MarkupInterpolator
 */
final class MarkupInterpolator {

	private const HEADING_TAGS = array( 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' );
	private const SUB_TAGS     = array( 'div', 'p' );

	/**
	 * Map field_schema type → Gutenberg attribute config.
	 *
	 * @param array<string, mixed> $field    Field schema entry.
	 * @param array<string, mixed> $defaults default_attrs.
	 * @return array<string, mixed>
	 */
	public static function attribute_for_field( array $field, array $defaults ): array {
		$key  = (string) ( $field['key'] ?? '' );
		$type = (string) ( $field['type'] ?? 'text' );
		$raw  = $defaults[ $key ] ?? null;

		switch ( $type ) {
			case 'link':
				return array(
					'type'    => 'object',
					'default' => self::normalize_link( is_array( $raw ) ? $raw : array() ),
				);
			case 'media':
				return array(
					'type'    => 'object',
					'default' => self::normalize_media( is_array( $raw ) ? $raw : array(), $field ),
				);
			case 'repeater':
				return array(
					'type'    => 'array',
					'default' => is_array( $raw ) ? array_values( $raw ) : array(),
				);
			case 'elementsOrder':
				return array(
					'type'    => 'object',
					'default' => ElementsOrder::sanitize(
						is_array( $raw ) ? $raw : ( isset( $field['default'] ) ? $field['default'] : array() )
					),
				);
			case 'elementsVisibility':
				$keys = array();
				if ( isset( $field['visibilityKeys'] ) && is_array( $field['visibilityKeys'] ) ) {
					$keys = $field['visibilityKeys'];
				} elseif ( isset( $field['default'] ) && is_array( $field['default'] ) ) {
					$keys = $field['default'];
				}
				return array(
					'type'    => 'object',
					'default' => ElementsVisibility::sanitize(
						is_array( $raw ) ? $raw : array(),
						array_values(
							array_filter(
								$keys,
								static function ( $k ) {
									return is_string( $k ) && '' !== $k;
								}
							)
						)
					),
				);
			case 'tag':
				$fallback = (string) ( $field['default'] ?? 'div' );
				return array(
					'type'    => 'string',
					'default' => is_string( $raw ) && '' !== $raw ? $raw : $fallback,
				);
			case 'toggle':
			case 'boolean':
				return array(
					'type'    => 'boolean',
					'default' => self::truthy( $raw ) || self::truthy( $field['default'] ?? false ),
				);
			case 'number':
				$fallback = isset( $field['default'] ) ? (float) $field['default'] : 0;
				return array(
					'type'    => 'number',
					'default' => null !== $raw && '' !== $raw && is_numeric( $raw )
						? (float) $raw
						: $fallback,
				);
			case 'select':
				$opts     = isset( $field['options'] ) && is_array( $field['options'] ) ? $field['options'] : array();
				$fallback = (string) ( $field['default'] ?? '' );
				if ( '' === $fallback && $opts ) {
					$first = $opts[0];
					$fallback = is_string( $first )
						? $first
						: (string) ( is_array( $first ) ? ( $first['value'] ?? '' ) : '' );
				}
				return array(
					'type'    => 'string',
					'default' => is_string( $raw ) && '' !== $raw ? $raw : $fallback,
				);
			default:
				return array(
					'type'    => 'string',
					'default' => null !== $raw && ! is_array( $raw ) ? (string) $raw : '',
				);
		}
	}

	/**
	 * @param array<string, mixed> $link Link object.
	 * @return array{title: string, url: string, opensInNewTab: bool}
	 */
	public static function normalize_link( array $link ): array {
		return array(
			'title'         => isset( $link['title'] ) ? (string) $link['title'] : '',
			'url'           => isset( $link['url'] ) ? (string) $link['url'] : '',
			'opensInNewTab' => ! empty( $link['opensInNewTab'] ),
		);
	}

	/**
	 * @param array<string, mixed> $media Media object.
	 * @param array<string, mixed> $field Field schema.
	 * @return array{id: int, url: string, alt: string, mime: string, type: string, embedHtml: string, providerName: string}
	 */
	public static function normalize_media( array $media, array $field = array() ): array {
		$kind = (string) ( $media['type'] ?? $field['mediaType'] ?? 'image' );
		if ( ! in_array( $kind, array( 'image', 'video', 'iframe' ), true ) ) {
			$kind = 'image';
		}
		$url = isset( $media['url'] ) ? (string) $media['url'] : '';
		$url = self::resolve_media_url( $url );

		$embed_html = '';
		if ( 'iframe' === $kind && isset( $media['embedHtml'] ) ) {
			$embed_html = (string) $media['embedHtml'];
		}

		return array(
			'id'           => isset( $media['id'] ) ? (int) $media['id'] : 0,
			'url'          => $url,
			'alt'          => isset( $media['alt'] ) ? (string) $media['alt'] : '',
			'mime'         => isset( $media['mime'] ) ? (string) $media['mime'] : '',
			'type'         => $kind,
			'embedHtml'    => 'iframe' === $kind ? $embed_html : '',
			'providerName' => ( 'iframe' === $kind && isset( $media['providerName'] ) )
				? (string) $media['providerName']
				: '',
		);
	}

	/**
	 * Allow oEmbed iframe markup (and common wrappers) for media embeds.
	 *
	 * @param string $html Raw embed HTML.
	 */
	public static function kses_embed_html( string $html ): string {
		$html = trim( $html );
		if ( '' === $html ) {
			return '';
		}
		$allowed = wp_kses_allowed_html( 'post' );
		$allowed['iframe'] = array(
			'src'             => true,
			'height'          => true,
			'width'           => true,
			'title'           => true,
			'allow'           => true,
			'allowfullscreen' => true,
			'frameborder'     => true,
			'scrolling'       => true,
			'style'           => true,
			'class'           => true,
			'id'              => true,
			'loading'         => true,
			'referrerpolicy'  => true,
			'sandbox'         => true,
			'name'            => true,
		);
		$allowed['blockquote'] = array_merge(
			$allowed['blockquote'] ?? array(),
			array(
				'class'                  => true,
				'data-instgrm-permalink' => true,
				'data-instgrm-version'   => true,
			)
		);
		return self::ensure_embed_iframe_attrs( wp_kses( $html, $allowed ) );
	}

	/**
	 * YouTube Error 153: embeds need a usable Referer in wp-admin / editor canvases.
	 *
	 * @param string $html Embed HTML.
	 */
	public static function ensure_embed_iframe_attrs( string $html ): string {
		if ( '' === trim( $html ) ) {
			return '';
		}
		$allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share';
		return (string) preg_replace_callback(
			'/<iframe\b([^>]*)>/i',
			static function ( array $m ) use ( $allow ): string {
				$attrs = (string) ( $m[1] ?? '' );
				if ( ! preg_match( '/\breferrerpolicy\s*=/i', $attrs ) ) {
					$attrs .= ' referrerpolicy="strict-origin-when-cross-origin"';
				} else {
					$attrs = preg_replace(
						'/\breferrerpolicy\s*=\s*(["\']?)[^"\'>\s]*\1/i',
						'referrerpolicy="strict-origin-when-cross-origin"',
						$attrs
					);
				}
				if ( ! preg_match( '/\ballow\s*=/i', $attrs ) ) {
					$attrs .= ' allow="' . esc_attr( $allow ) . '"';
				}
				if ( ! preg_match( '/\ballowfullscreen\b/i', $attrs ) ) {
					$attrs .= ' allowfullscreen';
				}
				// Prefer fill styles when present; drop fixed HTML width/height attrs.
				$attrs = preg_replace( '/\s*\b(?:width|height)\s*=\s*(["\']?)[^"\'\s>]*\1/i', '', $attrs );
				return '<iframe' . $attrs . '>';
			},
			$html
		);
	}

	/**
	 * Whether markup already uses the classic padding-bottom ratio shell.
	 *
	 * @param string $html Embed HTML.
	 */
	public static function has_padding_bottom_ratio_shell( string $html ): bool {
		return (bool) preg_match( '/padding-bottom\s*:\s*[\d.]+%/i', $html );
	}

	/**
	 * Wrap a bare iframe in the classic padding-bottom ratio shell.
	 *
	 * @param string $html Iframe or oEmbed HTML.
	 */
	public static function wrap_embed_ratio_shell( string $html ): string {
		$inner = trim( $html );
		if ( '' === $inner ) {
			return '';
		}
		if ( self::has_padding_bottom_ratio_shell( $inner ) ) {
			return $inner;
		}

		$fill = 'top:0;left:0;width:100%;height:100%;position:absolute;border:0;';
		$inner = (string) preg_replace_callback(
			'/<iframe\b([^>]*)>/i',
			static function ( array $m ) use ( $fill ): string {
				$attrs = (string) ( $m[1] ?? '' );
				if ( preg_match( '/\bstyle\s*=\s*(["\'])([\s\S]*?)\1/i', $attrs, $sm ) ) {
					$style = (string) ( $sm[2] ?? '' );
					if ( ! preg_match( '/position\s*:\s*absolute/i', $style ) ) {
						$attrs = preg_replace(
							'/\bstyle\s*=\s*(["\'])([\s\S]*?)\1/i',
							'style=$1' . $fill . ( '' !== $style ? ';' . $style : '' ) . '$1',
							$attrs,
							1
						);
					}
				} else {
					$attrs .= ' style="' . esc_attr( $fill ) . '"';
				}
				return '<iframe' . $attrs . '>';
			},
			$inner,
			1
		);

		$ratio = 'left:0;width:100%;height:0;position:relative;padding-bottom:56.25%;';
		return '<div class="bm-embed-ratio" style="' . esc_attr( $ratio ) . '">' . $inner . '</div>';
	}

	/**
	 * Normalize embed markup for .wp-block-embed__wrapper.
	 *
	 * @param string $html Embed HTML.
	 */
	public static function prepare_embed_wrapper_inner( string $html ): string {
		return self::wrap_embed_ratio_shell( self::ensure_embed_iframe_attrs( $html ) );
	}

	/**
	 * Providers we can render as a plain iframe (more reliable than raw oEmbed HTML).
	 *
	 * @param string $url Media URL.
	 */
	public static function is_direct_video_embed_provider( string $url ): bool {
		$parts = wp_parse_url( trim( $url ) );
		if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
			return false;
		}
		$host = preg_replace( '/^www\./', '', strtolower( (string) $parts['host'] ) );
		return in_array(
			$host,
			array(
				'youtube.com',
				'm.youtube.com',
				'music.youtube.com',
				'youtube-nocookie.com',
				'youtu.be',
				'vimeo.com',
				'player.vimeo.com',
			),
			true
		);
	}

	/**
	 * Expand bm-asset: relative plugin paths.
	 *
	 * @param string $url Raw URL.
	 */
	public static function resolve_media_url( string $url ): string {
		$raw = trim( $url );
		if ( 0 !== strpos( $raw, 'bm-asset:' ) ) {
			return $raw;
		}
		$rel = ltrim( substr( $raw, strlen( 'bm-asset:' ) ), '/' );
		return trailingslashit( JOCBM_URL ) . $rel;
	}

	/**
	 * Convert common watch URLs to embeddable iframe src.
	 *
	 * @param string $url Raw URL.
	 */
	public static function normalize_embed_url( string $url ): string {
		$raw = trim( $url );
		if ( '' === $raw ) {
			return '';
		}

		$parts = wp_parse_url( $raw );
		if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
			return $raw;
		}

		$host = preg_replace( '/^www\./', '', strtolower( (string) $parts['host'] ) );
		$path = (string) ( $parts['path'] ?? '' );

		if ( in_array(
			$host,
			array( 'youtube.com', 'm.youtube.com', 'music.youtube.com', 'youtube-nocookie.com' ),
			true
		) ) {
			parse_str( (string) ( $parts['query'] ?? '' ), $query );
			if ( ! empty( $query['v'] ) ) {
				return 'https://www.youtube.com/embed/' . rawurlencode( (string) $query['v'] );
			}
			if ( preg_match( '#/shorts/([^/]+)#', $path, $m ) ) {
				return 'https://www.youtube.com/embed/' . rawurlencode( $m[1] );
			}
			if ( preg_match( '#/live/([^/]+)#', $path, $m ) ) {
				return 'https://www.youtube.com/embed/' . rawurlencode( $m[1] );
			}
			if ( preg_match( '#/embed/([^/?]+)#', $path, $m ) ) {
				return 'https://www.youtube.com/embed/' . rawurlencode( $m[1] );
			}
		}

		if ( 'youtu.be' === $host ) {
			$id = ltrim( $path, '/' );
			$id = explode( '/', $id )[0] ?? '';
			if ( '' !== $id ) {
				return 'https://www.youtube.com/embed/' . rawurlencode( $id );
			}
		}

		if ( in_array( $host, array( 'vimeo.com', 'player.vimeo.com' ), true ) ) {
			$segments = array_values( array_filter( explode( '/', $path ) ) );
			$id       = '';
			if ( isset( $segments[0] ) && 'video' === $segments[0] && ! empty( $segments[1] ) ) {
				$id = $segments[1];
			} elseif ( ! empty( $segments ) ) {
				$id = $segments[ count( $segments ) - 1 ];
			}
			if ( $id && preg_match( '/^\d+$/', $id ) ) {
				return 'https://player.vimeo.com/video/' . $id;
			}
		}

		return $raw;
	}

	/**
	 * Render media attr as img / video / iframe HTML.
	 *
	 * @param array<string, mixed> $media Media object.
	 * @param string               $el_key data-bm-el key.
	 */
	public static function media_to_html( array $media, string $el_key = 'image' ): string {
		$m  = self::normalize_media( $media );
		$el = preg_replace( '/[^a-zA-Z0-9_-]/', '', $el_key ) ?: 'image';

		// Frame owns width + content-align L/R margins; replaced media fills the frame.
		$image_style = 'display:block;width:100%;max-width:100%;height:var(--bm-mf-img-height, var(--bm-image-height, auto));border-top-left-radius:var(--bm-mf-img-radius-tl, var(--bm-image-border-top-left-radius, var(--bm-image-border-radius, 8px)));border-top-right-radius:var(--bm-mf-img-radius-tr, var(--bm-image-border-top-right-radius, var(--bm-image-border-radius, 8px)));border-bottom-right-radius:var(--bm-mf-img-radius-br, var(--bm-image-border-bottom-right-radius, var(--bm-image-border-radius, 8px)));border-bottom-left-radius:var(--bm-mf-img-radius-bl, var(--bm-image-border-bottom-left-radius, var(--bm-image-border-radius, 8px)));box-shadow:var(--bm-image-shadow, none);margin:0;box-sizing:border-box;object-fit:var(--bm-image-object-fit, cover);object-position:center;aspect-ratio:var(--bm-mf-img-aspect, var(--bm-image-aspect-ratio, 16/9));border:0';
		$embed_style = 'display:block;width:100%;max-width:100%;height:var(--bm-mf-img-height, var(--bm-image-height, auto));border-top-left-radius:var(--bm-mf-img-radius-tl, var(--bm-image-border-top-left-radius, var(--bm-image-border-radius, 8px)));border-top-right-radius:var(--bm-mf-img-radius-tr, var(--bm-image-border-top-right-radius, var(--bm-image-border-radius, 8px)));border-bottom-right-radius:var(--bm-mf-img-radius-br, var(--bm-image-border-bottom-right-radius, var(--bm-image-border-radius, 8px)));border-bottom-left-radius:var(--bm-mf-img-radius-bl, var(--bm-image-border-bottom-left-radius, var(--bm-image-border-radius, 8px)));box-shadow:var(--bm-image-shadow, none);margin:0;box-sizing:border-box;object-fit:contain;object-position:center;aspect-ratio:var(--bm-mf-img-aspect, var(--bm-image-aspect-ratio, 16/9));background:rgba(0,0,0,0.08);border:0';
		// Embed host: classic padding-bottom ratio box lives inside .wp-block-embed__wrapper.
		$embed_host_style = 'display:block;width:100%;max-width:100%;border-top-left-radius:var(--bm-mf-img-radius-tl, var(--bm-image-border-top-left-radius, var(--bm-image-border-radius, 8px)));border-top-right-radius:var(--bm-mf-img-radius-tr, var(--bm-image-border-top-right-radius, var(--bm-image-border-radius, 8px)));border-bottom-right-radius:var(--bm-mf-img-radius-br, var(--bm-image-border-bottom-right-radius, var(--bm-image-border-radius, 8px)));border-bottom-left-radius:var(--bm-mf-img-radius-bl, var(--bm-image-border-bottom-left-radius, var(--bm-image-border-radius, 8px)));box-shadow:var(--bm-image-shadow, none);margin:0;box-sizing:border-box;background:rgba(0,0,0,0.08);overflow:hidden';
		$frame_style = 'display:block;width:var(--bm-mf-img-width, var(--bm-image-width, 100%));max-width:100%;margin-left:var(--bm-mf-media-margin-start, var(--bm-media-margin-inline-start, 0));margin-right:var(--bm-mf-media-margin-end, var(--bm-media-margin-inline-end, 0));box-sizing:border-box;line-height:0';
		$iframe_fill = 'top:0;left:0;width:100%;height:100%;position:absolute;border:0;';

		$inner = '';
		if ( 'video' === $m['type'] ) {
			if ( '' === $m['url'] ) {
				return '';
			}
			$inner = sprintf(
				'<video class="bm-hero__image bm-hero__video" src="%1$s" controls playsinline style="%2$s"></video>',
				esc_url( $m['url'] ),
				esc_attr( $embed_style )
			);
		} elseif ( 'iframe' === $m['type'] ) {
			$title = $m['alt'] !== '' ? $m['alt'] : __( 'Embedded media', 'joc-blockora-morph' );
			$iframe_html = '';
			// YouTube/Vimeo: direct iframe with referrerpolicy (avoids Error 153 in editors).
			if ( self::is_direct_video_embed_provider( $m['url'] ) ) {
				$src = self::normalize_embed_url( $m['url'] );
				if ( '' === $src ) {
					return '';
				}
				$iframe_html = sprintf(
					'<iframe class="bm-hero__iframe" src="%1$s" title="%2$s" style="%3$s" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>',
					esc_url( $src ),
					esc_attr( $title ),
					esc_attr( $iframe_fill )
				);
			} else {
				$embed = '';
				if ( $m['url'] !== '' && function_exists( 'wp_oembed_get' ) ) {
					$fetched = wp_oembed_get( $m['url'] );
					if ( is_string( $fetched ) && '' !== trim( $fetched ) ) {
						$embed = self::kses_embed_html( (string) $fetched );
					}
				}
				if ( '' === $embed && '' !== trim( (string) ( $m['embedHtml'] ?? '' ) ) ) {
					$embed = self::kses_embed_html( (string) $m['embedHtml'] );
				}
				if ( '' !== $embed ) {
					$iframe_html = $embed;
				} else {
					$src = self::normalize_embed_url( $m['url'] );
					if ( '' === $src ) {
						return '';
					}
					$iframe_html = sprintf(
						'<iframe class="bm-hero__iframe" src="%1$s" title="%2$s" style="%3$s" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>',
						esc_url( $src ),
						esc_attr( $title ),
						esc_attr( $iframe_fill )
					);
				}
			}
			$iframe_html = self::prepare_embed_wrapper_inner( $iframe_html );
			$inner = sprintf(
				'<div class="bm-hero__embed wp-block-embed" style="%1$s"><div class="wp-block-embed__wrapper">%2$s</div></div>',
				esc_attr( $embed_host_style ),
				$iframe_html
			);
		} else {
			$inner = sprintf(
				'<img class="bm-hero__image" src="%1$s" alt="%2$s" style="%3$s" />',
				esc_url( $m['url'] ),
				esc_attr( $m['alt'] ),
				esc_attr( $image_style )
			);
		}

		return sprintf(
			'<div class="bm-media-frame" data-bm-el="%1$s" style="%2$s">%3$s</div>',
			esc_attr( $el ),
			esc_attr( $frame_style ),
			$inner
		);
	}

	/**
	 * Attach html property on media-shaped attribute values.
	 *
	 * @param array<string, mixed> $attrs Attrs.
	 * @return array<string, mixed>
	 */
	private static function enrich_media_html( array $attrs ): array {
		foreach ( $attrs as $key => $val ) {
			if ( ! is_array( $val ) || ! self::is_assoc( $val ) ) {
				continue;
			}
			$type = (string) ( $val['type'] ?? '' );
			if ( ! in_array( $type, array( 'image', 'video', 'iframe' ), true ) ) {
				continue;
			}
			if ( ! array_key_exists( 'url', $val ) && ! array_key_exists( 'alt', $val ) && ! array_key_exists( 'id', $val ) ) {
				continue;
			}
			// Skip link-like objects.
			if ( isset( $val['title'] ) && isset( $val['opensInNewTab'] ) ) {
				continue;
			}
			$normalized           = self::normalize_media( $val );
			$attrs[ $key ]        = $normalized;
			$attrs[ $key ]['html'] = self::media_to_html( $normalized, (string) $key );
		}
		return $attrs;
	}

	/**
	 * Standalone button icon tokens ({{iconHtml}}, {{btnIconFlex}}).
	 *
	 * @param array<string, mixed> $attrs Attrs.
	 * @return array<string, mixed>
	 */
	private static function enrich_button_icon( array $attrs ): array {
		$raw_icon = isset( $attrs['icon'] ) ? trim( (string) $attrs['icon'] ) : '';
		$ref      = IconRegistry::normalize_ref( $raw_icon );
		$icon_pos = ( isset( $attrs['iconPosition'] ) && 'right' === strtolower( (string) $attrs['iconPosition'] ) )
			? 'right'
			: 'left';

		$icon_style = 'font-size:var(--bm-label-icon-size, 20px);width:1em;height:1em;color:var(--bm-label-icon-color, currentColor)';

		$attrs['icon']         = $ref;
		$attrs['iconPosition'] = $icon_pos;
		$attrs['btnIconFlex']  = ( $ref && 'right' === $icon_pos ) ? 'row-reverse' : 'row';
		$attrs['iconHtml']     = $ref
			? IconRegistry::render_html( $ref, $icon_style, 'bm-button__icon bm-hero__btn-icon' )
			: '';

		return $attrs;
	}

	/**
	 * Whether an element should render (default visible).
	 *
	 * @param string               $key   Element key.
	 * @param array<string, mixed> $attrs Attributes.
	 */
	public static function is_element_visible( string $key, array $attrs, string $breakpoint = 'desktop' ): bool {
		return ElementsVisibility::is_visible( $key, $attrs, $breakpoint );
	}

	/**
	 * Whether element content is empty.
	 *
	 * @param string               $key   Element key.
	 * @param array<string, mixed> $attrs Attributes.
	 */
	public static function is_element_empty( string $key, array $attrs ): bool {
		if ( 'buttons' === $key ) {
			$buttons = $attrs['buttons'] ?? null;
			if ( ! is_array( $buttons ) || self::is_assoc( $buttons ) || ! $buttons ) {
				return true;
			}
			foreach ( $buttons as $item ) {
				if ( is_array( $item ) && self::button_item_has_content( $item ) ) {
					return false;
				}
			}
			return true;
		}
		if ( 'logos' === $key ) {
			$logos = $attrs['logos'] ?? null;
			if ( ! is_array( $logos ) || self::is_assoc( $logos ) || ! $logos ) {
				return true;
			}
			foreach ( $logos as $item ) {
				if ( ! is_array( $item ) ) {
					continue;
				}
				$url = '';
				if ( isset( $item['image'] ) && is_array( $item['image'] ) ) {
					$url = (string) ( $item['image']['url'] ?? '' );
				} elseif ( isset( $item['url'] ) ) {
					$url = (string) $item['url'];
				}
				if ( '' !== trim( $url ) ) {
					return false;
				}
			}
			return true;
		}
		if ( in_array( $key, array( 'image', 'video', 'media' ), true ) ) {
			$media = $attrs[ $key ] ?? null;
			if ( ! is_array( $media ) ) {
				return true;
			}
			return '' === trim( (string) ( $media['url'] ?? '' ) );
		}
		$val = $attrs[ $key ] ?? null;
		if ( null === $val ) {
			return true;
		}
		if ( is_string( $val ) ) {
			$plain = wp_strip_all_tags( str_ireplace( '&nbsp;', ' ', $val ) );
			return '' === trim( $plain );
		}
		return false;
	}

	/**
	 * @param string               $key   Element key.
	 * @param array<string, mixed> $attrs Attributes.
	 */
	public static function should_render_element( string $key, array $attrs ): bool {
		return ElementsVisibility::is_visible_on_any( $key, $attrs ) && ! self::is_element_empty( $key, $attrs );
	}

	/**
	 * Clear media html for hidden/empty media attrs.
	 *
	 * @param array<string, mixed> $attrs Attributes.
	 * @return array<string, mixed>
	 */
	private static function clear_hidden_media_html( array $attrs ): array {
		foreach ( array( 'image', 'video', 'media' ) as $key ) {
			if ( ! self::should_render_element( $key, $attrs ) && isset( $attrs[ $key ] ) && is_array( $attrs[ $key ] ) ) {
				$attrs[ $key ]['html'] = '';
			}
		}
		return $attrs;
	}

	/**
	 * Remove empty hero media wrappers.
	 *
	 * @param string $html HTML.
	 */
	private static function strip_empty_hero_media( string $html ): string {
		return (string) preg_replace(
			'/<div\b([^>]*\bclass="[^"]*\bbm-hero__media\b[^"]*"[^>]*)>\s*<\/div>/i',
			'',
			$html
		);
	}

	/**
	 * Expand legacy card `border:1px solid var(--bm-card-border-color)` so Surface
	 * boxBorder width / style / per-side controls apply in preview + frontend.
	 *
	 * @param string $markup Markup HTML.
	 */
	public static function upgrade_card_border_markup( string $markup ): string {
		if ( '' === $markup || false === strpos( $markup, 'bm-card' ) ) {
			return $markup;
		}

		$ensure_shadow = static function ( string $html ): string {
			if ( false !== strpos( $html, 'box-shadow:var(--bm-card-shadow' ) ) {
				return $html;
			}
			$with_shadow = preg_replace_callback(
				'/(<article\b[^>]*\bclass="[^"]*\bbm-card\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
				static function ( array $m ): string {
					$style = $m[2];
					if ( false !== strpos( $style, 'box-shadow:var(--bm-card-shadow' ) ) {
						return $m[0];
					}
					if ( preg_match( '/box-sizing\s*:/i', $style ) ) {
						$style = preg_replace(
							'/box-sizing\s*:/i',
							'box-shadow:var(--bm-card-shadow, none);box-sizing:',
							$style,
							1
						);
					} else {
						$style .= 'box-shadow:var(--bm-card-shadow, none);';
					}
					return $m[1] . $style . $m[3];
				},
				$html
			);
			return is_string( $with_shadow ) ? $with_shadow : $html;
		};

		$ensure_columns = static function ( string $html ): string {
			if ( false !== strpos( $html, 'grid-template-columns:repeat(var(--bm-card-columns' ) ) {
				return $html;
			}
			$patched = preg_replace(
				'/grid-template-columns\s*:\s*repeat\(\s*auto-fit\s*,\s*minmax\(\s*16rem\s*,\s*1fr\s*\)\s*\)/i',
				'grid-template-columns:repeat(var(--bm-card-columns, 4), minmax(0, 1fr))',
				$html
			);
			return is_string( $patched ) ? $patched : $html;
		};

		if ( false === strpos( $markup, 'border-top-width:var(--bm-card-border-top-width' ) ) {
			$longhands =
				'border-style:var(--bm-card-border-style, solid);' .
				'border-color:var(--bm-card-border-color, rgba(0, 0, 0, 0.08));' .
				'border-top-width:var(--bm-card-border-top-width, var(--bm-card-border-width, 1px));' .
				'border-right-width:var(--bm-card-border-right-width, var(--bm-card-border-width, 1px));' .
				'border-bottom-width:var(--bm-card-border-bottom-width, var(--bm-card-border-width, 1px));' .
				'border-left-width:var(--bm-card-border-left-width, var(--bm-card-border-width, 1px));';

			$patched = preg_replace(
				'/border\s*:\s*1px\s+solid\s+var\(\s*--bm-card-border-color(?:\s*,[^)]*)?\s*\)\s*;?/i',
				$longhands,
				$markup
			);
			if ( is_string( $patched ) ) {
				$markup = $patched;
			}
		}

		return $ensure_columns( $ensure_shadow( $markup ) );
	}

	/**
	 * Ensure feature-card icon wrappers honor padding / border / radius / shadow vars.
	 *
	 * @param string $markup Markup HTML.
	 */
	public static function upgrade_card_icon_surface_markup( string $markup ): string {
		if ( '' === $markup || false === strpos( $markup, 'bm-card__icon-wrapper' ) ) {
			return $markup;
		}

		$surface_css =
			'display:inline-flex;' .
			'align-items:center;' .
			'justify-content:center;' .
			'box-sizing:border-box;' .
			'background:var(--bm-icon-bg, transparent);' .
			'padding-top:var(--bm-icon-padding-top, 0);' .
			'padding-right:var(--bm-icon-padding-right, 0);' .
			'padding-bottom:var(--bm-icon-padding-bottom, 0);' .
			'padding-left:var(--bm-icon-padding-left, 0);' .
			'border-style:var(--bm-icon-border-style, none);' .
			'border-color:var(--bm-icon-border-color, transparent);' .
			'border-top-width:var(--bm-icon-border-top-width, var(--bm-icon-border-width, 0));' .
			'border-right-width:var(--bm-icon-border-right-width, var(--bm-icon-border-width, 0));' .
			'border-bottom-width:var(--bm-icon-border-bottom-width, var(--bm-icon-border-width, 0));' .
			'border-left-width:var(--bm-icon-border-left-width, var(--bm-icon-border-width, 0));' .
			'border-top-left-radius:var(--bm-icon-border-top-left-radius, var(--bm-icon-border-radius, 0));' .
			'border-top-right-radius:var(--bm-icon-border-top-right-radius, var(--bm-icon-border-radius, 0));' .
			'border-bottom-right-radius:var(--bm-icon-border-bottom-right-radius, var(--bm-icon-border-radius, 0));' .
			'border-bottom-left-radius:var(--bm-icon-border-bottom-left-radius, var(--bm-icon-border-radius, 0));' .
			'box-shadow:var(--bm-icon-shadow, none);';

		$patched = preg_replace_callback(
			'/(<div\b[^>]*\bclass="[^"]*\bbm-card__icon-wrapper\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ) use ( $surface_css ): string {
				$style = $m[2];
				if ( false === strpos( $style, '--bm-icon-bg' ) ) {
					$style = 'background:var(--bm-icon-bg, transparent);' . $style;
				}
				if (
					false !== strpos( $style, 'margin-top:var(--bm-icon-margin-top' )
					&& false === strpos( $style, 'margin-right:var(--bm-icon-margin-right' )
				) {
					$style = preg_replace(
						'/margin-top:var\(--bm-icon-margin-top,\s*0\);?/i',
						'margin-top:var(--bm-icon-margin-top, 0);margin-right:var(--bm-icon-margin-right, 0);',
						$style,
						1
					);
				}
				if (
					false !== strpos( $style, 'margin-bottom:var(--bm-icon-margin-bottom' )
					&& false === strpos( $style, 'margin-left:var(--bm-icon-margin-left' )
				) {
					$style = preg_replace(
						'/(margin-bottom:var\(--bm-icon-margin-bottom,[^)]+\);?)/i',
						'$1margin-left:var(--bm-icon-margin-left, 0);',
						$style,
						1
					);
				}
				if ( false !== strpos( $style, 'padding-top:var(--bm-icon-padding-top' ) ) {
					return $m[1] . $style . $m[3];
				}
				if ( false !== strpos( $style, '{{iconWrapperStyle}}' ) ) {
					$style = str_replace(
						'{{iconWrapperStyle}}',
						$surface_css . '{{iconWrapperStyle}}',
						$style
					);
				} else {
					$style .= $surface_css;
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);

		return is_string( $patched ) ? $patched : $markup;
	}

	/**
	 * Ensure card bodies / button rows honor Content alignment (left / center / right).
	 *
	 * @param string $markup Markup HTML.
	 */
	public static function upgrade_card_content_align_markup( string $markup ): string {
		if ( '' === $markup || false === strpos( $markup, 'bm-card__body' ) ) {
			return $markup;
		}

		$patched_body = preg_replace_callback(
			'/(<div\b[^>]*\bclass="[^"]*\bbm-card__body\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( false === strpos( $style, '--bm-text-align' ) ) {
					$style = 'text-align:var(--bm-text-align, left);' . $style;
				}
				if (
					preg_match( '/display\s*:\s*flex/i', $style )
					&& preg_match( '/flex-direction\s*:\s*column/i', $style )
					&& ! preg_match( '/align-items\s*:/i', $style )
				) {
					$style = preg_replace(
						'/flex-direction\s*:\s*column/i',
						'flex-direction:column;align-items:var(--bm-align-items, flex-start)',
						$style,
						1
					);
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_body ) ) {
			$markup = $patched_body;
		}

		$patched_buttons = preg_replace_callback(
			'/(<div\b[^>]*\bclass="[^"]*\bbm-card__buttons\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( false !== strpos( $style, '--bm-justify-content' ) ) {
					return $m[0];
				}
				if ( preg_match( '/display\s*:\s*flex/i', $style ) ) {
					$style = preg_replace(
						'/display\s*:\s*flex/i',
						'display:flex;justify-content:var(--bm-justify-content, flex-start)',
						$style,
						1
					);
				} else {
					$style = 'justify-content:var(--bm-justify-content, flex-start);' . $style;
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_buttons ) ) {
			$markup = $patched_buttons;
		}

		$patched_tag = preg_replace_callback(
			'/(<span\b[^>]*\bclass="[^"]*\bbm-pricing__tag\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( false !== strpos( $style, '--bm-align-items' ) ) {
					return $m[0];
				}
				if ( preg_match( '/align-self\s*:\s*flex-start/i', $style ) ) {
					$style = preg_replace(
						'/align-self\s*:\s*flex-start/i',
						'align-self:var(--bm-align-items, flex-start)',
						$style,
						1
					);
				} else {
					$style = 'align-self:var(--bm-align-items, flex-start);' . $style;
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_tag ) ) {
			$markup = $patched_tag;
		}

		$patched_btn = preg_replace_callback(
			'/(<a\b[^>]*\bclass="[^"]*\bbm-pricing__button\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( false !== strpos( $style, '--bm-align-items' ) ) {
					return $m[0];
				}
				if ( preg_match( '/display\s*:\s*inline-flex/i', $style ) ) {
					$style = preg_replace(
						'/display\s*:\s*inline-flex/i',
						'display:inline-flex;align-self:var(--bm-align-items, flex-start)',
						$style,
						1
					);
				} else {
					$style = 'align-self:var(--bm-align-items, flex-start);' . $style;
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_btn ) ) {
			$markup = $patched_btn;
		}

		return $markup;
	}

	/**
	 * Testimonials: surface padding off, content padding + gap on the body,
	 * author meta pinned to the bottom, field margins default to 0.
	 *
	 * @param string $markup Markup HTML.
	 */
	public static function upgrade_testimonial_card_layout_markup( string $markup ): string {
		if (
			'' === $markup
			|| ( false === strpos( $markup, 'bm-testimonial' )
				&& false === strpos( $markup, 'bm-testimonials' ) )
		) {
			return $markup;
		}

		$patched_card = preg_replace_callback(
			'/(<(?:blockquote)\b[^>]*\bclass="[^"]*\bbm-testimonial\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( preg_match( '/padding\s*:\s*var\(\s*--bm-card-padding/i', $style ) ) {
					$style = preg_replace(
						'/padding\s*:\s*var\(\s*--bm-card-padding[^)]*\)\s*;?/i',
						'padding:0;',
						$style
					) ?? $style;
				} elseif ( ! preg_match( '/padding\s*:/i', $style ) ) {
					$style = 'padding:0;' . $style;
				}
				if ( ! preg_match( '/display\s*:\s*flex/i', $style ) ) {
					$style = preg_replace( '/display\s*:\s*var\([^)]+\)\s*;?/i', '', $style ) ?? $style;
					$style = 'display:flex;flex-direction:column;align-self:stretch;height:auto;' . $style;
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_card ) ) {
			$markup = $patched_card;
		}

		$patched_body = preg_replace_callback(
			'/(<div\b[^>]*\bclass="[^"]*\bbm-card__body\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( false === strpos( $style, '--bm-card-body-gap' ) ) {
					$style = 'gap:var(--bm-card-body-gap, 2.5rem);' . $style;
				}
				if ( preg_match( '/padding\s*:\s*var\(\s*--bm-card-body-padding/i', $style ) ) {
					$style = preg_replace(
						'/padding\s*:\s*var\(\s*--bm-card-body-padding[^)]*\)\s*;?/i',
						'padding:var(--bm-card-body-padding, 1.75rem);',
						$style
					) ?? $style;
				} elseif ( ! preg_match( '/padding\s*:/i', $style ) ) {
					$style = 'padding:var(--bm-card-body-padding, 1.75rem);' . $style;
				}
				if ( ! preg_match( '/display\s*:\s*flex/i', $style ) ) {
					$style = preg_replace( '/display\s*:\s*var\([^)]+\)\s*;?/i', '', $style ) ?? $style;
					$style = 'display:flex;flex-direction:column;flex:1 1 auto;align-items:stretch;' . $style;
				} elseif ( ! preg_match( '/flex\s*:\s*1/i', $style ) ) {
					$style = 'flex:1 1 auto;' . $style;
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_body ) ) {
			$markup = $patched_body;
		}

		$patched_content = preg_replace_callback(
			'/(<div\b[^>]*\bclass="[^"]*\bbm-testimonial__content\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				$style = preg_replace(
					'/margin-bottom\s*:\s*var\(\s*--bm-content-margin-bottom[^)]*\)\s*;?/i',
					'margin-bottom:var(--bm-content-margin-bottom, 0);',
					$style
				) ?? $style;
				$style = preg_replace(
					'/margin-top\s*:\s*var\(\s*--bm-content-margin-top[^)]*\)\s*;?/i',
					'margin-top:var(--bm-content-margin-top, 0);',
					$style
				) ?? $style;
				if ( false === strpos( $style, '--bm-message-text-align' ) ) {
					if ( preg_match( '/text-align\s*:/i', $style ) ) {
						$style = preg_replace(
							'/text-align\s*:[^;]+;?/i',
							'text-align:var(--bm-message-text-align, var(--bm-text-align, left));',
							$style
						) ?? $style;
					} else {
						$style = 'text-align:var(--bm-message-text-align, var(--bm-text-align, left));' . $style;
					}
				}
				if ( ! preg_match( '/flex\s*:\s*1/i', $style ) ) {
					$style .= 'flex:1 1 auto;';
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_content ) ) {
			$markup = $patched_content;
		}

		$patched_quote = preg_replace_callback(
			'/(<(?:div)\b[^>]*\bclass="[^"]*\bbm-testimonial__quote\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = preg_replace(
					'/margin-bottom\s*:\s*var\(\s*--bm-icon-margin-bottom[^)]*\)\s*;?/i',
					'margin-bottom:var(--bm-icon-margin-bottom, 0);',
					$m[2]
				) ?? $m[2];
				if ( false === strpos( $style, '--bm-quote-align-self' ) ) {
					if ( preg_match( '/align-self\s*:/i', $style ) ) {
						$style = preg_replace(
							'/align-self\s*:[^;]+;?/i',
							'align-self:var(--bm-quote-align-self, var(--bm-align-items, flex-start));',
							$style
						) ?? $style;
					} else {
						$style = 'align-self:var(--bm-quote-align-self, var(--bm-align-items, flex-start));' . $style;
					}
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_quote ) ) {
			$markup = $patched_quote;
		}

		$patched_meta = preg_replace_callback(
			'/(<(?:footer)\b[^>]*\bclass="[^"]*\bbm-testimonial__meta\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( ! preg_match( '/margin-top\s*:\s*auto/i', $style ) ) {
					$style = preg_replace( '/margin-top\s*:[^;]+;?/i', '', $style ) ?? $style;
					$style = 'margin-top:auto;' . $style;
				}
				if ( false === strpos( $style, '--bm-author-image-flex-direction' ) ) {
					if ( preg_match( '/flex-direction\s*:/i', $style ) ) {
						$style = preg_replace(
							'/flex-direction\s*:[^;]+;?/i',
							'flex-direction:var(--bm-author-image-flex-direction, row);',
							$style
						) ?? $style;
					} else {
						$style = 'flex-direction:var(--bm-author-image-flex-direction, row);' . $style;
					}
				}
				if ( false === strpos( $style, '--bm-author-image-align-items' ) ) {
					if ( preg_match( '/align-items\s*:/i', $style ) ) {
						$style = preg_replace(
							'/align-items\s*:[^;]+;?/i',
							'align-items:var(--bm-author-image-align-items, center);',
							$style
						) ?? $style;
					} else {
						$style = 'align-items:var(--bm-author-image-align-items, center);' . $style;
					}
				}
				if ( false === strpos( $style, '--bm-author-justify-content' ) ) {
					if ( preg_match( '/justify-content\s*:/i', $style ) ) {
						$style = preg_replace(
							'/justify-content\s*:[^;]+;?/i',
							'justify-content:var(--bm-author-justify-content, var(--bm-justify-content, flex-start));',
							$style
						) ?? $style;
					} else {
						$style = 'justify-content:var(--bm-author-justify-content, var(--bm-justify-content, flex-start));' . $style;
					}
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_meta ) ) {
			$markup = $patched_meta;
		}

		$patched_avatar = preg_replace_callback(
			'/(<(?:img)\b[^>]*\bclass="[^"]*\bbm-testimonial__avatar\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( false === strpos( $style, '--bm-author-image-order' ) ) {
					if ( preg_match( '/(?:^|;)\s*order\s*:/i', $style ) ) {
						$style = preg_replace(
							'/(?:^|;)\s*order\s*:[^;]+;?/i',
							';order:var(--bm-author-image-order, 0);',
							$style
						) ?? $style;
					} else {
						$style = 'order:var(--bm-author-image-order, 0);' . $style;
					}
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_avatar ) ) {
			$markup = $patched_avatar;
		}

		$patched_author = preg_replace_callback(
			'/(<(?:div)\b[^>]*\bclass="[^"]*\bbm-testimonial__author\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( ! preg_match( '/(?:^|;)\s*order\s*:/i', $style ) ) {
					$style = 'order:0;' . $style;
				}
				if ( false === strpos( $style, '--bm-author-text-align' ) ) {
					if ( preg_match( '/text-align\s*:/i', $style ) ) {
						$style = preg_replace(
							'/text-align\s*:[^;]+;?/i',
							'text-align:var(--bm-author-text-align, var(--bm-text-align, left));',
							$style
						) ?? $style;
					} else {
						$style = 'text-align:var(--bm-author-text-align, var(--bm-text-align, left));' . $style;
					}
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_author ) ) {
			$markup = $patched_author;
		}

		return $markup;
	}

	/**
	 * Keep the CTA last in the card stack even when the pin toggle is off.
	 * Without a fallback the empty var makes `order` invalid, which resets it
	 * to 0 and jumps the button above elements ordered by element-order CSS.
	 *
	 * @param string $style Inline style attribute contents.
	 */
	private static function pin_button_order_fallback( string $style ): string {
		$style = preg_replace(
			'/order\s*:\s*var\(\s*--bm-buttons-flex-order\s*\)/i',
			'order:var(--bm-buttons-flex-order, 999)',
			$style
		) ?? $style;
		if ( false === strpos( $style, '--bm-buttons-flex-order' ) ) {
			$style = 'order:var(--bm-buttons-flex-order, 999);' . $style;
		}
		return $style;
	}

	/**
	 * Pricing matrix: card columns grid, pin CTA, features selectable, card surface vars.
	 *
	 * @param string $markup Markup HTML.
	 */
	public static function upgrade_pricing_matrix_markup( string $markup ): string {
		if ( '' === $markup || false === strpos( $markup, 'bm-pricing' ) ) {
			return $markup;
		}

		// Nested features repeater → single HTML token inside the features <ul>.
		$features_token = preg_replace(
			'/\{\{#\s*features\s*\}\}[\s\S]*?\{\{\/\s*features\s*\}\}/i',
			'{{features}}',
			$markup
		);
		if ( is_string( $features_token ) ) {
			$markup = $features_token;
		}

		if ( false !== strpos( $markup, 'bm-pricing__grid' ) && false === strpos( $markup, '--bm-card-columns' ) ) {
			$with_cols = preg_replace(
				'/grid-template-columns\s*:\s*repeat\(\s*auto-fit\s*,\s*minmax\([^)]+\)\s*\)\s*;?/i',
				'grid-template-columns:repeat(var(--bm-card-columns, 4), minmax(0, 1fr));grid-auto-rows:minmax(min-content, auto);align-items:stretch;',
				$markup
			);
			if ( is_string( $with_cols ) ) {
				$markup = $with_cols;
			}
		}

		$with_card_class = preg_replace_callback(
			'/(<(?:article)\b)([^>]*\bclass=")([^"]*\bbm-pricing__card\b[^"]*)(")/i',
			static function ( array $m ): string {
				if ( preg_match( '/\bbm-card\b/', $m[3] ) ) {
					return $m[0];
				}
				return $m[1] . $m[2] . $m[3] . ' bm-card' . $m[4];
			},
			$markup
		);
		if ( is_string( $with_card_class ) ) {
			$markup = $with_card_class;
		}

		$patched_card = preg_replace_callback(
			'/(<(?:article)\b[^>]*\bclass="[^"]*\bbm-pricing__card\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( false === strpos( $style, '--bm-card-display' ) ) {
					$style = preg_replace( '/display\s*:\s*flex\s*;?/i', '', $style ) ?? $style;
					$style = 'display:var(--bm-card-display, flex);flex-direction:column;align-self:stretch;height:var(--bm-card-height, auto);' . $style;
				}
				if ( preg_match( '/padding\s*:\s*var\(\s*--bm-card-padding/i', $style ) ) {
					$style = preg_replace(
						'/padding\s*:\s*var\(\s*--bm-card-padding[^)]*\)\s*;?/i',
						'padding:var(--bm-card-padding, 0);',
						$style
					) ?? $style;
				}
				if ( false === strpos( $style, '--bm-card-shadow' ) && ! preg_match( '/box-shadow\s*:/i', $style ) ) {
					$style = 'box-shadow:var(--bm-card-shadow, none);' . $style;
				}
				if ( false === strpos( $style, '--bm-card-border-top-width' ) && preg_match( '/border\s*:\s*1px\s+solid/i', $style ) ) {
					$style = preg_replace(
						'/border\s*:\s*1px\s+solid\s+var\(--bm-card-border-color\)\s*;?/i',
						'border-style:var(--bm-card-border-style, solid);border-color:var(--bm-card-border-color, rgba(0, 0, 0, 0.08));border-top-width:var(--bm-card-border-top-width, var(--bm-card-border-width, 1px));border-right-width:var(--bm-card-border-right-width, var(--bm-card-border-width, 1px));border-bottom-width:var(--bm-card-border-bottom-width, var(--bm-card-border-width, 1px));border-left-width:var(--bm-card-border-left-width, var(--bm-card-border-width, 1px));',
						$style
					) ?? $style;
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_card ) ) {
			$markup = $patched_card;
		}

		$patched_body = preg_replace_callback(
			'/(<div\b[^>]*\bclass="[^"]*\bbm-card__body\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( false === strpos( $style, '--bm-card-body-gap' ) ) {
					$style = 'gap:var(--bm-card-body-gap, 1rem);' . $style;
				} else {
					$style = preg_replace(
						'/gap\s*:\s*var\(\s*--bm-card-body-gap[^)]*\)\s*;?/i',
						'gap:var(--bm-card-body-gap, 1rem);',
						$style
					) ?? $style;
				}
				if ( preg_match( '/padding\s*:\s*var\(\s*--bm-card-body-padding/i', $style ) ) {
					$style = preg_replace(
						'/padding\s*:\s*var\(\s*--bm-card-body-padding[^)]*\)\s*;?/i',
						'padding:var(--bm-card-body-padding, 1.75rem);',
						$style
					) ?? $style;
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_body ) ) {
			$markup = $patched_body;
		}

		$patched_features = preg_replace_callback(
			'/(<(?:ul)\b[^>]*\bclass="[^"]*\bbm-pricing__features\b[^"]*")([^>]*>)/i',
			static function ( array $m ): string {
				if ( preg_match( '/\bdata-bm-el\s*=/', $m[0] ) ) {
					return $m[0];
				}
				return $m[1] . ' data-bm-el="features"' . $m[2];
			},
			$markup
		);
		if ( is_string( $patched_features ) ) {
			$markup = $patched_features;
		}

		$patched_tag_surface = preg_replace_callback(
			'/(<(?:span)\b[^>]*\bclass="[^"]*\bbm-pricing__tag\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( false === strpos( $style, '--bm-tag-shadow' ) ) {
					$style = preg_replace(
						'/border-radius\s*:\s*var\(\s*--bm-tag-border-radius[^;]*;?/i',
						'',
						$style
					) ?? $style;
					$style .= 'border-style:var(--bm-tag-border-style, none);' .
						'border-color:var(--bm-tag-border-color, transparent);' .
						'border-top-width:var(--bm-tag-border-top-width, var(--bm-tag-border-width, 0));' .
						'border-right-width:var(--bm-tag-border-right-width, var(--bm-tag-border-width, 0));' .
						'border-bottom-width:var(--bm-tag-border-bottom-width, var(--bm-tag-border-width, 0));' .
						'border-left-width:var(--bm-tag-border-left-width, var(--bm-tag-border-width, 0));' .
						'border-top-left-radius:var(--bm-tag-border-top-left-radius, var(--bm-tag-border-radius, 999px));' .
						'border-top-right-radius:var(--bm-tag-border-top-right-radius, var(--bm-tag-border-radius, 999px));' .
						'border-bottom-right-radius:var(--bm-tag-border-bottom-right-radius, var(--bm-tag-border-radius, 999px));' .
						'border-bottom-left-radius:var(--bm-tag-border-bottom-left-radius, var(--bm-tag-border-radius, 999px));' .
						'box-shadow:var(--bm-tag-shadow, none);';
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_tag_surface ) ) {
			$markup = $patched_tag_surface;
		}

		$patched_features_style = preg_replace_callback(
			'/(<(?:ul)\b[^>]*\bclass="[^"]*\bbm-pricing__features\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( false === strpos( $style, '--bm-align-items' ) ) {
					if ( preg_match( '/align-items\s*:/i', $style ) ) {
						$style = preg_replace(
							'/align-items\s*:[^;]+;?/i',
							'align-items:var(--bm-align-items, flex-start);',
							$style
						) ?? $style;
					} elseif ( preg_match( '/flex-direction\s*:\s*column/i', $style ) ) {
						$style = preg_replace(
							'/flex-direction\s*:\s*column/i',
							'flex-direction:column;align-items:var(--bm-align-items, flex-start)',
							$style
						) ?? $style;
					} else {
						$style = 'align-items:var(--bm-align-items, flex-start);' . $style;
					}
				}
				if ( false === strpos( $style, '--bm-text-align' ) ) {
					if ( preg_match( '/text-align\s*:/i', $style ) ) {
						$style = preg_replace(
							'/text-align\s*:[^;]+;?/i',
							'text-align:var(--bm-text-align, left);',
							$style
						) ?? $style;
					} else {
						$style = 'text-align:var(--bm-text-align, left);' . $style;
					}
				}
				// Growing the list is what pushes the CTA down, so it has to
				// follow the pin toggle instead of being hardcoded.
				if ( false === strpos( $style, '--bm-features-flex' ) ) {
					if ( preg_match( '/(^|[;\s])flex\s*:/i', $style ) ) {
						$style = preg_replace(
							'/(^|[;\s])flex\s*:[^;]+;?/i',
							'$1flex:var(--bm-features-flex, 1 1 auto);',
							$style
						) ?? $style;
					} else {
						$style = 'flex:var(--bm-features-flex, 1 1 auto);' . $style;
					}
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_features_style ) ) {
			$markup = $patched_features_style;
		}

		$patched_feature_item = preg_replace_callback(
			'/(<(?:li)\b[^>]*\bclass="[^"]*\bbm-pricing__feature\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( ! preg_match( '/width\s*:\s*fit-content/i', $style ) ) {
					$style = 'width:fit-content;max-width:100%;' . $style;
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_feature_item ) ) {
			$markup = $patched_feature_item;
		}

		$patched_button = preg_replace_callback(
			'/(<(?:a)\b[^>]*\bclass="[^"]*\bbm-pricing__button\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = self::pin_button_order_fallback( $m[2] );
				if ( false === strpos( $style, '--bm-buttons-pin-top' ) ) {
					if ( preg_match( '/margin-top\s*:/i', $style ) ) {
						$style = preg_replace(
							'/margin-top\s*:[^;]+;?/i',
							'margin-top:var(--bm-buttons-pin-top, var(--bm-button-margin-top, 0));',
							$style
						) ?? $style;
					} else {
						$style = 'margin-top:var(--bm-buttons-pin-top, var(--bm-button-margin-top, 0));' . $style;
					}
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_button ) ) {
			$markup = $patched_button;
		}

		return $markup;
	}

	/**
	 * Ensure feature/blog cards can pin buttons to the bottom of equal-height cards.
	 * Flex layout is gated behind --bm-card-display so element-order CSS does not
	 * reshuffle image/body when the toggle is off.
	 *
	 * @param string $markup Markup HTML.
	 */
	public static function upgrade_card_buttons_align_markup( string $markup ): string {
		if (
			'' === $markup
			|| false === strpos( $markup, 'bm-card__buttons' )
			|| ( false === strpos( $markup, 'bm-card' ) && false === strpos( $markup, 'bm-cards' ) )
		) {
			return $markup;
		}

		// Rows must size to content — height:100% on cards collapses/overlaps rows.
		if ( false !== strpos( $markup, 'bm-cards__grid' ) && false === strpos( $markup, 'grid-auto-rows:' ) ) {
			$with_rows = preg_replace(
				'/(grid-template-columns:repeat\(var\(--bm-card-columns,\s*4\),\s*minmax\(0,\s*1fr\)\);)/i',
				'$1grid-auto-rows:minmax(min-content, auto);align-items:stretch;',
				$markup
			);
			if ( is_string( $with_rows ) ) {
				$markup = $with_rows;
			}
		}

		$patched_card = preg_replace_callback(
			'/(<(?:article)\b[^>]*\bclass="[^"]*\bbm-card\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( false === strpos( $style, '--bm-card-display' ) ) {
					$style = preg_replace( '/display\s*:\s*flex\s*;?/i', '', $style ) ?? $style;
					$style = preg_replace( '/height\s*:\s*100%\s*;?/i', '', $style ) ?? $style;
					$style = 'display:var(--bm-card-display, block);flex-direction:column;align-self:stretch;height:var(--bm-card-height, auto);' . $style;
				} else {
					if ( ! preg_match( '/align-self\s*:/i', $style ) ) {
						$style = 'align-self:stretch;' . $style;
					}
					$style = preg_replace( '/min-height\s*:\s*0\s*;?/i', '', $style ) ?? $style;
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_card ) ) {
			$markup = $patched_card;
		}

		$patched_body = preg_replace_callback(
			'/(<div\b[^>]*\bclass="[^"]*\bbm-card__body\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( false === strpos( $style, '--bm-card-body-display' ) ) {
					if ( preg_match( '/align-items\s*:\s*var\(\s*--bm-align-items/i', $style ) ) {
						if ( ! preg_match( '/order\s*:/i', $style ) ) {
							$style = 'order:1;' . $style;
						}
						if ( false === strpos( $style, '--bm-card-body-gap' ) ) {
							$style = 'gap:var(--bm-card-body-gap, 1rem);' . $style;
						}
						return $m[1] . $style . $m[3];
					}
					$style = preg_replace( '/display\s*:\s*flex\s*;?/i', '', $style ) ?? $style;
					$style = preg_replace( '/flex\s*:\s*1\s*1\s*auto\s*;?/i', '', $style ) ?? $style;
					$style = preg_replace( '/flex\s*:\s*1\s*;?/i', '', $style ) ?? $style;
					$style = 'display:var(--bm-card-body-display, flex);flex-direction:column;flex:var(--bm-card-body-flex, 1 1 auto);min-height:auto;align-items:stretch;gap:var(--bm-card-body-gap, 1rem);order:1;' . $style;
				} else {
					$style = preg_replace( '/min-height\s*:\s*0\s*;?/i', 'min-height:auto;', $style ) ?? $style;
					if ( ! preg_match( '/order\s*:/i', $style ) ) {
						$style = 'order:1;' . $style;
					}
					if ( false === strpos( $style, '--bm-card-body-gap' ) ) {
						$style = 'gap:var(--bm-card-body-gap, 1rem);' . $style;
					}
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_body ) ) {
			$markup = $patched_body;
		}

		$patched_image = preg_replace_callback(
			'/(<img\b[^>]*\bclass="[^"]*\bbm-card__image\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( ! preg_match( '/order\s*:\s*-1/i', $style ) ) {
					$style = 'flex-shrink:0;order:-1;' . $style;
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_image ) ) {
			$markup = $patched_image;
		}

		$patched_icon = preg_replace_callback(
			'/(<div\b[^>]*\bclass="[^"]*\bbm-card__icon-wrapper\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( false !== strpos( $style, '--bm-align-items' ) ) {
					return $m[0];
				}
				if ( preg_match( '/align-self\s*:/i', $style ) ) {
					$style = preg_replace(
						'/align-self\s*:[^;]+;?/i',
						'align-self:var(--bm-align-items, flex-start);',
						$style,
						1
					);
				} else {
					$style = 'align-self:var(--bm-align-items, flex-start);' . $style;
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_icon ) ) {
			$markup = $patched_icon;
		}

		$patched_buttons = preg_replace_callback(
			'/(<div\b[^>]*\bclass="[^"]*\bbm-card__buttons\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style = $m[2];
				if ( false === strpos( $style, '--bm-buttons-pin-top' ) ) {
					if ( preg_match( '/margin\s*:\s*var\(\s*--bm-buttons-margin-top/i', $style ) ) {
						$style = preg_replace(
							'/margin\s*:\s*var\(\s*--bm-buttons-margin-top[^;]*;/i',
							'margin:var(--bm-buttons-margin-top, 0) 0 var(--bm-buttons-margin-bottom, 0);margin-top:var(--bm-buttons-pin-top, var(--bm-buttons-margin-top, 0));',
							$style,
							1
						);
					} elseif ( preg_match( '/margin-top\s*:/i', $style ) ) {
						$style = preg_replace(
							'/margin-top\s*:[^;]+;?/i',
							'margin-top:var(--bm-buttons-pin-top, var(--bm-buttons-margin-top, 0));',
							$style,
							1
						);
						if ( ! preg_match( '/margin\s*:\s*var\(\s*--bm-buttons-margin-top/i', $style ) ) {
							$style = 'margin:var(--bm-buttons-margin-top, 0) 0 var(--bm-buttons-margin-bottom, 0);' . $style;
						}
					} else {
						$style = 'margin:var(--bm-buttons-margin-top, 0) 0 var(--bm-buttons-margin-bottom, 0);margin-top:var(--bm-buttons-pin-top, var(--bm-buttons-margin-top, 0));' . $style;
					}
				} elseif (
					preg_match( '/margin\s*:\s*var\(\s*--bm-buttons-pin-top/i', $style )
					&& ! preg_match( '/margin-top\s*:\s*var\(\s*--bm-buttons-pin-top/i', $style )
				) {
					$style = preg_replace(
						'/margin\s*:\s*var\(\s*--bm-buttons-pin-top[^;]*;/i',
						'margin:var(--bm-buttons-margin-top, 0) 0 var(--bm-buttons-margin-bottom, 0);margin-top:var(--bm-buttons-pin-top, var(--bm-buttons-margin-top, 0));',
						$style,
						1
					);
				}
				if ( ! preg_match( '/width\s*:\s*100%/i', $style ) ) {
					$style = 'width:100%;box-sizing:border-box;' . $style;
				}
				$style = self::pin_button_order_fallback( $style );
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		if ( is_string( $patched_buttons ) ) {
			$markup = $patched_buttons;
		}

		return $markup;
	}

	/**
	 * Expand legacy 2-value padding/margin so per-side vars (esp. bottom) apply.
	 *
	 * @param string $markup Markup HTML.
	 */
	public static function upgrade_box_spacing_markup( string $markup ): string {
		$pad_long = 'padding-top:var(--bm-padding-top, var(--bm-padding-y));padding-right:var(--bm-padding-right, var(--bm-padding-x));padding-bottom:var(--bm-padding-bottom, var(--bm-padding-y));padding-left:var(--bm-padding-left, var(--bm-padding-x))';
		$mar_long = 'margin-top:var(--bm-margin-top, var(--bm-margin-y));margin-right:var(--bm-margin-right, var(--bm-margin-x));margin-bottom:var(--bm-margin-bottom, var(--bm-margin-y));margin-left:var(--bm-margin-left, var(--bm-margin-x))';

		$markup = preg_replace(
			'/padding\s*:\s*var\(\s*--bm-padding-top\s*,\s*var\(\s*--bm-padding-y\s*\)\s*\)\s+var\(\s*--bm-padding-right\s*,\s*var\(\s*--bm-padding-x\s*\)\s*\)\s+var\(\s*--bm-padding-bottom\s*,\s*var\(\s*--bm-padding-y\s*\)\s*\)\s+var\(\s*--bm-padding-left\s*,\s*var\(\s*--bm-padding-x\s*\)\s*\)/i',
			$pad_long,
			$markup
		) ?? $markup;

		$markup = preg_replace(
			'/padding\s*:\s*var\(\s*--bm-padding-y\s*\)\s+var\(\s*--bm-padding-x\s*\)/i',
			$pad_long,
			$markup
		) ?? $markup;

		$markup = preg_replace(
			'/margin\s*:\s*var\(\s*--bm-margin-top\s*,\s*var\(\s*--bm-margin-y\s*\)\s*\)\s+var\(\s*--bm-margin-right\s*,\s*var\(\s*--bm-margin-x\s*\)\s*\)\s+var\(\s*--bm-margin-bottom\s*,\s*var\(\s*--bm-margin-y\s*\)\s*\)\s+var\(\s*--bm-margin-left\s*,\s*var\(\s*--bm-margin-x\s*\)\s*\)/i',
			$mar_long,
			$markup
		) ?? $markup;

		$markup = preg_replace(
			'/margin\s*:\s*var\(\s*--bm-margin-y\s*\)\s+var\(\s*--bm-margin-x\s*\)/i',
			$mar_long,
			$markup
		) ?? $markup;

		$markup = preg_replace(
			'/background-color\s*:\s*var\(\s*--bm-bg\s*\)\s*;\s*background-image\s*:\s*var\(\s*--bm-bg-image(?:\s*,\s*none)?\s*\)\s*,\s*var\(\s*--bm-bg\s*\)\s*;/i',
			'background-color:var(--bm-bg);background-image:var(--bm-bg-image, none),var(--bm-bg-gradient, none);',
			$markup
		) ?? $markup;

		$markup = preg_replace(
			'/background-color\s*:\s*var\(\s*--bm-bg\s*\)\s*;\s*background-image\s*:\s*var\(\s*--bm-bg-image\s*\)\s*;/i',
			'background-color:var(--bm-bg);background-image:var(--bm-bg-image, none),var(--bm-bg-gradient, none);',
			$markup
		) ?? $markup;

		$markup = preg_replace(
			'/background-color\s*:\s*var\(\s*--bm-bg\s*\)\s*;\s*background-image\s*:\s*var\(\s*--bm-bg\s*\)\s*;/i',
			'background-color:var(--bm-bg);background-image:var(--bm-bg-gradient, none);',
			$markup
		) ?? $markup;

		$markup = preg_replace_callback(
			'/(<[^>]*\bdata-bm-el="badge"[^>]*\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$style     = $m[2];
				$badge_pad = 'padding-top:0.35rem;padding-right:var(--bm-badge-padding-right, 0.75rem);padding-bottom:0.35rem;padding-left:var(--bm-badge-padding-left, 0.75rem)';
				$badge_surface =
					'border-style:var(--bm-badge-border-style, none);' .
					'border-color:var(--bm-badge-border-color, transparent);' .
					'border-top-width:var(--bm-badge-border-top-width, var(--bm-badge-border-width, 0));' .
					'border-right-width:var(--bm-badge-border-right-width, var(--bm-badge-border-width, 0));' .
					'border-bottom-width:var(--bm-badge-border-bottom-width, var(--bm-badge-border-width, 0));' .
					'border-left-width:var(--bm-badge-border-left-width, var(--bm-badge-border-width, 0));' .
					'border-top-left-radius:var(--bm-badge-border-top-left-radius, var(--bm-badge-border-radius, 4px));' .
					'border-top-right-radius:var(--bm-badge-border-top-right-radius, var(--bm-badge-border-radius, 4px));' .
					'border-bottom-right-radius:var(--bm-badge-border-bottom-right-radius, var(--bm-badge-border-radius, 4px));' .
					'border-bottom-left-radius:var(--bm-badge-border-bottom-left-radius, var(--bm-badge-border-radius, 4px));' .
					'box-shadow:var(--bm-badge-shadow, none)';
				if ( preg_match( '/padding\s*:\s*0\.35rem\s+0\.75rem/i', $style ) ) {
					$style = preg_replace(
						'/padding\s*:\s*0\.35rem\s+0\.75rem/i',
						$badge_pad,
						$style
					) ?? $style;
				}
				$style = preg_replace(
					'/margin-left\s*:\s*var\(\s*--bm-badge-padding-left[^)]*\)\s*;?/i',
					'',
					$style
				) ?? $style;
				$style = preg_replace(
					'/margin-right\s*:\s*var\(\s*--bm-badge-padding-right[^)]*\)\s*;?/i',
					'',
					$style
				) ?? $style;
				if ( ! preg_match( '/padding-left\s*:/i', $style ) ) {
					$style .= ';' . $badge_pad;
				}
				// Replace hardcoded chip radius so inspector --bm-badge-border-* vars apply.
				if ( preg_match( '/border-radius\s*:\s*4px/i', $style ) ) {
					$style = preg_replace( '/border-radius\s*:\s*4px\s*;?/i', '', $style ) ?? $style;
				}
				if ( ! preg_match( '/--bm-badge-border-radius/i', $style ) ) {
					$style .= ';' . $badge_surface;
				} elseif ( ! preg_match( '/box-shadow\s*:\s*var\(\s*--bm-badge-shadow/i', $style ) ) {
					$style .= ';box-shadow:var(--bm-badge-shadow, none)';
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		) ?? $markup;

		$markup = preg_replace_callback(
			'/(<[^>]*\bdata-bm-el="(header|subheader)"[^>]*\bstyle=")([^"]*)(")/i',
			static function ( array $m ): string {
				$el    = $m[2];
				$style = $m[3];
				if ( ! preg_match( '/padding-left\s*:/i', $style ) ) {
					$style .= ";padding-left:var(--bm-{$el}-padding-left, 0)";
				}
				if ( ! preg_match( '/padding-right\s*:/i', $style ) ) {
					$style .= ";padding-right:var(--bm-{$el}-padding-right, 0)";
				}
				if ( 'subheader' === $el ) {
					$style = preg_replace( '/;?\s*max-width\s*:\s*40rem/i', '', $style ) ?? $style;
					$style = preg_replace( '/align-self\s*:\s*center/i', 'align-self:stretch', $style ) ?? $style;
					$style = preg_replace( '/;?\s*margin-left\s*:\s*auto/i', '', $style ) ?? $style;
					$style = preg_replace( '/;?\s*margin-right\s*:\s*auto/i', '', $style ) ?? $style;
				}
				if ( ! preg_match( '/align-self\s*:/i', $style ) ) {
					$style .= ';align-self:stretch';
				}
				if ( ! preg_match( '/(?:^|;)\s*width\s*:/i', $style ) ) {
					$style .= ';width:100%';
				}
				if ( ! preg_match( '/box-sizing\s*:/i', $style ) ) {
					$style .= ';box-sizing:border-box';
				}
				return $m[1] . $style . $m[4];
			},
			$markup
		) ?? $markup;

		return $markup;
	}

	/**
	 * Ensure tab labels expose surface CSS vars + active Default/Active chrome.
	 *
	 * @param string $markup Markup.
	 */
	public static function upgrade_tabs_label_markup( string $markup ): string {
		$strip_props = array(
			'background',
			'background-color',
			'border-style',
			'border-color',
			'border-top-width',
			'border-right-width',
			'border-bottom-width',
			'border-left-width',
			'border-width',
			'border-top-left-radius',
			'border-top-right-radius',
			'border-bottom-right-radius',
			'border-bottom-left-radius',
			'border-radius',
			'box-shadow',
		);

		$markup = preg_replace_callback(
			'/(<label\b[^>]*(?:data-bm-el="tabTitle"|class="[^"]*\bbm-tabs__label\b[^"]*")[^>]*\bstyle=")([^"]*)(")/i',
			static function ( array $m ) use ( $strip_props ): string {
				$style = $m[2];
				foreach ( $strip_props as $prop ) {
					$style = preg_replace(
						'/(^|;)\s*' . preg_quote( $prop, '/' ) . '\s*:[^;]*/i',
						'$1',
						$style
					) ?? $style;
				}
				$style = preg_replace( '/;;+/', ';', $style ) ?? $style;
				$style = trim( $style, ';' );
				if ( ! preg_match( '/(?:^|;)\s*display\s*:/i', $style ) ) {
					$style = 'display:inline-flex;align-items:center;gap:0.45em;' . $style;
				} elseif ( preg_match( '/display\s*:\s*inline-block/i', $style ) ) {
					$style = preg_replace(
						'/display\s*:\s*inline-block/i',
						'display:inline-flex;align-items:center;gap:0.45em',
						$style
					) ?? $style;
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		) ?? $markup;

		$surface_rules = '.bm-tabs .bm-tabs__label {
      background: var(--bm-tab-bg, transparent);
      border-style: var(--bm-tab-border-style, solid);
      border-color: var(--bm-tab-border-color, transparent);
      border-top-width: var(--bm-tab-border-top-width, var(--bm-tab-border-width, 0px));
      border-right-width: var(--bm-tab-border-right-width, var(--bm-tab-border-width, 0px));
      border-bottom-width: var(--bm-tab-border-bottom-width, var(--bm-tab-border-width, 0px));
      border-left-width: var(--bm-tab-border-left-width, var(--bm-tab-border-width, 0px));
      border-top-left-radius: var(--bm-tab-border-top-left-radius, var(--bm-tab-border-radius, 0px));
      border-top-right-radius: var(--bm-tab-border-top-right-radius, var(--bm-tab-border-radius, 0px));
      border-bottom-right-radius: var(--bm-tab-border-bottom-right-radius, var(--bm-tab-border-radius, 0px));
      border-bottom-left-radius: var(--bm-tab-border-bottom-left-radius, var(--bm-tab-border-radius, 0px));
    }
    .bm-tabs .bm-tabs__input:checked + .bm-tabs__label {
      color: var(--bm-tab-active-color, var(--bm-tabTitle-color, #3525cd));
      background: var(--bm-tab-active-bg, var(--bm-tab-bg, transparent));
      border-style: var(--bm-tab-active-border-style, var(--bm-tab-border-style, solid));
      border-color: var(--bm-tab-active-border-color, var(--bm-tab-active-color, #3525cd));
      border-top-width: var(--bm-tab-active-border-top-width, 0px);
      border-right-width: var(--bm-tab-active-border-right-width, 0px);
      border-bottom-width: var(--bm-tab-active-border-bottom-width, 2px);
      border-left-width: var(--bm-tab-active-border-left-width, 0px);
    }';

		if ( preg_match( '/<style[\s>]/i', $markup ) && preg_match( '/bm-tabs/i', $markup ) ) {
			$markup = preg_replace(
				'/\.bm-tabs\s+\.bm-tabs__label\s*\{[^}]*\}\s*|\.bm-tabs\s+\.bm-tabs__input:checked\s*\+\s*\.bm-tabs__label\s*\{[^}]*\}\s*/i',
				'',
				$markup
			) ?? $markup;
			$markup = preg_replace(
				'/(<style[^>]*>)/i',
				'$1' . "\n    " . $surface_rules . "\n",
				$markup,
				1
			) ?? $markup;
		}

		return self::upgrade_tabs_nav_chrome( self::upgrade_tabs_panel_padding_markup( $markup ) );
	}

	/**
	 * Tab panels: longhand padding vars (replace legacy --bm-panel-padding shorthand).
	 * Mirrors src/interpolate.js so PHP frontend matches the Gutenberg canvas.
	 *
	 * @param string $markup Markup.
	 */
	private static function upgrade_tabs_panel_padding_markup( string $markup ): string {
		$panel_pad = 'padding-top:var(--bm-tabContent-padding-top, 1.25rem);padding-right:var(--bm-tabContent-padding-right, 0);padding-bottom:var(--bm-tabContent-padding-bottom, 0);padding-left:var(--bm-tabContent-padding-left, 0)';
		$next      = preg_replace_callback(
			'/(<[^>]*\b(?:data-bm-el="tabContent"|class="[^"]*\bbm-tabs__panel\b[^"]*")[^>]*\bstyle=")([^"]*)(")/i',
			static function ( array $m ) use ( $panel_pad ): string {
				$style = $m[2];
				if (
					preg_match( '/padding\s*:\s*var\(\s*--bm-panel-padding/i', $style ) ||
					preg_match( '/padding\s*:\s*[^;]+/i', $style )
				) {
					$style = preg_replace( '/(?:^|;)\s*padding\s*:[^;]*/i', '', $style ) ?? $style;
				}
				if ( ! preg_match( '/padding-top\s*:/i', $style ) ) {
					$style = ltrim( $style . ';' . $panel_pad, ';' );
				} elseif ( ! preg_match( '/--bm-tabContent-padding-top/i', $style ) ) {
					$style = preg_replace(
						'/padding-top\s*:[^;]*/i',
						'padding-top:var(--bm-tabContent-padding-top, 1.25rem)',
						$style
					) ?? $style;
					$style = preg_replace(
						'/padding-right\s*:[^;]*/i',
						'padding-right:var(--bm-tabContent-padding-right, 0)',
						$style
					) ?? $style;
					$style = preg_replace(
						'/padding-bottom\s*:[^;]*/i',
						'padding-bottom:var(--bm-tabContent-padding-bottom, 0)',
						$style
					) ?? $style;
					$style = preg_replace(
						'/padding-left\s*:[^;]*/i',
						'padding-left:var(--bm-tabContent-padding-left, 0)',
						$style
					) ?? $style;
				}
				$style = preg_replace( '/;;+/', ';', $style ) ?? $style;
				$style = trim( $style, ';' );
				return $m[1] . $style . $m[3];
			},
			$markup
		);
		return is_string( $next ) ? $next : $markup;
	}

	/**
	 * Ensure burger menu chrome exists for older tabs markup.
	 *
	 * @param string $markup Markup.
	 */
	private static function upgrade_tabs_nav_chrome( string $markup ): string {
		if ( ! preg_match( '/bm-tabs__nav/i', $markup ) ) {
			return $markup;
		}

		$close_onclick = "(function(el){var r=el.closest('.bm-tabs');var c=r&&r.querySelector('.bm-tabs__menu-check');if(c)c.checked=false;})(this)";

		if ( ! preg_match( '/bm-tabs__menu-check/i', $markup ) ) {
			$chrome = '<input class="bm-tabs__menu-check" type="checkbox" id="{{tabsId}}-nav" aria-hidden="true" tabindex="-1" style="
      position:absolute;
      opacity:0;
      pointer-events:none;
      width:1px;
      height:1px;
    " />
    <div class="bm-tabs__nav-bar" style="
      display:none;
      align-items:center;
      justify-content:flex-end;
      width:100%;
      box-sizing:border-box;
      min-height:var(--bm-tab-burger-toggle, 2.5rem);
    ">
      <label class="bm-tabs__menu-toggle" for="{{tabsId}}-nav" aria-label="Open tabs menu" style="
        display:inline-flex;
        align-items:center;
        justify-content:center;
        width:var(--bm-tab-burger-toggle, 2.5rem);
        height:var(--bm-tab-burger-toggle, 2.5rem);
        margin:0;
        margin-left:auto;
        padding:0;
        border:0;
        border-radius:8px;
        background:transparent;
        color:var(--bm-tab-burger-color, #4b5563);
        cursor:pointer;
        box-sizing:border-box;
      ">
        <span class="bm-tabs__burger" aria-hidden="true"></span>
      </label>
    </div>
    <div class="bm-tabs__list" style="
      display:flex;
      flex-wrap:wrap;
      align-items:flex-end;
      gap:0 0.5rem;
      box-sizing:border-box;
    ">';

			$markup = preg_replace(
				'/(<div\b[^>]*\bclass="[^"]*\bbm-tabs__nav\b[^"]*"[^>]*>)/i',
				'$1' . "\n    " . $chrome,
				$markup,
				1
			) ?? $markup;

			$closed = false;
			$markup = preg_replace_callback(
				'/\{\{\/tabs\}\}/i',
				static function ( array $m ) use ( &$closed ): string {
					if ( $closed ) {
						return $m[0];
					}
					$closed = true;
					return $m[0] . "\n    </div>";
				},
				$markup
			) ?? $markup;
		} elseif ( ! preg_match( '/bm-tabs__list/i', $markup ) ) {
			$markup = preg_replace(
				'/(<div\b[^>]*\bbm-tabs__nav-bar\b[^>]*>[\s\S]*?<\/div>\s*)/i',
				'$1<div class="bm-tabs__list" style="display:flex;flex-wrap:wrap;align-items:flex-end;gap:0 0.5rem;box-sizing:border-box;">',
				$markup,
				1
			) ?? $markup;
			$closed = false;
			$markup = preg_replace_callback(
				'/\{\{\/tabs\}\}/i',
				static function ( array $m ) use ( &$closed ): string {
					if ( $closed ) {
						return $m[0];
					}
					$closed = true;
					return $m[0] . "\n    </div>";
				},
				$markup
			) ?? $markup;
		}

		$markup = preg_replace(
			'/<label(?![^>]*\bonclick\s*=)(\s[^>]*\bbm-tabs__label\b[^>]*)>/i',
			'<label$1 onclick="' . $close_onclick . '">',
			$markup
		) ?? $markup;

		// Prefer dedicated burger color over shared tab title color.
		$markup = preg_replace(
			'/(<label\b[^>]*\bbm-tabs__menu-toggle\b[^>]*\bstyle="[^"]*)color\s*:\s*var\(\s*--bm-tabTitle-color\s*,\s*var\(\s*--bm-tab-color\s*,\s*#64748b\s*\)\s*\)/i',
			'$1color:var(--bm-tab-burger-color, #4b5563)',
			$markup
		) ?? $markup;

		// Size-aware toggle hit area.
		$markup = preg_replace(
			'/(<label\b[^>]*\bbm-tabs__menu-toggle\b[^>]*\bstyle="[^"]*)width\s*:\s*2\.5rem/i',
			'$1width:var(--bm-tab-burger-toggle, 2.5rem)',
			$markup
		) ?? $markup;
		$markup = preg_replace(
			'/(<label\b[^>]*\bbm-tabs__menu-toggle\b[^>]*\bstyle="[^"]*width:var\(--bm-tab-burger-toggle,\s*2\.5rem\)[^"]*)height\s*:\s*2\.5rem/i',
			'$1height:var(--bm-tab-burger-toggle, 2.5rem)',
			$markup
		) ?? $markup;
		$markup = preg_replace(
			'/(<div\b[^>]*\bbm-tabs__nav-bar\b[^>]*\bstyle="[^"]*)min-height\s*:\s*2\.75rem/i',
			'$1min-height:var(--bm-tab-burger-toggle, 2.5rem)',
			$markup
		) ?? $markup;

		return $markup;
	}

	/**
	 * Resolve baked title-icon display from cssVars (flat or breakpoint map).
	 *
	 * @param mixed $css_vars Css vars map.
	 * @return string 'inline-flex'|'none'
	 */
	public static function resolve_title_icon_display( $css_vars ): string {
		$vars = is_array( $css_vars ) ? $css_vars : array();
		$raw  = '';
		if ( isset( $vars['desktop'] ) && is_array( $vars['desktop'] ) ) {
			$raw = (string) ( $vars['desktop']['--bm-title-icons-display'] ?? '' );
		}
		if ( '' === $raw ) {
			$raw = (string) ( $vars['--bm-title-icons-display'] ?? '' );
		}
		if ( 'inline-flex' === strtolower( trim( $raw ) ) ) {
			return 'inline-flex';
		}
		if ( '' === trim( $raw ) ) {
			$flag = '';
			if ( isset( $vars['desktop'] ) && is_array( $vars['desktop'] ) ) {
				$flag = (string) ( $vars['desktop']['--bm-title-icons'] ?? '' );
			}
			if ( '' === $flag ) {
				$flag = (string) ( $vars['--bm-title-icons'] ?? '' );
			}
			if ( self::is_css_flag_on( $flag ) ) {
				return 'inline-flex';
			}
		}
		return 'none';
	}

	/**
	 * Inject left icon + editable title text wrappers for tabs / accordion.
	 *
	 * @param string $markup Markup HTML.
	 * @return string
	 */
	public static function upgrade_title_icon_markup( string $markup ): string {
		$markup = preg_replace(
			'/style="(display\s*:\s*)var\(\s*--bm-title-icons-display\s*,\s*none\s*\)/i',
			'style="$1{{titleIconDisplay}}',
			$markup
		) ?? $markup;

		$icon_tabs = '<span class="bm-tabs__icon" aria-hidden="true" style="display:{{titleIconDisplay}};flex-shrink:0;line-height:0;align-items:center;justify-content:center;">{{iconHtml}}</span>';
		$icon_acc  = '<span class="bm-accordion__icon" aria-hidden="true" style="display:{{titleIconDisplay}};flex-shrink:0;line-height:0;align-items:center;justify-content:center;">{{iconHtml}}</span>';

		$markup = preg_replace_callback(
			'/(<label\b[^>]*\bbm-tabs__label\b[^>]*>)([\s\S]*?)(<\/label>)/i',
			static function ( array $m ) use ( $icon_tabs ): string {
				$inner = $m[2];
				if ( preg_match( '/\bbm-tabs__icon\b/i', $inner ) ) {
					return $m[0];
				}
				if ( preg_match( '/\bbm-edit-text\b/i', $inner ) ) {
					return $m[1] . $icon_tabs . $inner . $m[3];
				}
				$trimmed = trim( (string) $inner );
				return $m[1]
					. $icon_tabs
					. '<span class="bm-tabs__label-text bm-edit-text">'
					. $trimmed
					. '</span>'
					. $m[3];
			},
			$markup
		) ?? $markup;

		$markup = preg_replace_callback(
			'/(<summary\b[^>]*\bbm-accordion__title\b[^>]*>)([\s\S]*?)(<\/summary>)/i',
			static function ( array $m ) use ( $icon_acc ): string {
				$inner = $m[2];
				if ( preg_match( '/\bbm-accordion__icon\b/i', $inner ) ) {
					return $m[0];
				}
				if ( preg_match( '/\bbm-edit-text\b/i', $inner ) ) {
					return $m[1] . $icon_acc . $inner . $m[3];
				}
				$trimmed = trim( (string) $inner );
				return $m[1]
					. $icon_acc
					. '<span class="bm-accordion__title-text bm-edit-text">'
					. $trimmed
					. '</span>'
					. $m[3];
			},
			$markup
		) ?? $markup;

		return $markup;
	}

	/**
	 * Inject selectable accordion arrow host (closed/open icon slots).
	 *
	 * @param string $markup Markup HTML.
	 * @return string
	 */
	public static function upgrade_accordion_arrow_markup( string $markup ): string {
		if ( '' === $markup || false === strpos( $markup, 'bm-accordion' ) ) {
			return $markup;
		}

		// Ensure style-class token on the accordion root.
		if ( false === strpos( $markup, 'accordionArrowCustomClass' ) && false === strpos( $markup, 'accordionArrowStyleClass' ) ) {
			$markup = preg_replace(
				'/(<section\b[^>]*\bclass="[^"]*\bbm-accordion\b[^"]*)(")/i',
				'$1{{accordionArrowCustomClass}}$2',
				$markup,
				1
			) ?? $markup;
		}

		$arrow = '<span class="bm-accordion__arrow" data-bm-el="accordionArrow" aria-hidden="true" style="'
			. 'display:var(--bm-accordion-arrow-display, inline-flex);'
			. 'flex-shrink:0;align-items:center;justify-content:center;'
			. 'order:var(--bm-accordion-arrow-order, 0);'
			. 'margin-left:var(--bm-accordion-arrow-ml, auto);'
			. 'margin-right:var(--bm-accordion-arrow-mr, 0);'
			. 'box-sizing:border-box;'
			. 'color:var(--bm-accordionArrow-color, currentColor);'
			. 'background:var(--bm-accordionArrow-bg, transparent);'
			. 'padding:0;'
			. 'width:var(--bm-accordion-arrow-box-size, 2rem);'
			. 'height:var(--bm-accordion-arrow-box-size, 2rem);'
			. 'aspect-ratio:1 / 1;'
			. 'border-style:var(--bm-accordionArrow-border-style, none);'
			. 'border-color:var(--bm-accordionArrow-border-color, transparent);'
			. 'border-top-width:var(--bm-accordionArrow-border-top-width, var(--bm-accordionArrow-border-width, 0));'
			. 'border-right-width:var(--bm-accordionArrow-border-right-width, var(--bm-accordionArrow-border-width, 0));'
			. 'border-bottom-width:var(--bm-accordionArrow-border-bottom-width, var(--bm-accordionArrow-border-width, 0));'
			. 'border-left-width:var(--bm-accordionArrow-border-left-width, var(--bm-accordionArrow-border-width, 0));'
			. 'border-top-left-radius:var(--bm-accordionArrow-border-top-left-radius, var(--bm-accordionArrow-border-radius, 0));'
			. 'border-top-right-radius:var(--bm-accordionArrow-border-top-right-radius, var(--bm-accordionArrow-border-radius, 0));'
			. 'border-bottom-right-radius:var(--bm-accordionArrow-border-bottom-right-radius, var(--bm-accordionArrow-border-radius, 0));'
			. 'border-bottom-left-radius:var(--bm-accordionArrow-border-bottom-left-radius, var(--bm-accordionArrow-border-radius, 0));'
			. 'box-shadow:var(--bm-accordionArrow-shadow, none);'
			. 'font-size:var(--bm-accordion-arrow-font-size, 1rem);'
			. 'line-height:0;'
			. '"><span class="bm-accordion__arrow-icon bm-accordion__arrow-icon--closed">{{arrowClosedHtml}}</span>'
			. '<span class="bm-accordion__arrow-icon bm-accordion__arrow-icon--open">{{arrowOpenHtml}}</span></span>';

		$markup = preg_replace_callback(
			'/(<summary\b[^>]*\bbm-accordion__title\b[^>]*>)([\s\S]*?)(<\/summary>)/i',
			static function ( array $m ) use ( $arrow ): string {
				$inner = $m[2];
				if ( preg_match( '/\bbm-accordion__arrow\b/i', $inner ) ) {
					return $m[0];
				}
				return $m[1] . $inner . $arrow . $m[3];
			},
			$markup
		) ?? $markup;

		return $markup;
	}

	/**
	 * Prefer {{labelHtml}} so empty labels omit .bm-hero__btn-label (icon-only buttons).
	 *
	 * @param string $markup Markup.
	 */
	public static function upgrade_hero_button_label_markup( string $markup ): string {
		$markup = preg_replace(
			'/\{\{iconHtml\}\}\s*<span\b[^>]*\bbm-hero__btn-label\b[^>]*>\s*\{\{\s*link\.title\s*\}\}\s*<\/span>/i',
			'{{iconHtml}}{{labelHtml}}',
			$markup
		) ?? $markup;

		$markup = preg_replace(
			'/<span\b[^>]*\bbm-hero__btn-label\b[^>]*>\s*\{\{\s*link\.title\s*\}\}\s*<\/span>/i',
			'{{labelHtml}}',
			$markup
		) ?? $markup;

		return $markup;
	}

	/**
	 * Remove empty label spans (stale markup / whitespace-only titles).
	 *
	 * @param string $html Rendered HTML.
	 */
	private static function strip_empty_hero_button_labels( string $html ): string {
		$stripped = preg_replace(
			'/<span\b([^>]*\bbm-hero__btn-label\b[^>]*)>\s*<\/span>/i',
			'',
			$html
		);
		return is_string( $stripped ) ? $stripped : $html;
	}

	/**
	 * Replace tokens, sections, and content stack in markup.
	 *
	 * @param string               $markup Markup HTML.
	 * @param array<string, mixed> $attrs  Attribute values.
	 */
	public static function interpolate( string $markup, array $attrs ): string {
		$attrs = self::enrich_media_html( $attrs );
		$attrs = self::clear_hidden_media_html( $attrs );
		$attrs = self::enrich_button_icon( $attrs );
		$attrs['sliderConfigAttr'] = self::slider_config_attr( $attrs );
		$attrs['sliderEffectClass'] = self::slider_effect_class( $attrs );
		$attrs['sliderArrowSizeClass'] = self::slider_arrow_size_class( $attrs );
		$attrs['sliderArrowBehaviorClass'] = self::slider_arrow_behavior_class( $attrs );
		$attrs['accordionMultiple'] = self::resolve_accordion_multiple_flag( $attrs ) ? '1' : '0';
		$attrs = self::enrich_accordion_arrow_tokens( $attrs );
		list( $body, $partials ) = self::extract_partials(
			self::upgrade_accordion_multiple_markup(
				self::upgrade_accordion_arrow_markup(
					self::upgrade_title_icon_markup(
						self::upgrade_tabs_label_markup(
							self::upgrade_hero_button_label_markup(
								self::upgrade_card_content_align_markup(
									self::upgrade_pricing_matrix_markup(
										self::upgrade_testimonial_card_layout_markup(
											self::upgrade_card_buttons_align_markup(
												self::upgrade_card_icon_surface_markup(
													self::upgrade_card_border_markup( self::upgrade_box_spacing_markup( $markup ) )
												)
											)
										)
									)
								)
							)
						)
					)
				)
			)
		);

		$defaults = $partials
			? ElementsOrder::defaults_for_partials( array_keys( $partials ) )
			: ElementsOrder::DEFAULTS;
		$order    = ElementsOrder::for_dom( $attrs['elementsOrder'] ?? null, $defaults );
		if ( ! $order && $partials ) {
			$order = array_keys( $partials );
		}

		$stack = '';
		foreach ( $order as $key ) {
			if ( isset( $partials[ $key ] ) && self::should_render_element( $key, $attrs ) ) {
				$stack .= $partials[ $key ];
			}
		}
		$body = str_replace( '{{contentStack}}', $stack, $body );

		if ( isset( $attrs['tabs'] ) && is_array( $attrs['tabs'] ) && ! self::is_assoc( $attrs['tabs'] ) ) {
			static $tabs_seq = 0;
			++$tabs_seq;
			if ( empty( $attrs['tabsId'] ) ) {
				$salt = '';
				if ( ! empty( $attrs['instanceId'] ) ) {
					$salt = substr( preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $attrs['instanceId'] ), 0, 20 );
				}
				$hash = substr( md5( (string) wp_json_encode( $attrs['tabs'] ) ), 0, 8 );
				$attrs['tabsId'] = $salt
					? 'bm-tabs-' . $salt . '-' . $tabs_seq . '-' . $hash
					: 'bm-tabs-' . $tabs_seq . '-' . $hash;
			}
		}

		$body = self::process_sections( $body, $attrs );
		$body = self::process_tokens( $body, $attrs );
		$body = self::strip_empty_hero_media( $body );
		$body = self::strip_empty_hero_button_labels( $body );
		$body = self::apply_accordion_open_attr( $body, $attrs['cssVars'] ?? array() );
		$body = self::apply_element_html_attrs( $body, $attrs['cssVars'] ?? array() );

		return $body;
	}

	/**
	 * Apply per-element Class name / ID from cssVars onto `[data-bm-el]` tags.
	 *
	 * @param string               $html     Markup.
	 * @param array<string, mixed> $css_vars Nested or flat cssVars.
	 */
	public static function apply_element_html_attrs( string $html, $css_vars ): string {
		if ( '' === $html ) {
			return $html;
		}

		$flat = CssVars::effective( $css_vars, 'desktop' );
		if ( ! is_array( $flat ) || ! $flat ) {
			return $html;
		}

		$by_el = array();
		foreach ( $flat as $key => $raw ) {
			$parsed = CssVars::parse_element_html_attr_key( (string) $key );
			if ( null === $parsed ) {
				continue;
			}
			$value = trim( (string) $raw );
			if ( '' === $value ) {
				continue;
			}
			$clean = 'id' === $parsed['attr']
				? CssVars::sanitize_element_html_id( $value )
				: CssVars::sanitize_element_class_name( $value );
			if ( '' === $clean ) {
				continue;
			}
			$by_el[ $parsed['el'] ][ $parsed['attr'] ] = $clean;
		}

		foreach ( $by_el as $el => $attrs ) {
			$pattern = '/(<[^>]*\bdata-bm-el="' . preg_quote( (string) $el, '/' ) . '"[^>]*)(>)/i';
			$html    = preg_replace_callback(
				$pattern,
				static function ( array $m ) use ( $attrs ): string {
					$tag = $m[1];
					if ( ! empty( $attrs['class'] ) ) {
						if ( preg_match( '/\bclass\s*=\s*"/i', $tag ) ) {
							$tag = preg_replace_callback(
								'/\bclass\s*=\s*"([^"]*)"/i',
								static function ( array $cm ) use ( $attrs ): string {
									$merged = trim( preg_replace( '/\s+/', ' ', $cm[1] . ' ' . $attrs['class'] ) ?? '' );
									return 'class="' . esc_attr( $merged ) . '"';
								},
								$tag
							) ?? $tag;
						} else {
							$tag .= ' class="' . esc_attr( $attrs['class'] ) . '"';
						}
					}
					if ( ! empty( $attrs['id'] ) ) {
						if ( preg_match( '/\bid\s*=\s*"/i', $tag ) ) {
							$tag = preg_replace(
								'/\bid\s*=\s*"[^"]*"/i',
								'id="' . esc_attr( $attrs['id'] ) . '"',
								$tag
							) ?? $tag;
						} else {
							$tag .= ' id="' . esc_attr( $attrs['id'] ) . '"';
						}
					}
					return $tag . $m[2];
				},
				$html
			) ?? $html;
		}

		return $html;
	}

	/**
	 * Bake accordion arrow open/closed icon HTML + style-mode class token.
	 *
	 * @param array<string, mixed> $attrs Attrs.
	 * @return array<string, mixed>
	 */
	private static function enrich_accordion_arrow_tokens( array $attrs ): array {
		$vars = isset( $attrs['cssVars'] ) && is_array( $attrs['cssVars'] ) ? $attrs['cssVars'] : $attrs;

		$read_var = static function ( string $key ) use ( $vars ): string {
			if ( isset( $vars['desktop'] ) && is_array( $vars['desktop'] ) ) {
				$from_desktop = trim( (string) ( $vars['desktop'][ $key ] ?? '' ) );
				if ( '' !== $from_desktop ) {
					return $from_desktop;
				}
			}
			return trim( (string) ( $vars[ $key ] ?? '' ) );
		};

		$style = strtolower( $read_var( '--bm-accordion-arrow-style' ) );
		$style = preg_replace( '/[^a-z0-9-]/', '', $style ) ?? '';
		if ( '' === $style ) {
			$style = self::is_css_flag_on( $read_var( '--bm-accordion-arrow-custom' ) ) ? 'custom' : 'arrow';
		}

		if ( 'arrow' === $style ) {
			$class = '';
		} elseif ( 'custom' === $style ) {
			$class = ' bm-accordion--arrow-style-custom bm-accordion--arrow-custom';
		} else {
			// Built-in styles: plus-minus today; more later via CSS `.bm-accordion--arrow-style-{id}`.
			$class = ' bm-accordion--arrow-style-' . $style;
		}
		$attrs['accordionArrowCustomClass'] = $class;
		$attrs['accordionArrowStyleClass']  = $class;

		$font_size = '1em';
		$raw       = $read_var( '--bm-accordion-arrow-font-size' );
		if ( '' !== $raw ) {
			$font_size = $raw;
		}
		$icon_style = 'font-size:' . $font_size . ';width:1em;height:1em;color:inherit';

		$closed_ref = IconRegistry::normalize_ref( trim( (string) ( $attrs['arrowIconClosed'] ?? '' ) ) );
		$open_ref   = IconRegistry::normalize_ref( trim( (string) ( $attrs['arrowIconOpen'] ?? '' ) ) );
		$attrs['arrowClosedHtml'] = ( 'custom' === $style && $closed_ref )
			? IconRegistry::render_html( $closed_ref, $icon_style, 'bm-accordion__arrow-glyph' )
			: '';
		$attrs['arrowOpenHtml'] = ( 'custom' === $style && $open_ref )
			? IconRegistry::render_html( $open_ref, $icon_style, 'bm-accordion__arrow-glyph' )
			: '';

		return $attrs;
	}

	/**
	 * Whether a css-flag style value is on.
	 *
	 * @param mixed $value Raw flag.
	 */
	public static function is_css_flag_on( $value ): bool {
		$v = strtolower( trim( (string) $value ) );
		return in_array( $v, array( '1', 'true', 'yes', 'on' ), true );
	}

	/**
	 * Resolve accordion initially-open mode from attrs / css vars.
	 * Supports legacy toggle values (`1`/`true` → `all`, `0`/`false` → `none`).
	 *
	 * @param array<string, mixed> $attrs Attrs or vars map.
	 * @return string 'first'|'all'|'none'
	 */
	public static function accordion_open_mode( array $attrs ): string {
		$vars = isset( $attrs['cssVars'] ) && is_array( $attrs['cssVars'] ) ? $attrs['cssVars'] : null;
		$raw  = '';
		if ( is_array( $vars ) ) {
			if ( isset( $vars['desktop'] ) && is_array( $vars['desktop'] ) ) {
				$raw = (string) ( $vars['desktop']['--bm-accordion-open-default'] ?? '' );
			}
			if ( '' === $raw ) {
				$raw = (string) ( $vars['--bm-accordion-open-default'] ?? '' );
			}
		}
		if ( '' === $raw ) {
			if ( isset( $attrs['desktop'] ) && is_array( $attrs['desktop'] ) ) {
				$raw = (string) ( $attrs['desktop']['--bm-accordion-open-default'] ?? '' );
			}
			if ( '' === $raw ) {
				$raw = (string) ( $attrs['--bm-accordion-open-default'] ?? '' );
			}
		}
		$v = strtolower( trim( $raw ) );
		if ( 'all' === $v || self::is_css_flag_on( $v ) ) {
			return 'all';
		}
		if ( in_array( $v, array( 'none', '0', 'false', 'off', 'closed' ), true ) ) {
			return 'none';
		}
		return 'first';
	}

	/**
	 * Whether multiple accordion items may stay open together.
	 * Forced on when Initially open is "all".
	 *
	 * @param array<string, mixed> $attrs Attrs or vars map.
	 */
	public static function resolve_accordion_multiple_flag( array $attrs ): bool {
		if ( 'all' === self::accordion_open_mode( $attrs ) ) {
			return true;
		}
		$vars = isset( $attrs['cssVars'] ) && is_array( $attrs['cssVars'] ) ? $attrs['cssVars'] : null;
		$raw  = null;
		if ( is_array( $vars ) ) {
			if ( isset( $vars['desktop'] ) && is_array( $vars['desktop'] ) && array_key_exists( '--bm-accordion-multiple', $vars['desktop'] ) ) {
				$raw = $vars['desktop']['--bm-accordion-multiple'];
			} elseif ( array_key_exists( '--bm-accordion-multiple', $vars ) ) {
				$raw = $vars['--bm-accordion-multiple'];
			}
		}
		if ( null === $raw ) {
			if ( isset( $attrs['desktop'] ) && is_array( $attrs['desktop'] ) && array_key_exists( '--bm-accordion-multiple', $attrs['desktop'] ) ) {
				$raw = $attrs['desktop']['--bm-accordion-multiple'];
			} elseif ( array_key_exists( '--bm-accordion-multiple', $attrs ) ) {
				$raw = $attrs['--bm-accordion-multiple'];
			}
		}
		if ( null === $raw || '' === trim( (string) $raw ) ) {
			return true;
		}
		return self::is_css_flag_on( $raw );
	}

	/**
	 * Ensure accordion roots expose data-bm-accordion-multiple for exclusive JS.
	 *
	 * @param string $markup Markup HTML.
	 */
	public static function upgrade_accordion_multiple_markup( string $markup ): string {
		$upgraded = preg_replace_callback(
			'/<section\b([^>]*\bbm-accordion\b[^>]*)>/i',
			static function ( array $m ): string {
				$attrs = $m[1];
				if ( preg_match( '/\bdata-bm-accordion-multiple\s*=/i', $attrs ) ) {
					return $m[0];
				}
				return '<section' . $attrs . ' data-bm-accordion-multiple="{{accordionMultiple}}">';
			},
			$markup
		);
		return is_string( $upgraded ) ? $upgraded : $markup;
	}

	/**
	 * @deprecated Use accordion_open_mode — kept for call-site compat.
	 *
	 * @param array<string, mixed> $attrs Attrs or vars map.
	 */
	public static function accordion_open_default_on( array $attrs ): bool {
		return 'all' === self::accordion_open_mode( $attrs );
	}

	/**
	 * Ensure accordion details open attr matches css mode.
	 * `first` opens only the first item; `all` opens every item; `none` opens none.
	 *
	 * @param string               $html     Markup.
	 * @param array<string, mixed> $css_vars Nested or flat vars map.
	 */
	public static function apply_accordion_open_attr( string $html, array $css_vars = array() ): string {
		$mode  = self::accordion_open_mode( array( 'cssVars' => $css_vars ) );
		$index = 0;
		return preg_replace_callback(
			'/<details([^>]*\bbm-accordion__item\b[^>]*)>/i',
			static function ( $m ) use ( $mode, &$index ) {
				$attrs = preg_replace( '/\{\{\s*detailsOpen\s*\}\}/i', '', (string) $m[1] );
				$attrs = preg_replace( '/\s+\bopen\b(?=(?:\s|>|$))/i', '', (string) $attrs );
				$should_open = ( 'all' === $mode ) || ( 'first' === $mode && 0 === $index );
				++$index;
				if ( $should_open ) {
					$attrs .= ' open';
				}
				return '<details' . $attrs . '>';
			},
			$html
		) ?? $html;
	}

	/**
	 * @param string $markup Markup.
	 * @return array{0: string, 1: array<string, string>}
	 */
	private static function extract_partials( string $markup ): array {
		$partials = array();
		$body     = preg_replace_callback(
			'/<!--\s*bm-partial:([a-zA-Z0-9_-]+)\s*-->(.*?)<!--\s*\/bm-partial:\1\s*-->/s',
			static function ( $m ) use ( &$partials ) {
				$partials[ $m[1] ] = $m[2];
				return '';
			},
			$markup
		);

		return array( $body ?? $markup, $partials );
	}

	/**
	 * Expand {{#key}}...{{/key}} for array attrs.
	 *
	 * @param string               $markup Markup.
	 * @param array<string, mixed> $attrs  Attrs.
	 */
	private static function process_sections( string $markup, array $attrs ): string {
		return preg_replace_callback(
			'/\{\{#\s*([a-zA-Z0-9_]+)\s*\}\}(.*?)\{\{\/\s*\1\s*\}\}/s',
			static function ( $m ) use ( $attrs ) {
				$key  = $m[1];
				$inner = $m[2];
				$items = $attrs[ $key ] ?? null;
				if ( ! is_array( $items ) ) {
					return '';
				}
				// Associative object (link/media) — not a repeater.
				if ( self::is_assoc( $items ) ) {
					return '';
				}
				$out = '';
				foreach ( array_values( $items ) as $index => $item ) {
					if ( ! is_array( $item ) ) {
						$item = array( 'value' => $item );
					}
					$ctx           = $item;
					$ctx['@index'] = (string) $index;
					if ( 'buttons' === $key ) {
						if ( self::is_button_item_hidden( $ctx ) ) {
							continue;
						}
						$ctx = self::flatten_button_item( $ctx, (int) $index );
						if ( '' === (string) ( $ctx['labelHtml'] ?? '' ) && '' === (string) ( $ctx['iconHtml'] ?? '' ) ) {
							continue;
						}
					} elseif ( 'logos' === $key ) {
						$ctx = self::flatten_logo_item( $ctx, (int) $index );
					} elseif ( 'slides' === $key ) {
						$order = array();
						if ( isset( $ctx['elementsOrder'] ) ) {
							$order = ElementsOrder::for_dom( $ctx['elementsOrder'] );
						} elseif ( isset( $attrs['elementsOrder'] ) ) {
							// Legacy block-level order (pre per-slide).
							$order = ElementsOrder::for_dom( $attrs['elementsOrder'] );
						}
						$ctx = self::flatten_slide_item( $ctx, (int) $index, $order );
					} else {
						$is_accordion_items = ( 'items' === $key )
							&& ( array_key_exists( 'accordionTitle', $item )
								|| array_key_exists( 'accordionContent', $item )
								|| array_key_exists( 'accordionTitle', $ctx )
								|| array_key_exists( 'accordionContent', $ctx ) );
						if ( 'tabs' === $key || $is_accordion_items ) {
							$ctx = array_merge(
								array(
									'icon'     => '',
									'iconSize' => '1.25rem',
								),
								$ctx
							);
						}
						$ctx = self::flatten_item_helpers( $ctx, (int) $index );
						if ( 'tabs' === $key || $is_accordion_items ) {
							$ctx['titleIconDisplay'] = self::resolve_title_icon_display(
								$attrs['cssVars'] ?? $attrs
							);
						}
						if ( 'tabs' === $key ) {
							$ctx['radioChecked'] = 0 === (int) $index ? ' checked' : '';
							$ctx['tabsId']       = (string) ( $attrs['tabsId'] ?? 'bm-tabs' );
						}
						if ( $is_accordion_items ) {
							$mode                 = self::accordion_open_mode( $attrs );
							$ctx['detailsOpen'] = ( 'all' === $mode || ( 'first' === $mode && 0 === (int) $index ) )
								? ' open'
								: '';
							$ctx['arrowClosedHtml'] = (string) ( $attrs['arrowClosedHtml'] ?? '' );
							$ctx['arrowOpenHtml']   = (string) ( $attrs['arrowOpenHtml'] ?? '' );
						}
					}
					// Nested repeaters (e.g. buttons/features inside cards/plans).
					$chunk = self::process_tokens( self::process_sections( $inner, $ctx ), $ctx );
					$out  .= $chunk;
				}
				return $out;
			},
			$markup
		) ?? $markup;
	}

	/**
	 * Flatten media helpers for logo repeater items.
	 *
	 * @param array<string, mixed> $item  Item.
	 * @param int                  $index Index.
	 * @return array<string, mixed>
	 */
	private static function flatten_logo_item( array $item, int $index ): array {
		$media = self::normalize_media(
			isset( $item['image'] ) && is_array( $item['image'] ) ? $item['image'] : array()
		);
		$item['image']      = $media;
		$item['image.url']  = $media['url'];
		$item['image.alt']  = $media['alt'];
		$item['@index']     = (string) $index;
		return $item;
	}

	/**
	 * Flatten nested media / link objects on generic repeater items (cards, plans, …).
	 *
	 * @param array<string, mixed> $item  Item.
	 * @param int                  $index Index.
	 * @return array<string, mixed>
	 */
	private static function flatten_item_helpers( array $item, int $index ): array {
		$item['@index'] = (string) $index;

		if ( isset( $item['image'] ) && is_array( $item['image'] ) ) {
			$media             = self::normalize_media( $item['image'] );
			$item['image']     = $media;
			$item['image.url'] = $media['url'];
			$item['image.alt'] = $media['alt'];
		}

		// Legacy single link → buttons[] so nested {{#buttons}} markup works.
		$has_buttons = isset( $item['buttons'] ) && is_array( $item['buttons'] ) && $item['buttons'] && ! self::is_assoc( $item['buttons'] );
		$has_link    = isset( $item['link'] ) && is_array( $item['link'] ) && self::is_assoc( $item['link'] );
		if ( ! $has_buttons && $has_link ) {
			$item['buttons'] = array( array( 'link' => $item['link'] ) );
			unset( $item['link'] );
			$has_link = false;
		}

		if ( isset( $item['button'] ) && is_array( $item['button'] ) && self::is_assoc( $item['button'] ) ) {
			$link                      = self::normalize_link( $item['button'] );
			$item['button']            = $link;
			$item['button.title']      = $link['title'];
			$item['button.url']        = $link['url'];
			$item['button.target']     = $link['opensInNewTab'] ? '_blank' : '';
			$item['button.rel']        = $link['opensInNewTab'] ? 'noopener noreferrer' : '';
			$item['button.targetAttr'] = $link['opensInNewTab'] ? ' target="_blank" rel="noopener noreferrer"' : '';
		}

		if ( isset( $item['link'] ) && is_array( $item['link'] ) && self::is_assoc( $item['link'] ) ) {
			$link                    = self::normalize_link( $item['link'] );
			$item['link']            = $link;
			$item['link.title']      = $link['title'];
			$item['link.url']        = $link['url'];
			$item['link.target']     = $link['opensInNewTab'] ? '_blank' : '';
			$item['link.rel']        = $link['opensInNewTab'] ? 'noopener noreferrer' : '';
			$item['link.targetAttr'] = $link['opensInNewTab'] ? ' target="_blank" rel="noopener noreferrer"' : '';
		}

		if ( array_key_exists( 'icon', $item ) ) {
			$ref      = IconRegistry::normalize_ref( trim( (string) ( $item['icon'] ?? '' ) ) );
			$raw_size = trim( (string) ( $item['iconSize'] ?? '' ) );
			$safe_size = preg_match( '/^[\d.]+\s*(px|rem|em|%|ch|vh|vw)?$/i', $raw_size )
				? $raw_size
				: '';
			$font_size = $safe_size
				? $safe_size
				: 'var(--bm-icon-font-size, var(--bm-card-icon-size, 2rem))';
			$raw_color  = isset( $item['iconColor'] ) ? trim( (string) $item['iconColor'] ) : '';
			$safe_color = '';
			if ( '' !== $raw_color && preg_match( '/^(#([0-9a-f]{3,8})|rgba?\([^)]+\)|hsla?\([^)]+\)|currentcolor|inherit|transparent|[a-z]+)$/i', $raw_color ) ) {
				$safe_color = $raw_color;
			}
			$icon_color_css = '' !== $safe_color
				? $safe_color
				: 'var(--bm-icon-color, var(--bm-title-icon-color, var(--bm-card-icon-color, currentColor)))';
			$icon_style = 'font-size:' . $font_size . ';width:1em;height:1em;color:' . $icon_color_css;
			$item['icon']             = $ref;
			$item['iconSize']         = $safe_size ? $safe_size : ( $raw_size !== '' ? $raw_size : '1.25rem' );
			$item['iconColor']        = '' !== $safe_color ? $safe_color : $raw_color;
			$item['iconHtml']         = $ref
				? IconRegistry::render_html( $ref, $icon_style, 'bm-card__icon' )
				: '';
			$item['iconWrapperStyle'] = $ref ? '' : 'display:none;';
		}

		if ( array_key_exists( 'features', $item ) ) {
			$item['features'] = self::normalize_features_html( $item['features'] );
		}

		return $item;
	}

	/**
	 * Normalize plan features to HTML list items (legacy repeater → string).
	 *
	 * @param mixed $features Features value.
	 */
	public static function normalize_features_html( $features ): string {
		if ( is_string( $features ) ) {
			$raw = trim( $features );
			if ( '' === $raw ) {
				return '<li><br></li>';
			}
			if ( preg_match( '/<li\b/i', $raw ) ) {
				$stripped = preg_replace(
					'/<span\b[^>]*\bbm-pricing__feature-check\b[^>]*>[\s\S]*?<\/span>/i',
					'',
					$raw
				);
				return is_string( $stripped ) ? $stripped : $raw;
			}
			return '<li>' . esc_html( $raw ) . '</li>';
		}

		if ( ! is_array( $features ) || ! $features ) {
			return '<li><br></li>';
		}

		$parts = array();
		foreach ( $features as $item ) {
			if ( is_string( $item ) ) {
				$parts[] = '<li>' . esc_html( $item ) . '</li>';
				continue;
			}
			if ( is_array( $item ) ) {
				$text = '';
				if ( isset( $item['text'] ) ) {
					$text = (string) $item['text'];
				} elseif ( isset( $item['title'] ) ) {
					$text = (string) $item['title'];
				}
				$parts[] = '<li>' . esc_html( $text ) . '</li>';
				continue;
			}
			$parts[] = '<li><br></li>';
		}
		return implode( '', $parts );
	}

	/**
	 * Flatten link + style helpers for button repeater items.
	 *
	 * @param array<string, mixed> $item  Item.
	 * @param int                  $index Index.
	 * @return array<string, mixed>
	 */
	private static function flatten_button_item( array $item, int $index ): array {
		$link = self::normalize_link( isset( $item['link'] ) && is_array( $item['link'] ) ? $item['link'] : array() );
		$item['link']           = $link;
		$item['link.title']     = $link['title'];
		$item['link.url']       = $link['url'];
		$item['link.target']    = $link['opensInNewTab'] ? '_blank' : '';
		$item['link.rel']       = $link['opensInNewTab'] ? 'noopener noreferrer' : '';
		$item['link.targetAttr'] = $link['opensInNewTab'] ? ' target="_blank" rel="noopener noreferrer"' : '';

		$styles = self::resolve_button_item_styles( $item );
		$btn_parts = array(
			sprintf( '--bm-btn-bg:%s', esc_attr( $styles['bg'] ) ),
			sprintf( '--bm-btn-color:%s', esc_attr( $styles['color'] ) ),
			sprintf( '--bm-btn-font:%s', esc_attr( $styles['fontFamily'] ?: 'inherit' ) ),
			sprintf( '--bm-btn-font-size:%s', esc_attr( ! empty( $styles['fontSize'] ) ? $styles['fontSize'] : '1rem' ) ),
			sprintf( '--bm-btn-bg-hover:%s', esc_attr( $styles['bgHover'] ) ),
			sprintf( '--bm-btn-color-hover:%s', esc_attr( $styles['colorHover'] ) ),
			sprintf( '--bm-btn-pad-y:%s', esc_attr( $styles['padY'] ) ),
			sprintf( '--bm-btn-pad-x:%s', esc_attr( $styles['padX'] ) ),
			sprintf( '--bm-btn-radius:%s', esc_attr( $styles['borderRadius'] ) ),
			sprintf( '--bm-btn-border-width:%s', esc_attr( $styles['borderWidth'] ) ),
			sprintf( '--bm-btn-border-style:%s', esc_attr( $styles['borderStyle'] ) ),
			sprintf( '--bm-btn-border-color:%s', esc_attr( $styles['borderColor'] ) ),
			sprintf( '--bm-btn-border-top-width:%s', esc_attr( $styles['borderTopWidth'] ) ),
			sprintf( '--bm-btn-border-right-width:%s', esc_attr( $styles['borderRightWidth'] ) ),
			sprintf( '--bm-btn-border-bottom-width:%s', esc_attr( $styles['borderBottomWidth'] ) ),
			sprintf( '--bm-btn-border-left-width:%s', esc_attr( $styles['borderLeftWidth'] ) ),
			sprintf( '--bm-btn-shadow:%s', esc_attr( $styles['shadow'] ?: 'none' ) ),
			// Literal box-shadow — core safecss historically drops --*-shadow
			// custom props that contain rgba(), leaving var(--bm-btn-shadow) empty.
			sprintf( 'box-shadow:%s', esc_attr( $styles['shadow'] ?: 'none' ) ),
		);
		foreach (
			array(
				'borderTopLeftRadius'     => '--bm-btn-border-top-left-radius',
				'borderTopRightRadius'    => '--bm-btn-border-top-right-radius',
				'borderBottomRightRadius' => '--bm-btn-border-bottom-right-radius',
				'borderBottomLeftRadius'  => '--bm-btn-border-bottom-left-radius',
			) as $prop => $css_var
		) {
			$val = isset( $styles[ $prop ] ) ? trim( (string) $styles[ $prop ] ) : '';
			if ( '' !== $val ) {
				$btn_parts[] = sprintf( '%s:%s', $css_var, esc_attr( $val ) );
			}
		}
		$item['btnStyle'] = implode( ';', $btn_parts ) . ';';
		$item['btnHoverBg']     = $styles['bgHover'];
		$item['btnHoverColor']  = $styles['colorHover'];
		$item['btnHoverShadow'] = $styles['shadow'];
		$item['btnIndex']       = (string) $index;

		$raw_icon = isset( $styles['icon'] ) ? trim( (string) $styles['icon'] ) : '';
		$ref      = IconRegistry::normalize_ref( $raw_icon );
		$icon_pos = ( isset( $styles['iconPosition'] ) && 'right' === strtolower( (string) $styles['iconPosition'] ) )
			? 'right'
			: 'left';
		$raw_size = isset( $styles['iconSize'] ) ? trim( (string) $styles['iconSize'] ) : '';
		$icon_size = '20px';
		if ( '' !== $raw_size ) {
			if ( preg_match( '/^-?[\d.]+$/', $raw_size ) ) {
				$icon_size = $raw_size . 'px';
			} elseif ( preg_match( '/^-?[\d.]+(px|rem|em|%)$/i', $raw_size ) ) {
				$icon_size = $raw_size;
			}
		}
		$raw_color = isset( $styles['iconColor'] ) ? trim( (string) $styles['iconColor'] ) : '';
		$icon_color = '';
		if ( '' !== $raw_color && preg_match( '/^(#([0-9a-f]{3,8})|rgba?\([^)]+\)|hsla?\([^)]+\)|currentcolor|inherit|transparent|[a-z]+)$/i', $raw_color ) ) {
			$icon_color = $raw_color;
		}
		$icon_style = 'font-size:' . $icon_size . ';width:1em;height:1em';
		if ( '' !== $icon_color ) {
			$icon_style .= ';color:' . $icon_color;
		}

		$item['icon']         = $ref;
		$item['iconPosition'] = $icon_pos;
		$item['btnIconFlex']  = ( $ref && 'right' === $icon_pos ) ? 'row-reverse' : 'row';
		$item['iconHtml']     = $ref
			? IconRegistry::render_html( $ref, $icon_style, 'bm-hero__btn-icon' )
			: '';
		$title                = trim( (string) $link['title'] );
		$item['labelHtml']    = '' !== $title
			? '<span class="bm-hero__btn-label">' . esc_html( $title ) . '</span>'
			: '';
		$item['@index']       = (string) $index;

		return $item;
	}

	/**
	 * Placement-only hide for Gutenberg / frontend (not My Blocks).
	 *
	 * @param array<string, mixed> $item Button item.
	 */
	public static function is_button_item_hidden( array $item ): bool {
		if ( ! array_key_exists( 'hidden', $item ) ) {
			return false;
		}
		$v = $item['hidden'];
		return true === $v || 1 === $v || '1' === $v || 'true' === $v;
	}

	/**
	 * Whether a button repeater item has a visible label or icon.
	 *
	 * @param array<string, mixed> $item Button item.
	 */
	public static function button_item_has_content( array $item ): bool {
		if ( self::is_button_item_hidden( $item ) ) {
			return false;
		}
		$title = '';
		if ( isset( $item['link'] ) && is_array( $item['link'] ) ) {
			$title = trim( (string) ( $item['link']['title'] ?? '' ) );
		}
		$raw_icon = isset( $item['icon'] ) ? trim( (string) $item['icon'] ) : '';
		$icon     = preg_match( '/^[a-z0-9-]+$/i', $raw_icon ) ? $raw_icon : '';
		if ( '' !== $title || '' !== $icon ) {
			return true;
		}
		$mode = isset( $item['styleMode'] ) ? strtolower( (string) $item['styleMode'] ) : '';
		$lib  = isset( $item['libraryBlockId'] ) ? (int) $item['libraryBlockId'] : 0;
		return 'library' === $mode && 0 !== $lib;
	}

	/**
	 * Resolve button surface styles (direct properties; library kept for legacy).
	 *
	 * @param array<string, mixed> $item Button item.
	 * @return array{bg: string, color: string, fontFamily: string, bgHover: string, colorHover: string, padY: string, padX: string, borderRadius: string, shadow: string}
	 */
	public static function resolve_button_item_styles( array $item ): array {
		$size_presets = array(
			'small'  => array( 'padY' => '0.4rem', 'padX' => '0.9rem' ),
			'medium' => array( 'padY' => '0.75rem', 'padX' => '1.25rem' ),
			'large'  => array( 'padY' => '1rem', 'padX' => '1.75rem' ),
		);
		$size = isset( $item['size'] ) ? (string) $item['size'] : 'medium';
		if ( ! isset( $size_presets[ $size ] ) && 'custom' !== $size ) {
			$size = 'medium';
		}
		$preset = $size_presets[ $size ] ?? $size_presets['medium'];

		$pad_y = isset( $item['padY'] ) ? trim( (string) $item['padY'] ) : '';
		$pad_x = isset( $item['padX'] ) ? trim( (string) $item['padX'] ) : '';
		if ( '' === $pad_y ) {
			$pad_y = $preset['padY'];
		}
		if ( '' === $pad_x ) {
			$pad_x = $preset['padX'];
		}

		$shadow = isset( $item['shadow'] ) ? trim( (string) $item['shadow'] ) : '';
		if ( '' === $shadow && isset( $item['shadowHover'] ) ) {
			$shadow = trim( (string) $item['shadowHover'] );
		}
		if ( '' === $shadow ) {
			$shadow = 'none';
		}

		$radius = isset( $item['borderRadius'] ) ? trim( (string) $item['borderRadius'] ) : '';
		if ( '' === $radius ) {
			$radius = '8px';
		}

		$border_width = isset( $item['borderWidth'] ) ? trim( (string) $item['borderWidth'] ) : '';
		if ( '' === $border_width ) {
			$border_width = '0px';
		}
		$border_style = isset( $item['borderStyle'] ) ? trim( (string) $item['borderStyle'] ) : '';
		if ( '' === $border_style ) {
			$border_style = 'none';
		}
		$border_color = isset( $item['borderColor'] ) ? (string) $item['borderColor'] : 'transparent';
		$border_mode  = isset( $item['borderWidthMode'] ) ? trim( (string) $item['borderWidthMode'] ) : '';
		if ( '' === $border_mode ) {
			$border_mode = 'linked';
		}

		$pick_side = static function ( $key, $default = '0' ) use ( $item ): string {
			if ( ! isset( $item[ $key ] ) ) {
				return $default;
			}
			return (string) $item[ $key ];
		};
		$pick_corner = static function ( $key ) use ( $item ): string {
			if ( ! isset( $item[ $key ] ) ) {
				return '';
			}
			return trim( (string) $item[ $key ] );
		};

		$custom = array(
			'bg'                      => (string) ( $item['bg'] ?? '#ffffff' ),
			'color'                   => (string) ( $item['color'] ?? '#3525cd' ),
			'fontFamily'              => (string) ( $item['fontFamily'] ?? '' ),
			'fontSize'                => (string) ( $item['fontSize'] ?? '' ),
			'bgHover'                 => (string) ( $item['bgHover'] ?? '#f2f3ff' ),
			'colorHover'              => (string) ( $item['colorHover'] ?? '#3525cd' ),
			'padY'                    => $pad_y,
			'padX'                    => $pad_x,
			'borderRadius'            => $radius,
			'borderWidth'             => $border_width,
			'borderStyle'             => $border_style,
			'borderColor'             => $border_color,
			'borderWidthMode'         => $border_mode,
			'borderTopWidth'          => $pick_side( 'borderTopWidth' ),
			'borderRightWidth'        => $pick_side( 'borderRightWidth' ),
			'borderBottomWidth'       => $pick_side( 'borderBottomWidth' ),
			'borderLeftWidth'         => $pick_side( 'borderLeftWidth' ),
			'borderTopLeftRadius'     => $pick_corner( 'borderTopLeftRadius' ),
			'borderTopRightRadius'    => $pick_corner( 'borderTopRightRadius' ),
			'borderBottomRightRadius' => $pick_corner( 'borderBottomRightRadius' ),
			'borderBottomLeftRadius'  => $pick_corner( 'borderBottomLeftRadius' ),
			'shadow'                  => $shadow,
			'icon'                    => (string) ( $item['icon'] ?? '' ),
			'iconPosition'            => ( isset( $item['iconPosition'] ) && 'right' === strtolower( (string) $item['iconPosition'] ) ) ? 'right' : 'left',
			'iconSize'                => (string) ( $item['iconSize'] ?? '20px' ),
			'iconColor'               => (string) ( $item['iconColor'] ?? '' ),
		);

		if ( ( $item['styleMode'] ?? 'custom' ) !== 'library' ) {
			return $custom;
		}

		$id = isset( $item['libraryBlockId'] ) ? (int) $item['libraryBlockId'] : 0;
		if ( 0 === $id ) {
			return $custom;
		}

		$lib = self::load_library_button_styles( $id );
		if ( ! $lib ) {
			return $custom;
		}

		// Catalog owns Design while Style reference is linked.
		// Icon glyph + iconPosition stay on the placement (content), not My Blocks.
		$placement_icon = isset( $item['icon'] ) ? trim( (string) $item['icon'] ) : '';
		$lib_icon       = isset( $lib['icon'] ) ? trim( (string) $lib['icon'] ) : '';
		$placement_pos  = isset( $item['iconPosition'] ) ? strtolower( (string) $item['iconPosition'] ) : '';
		$lib_pick       = static function ( string $key, string $fallback ) use ( $lib ): string {
			if ( ! isset( $lib[ $key ] ) ) {
				return $fallback;
			}
			$val = trim( (string) $lib[ $key ] );
			return '' !== $val ? $val : $fallback;
		};
		return array(
			'bg'                      => (string) ( $lib['bg'] ?? $custom['bg'] ),
			'color'                   => (string) ( $lib['color'] ?? $custom['color'] ),
			'fontFamily'              => (string) ( $lib['fontFamily'] ?? $custom['fontFamily'] ),
			'fontSize'                => ! empty( $lib['fontSize'] ) ? (string) $lib['fontSize'] : $custom['fontSize'],
			'bgHover'                 => (string) ( $lib['bgHover'] ?? $custom['bgHover'] ),
			'colorHover'              => (string) ( $lib['colorHover'] ?? $custom['colorHover'] ),
			'padY'                    => ! empty( $lib['padY'] ) ? (string) $lib['padY'] : $custom['padY'],
			'padX'                    => ! empty( $lib['padX'] ) ? (string) $lib['padX'] : $custom['padX'],
			'borderRadius'            => ! empty( $lib['borderRadius'] ) ? (string) $lib['borderRadius'] : $custom['borderRadius'],
			'borderWidth'             => $lib_pick( 'borderWidth', $custom['borderWidth'] ),
			'borderStyle'             => $lib_pick( 'borderStyle', $custom['borderStyle'] ),
			'borderColor'             => isset( $lib['borderColor'] ) ? (string) $lib['borderColor'] : $custom['borderColor'],
			'borderWidthMode'         => $lib_pick( 'borderWidthMode', $custom['borderWidthMode'] ),
			'borderTopWidth'          => isset( $lib['borderTopWidth'] ) ? (string) $lib['borderTopWidth'] : $custom['borderTopWidth'],
			'borderRightWidth'        => isset( $lib['borderRightWidth'] ) ? (string) $lib['borderRightWidth'] : $custom['borderRightWidth'],
			'borderBottomWidth'       => isset( $lib['borderBottomWidth'] ) ? (string) $lib['borderBottomWidth'] : $custom['borderBottomWidth'],
			'borderLeftWidth'         => isset( $lib['borderLeftWidth'] ) ? (string) $lib['borderLeftWidth'] : $custom['borderLeftWidth'],
			'borderTopLeftRadius'     => isset( $lib['borderTopLeftRadius'] ) ? (string) $lib['borderTopLeftRadius'] : $custom['borderTopLeftRadius'],
			'borderTopRightRadius'    => isset( $lib['borderTopRightRadius'] ) ? (string) $lib['borderTopRightRadius'] : $custom['borderTopRightRadius'],
			'borderBottomRightRadius' => isset( $lib['borderBottomRightRadius'] ) ? (string) $lib['borderBottomRightRadius'] : $custom['borderBottomRightRadius'],
			'borderBottomLeftRadius'  => isset( $lib['borderBottomLeftRadius'] ) ? (string) $lib['borderBottomLeftRadius'] : $custom['borderBottomLeftRadius'],
			'shadow'                  => ! empty( $lib['shadow'] ) ? (string) $lib['shadow'] : $custom['shadow'],
			'icon'                    => '' !== $placement_icon ? $placement_icon : $lib_icon,
			'iconPosition'            => 'right' === $placement_pos ? 'right' : ( '' !== $placement_pos ? 'left' : $custom['iconPosition'] ),
			'iconSize'                => ! empty( $lib['iconSize'] ) ? (string) $lib['iconSize'] : $custom['iconSize'],
			'iconColor'               => isset( $lib['iconColor'] ) ? (string) $lib['iconColor'] : $custom['iconColor'],
		);
	}

	/**
	 * Map button css_vars + default_attrs into the hero button style bag.
	 *
	 * @param array<string, mixed> $css_vars Raw or nested css vars.
	 * @param array<string, mixed> $attrs    Default attrs (icon, …).
	 * @return array<string, string>
	 */
	public static function button_styles_from_vars_attrs( array $css_vars, array $attrs = array() ): array {
		$vars = CssVars::effective( $css_vars, 'desktop' );
		return array(
			'bg'                      => (string) ( $vars['--bm-label-bg'] ?? $vars['--bm-accent'] ?? '' ),
			'color'                   => (string) ( $vars['--bm-label-color'] ?? $vars['--bm-text'] ?? '' ),
			'fontFamily'              => (string) ( $vars['--bm-label-font-family'] ?? $vars['--bm-font-family'] ?? '' ),
			'fontSize'                => (string) ( $vars['--bm-label-font-size'] ?? '' ),
			'bgHover'                 => (string) ( $vars['--bm-label-bg-hover'] ?? '' ),
			'colorHover'              => (string) ( $vars['--bm-label-color-hover'] ?? '' ),
			'size'                    => (string) ( $vars['--bm-label-size'] ?? 'medium' ),
			'padY'                    => (string) ( $vars['--bm-label-pad-y'] ?? '' ),
			'padX'                    => (string) ( $vars['--bm-label-pad-x'] ?? '' ),
			'borderRadius'            => (string) ( $vars['--bm-label-border-radius'] ?? '' ),
			'borderWidth'             => (string) ( $vars['--bm-label-border-width'] ?? '' ),
			'borderStyle'             => (string) ( $vars['--bm-label-border-style'] ?? '' ),
			'borderColor'             => (string) ( $vars['--bm-label-border-color'] ?? '' ),
			'borderWidthMode'         => (string) ( $vars['--bm-label-border-width-mode'] ?? '' ),
			'borderTopWidth'          => (string) ( $vars['--bm-label-border-top-width'] ?? '' ),
			'borderRightWidth'        => (string) ( $vars['--bm-label-border-right-width'] ?? '' ),
			'borderBottomWidth'       => (string) ( $vars['--bm-label-border-bottom-width'] ?? '' ),
			'borderLeftWidth'         => (string) ( $vars['--bm-label-border-left-width'] ?? '' ),
			'borderTopLeftRadius'     => (string) ( $vars['--bm-label-border-top-left-radius'] ?? '' ),
			'borderTopRightRadius'    => (string) ( $vars['--bm-label-border-top-right-radius'] ?? '' ),
			'borderBottomRightRadius' => (string) ( $vars['--bm-label-border-bottom-right-radius'] ?? '' ),
			'borderBottomLeftRadius'  => (string) ( $vars['--bm-label-border-bottom-left-radius'] ?? '' ),
			'shadow'                  => (string) ( $vars['--bm-label-shadow'] ?? $vars['--bm-label-shadow-hover'] ?? '' ),
			'shadowPreset'            => (string) ( $vars['--bm-label-shadow-preset'] ?? 'none' ),
			'shadowColor'             => (string) ( $vars['--bm-label-shadow-color'] ?? '' ),
			'icon'                    => (string) ( $attrs['icon'] ?? '' ),
			'iconPosition'            => (string) ( $attrs['iconPosition'] ?? 'left' ),
			'iconSize'                => (string) ( $vars['--bm-label-icon-size'] ?? '20px' ),
			'iconColor'               => (string) ( $vars['--bm-label-icon-color'] ?? '' ),
		);
	}

	/**
	 * @param int $library_id Positive user_blocks.id, or negative (-system_blocks.id).
	 * @return array<string, string>|null
	 */
	private static function load_library_button_styles( int $library_id ): ?array {
		if ( 0 === $library_id ) {
			return null;
		}

		global $wpdb;

		if ( $library_id < 0 ) {
			$table = Schema::table( 'system_blocks' );
			if ( ! $table ) {
				return null;
			}
			$row = $wpdb->get_row(
				$wpdb->prepare(
					'SELECT css_vars, default_attrs FROM %i WHERE id = %d LIMIT 1',
					$table,
					abs( $library_id )
				),
				ARRAY_A
			);
		} else {
			$table = Schema::table( 'user_blocks' );
			if ( ! $table ) {
				return null;
			}
			$row = $wpdb->get_row(
				$wpdb->prepare(
					'SELECT css_vars, default_attrs FROM %i WHERE id = %d LIMIT 1',
					$table,
					$library_id
				),
				ARRAY_A
			);
		}

		if ( ! $row ) {
			return null;
		}

		return self::button_styles_from_vars_attrs(
			json_decode( $row['css_vars'] ?: '{}', true ) ?: array(),
			json_decode( $row['default_attrs'] ?: '{}', true ) ?: array()
		);
	}

	/**
	 * Replace {{token}} and {{a.b}} paths.
	 *
	 * @param string               $markup Markup.
	 * @param array<string, mixed> $attrs  Context.
	 */
	private static function process_tokens( string $markup, array $attrs ): string {
		$lookup = array();
		foreach ( $attrs as $key => $value ) {
			$lookup[ strtolower( (string) $key ) ] = $value;
		}

		return preg_replace_callback(
			'/\{\{\s*([a-zA-Z0-9_@]+(?:\.[a-zA-Z0-9_]+)?)\s*\}\}/',
			static function ( $m ) use ( $attrs, $lookup ) {
				$path = $m[1];
				$val  = self::resolve_path( $path, $attrs, $lookup );
				return self::stringify_token( $path, $val );
			},
			$markup
		) ?? '';
	}

	/**
	 * @param string               $path   Token path.
	 * @param array<string, mixed> $attrs  Attrs.
	 * @param array<string, mixed> $lookup Lowercase map.
	 * @return mixed
	 */
	private static function resolve_path( string $path, array $attrs, array $lookup ) {
		if ( array_key_exists( $path, $attrs ) ) {
			return $attrs[ $path ];
		}
		$lower = strtolower( $path );
		if ( array_key_exists( $lower, $lookup ) ) {
			return $lookup[ $lower ];
		}
		if ( false !== strpos( $path, '.' ) ) {
			$parts = explode( '.', $path, 2 );
			$root  = $parts[0];
			$rest  = $parts[1];
			$base  = $attrs[ $root ] ?? $lookup[ strtolower( $root ) ] ?? null;
			if ( is_array( $base ) && array_key_exists( $rest, $base ) ) {
				return $base[ $rest ];
			}
			// Flattened keys like link.title already set on context.
			if ( array_key_exists( $path, $attrs ) ) {
				return $attrs[ $path ];
			}
		}
		return null;
	}

	/**
	 * @param string $path Token path.
	 * @param mixed  $val  Value.
	 */
	private static function stringify_token( string $path, $val ): string {
		if ( null === $val || is_array( $val ) ) {
			if ( is_array( $val ) ) {
				// Bare {{link}} → title; {{media}} → url.
				if ( isset( $val['url'] ) && isset( $val['title'] ) ) {
					return esc_html( (string) $val['title'] );
				}
				if ( isset( $val['url'] ) ) {
					return esc_url( (string) $val['url'] );
				}
			}
			return '';
		}

		$str = (string) $val;

		if ( 'link.targetAttr' === $path || false !== strpos( $path, 'targetAttr' ) || 'radioChecked' === $path || 'detailsOpen' === $path ) {
			return $str; // Already built safe attr string.
		}

		if (
			false !== strpos( $path, 'url' ) ||
			false !== strpos( strtolower( $path ), '.url' ) ||
			preg_match( '/url$/i', $path )
		) {
			return esc_url( $str );
		}

		if (
			in_array( $path, array( 'headerTag', 'subheaderTag' ), true )
			|| preg_match( '/(?:^|\.)[a-zA-Z0-9_]*[a-z0-9]Tag$/', $path )
		) {
			return self::sanitize_tag( $str, $path );
		}

		if ( false !== strpos( $path, 'btnStyle' ) || false !== strpos( $path, 'Style' ) || 'iconHtml' === $path || 'iconWrapperStyle' === $path || 'labelHtml' === $path || 'titleIconDisplay' === $path || 'accordionMultiple' === $path || 'accordionArrowCustomClass' === $path || 'accordionArrowStyleClass' === $path || 'arrowClosedHtml' === $path || 'arrowOpenHtml' === $path || 'slideHtml' === $path || 'sliderConfigAttr' === $path || 'sliderEffectClass' === $path || 'sliderArrowSizeClass' === $path || 'sliderArrowBehaviorClass' === $path ) {
			return $str; // Pre-escaped style / icon / label / slide HTML fragment.
		}

		if ( preg_match( '/\.html$/i', $path ) ) {
			return $str; // Trusted media HTML from media_to_html().
		}

		if ( false !== strpos( $path, 'rel' ) || false !== strpos( $path, 'target' ) ) {
			return esc_attr( $str );
		}

		if ( 'header' === $path || preg_match( '/(^|\.)header$/i', $path ) ) {
			return AllowedHtml::sanitize( $str, AllowedHtml::TITLE_COMMANDS );
		}
		if ( 'subheader' === $path || preg_match( '/(^|\.)subheader$/i', $path ) ) {
			return AllowedHtml::sanitize( $str, null );
		}

		return wp_kses_post( $str );
	}

	/**
	 * @param string $tag  Candidate tag.
	 * @param string $path Token path.
	 */
	private static function sanitize_tag( string $tag, string $path ): string {
		$tag = strtolower( preg_replace( '/[^a-z0-9]/i', '', $tag ) ?? '' );
		$allow = false !== stripos( $path, 'sub' ) ? self::SUB_TAGS : self::HEADING_TAGS;
		return in_array( $tag, $allow, true ) ? $tag : 'div';
	}

	/**
	 * Slider options → HTML-escaped JSON for data-bm-slider.
	 *
	 * @param array<string, mixed> $attrs Attrs.
	 */
	public static function slider_config_attr( array $attrs ): string {
		$effect = strtolower( (string) ( $attrs['effect'] ?? 'slide' ) );
		if ( 'loop' === $effect || 'rewind' === $effect ) {
			$effect = 'slide';
		}
		$allowed = array( 'slide', 'fade', 'zoom', 'fade-up', 'fade-down', 'blur' );
		if ( ! in_array( $effect, $allowed, true ) ) {
			$effect = 'slide';
		}
		$type = 'slide' === $effect ? 'loop' : 'fade';
		$config = array(
			'type'         => $type,
			'effect'       => $effect,
			'arrowSize'    => self::resolve_slider_arrow_size( $attrs['arrowSize'] ?? 'md' ),
			'arrowBehavior'=> self::resolve_slider_arrow_behavior( $attrs['arrowBehavior'] ?? 'fixed' ),
			'speed'        => isset( $attrs['speed'] ) && is_numeric( $attrs['speed'] ) ? (int) $attrs['speed'] : 400,
			'autoplay'     => self::truthy( $attrs['autoplay'] ?? false ),
			'interval'     => isset( $attrs['autoplayInterval'] ) && is_numeric( $attrs['autoplayInterval'] )
				? (int) $attrs['autoplayInterval']
				: ( isset( $attrs['interval'] ) && is_numeric( $attrs['interval'] ) ? (int) $attrs['interval'] : 4000 ),
			'arrows'       => self::truthy( $attrs['enableArrow'] ?? $attrs['arrows'] ?? true, true ),
			'pagination'   => self::truthy( $attrs['enableDots'] ?? $attrs['pagination'] ?? true, true ),
			'drag'         => self::truthy( $attrs['drag'] ?? $attrs['swipe'] ?? true, true ),
			'equalHeight'  => self::truthy( $attrs['equalHeight'] ?? true, true ),
			'rewind'       => 'loop' !== $type,
		);
		$json = wp_json_encode( $config );
		return is_string( $json ) ? esc_attr( $json ) : '';
	}

	/**
	 * CSS class for the slider animation effect.
	 *
	 * @param array<string, mixed> $attrs Attrs.
	 */
	public static function slider_effect_class( array $attrs ): string {
		$effect = strtolower( (string) ( $attrs['effect'] ?? 'slide' ) );
		if ( 'loop' === $effect || 'rewind' === $effect ) {
			$effect = 'slide';
		}
		$allowed = array( 'slide', 'fade', 'zoom', 'fade-up', 'fade-down', 'blur' );
		if ( ! in_array( $effect, $allowed, true ) ) {
			$effect = 'slide';
		}
		return 'bm-slider--effect-' . $effect;
	}

	/**
	 * Normalize arrow size slug.
	 *
	 * @param mixed $size Size.
	 */
	public static function resolve_slider_arrow_size( $size ): string {
		$raw = strtolower( (string) ( $size ?? 'md' ) );
		$allowed = array( 'sm', 'md', 'lg', 'xl', 'xxl', 'xxxl' );
		return in_array( $raw, $allowed, true ) ? $raw : 'md';
	}

	/**
	 * CSS class for slider arrow size.
	 *
	 * @param array<string, mixed> $attrs Attrs.
	 */
	public static function slider_arrow_size_class( array $attrs ): string {
		return 'bm-slider--arrow-' . self::resolve_slider_arrow_size( $attrs['arrowSize'] ?? 'md' );
	}

	/**
	 * Normalize arrow behavior slug.
	 *
	 * @param mixed $behavior Behavior.
	 */
	public static function resolve_slider_arrow_behavior( $behavior ): string {
		$raw = strtolower( (string) ( $behavior ?? 'fixed' ) );
		$allowed = array( 'fixed', 'hover' );
		return in_array( $raw, $allowed, true ) ? $raw : 'fixed';
	}

	/**
	 * CSS class for slider arrow behavior.
	 *
	 * @param array<string, mixed> $attrs Attrs.
	 */
	public static function slider_arrow_behavior_class( array $attrs ): string {
		return 'bm-slider--arrows-' . self::resolve_slider_arrow_behavior( $attrs['arrowBehavior'] ?? 'fixed' );
	}

	/**
	 * Resolve slide content stack order for a layout.
	 *
	 * @param array<int, mixed> $order  Requested order keys.
	 * @param string            $layout Slide layout.
	 * @return array<int, string>
	 */
	private static function resolve_slide_content_order( array $order, string $layout = 'simple' ): array {
		$layout = strtolower( $layout );
		if ( 'video-background' === $layout || 'image-left' === $layout || 'image-right' === $layout ) {
			// Column / video layouts: media is outside the content stack (not reorderable).
			$defaults = array( 'badge', 'header', 'subheader', 'logos', 'buttons' );
		} else {
			$defaults = array( 'badge', 'header', 'subheader', 'image', 'logos', 'buttons' );
		}
		$next = array();
		foreach ( $order as $key ) {
			if ( is_string( $key ) && in_array( $key, $defaults, true ) && ! in_array( $key, $next, true ) ) {
				$next[] = $key;
			}
		}
		if ( ! $next ) {
			$next = $defaults;
		} else {
			foreach ( $defaults as $key ) {
				if ( ! in_array( $key, $next, true ) ) {
					$next[] = $key;
				}
			}
		}
		return $next;
	}

	/**
	 * Flatten one hero slider slide into tokens (includes raw slideHtml).
	 *
	 * @param array<string, mixed> $item  Slide.
	 * @param int                  $index Index.
	 * @param array<int, mixed>    $order Content element order from block attrs.
	 * @return array<string, mixed>
	 */
	private static function flatten_slide_item( array $item, int $index, array $order = array() ): array {
		$layout        = strtolower( (string) ( $item['layout'] ?? 'simple' ) );
		$badge         = trim( (string) ( $item['badge'] ?? '' ) );
		$header        = trim( (string) ( $item['header'] ?? '' ) );
		$subheader     = trim( (string) ( $item['subheader'] ?? '' ) );
		$header_tag    = self::sanitize_heading_tag( (string) ( $item['headerTag'] ?? 'h2' ), 'h2' );
		$subheader_tag = self::sanitize_sub_tag( (string) ( $item['subheaderTag'] ?? 'div' ), 'div' );

		$image      = isset( $item['image'] ) && is_array( $item['image'] ) ? $item['image'] : array();
		$video      = isset( $item['video'] ) && is_array( $item['video'] ) ? $item['video'] : array();
		$image_html = self::media_to_html( $image, 'image' );
		$video_url  = isset( $video['url'] ) ? self::resolve_media_url( trim( (string) $video['url'] ) ) : '';

		$buttons = isset( $item['buttons'] ) && is_array( $item['buttons'] ) && ! self::is_assoc( $item['buttons'] )
			? $item['buttons']
			: array();
		$logos   = isset( $item['logos'] ) && is_array( $item['logos'] ) && ! self::is_assoc( $item['logos'] )
			? $item['logos']
			: array();

		// Migrate legacy CTA fields into a single button.
		if ( ! $buttons ) {
			$cta_label = trim( (string) ( $item['ctaLabel'] ?? '' ) );
			$cta_url   = trim( (string) ( $item['ctaUrl'] ?? '#' ) );
			if ( '' === $cta_url ) {
				$cta_url = '#';
			}
			if ( '' !== $cta_label ) {
				$buttons = array(
					array(
						'link'         => array(
							'title'         => $cta_label,
							'url'           => $cta_url,
							'opensInNewTab' => false,
						),
						'bg'           => '#ffffff',
						'color'        => '#3525cd',
						'bgHover'      => '#f2f3ff',
						'colorHover'   => '#3525cd',
						'size'         => 'medium',
						'padY'         => '0.75rem',
						'padX'         => '1.25rem',
						'borderRadius' => '8px',
						'shadow'       => '0 12px 28px rgba(19, 27, 46, 0.18)',
						'icon'         => '',
						'iconPosition' => 'left',
						'iconSize'     => '20px',
						'iconColor'    => '',
					),
				);
			}
		}

		$buttons_html = '';
		$btn_parts    = '';
		foreach ( $buttons as $bi => $raw_btn ) {
			if ( ! is_array( $raw_btn ) ) {
				continue;
			}
			if ( self::is_button_item_hidden( $raw_btn ) ) {
				continue;
			}
			$ctx = self::flatten_button_item( $raw_btn, (int) $bi );
			if ( '' === (string) ( $ctx['labelHtml'] ?? '' ) && '' === (string) ( $ctx['iconHtml'] ?? '' ) ) {
				continue;
			}
			$btn_parts .= sprintf(
				'<a class="bm-hero__btn" data-bm-btn-index="%1$s" href="%2$s"%3$s style="display:inline-flex;align-items:center;gap:0.45em;flex-direction:%4$s;text-decoration:none;border-style:var(--bm-btn-border-style, none);border-color:var(--bm-btn-border-color, transparent);border-top-width:var(--bm-btn-border-top-width, var(--bm-btn-border-width, 0));border-right-width:var(--bm-btn-border-right-width, var(--bm-btn-border-width, 0));border-bottom-width:var(--bm-btn-border-bottom-width, var(--bm-btn-border-width, 0));border-left-width:var(--bm-btn-border-left-width, var(--bm-btn-border-width, 0));border-top-left-radius:var(--bm-btn-border-top-left-radius, var(--bm-btn-radius, 8px));border-top-right-radius:var(--bm-btn-border-top-right-radius, var(--bm-btn-radius, 8px));border-bottom-right-radius:var(--bm-btn-border-bottom-right-radius, var(--bm-btn-radius, 8px));border-bottom-left-radius:var(--bm-btn-border-bottom-left-radius, var(--bm-btn-radius, 8px));padding:var(--bm-btn-pad-y, var(--bm-buttons-pad-y, 0.75rem)) var(--bm-btn-pad-x, var(--bm-buttons-pad-x, 1.25rem));box-shadow:var(--bm-btn-shadow, none);font-size:var(--bm-btn-font-size, 1rem);font-weight:600;%5$s">%6$s%7$s</a>',
				esc_attr( (string) $bi ),
				esc_url( (string) ( $ctx['link.url'] ?? '#' ) ),
				$ctx['link.targetAttr'] ?? '',
				esc_attr( (string) ( $ctx['btnIconFlex'] ?? 'row' ) ),
				$ctx['btnStyle'] ?? '',
				$ctx['iconHtml'] ?? '',
				$ctx['labelHtml'] ?? ''
			);
		}
		if ( '' !== $btn_parts ) {
			$buttons_html = '<div class="bm-hero__buttons" data-bm-el="buttons" style="display:flex;flex-wrap:wrap;justify-content:var(--bm-justify-content, var(--bm-align-items, center));align-items:center;gap:var(--bm-buttons-gap, 0.75rem);flex-direction:var(--bm-buttons-flex-direction, row);margin:var(--bm-buttons-margin-top, 0.5rem) 0 var(--bm-buttons-margin-bottom, 0);width:100%">' . $btn_parts . '</div>';
		}

		$logos_html = '';
		$logo_parts = '';
		foreach ( $logos as $logo ) {
			if ( ! is_array( $logo ) ) {
				continue;
			}
			$logo_img = isset( $logo['image'] ) && is_array( $logo['image'] ) ? $logo['image'] : array();
			$url      = isset( $logo_img['url'] ) ? trim( (string) $logo_img['url'] ) : '';
			if ( '' === $url ) {
				continue;
			}
			$url         = self::resolve_media_url( $url );
			$alt         = isset( $logo_img['alt'] ) ? (string) $logo_img['alt'] : '';
			$logo_parts .= '<img class="bm-hero__logo" src="' . esc_url( $url ) . '" alt="' . esc_attr( $alt ) . '" loading="lazy" decoding="async" style="display:block;height:var(--bm-logos-height, 2.75rem);width:auto;max-width:100%;object-fit:contain" />';
		}
		if ( '' !== $logo_parts ) {
			$logos_html = '<div class="bm-hero__logos" data-bm-el="logos" style="display:flex;flex-wrap:wrap;justify-content:var(--bm-justify-content, var(--bm-align-items, center));align-items:center;gap:var(--bm-logos-gap, 1.5rem);margin:var(--bm-logos-margin-top, 1rem) 0 var(--bm-logos-margin-bottom, 0);width:100%">' . $logo_parts . '</div>';
		}

		/*
		 * Simple layout: media frame (data-bm-el="image") goes directly in the
		 * content flex stack — same as main hero partials. Extra .bm-hero__media
		 * wrapping broke CSS order when the frame was nested (and nested a second
		 * data-bm-el). Column layouts keep media in the grid column instead.
		 */
		$fragments = array(
			'badge'     => '' !== $badge
				? '<span class="bm-hero__badge" data-bm-el="badge" style="display:inline-block;font-size:var(--bm-badge-font-size);font-weight:var(--bm-badge-font-weight);line-height:var(--bm-badge-line-height);letter-spacing:var(--bm-badge-letter-spacing);color:var(--bm-badge-color, var(--bm-text));background:var(--bm-badge-bg);margin-top:var(--bm-badge-margin-top, 0);margin-bottom:var(--bm-badge-margin-bottom, 0);padding:0.35rem 0.75rem;border-style:var(--bm-badge-border-style, none);border-color:var(--bm-badge-border-color, transparent);border-top-width:var(--bm-badge-border-top-width, var(--bm-badge-border-width, 0));border-right-width:var(--bm-badge-border-right-width, var(--bm-badge-border-width, 0));border-bottom-width:var(--bm-badge-border-bottom-width, var(--bm-badge-border-width, 0));border-left-width:var(--bm-badge-border-left-width, var(--bm-badge-border-width, 0));border-top-left-radius:var(--bm-badge-border-top-left-radius, var(--bm-badge-border-radius, 4px));border-top-right-radius:var(--bm-badge-border-top-right-radius, var(--bm-badge-border-radius, 4px));border-bottom-right-radius:var(--bm-badge-border-bottom-right-radius, var(--bm-badge-border-radius, 4px));border-bottom-left-radius:var(--bm-badge-border-bottom-left-radius, var(--bm-badge-border-radius, 4px));box-shadow:var(--bm-badge-shadow, none);box-sizing:border-box">' . esc_html( $badge ) . '</span>'
				: '',
			'header'    => '' !== $header
				? '<' . $header_tag . ' class="bm-hero__header" data-bm-el="header" style="font-size:var(--bm-header-font-size);font-weight:var(--bm-header-font-weight);line-height:var(--bm-header-line-height);color:var(--bm-header-color, var(--bm-text));margin:0;align-self:stretch;width:100%;box-sizing:border-box">' . AllowedHtml::sanitize( $header, AllowedHtml::TITLE_COMMANDS ) . '</' . $header_tag . '>'
				: '',
			'subheader' => '' !== $subheader
				? '<' . $subheader_tag . ' class="bm-hero__subheader" data-bm-el="subheader" style="font-size:var(--bm-subheader-font-size);font-weight:var(--bm-subheader-font-weight);line-height:var(--bm-subheader-line-height);color:var(--bm-subheader-color, var(--bm-text));margin:0;align-self:stretch;width:100%;box-sizing:border-box">' . AllowedHtml::sanitize( $subheader, null ) . '</' . $subheader_tag . '>'
				: '',
			'buttons'   => $buttons_html,
			'logos'     => $logos_html,
			'image'     => 'simple' === $layout ? $image_html : '',
		);

		$content = '';
		foreach ( self::resolve_slide_content_order( $order, $layout ) as $key ) {
			if ( ! empty( $fragments[ $key ] ) ) {
				$content .= $fragments[ $key ];
			}
		}

		$section_style = 'position:relative;overflow:visible;display:flex;flex-direction:column;justify-content:center;box-sizing:border-box;background-color:var(--bm-bg);background-image:var(--bm-bg-image, none),var(--bm-bg-gradient, none);background-size:cover;background-position:center;color:var(--bm-text);min-height:var(--bm-min-height, 28rem);padding-top:var(--bm-padding-top, var(--bm-padding-y));padding-right:var(--bm-padding-right, var(--bm-padding-x));padding-bottom:var(--bm-padding-bottom, var(--bm-padding-y));padding-left:var(--bm-padding-left, var(--bm-padding-x))';

		$slide_index_attr = ' data-bm-slide-index="' . esc_attr( (string) $index ) . '"';

		if ( 'video-background' === $layout ) {
			$video_tag = '' !== $video_url
				? '<video class="bm-hero__video" data-bm-el="video" src="' . esc_url( $video_url ) . '" autoplay muted loop playsinline aria-hidden="true" style="position:absolute;top:0;right:0;bottom:0;left:0;z-index:0;width:100%;height:100%;object-fit:cover;pointer-events:none"></video>'
				: '';
			$slide_html = '<section class="bm-hero bm-hero--video bm-hero--slide"' . $slide_index_attr . ' data-layout="video-background" style="' . esc_attr( $section_style ) . '">' . $video_tag . '<div class="bm-hero__overlay" aria-hidden="true" style="position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;background:var(--bm-overlay);pointer-events:none"></div><div class="bm-hero__inner" style="position:relative;z-index:1;display:flex;flex-direction:column;align-items:var(--bm-align-items, center);gap:var(--bm-gap);max-width:var(--bm-content-max-width, 48rem);width:100%;margin:var(--bm-inner-margin, 0 auto);text-align:var(--bm-text-align, center)">' . $content . '</div></section>';
		} elseif ( 'image-left' === $layout || 'image-right' === $layout ) {
			/*
			 * Match main hero column templates: image-left = media then content,
			 * image-right = content then media. Inline CSS `order` alone is not
			 * enough — wp_kses strips `order` on the frontend.
			 */
			$mod          = 'image-right' === $layout ? 'image-right' : 'image-left';
			$media_cell   = '<div class="bm-hero__media" style="order:var(--bm-media-order, 0);width:var(--bm-mf-media-width, 100%);max-width:var(--bm-mf-media-max-width, 100%);height:var(--bm-mf-media-height, auto);align-self:var(--bm-mf-media-align-self, auto);display:flex;flex-direction:column;align-items:var(--bm-mf-media-align-items, var(--bm-align-items, flex-start));margin-left:var(--bm-mf-margin-left, 0);margin-right:var(--bm-mf-margin-right, 0);box-sizing:border-box">' . $image_html . '</div>';
			$content_cell = '<div class="bm-hero__content" style="display:flex;flex-direction:column;justify-content:center;align-items:var(--bm-align-items, flex-start);gap:var(--bm-gap);text-align:var(--bm-text-align, left);order:var(--bm-content-order, 0)">' . $content . '</div>';
			$columns      = 'image-right' === $layout
				? $content_cell . $media_cell
				: $media_cell . $content_cell;
			$slide_html   = '<section class="bm-hero bm-hero--' . esc_attr( $mod ) . ' bm-hero--slide"' . $slide_index_attr . ' data-layout="' . esc_attr( $mod ) . '" style="' . esc_attr( $section_style ) . '"><div class="bm-hero__overlay" aria-hidden="true" style="position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;background:var(--bm-overlay);pointer-events:none"></div><div class="bm-hero__grid" style="position:relative;z-index:1;display:grid;grid-template-columns:var(--bm-grid-template-columns, minmax(0,1fr) minmax(0,1fr));gap:var(--bm-columns-gap, var(--bm-gap));align-items:var(--bm-mf-grid-align, center);max-width:var(--bm-content-max-width, 72rem);width:100%;margin:var(--bm-inner-margin, 0 auto)">' . $columns . '</div></section>';
		} else {
			$slide_html = '<section class="bm-hero bm-hero--simple bm-hero--slide"' . $slide_index_attr . ' data-layout="simple" style="' . esc_attr( $section_style ) . ';text-align:var(--bm-text-align, center)"><div class="bm-hero__overlay" aria-hidden="true" style="position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;background:var(--bm-overlay);pointer-events:none"></div><div class="bm-hero__inner" style="position:relative;z-index:1;display:flex;flex-direction:column;align-items:var(--bm-align-items, center);gap:var(--bm-gap);max-width:var(--bm-content-max-width, 48rem);width:100%;margin:var(--bm-inner-margin, 0 auto)">' . $content . '</div></section>';
		}

		$item['layout']       = $layout;
		$item['badge']        = $badge;
		$item['header']       = $header;
		$item['subheader']    = $subheader;
		$item['headerTag']    = $header_tag;
		$item['subheaderTag'] = $subheader_tag;
		$item['buttons']      = $buttons;
		$item['logos']        = $logos;
		$item['image']        = $image;
		$item['video']        = $video;
		$item['image.html']   = $image_html;
		$item['video.url']    = $video_url;
		$item['slideHtml']    = $slide_html;
		$item['@index']       = (string) $index;
		return $item;
	}

	private static function sanitize_heading_tag( string $tag, string $fallback = 'h2' ): string {
		$t = strtolower( preg_replace( '/[^a-z0-9]/i', '', $tag ) ?? '' );
		return in_array( $t, self::HEADING_TAGS, true ) ? $t : $fallback;
	}

	/**
	 * @param string $tag      Tag.
	 * @param string $fallback Fallback.
	 */
	private static function sanitize_sub_tag( string $tag, string $fallback = 'div' ): string {
		$t = strtolower( preg_replace( '/[^a-z0-9]/i', '', $tag ) ?? '' );
		$allowed = array_merge( self::SUB_TAGS, self::HEADING_TAGS );
		return in_array( $t, $allowed, true ) ? $t : $fallback;
	}

	/**
	 * @param mixed $value    Value.
	 * @param bool  $fallback Fallback when empty.
	 */
	private static function truthy( $value, bool $fallback = false ): bool {
		if ( is_bool( $value ) ) {
			return $value;
		}
		if ( is_int( $value ) || is_float( $value ) ) {
			return 0 !== (int) $value;
		}
		if ( is_string( $value ) ) {
			$v = strtolower( trim( $value ) );
			if ( '' === $v ) {
				return $fallback;
			}
			return in_array( $v, array( '1', 'true', 'yes', 'on' ), true );
		}
		if ( null === $value ) {
			return $fallback;
		}
		return ! empty( $value );
	}

	/**
	 * @param array<string, mixed> $arr Array.
	 */
	private static function is_assoc( array $arr ): bool {
		if ( array() === $arr ) {
			return false;
		}
		return array_keys( $arr ) !== range( 0, count( $arr ) - 1 );
	}

	/**
	 * Whether attrs include a standalone button label (solid button block).
	 *
	 * @param array<string, mixed> $attrs Attributes.
	 */
	private static function block_has_label_button( array $attrs ): bool {
		return '' !== trim( (string) ( $attrs['label'] ?? '' ) );
	}

	/**
	 * Whether attrs include repeater / slide / card buttons using data-bm-btn-index.
	 *
	 * @param array<string, mixed> $attrs    Attributes.
	 * @param string               $repeater Repeater key.
	 */
	private static function block_has_indexed_buttons( array $attrs, string $repeater = 'buttons' ): bool {
		$items = $attrs[ $repeater ] ?? null;
		$has   = is_array( $items ) && ! self::is_assoc( $items ) && $items;
		if ( ! $has && 'buttons' === $repeater && isset( $attrs['slides'] ) && is_array( $attrs['slides'] ) ) {
			foreach ( $attrs['slides'] as $slide ) {
				if ( ! is_array( $slide ) ) {
					continue;
				}
				$slide_btns = $slide['buttons'] ?? null;
				if ( is_array( $slide_btns ) && ! self::is_assoc( $slide_btns ) && $slide_btns ) {
					$has = true;
					break;
				}
				if ( '' !== trim( (string) ( $slide['ctaLabel'] ?? '' ) ) ) {
					$has = true;
					break;
				}
			}
		}
		if ( ! $has && 'buttons' === $repeater && isset( $attrs['cards'] ) && is_array( $attrs['cards'] ) ) {
			foreach ( $attrs['cards'] as $card ) {
				if ( ! is_array( $card ) ) {
					continue;
				}
				$card_btns = $card['buttons'] ?? null;
				if ( is_array( $card_btns ) && ! self::is_assoc( $card_btns ) && $card_btns ) {
					$has = true;
					break;
				}
			}
		}
		if ( ! $has && 'buttons' === $repeater && isset( $attrs['items'] ) && is_array( $attrs['items'] ) ) {
			foreach ( $attrs['items'] as $item ) {
				if ( ! is_array( $item ) ) {
					continue;
				}
				$item_btns = $item['buttons'] ?? null;
				if ( is_array( $item_btns ) && ! self::is_assoc( $item_btns ) && $item_btns ) {
					$has = true;
					break;
				}
			}
		}
		if ( ! $has && 'buttons' === $repeater && isset( $attrs['tabs'] ) && is_array( $attrs['tabs'] ) ) {
			foreach ( $attrs['tabs'] as $tab ) {
				if ( ! is_array( $tab ) ) {
					continue;
				}
				$tab_btns = $tab['buttons'] ?? null;
				if ( is_array( $tab_btns ) && ! self::is_assoc( $tab_btns ) && $tab_btns ) {
					$has = true;
					break;
				}
			}
		}
		return $has;
	}

	/**
	 * CSS for per-button hover from repeater items.
	 *
	 * @param string               $selector Block selector.
	 * @param array<string, mixed> $attrs    Attributes.
	 * @param string               $repeater Repeater key.
	 */
	public static function button_hover_css( string $selector, array $attrs, string $repeater = 'buttons' ): string {
		$has_label   = 'buttons' === $repeater && self::block_has_label_button( $attrs );
		$has_indexed = self::block_has_indexed_buttons( $attrs, $repeater );
		if ( ! $has_label && ! $has_indexed ) {
			return '';
		}

		$parts = array();
		if ( $has_label ) {
			$parts[] = "{$selector} .bm-button__link {\n\tbackground: var(--bm-label-bg);\n\tbackground-color: var(--bm-label-bg);\n\tcolor: var(--bm-label-color);\n\tfont-family: var(--bm-label-font-family, inherit);\n\tbox-shadow: var(--bm-label-shadow, none);\n\tborder-style: var(--bm-label-border-style, none);\n\tborder-color: var(--bm-label-border-color, transparent);\n\tborder-top-width: var(--bm-label-border-top-width, var(--bm-label-border-width, 0));\n\tborder-right-width: var(--bm-label-border-right-width, var(--bm-label-border-width, 0));\n\tborder-bottom-width: var(--bm-label-border-bottom-width, var(--bm-label-border-width, 0));\n\tborder-left-width: var(--bm-label-border-left-width, var(--bm-label-border-width, 0));\n\tborder-top-left-radius: var(--bm-label-border-top-left-radius, var(--bm-label-border-radius, 8px));\n\tborder-top-right-radius: var(--bm-label-border-top-right-radius, var(--bm-label-border-radius, 8px));\n\tborder-bottom-right-radius: var(--bm-label-border-bottom-right-radius, var(--bm-label-border-radius, 8px));\n\tborder-bottom-left-radius: var(--bm-label-border-bottom-left-radius, var(--bm-label-border-radius, 8px));\n\ttransition: background 0.2s ease, background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, border 0.2s ease, border-color 0.2s ease;\n}";
			$parts[] = "{$selector} .bm-button__link:hover {\n\tbackground: var(--bm-label-bg-hover, var(--bm-label-bg)) !important;\n\tbackground-color: var(--bm-label-bg-hover, var(--bm-label-bg)) !important;\n\tcolor: var(--bm-label-color-hover, var(--bm-label-color)) !important;\n}";
		}
		if ( $has_indexed ) {
			$parts[] = "{$selector} [data-bm-btn-index] {\n\tbackground: var(--bm-btn-bg);\n\tbackground-color: var(--bm-btn-bg);\n\tcolor: var(--bm-btn-color);\n\tfont-family: var(--bm-btn-font, inherit);\n\tfont-size: var(--bm-btn-font-size, 1rem);\n\tbox-shadow: var(--bm-btn-shadow, none);\n\tborder-style: var(--bm-btn-border-style, none);\n\tborder-color: var(--bm-btn-border-color, transparent);\n\tborder-top-width: var(--bm-btn-border-top-width, var(--bm-btn-border-width, 0));\n\tborder-right-width: var(--bm-btn-border-right-width, var(--bm-btn-border-width, 0));\n\tborder-bottom-width: var(--bm-btn-border-bottom-width, var(--bm-btn-border-width, 0));\n\tborder-left-width: var(--bm-btn-border-left-width, var(--bm-btn-border-width, 0));\n\tborder-top-left-radius: var(--bm-btn-border-top-left-radius, var(--bm-btn-radius, 8px));\n\tborder-top-right-radius: var(--bm-btn-border-top-right-radius, var(--bm-btn-radius, 8px));\n\tborder-bottom-right-radius: var(--bm-btn-border-bottom-right-radius, var(--bm-btn-radius, 8px));\n\tborder-bottom-left-radius: var(--bm-btn-border-bottom-left-radius, var(--bm-btn-radius, 8px));\n\ttransition: background 0.2s ease, background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, border 0.2s ease, border-color 0.2s ease;\n}";
			$parts[] = "{$selector} [data-bm-btn-index]:hover {\n\tbackground: var(--bm-btn-bg-hover) !important;\n\tbackground-color: var(--bm-btn-bg-hover) !important;\n\tcolor: var(--bm-btn-color-hover) !important;\n}";
		}
		return implode( "\n\n", $parts );
	}
}
