<?php
/**
 * Responsive CSS custom-property helpers (desktop / tablet / mobile).
 *
 * @package JOCBM
 *
 * Copyright (C) 2026 JayOnCode
 * License: GPL-2.0-or-later
 */

namespace JOCBM\Services;

defined( 'ABSPATH' ) || exit;

/**
 * Class CssVars
 */
final class CssVars {

	public const BREAKPOINTS = array( 'desktop', 'tablet', 'mobile' );

	/** @var array<string, string> max-width media queries (tablet / mobile). */
	public const MEDIA = array(
		'tablet' => '(max-width: 1024px)',
		'mobile' => '(max-width: 480px)',
	);

	/** Nominal device widths for baked previews / column stacking. */
	public const DEVICE_WIDTH_PX = array(
		'tablet' => 1024,
		'mobile' => 480,
	);

	public const GRID_COLUMNS_TWO = 'minmax(0, 1fr) minmax(0, 1fr)';
	public const GRID_COLUMNS_ONE = 'minmax(0, 1fr)';

	/** Default block body font (self-hosted Inter). */
	public const DEFAULT_FONT_FAMILY = '"Inter", sans-serif';

	/**
	 * Resolve --bm-columns-breakpoint to a pixel number (default tablet 1024).
	 *
	 * @param string $value Raw value.
	 */
	public static function resolve_columns_breakpoint_px( string $value ): int {
		$raw = strtolower( trim( $value ) );
		if ( '' === $raw || 'tablet' === $raw ) {
			return self::DEVICE_WIDTH_PX['tablet'];
		}
		if ( 'mobile' === $raw ) {
			return self::DEVICE_WIDTH_PX['mobile'];
		}
		if ( preg_match( '/^(\d+(?:\.\d+)?)\s*px$/i', $raw, $m ) ) {
			return max( 1, (int) round( (float) $m[1] ) );
		}
		if ( preg_match( '/^(\d+(?:\.\d+)?)$/', $raw, $m ) ) {
			return max( 1, (int) round( (float) $m[1] ) );
		}
		return self::DEVICE_WIDTH_PX['tablet'];
	}

	/**
	 * Whether stack-order mode requests a reverse of the DOM sequence.
	 *
	 * @param string $mode Mode value.
	 */
	public static function is_stack_reverse( string $mode ): bool {
		$m = strtolower( trim( $mode ) );
		return in_array( $m, array( 'reverse', 'image-last', '1', 'true' ), true );
	}

	/**
	 * Order vars applied only when columns are stacked.
	 *
	 * @param string $mode     default|image-first|image-last|reverse
	 * @param string $dom_order media-first|content-first
	 * @return array<string, string>
	 */
	public static function stack_order_vars( string $mode, string $dom_order = 'media-first' ): array {
		$m           = strtolower( trim( $mode ) );
		$media_first = 'content-first' !== strtolower( trim( $dom_order ) );

		if ( 'image-first' === $m ) {
			return array(
				'--bm-media-order'   => '1',
				'--bm-content-order' => '2',
			);
		}
		if ( 'image-last' === $m ) {
			return array(
				'--bm-media-order'   => '2',
				'--bm-content-order' => '1',
			);
		}
		if ( self::is_stack_reverse( $m ) ) {
			return $media_first
				? array(
					'--bm-media-order'   => '2',
					'--bm-content-order' => '1',
				)
				: array(
					'--bm-media-order'   => '1',
					'--bm-content-order' => '2',
				);
		}
		return array(
			'--bm-media-order'   => '0',
			'--bm-content-order' => '0',
		);
	}

	/**
	 * Bake stack-order into inheritable --bm-stacked-* vars.
	 *
	 * @param array<string, string> $flat Flat vars.
	 * @return array<string, string>
	 */
	private static function expand_stacked_order_vars( array $flat ): array {
		$has_order = array_key_exists( '--bm-columns-stack-order', $flat );
		$has_bp    = self::has_columns_breakpoint( $flat );
		if ( ! $has_order && ! $has_bp ) {
			return $flat;
		}
		$mode = isset( $flat['--bm-columns-stack-order'] ) ? (string) $flat['--bm-columns-stack-order'] : 'default';
		// Reverse is flex column-reverse; do not also swap CSS order (that cancels out).
		$flat['--bm-stacked-media-order']   = '0';
		$flat['--bm-stacked-content-order'] = '0';
		$flat['--bm-stacked-direction']     = self::is_stack_reverse( $mode ) ? 'column-reverse' : 'column';
		return $flat;
	}

	/**
	 * Whether media frame mode is fullscreen.
	 *
	 * @param string $value Frame mode.
	 */
	public static function is_media_fullscreen( string $value ): bool {
		$v = strtolower( trim( $value ) );
		return in_array( $v, array( 'fullscreen', 'full', '1', 'true' ), true );
	}

	/**
	 * Bake --bm-media-frame into markup-consumed layout vars.
	 *
	 * @param array<string, string> $flat Flat vars.
	 * @return array<string, string>
	 */
	private static function expand_media_frame_vars( array $flat ): array {
		if ( ! array_key_exists( '--bm-media-frame', $flat ) ) {
			return $flat;
		}
		if ( ! self::is_media_fullscreen( (string) ( $flat['--bm-media-frame'] ?? '' ) ) ) {
			$flat['--bm-mf-bleed']                = '0';
			$flat['--bm-mf-grid-align']           = 'center';
			$flat['--bm-mf-media-align-self']     = 'auto';
			$flat['--bm-mf-media-height']         = 'auto';
			$flat['--bm-mf-media-align-items']    = 'var(--bm-align-items, flex-start)';
			$flat['--bm-mf-img-width']            = 'var(--bm-image-width, 100%)';
			$flat['--bm-mf-img-max-width']        = '100%';
			$flat['--bm-mf-img-height']           = 'var(--bm-image-height, auto)';
			$flat['--bm-mf-img-aspect']           = 'var(--bm-image-aspect-ratio, 16/9)';
			$flat['--bm-mf-img-radius']           = 'var(--bm-image-border-radius, 8px)';
			$flat['--bm-mf-img-radius-tl']        = 'var(--bm-image-border-top-left-radius, var(--bm-image-border-radius, 8px))';
			$flat['--bm-mf-img-radius-tr']        = 'var(--bm-image-border-top-right-radius, var(--bm-image-border-radius, 8px))';
			$flat['--bm-mf-img-radius-br']        = 'var(--bm-image-border-bottom-right-radius, var(--bm-image-border-radius, 8px))';
			$flat['--bm-mf-img-radius-bl']        = 'var(--bm-image-border-bottom-left-radius, var(--bm-image-border-radius, 8px))';
			$flat['--bm-mf-media-margin-start']   = 'var(--bm-media-margin-inline-start, 0)';
			$flat['--bm-mf-media-margin-end']     = 'var(--bm-media-margin-inline-end, 0)';
			$flat['--bm-mf-stack-grid-max-width'] = 'var(--bm-content-max-width, 72rem)';
			return $flat;
		}
		// Columns gap stays user-editable on every breakpoint (including fullscreen).
		$flat['--bm-mf-bleed']               = '1';
		$flat['--bm-mf-grid-align']          = 'stretch';
		$flat['--bm-mf-media-align-self']    = 'stretch';
		$flat['--bm-mf-media-height']        = 'auto';
		$flat['--bm-mf-media-align-items']   = 'stretch';
		$flat['--bm-mf-img-width']           = '100%';
		$flat['--bm-mf-img-max-width']       = 'none';
		$flat['--bm-mf-img-height']          = '100%';
		$flat['--bm-mf-img-aspect']          = 'auto';
		$flat['--bm-mf-img-radius']          = '0';
		$flat['--bm-mf-img-radius-tl']       = '0';
		$flat['--bm-mf-img-radius-tr']       = '0';
		$flat['--bm-mf-img-radius-br']       = '0';
		$flat['--bm-mf-img-radius-bl']       = '0';
		$flat['--bm-mf-media-margin-start']  = '0';
		$flat['--bm-mf-media-margin-end']    = '0';
		$flat['--bm-mf-stack-grid-max-width'] = 'none';
		return $flat;
	}

	/**
	 * Expand layout helper vars before emitting declarations.
	 *
	 * @param array<string, string> $flat Flat vars.
	 * @return array<string, string>
	 */
	private static function expand_layout_vars( array $flat ): array {
		return self::expand_media_frame_vars( self::expand_stacked_order_vars( $flat ) );
	}

	/**
	 * Flat for one breakpoint, inheriting --bm-media-frame so each viewport can
	 * choose fullscreen vs framed independently (with its own gap / padding).
	 *
	 * @param array  $n  Normalized vars.
	 * @param string $bp Breakpoint.
	 * @return array<string, string>
	 */
	private static function layout_flat_for_breakpoint( array $n, string $bp ): array {
		$flat = $n[ $bp ] ?? array();
		if ( ! array_key_exists( '--bm-media-frame', $flat ) ) {
			$frame = self::effective( $n, $bp )['--bm-media-frame'] ?? null;
			if ( null !== $frame && '' !== trim( (string) $frame ) ) {
				$flat['--bm-media-frame'] = $frame;
			}
		}
		return self::expand_layout_vars( $flat );
	}

	/**
	 * Side-by-side fullscreen: fill media half + bleed to section outer edge.
	 * Nested 1×1 grid keeps the image in-flow so the media cell does not collapse.
	 *
	 * @param string $selector CSS selector.
	 * @return string
	 */
	private static function media_frame_side_rules( string $selector ): string {
		$pad_l = 'var(--bm-padding-left, var(--bm-padding-x, 0px))';
		$pad_r = 'var(--bm-padding-right, var(--bm-padding-x, 0px))';
		$pad_t = 'var(--bm-padding-top, var(--bm-padding-y, 0px))';
		$pad_b = 'var(--bm-padding-bottom, var(--bm-padding-y, 0px))';
		$gap   = 'var(--bm-columns-gap, var(--bm-gap))';

		// Full-bleed grid: media reaches section edges; columns gap stays editable.
		$css  = "{$selector} .bm-hero__grid {\n\tmax-width: none !important;\n\twidth: calc(100% + {$pad_l} + {$pad_r}) !important;\n\tmargin-left: calc(-1 * {$pad_l}) !important;\n\tmargin-right: calc(-1 * {$pad_r}) !important;\n\tmargin-top: calc(-1 * {$pad_t}) !important;\n\tmargin-bottom: calc(-1 * {$pad_b}) !important;\n\tflex: 1 1 auto;\n\talign-self: stretch;\n\tmin-height: 0;\n\talign-items: stretch !important;\n\tgrid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important;\n\tgap: {$gap} !important;\n\tcolumn-gap: {$gap} !important;\n\trow-gap: {$gap} !important;\n}\n";
		$css .= "{$selector} .bm-hero__media {\n\tposition: relative !important;\n\talign-self: stretch !important;\n\tjustify-self: stretch !important;\n\tbox-sizing: border-box !important;\n\twidth: 100% !important;\n\tmax-width: none !important;\n\theight: auto !important;\n\tmin-width: 0 !important;\n\tmin-height: 0 !important;\n\tmargin: 0 !important;\n\toverflow: hidden !important;\n\tdisplay: grid !important;\n\tgrid-template: minmax(0, 1fr) / minmax(0, 1fr) !important;\n\talign-items: stretch !important;\n}\n";
		// Container spacing + max width live on content — media stays edge-to-edge.
		$css .= "{$selector} .bm-hero__content,\n{$selector} .bm-hero--image-left .bm-hero__content,\n{$selector} .bm-hero--image-right .bm-hero__content {\n\tjustify-content: center;\n\talign-self: stretch !important;\n\tbox-sizing: border-box !important;\n\tmin-height: 0;\n\twidth: 100% !important;\n\tmax-width: min(100%, var(--bm-content-max-width, 72rem)) !important;\n\tmargin-left: auto !important;\n\tmargin-right: auto !important;\n\tpadding-top: {$pad_t} !important;\n\tpadding-right: {$pad_r} !important;\n\tpadding-bottom: {$pad_b} !important;\n\tpadding-left: {$pad_l} !important;\n}\n";
		// Editor hit-wrap (.bm-el-hit) is the grid child — stretch it or fill CSS never reaches the media.
		$css .= "{$selector} .bm-hero__media > .bm-media-frame,\n{$selector} .bm-hero__media > .bm-el-hit {\n\tgrid-area: 1 / 1 !important;\n\tposition: relative !important;\n\twidth: 100% !important;\n\tmax-width: none !important;\n\theight: 100% !important;\n\tmin-width: 0 !important;\n\tmin-height: 100% !important;\n\talign-self: stretch !important;\n\tjustify-self: stretch !important;\n\tdisplay: grid !important;\n\tgrid-template: minmax(0, 1fr) / minmax(0, 1fr) !important;\n\talign-items: stretch !important;\n\tbox-sizing: border-box !important;\n\tline-height: 0 !important;\n}\n";
		$css .= "{$selector} .bm-hero__media .bm-hero__image:not(.bm-hero__embed),\n{$selector} .bm-hero__media > img,\n{$selector} .bm-hero__media > video,\n{$selector} .bm-hero__media > iframe,\n{$selector} .bm-hero__media > .bm-media-frame > img,\n{$selector} .bm-hero__media > .bm-media-frame > video,\n{$selector} .bm-hero__media > .bm-media-frame > iframe,\n{$selector} .bm-hero__media > .bm-el-hit > img,\n{$selector} .bm-hero__media > .bm-el-hit > video,\n{$selector} .bm-hero__media > .bm-el-hit > iframe {\n\tposition: static !important;\n\tinset: auto !important;\n\tgrid-area: 1 / 1 !important;\n\twidth: 100% !important;\n\tmax-width: none !important;\n\theight: 100% !important;\n\tmin-width: 0 !important;\n\tmin-height: 100% !important;\n\taspect-ratio: unset !important;\n\tborder-radius: 0 !important;\n\tobject-fit: var(--bm-image-object-fit, cover) !important;\n\tobject-position: center !important;\n\tmargin: 0 !important;\n\tdisplay: block !important;\n}\n";
		// Fullscreen embeds: fill the media pane (wrapper owns the box; iframe absolute).
		$css .= "{$selector} .bm-hero__media .bm-hero__embed,\n{$selector} .bm-hero__media > .bm-media-frame > .bm-hero__embed,\n{$selector} .bm-hero__media > .bm-el-hit > .bm-hero__embed {\n\tposition: relative !important;\n\tgrid-area: 1 / 1 !important;\n\twidth: 100% !important;\n\tmax-width: none !important;\n\theight: 100% !important;\n\tmin-width: 0 !important;\n\tmin-height: 100% !important;\n\taspect-ratio: unset !important;\n\tborder-radius: 0 !important;\n\tobject-fit: unset !important;\n\toverflow: hidden !important;\n\tmargin: 0 !important;\n\tdisplay: block !important;\n}\n";
		$css .= "{$selector} .bm-hero__media .bm-hero__embed .wp-block-embed__wrapper {\n\tposition: absolute !important;\n\tinset: 0 !important;\n\tdisplay: block !important;\n\twidth: 100% !important;\n\theight: 100% !important;\n\taspect-ratio: auto !important;\n\toverflow: hidden !important;\n\tline-height: 0 !important;\n}\n";
		$css .= "{$selector} .bm-hero__media .bm-hero__embed .bm-embed-ratio,\n{$selector} .bm-hero__media .bm-hero__embed .wp-block-embed__wrapper > div[style*=\"padding-bottom\"] {\n\tposition: absolute !important;\n\tinset: 0 !important;\n\twidth: 100% !important;\n\theight: 100% !important;\n\tpadding-bottom: 0 !important;\n}\n";
		$css .= "{$selector} .bm-hero__media .bm-hero__embed iframe,\n{$selector} .bm-hero__media .bm-hero__embed embed,\n{$selector} .bm-hero__media .bm-hero__embed object {\n\tposition: absolute !important;\n\tinset: 0 !important;\n\twidth: 100% !important;\n\tmax-width: none !important;\n\theight: 100% !important;\n\tmin-height: 0 !important;\n\taspect-ratio: auto !important;\n\tobject-fit: unset !important;\n\tborder: 0 !important;\n\tmargin: 0 !important;\n}";
		return $css;
	}

	/**
	 * Undo side-by-side fullscreen !important rules for framed mode.
	 * Needed so Gutenberg/instance CSS can beat a stale library stylesheet.
	 *
	 * @param string $selector CSS selector.
	 * @return string
	 */
	private static function media_frame_framed_rules( string $selector ): string {
		$gap = 'var(--bm-columns-gap, var(--bm-gap))';
		$css  = "{$selector} .bm-hero__grid {\n\tmax-width: var(--bm-content-max-width, 72rem) !important;\n\twidth: 100% !important;\n\tmargin: var(--bm-inner-margin, 0 auto) !important;\n\tflex: none;\n\talign-self: auto;\n\tmin-height: 0;\n\talign-items: var(--bm-mf-grid-align, center) !important;\n\tgrid-template-columns: var(--bm-grid-template-columns, minmax(0, 1fr) minmax(0, 1fr)) !important;\n\tgap: {$gap} !important;\n\tcolumn-gap: {$gap} !important;\n\trow-gap: {$gap} !important;\n}\n";
		$css .= "{$selector} .bm-hero__media {\n\tposition: relative !important;\n\talign-self: var(--bm-mf-media-align-self, auto) !important;\n\tjustify-self: auto !important;\n\tbox-sizing: border-box !important;\n\twidth: var(--bm-mf-media-width, 100%) !important;\n\tmax-width: var(--bm-mf-media-max-width, 100%) !important;\n\theight: var(--bm-mf-media-height, auto) !important;\n\tmin-width: 0 !important;\n\tmin-height: 0 !important;\n\tmargin-left: var(--bm-mf-margin-left, 0) !important;\n\tmargin-right: var(--bm-mf-margin-right, 0) !important;\n\toverflow: visible !important;\n\tdisplay: flex !important;\n\tflex-direction: column !important;\n\talign-items: var(--bm-mf-media-align-items, var(--bm-align-items, flex-start)) !important;\n}\n";
		$css .= "{$selector} .bm-hero__content,\n{$selector} .bm-hero--image-left .bm-hero__content,\n{$selector} .bm-hero--image-right .bm-hero__content {\n\tjustify-content: center;\n\talign-self: auto !important;\n\tbox-sizing: border-box !important;\n\tmin-height: 0;\n\twidth: auto !important;\n\tmax-width: none !important;\n\tmargin-left: 0 !important;\n\tmargin-right: 0 !important;\n\tpadding-top: 0 !important;\n\tpadding-right: 0 !important;\n\tpadding-bottom: 0 !important;\n\tpadding-left: 0 !important;\n}\n";
		$css .= "{$selector} .bm-hero__media > .bm-media-frame,\n{$selector} .bm-hero__media > .bm-el-hit {\n\tgrid-area: auto !important;\n\tposition: relative !important;\n\twidth: var(--bm-mf-img-width, var(--bm-image-width, 100%)) !important;\n\tmax-width: 100% !important;\n\theight: auto !important;\n\tmin-width: 0 !important;\n\tmin-height: 0 !important;\n\talign-self: auto !important;\n\tjustify-self: auto !important;\n\tmargin-left: var(--bm-mf-media-margin-start, var(--bm-media-margin-inline-start, 0)) !important;\n\tmargin-right: var(--bm-mf-media-margin-end, var(--bm-media-margin-inline-end, 0)) !important;\n\tdisplay: block !important;\n\tbox-sizing: border-box !important;\n\tline-height: 0 !important;\n}\n";
		$css .= "{$selector} .bm-hero__media .bm-hero__image:not(.bm-hero__embed),\n{$selector} .bm-hero__media > img,\n{$selector} .bm-hero__media > video,\n{$selector} .bm-hero__media > iframe,\n{$selector} .bm-hero__media > .bm-media-frame > img,\n{$selector} .bm-hero__media > .bm-media-frame > video,\n{$selector} .bm-hero__media > .bm-media-frame > iframe,\n{$selector} .bm-hero__media > .bm-media-frame > .bm-hero__embed,\n{$selector} .bm-hero__media > .bm-el-hit > img,\n{$selector} .bm-hero__media > .bm-el-hit > video,\n{$selector} .bm-hero__media > .bm-el-hit > iframe {\n\tposition: static !important;\n\tinset: auto !important;\n\tgrid-area: auto !important;\n\twidth: 100% !important;\n\tmax-width: 100% !important;\n\theight: var(--bm-mf-img-height, var(--bm-image-height, auto)) !important;\n\tmin-width: 0 !important;\n\tmin-height: 0 !important;\n\taspect-ratio: var(--bm-mf-img-aspect, var(--bm-image-aspect-ratio, 16/9)) !important;\n\tborder-top-left-radius: var(--bm-mf-img-radius-tl, var(--bm-image-border-top-left-radius, var(--bm-image-border-radius, 8px))) !important;\n\tborder-top-right-radius: var(--bm-mf-img-radius-tr, var(--bm-image-border-top-right-radius, var(--bm-image-border-radius, 8px))) !important;\n\tborder-bottom-right-radius: var(--bm-mf-img-radius-br, var(--bm-image-border-bottom-right-radius, var(--bm-image-border-radius, 8px))) !important;\n\tborder-bottom-left-radius: var(--bm-mf-img-radius-bl, var(--bm-image-border-bottom-left-radius, var(--bm-image-border-radius, 8px))) !important;\n\tobject-fit: var(--bm-image-object-fit, cover) !important;\n\tobject-position: center !important;\n\tmargin: 0 !important;\n\tdisplay: block !important;\n}";
		$css .= "{$selector} .bm-hero__media .bm-hero__embed,\n{$selector} .bm-hero__media > .bm-media-frame > .bm-hero__embed {\n\theight: auto !important;\n\tmin-height: 0 !important;\n\taspect-ratio: auto !important;\n\tobject-fit: unset !important;\n\toverflow: hidden !important;\n}\n";
		$css .= "{$selector} .bm-hero__media .bm-hero__embed .wp-block-embed__wrapper {\n\tposition: relative !important;\n\tdisplay: block !important;\n\twidth: 100% !important;\n\theight: auto !important;\n\taspect-ratio: auto !important;\n\toverflow: hidden !important;\n\tline-height: 0 !important;\n}\n";
		$css .= "{$selector} .bm-hero__media .bm-hero__embed iframe,\n{$selector} .bm-hero__media .bm-hero__embed embed,\n{$selector} .bm-hero__media .bm-hero__embed object {\n\tposition: absolute !important;\n\tinset: 0 !important;\n\twidth: 100% !important;\n\tmax-width: none !important;\n\theight: 100% !important;\n\tmin-height: 0 !important;\n\taspect-ratio: auto !important;\n\tobject-fit: unset !important;\n\tborder: 0 !important;\n\tmargin: 0 !important;\n}\n";
		return $css;
	}

	/**
	 * Append framed reset rules when media is not fullscreen.
	 *
	 * @param array<int, string> $parts    CSS parts.
	 * @param string             $selector Selector.
	 * @param array              $n        Normalized vars.
	 */
	private static function append_media_frame_framed_css( array &$parts, string $selector, array $n ): void {
		$desktop_frame = self::effective( $n, 'desktop' )['--bm-media-frame'] ?? '';
		if ( '' === trim( (string) $desktop_frame ) && empty( $n['tablet']['--bm-media-frame'] ) && empty( $n['mobile']['--bm-media-frame'] ) ) {
			return;
		}

		$stack_px = self::has_columns_breakpoint( $n['desktop'] )
			? self::resolve_columns_breakpoint_px( (string) $n['desktop']['--bm-columns-breakpoint'] )
			: null;
		$framed   = self::media_frame_framed_rules( $selector );

		if ( ! self::is_media_fullscreen( (string) $desktop_frame ) && '' !== trim( (string) $desktop_frame ) ) {
			if ( null !== $stack_px ) {
				$min     = (int) $stack_px + 1;
				$parts[] = "@media (min-width: {$min}px) {\n{$framed}\n}";
			} else {
				$parts[] = $framed;
			}
		}

		foreach ( array( 'tablet', 'mobile' ) as $bp ) {
			$frame = self::effective( $n, $bp )['--bm-media-frame'] ?? '';
			if ( self::is_media_fullscreen( (string) $frame ) ) {
				continue;
			}
			$device_px = (int) self::DEVICE_WIDTH_PX[ $bp ];
			$stacked   = null !== $stack_px && $device_px <= $stack_px;
			if ( $stacked ) {
				continue;
			}
			if ( ! array_key_exists( '--bm-media-frame', $n[ $bp ] ?? array() ) && ! self::is_media_fullscreen( (string) $desktop_frame ) ) {
				continue;
			}
			if ( '' === trim( (string) $frame ) ) {
				continue;
			}
			$parts[] = '@media ' . self::MEDIA[ $bp ] . " {\n{$framed}\n}";
		}
	}

	/**
	 * Stacked media frame bleed (gated by --bm-mf-bleed) + height reset.
	 * Fullscreen moves section padding onto .bm-hero__content so the image is edge-to-edge.
	 *
	 * @param string $selector CSS selector.
	 * @return string
	 */
	private static function media_frame_bleed_rules( string $selector ): string {
		$pad_l = 'var(--bm-padding-left, var(--bm-padding-x, 0px))';
		$pad_r = 'var(--bm-padding-right, var(--bm-padding-x, 0px))';
		$pad_t = 'var(--bm-padding-top, var(--bm-padding-y, 0px))';
		$pad_b = 'var(--bm-padding-bottom, var(--bm-padding-y, 0px))';
		$bleed = 'var(--bm-mf-bleed, 0)';

		$body  = "{$selector} {\n";
		$body .= "\t--bm-mf-margin-left: 0;\n";
		$body .= "\t--bm-mf-margin-right: 0;\n";
		$body .= "\t--bm-mf-media-width: 100%;\n";
		$body .= "\t--bm-mf-media-max-width: none;\n";
		$body .= "\t--bm-mf-media-height: auto;\n";
		$body .= "\t--bm-mf-img-height: var(--bm-image-height, auto);\n";
		$body .= "\t--bm-mf-img-aspect: var(--bm-image-aspect-ratio, 16/9);\n";
		$body .= "}\n";
		$body .= "{$selector} .bm-hero,\n{$selector}.bm-hero {\n";
		$body .= "\tpadding-top: calc({$pad_t} * (1 - {$bleed})) !important;\n";
		$body .= "\tpadding-right: calc({$pad_r} * (1 - {$bleed})) !important;\n";
		$body .= "\tpadding-bottom: calc({$pad_b} * (1 - {$bleed})) !important;\n";
		$body .= "\tpadding-left: calc({$pad_l} * (1 - {$bleed})) !important;\n";
		$body .= "}\n";
		$body .= "{$selector} .bm-hero__content,\n{$selector} .bm-hero--image-left .bm-hero__content,\n{$selector} .bm-hero--image-right .bm-hero__content {\n";
		$body .= "\tbox-sizing: border-box !important;\n";
		$body .= "\talign-self: auto !important;\n";
		$body .= "\twidth: 100% !important;\n";
		$body .= "\tmax-width: min(100%, calc({$bleed} * var(--bm-content-max-width, 72rem) + (1 - {$bleed}) * 100%)) !important;\n";
		$body .= "\tmargin-left: auto !important;\n";
		$body .= "\tmargin-right: auto !important;\n";
		$body .= "\tpadding-top: calc({$pad_t} * {$bleed}) !important;\n";
		$body .= "\tpadding-right: calc({$pad_r} * {$bleed}) !important;\n";
		$body .= "\tpadding-bottom: calc({$pad_b} * {$bleed}) !important;\n";
		$body .= "\tpadding-left: calc({$pad_l} * {$bleed}) !important;\n";
		$body .= "}\n";
		$body .= "{$selector} .bm-hero__media {\n\tposition: relative !important;\n\toverflow: hidden !important;\n\tdisplay: flex !important;\n\tflex-direction: column !important;\n\twidth: 100% !important;\n\tmax-width: none !important;\n\theight: auto !important;\n\tmin-height: 0 !important;\n\talign-self: auto !important;\n\tjustify-self: auto !important;\n\tmargin-left: 0 !important;\n\tmargin-right: 0 !important;\n}\n";
		$body .= "{$selector} .bm-hero__media > .bm-media-frame,\n{$selector} .bm-hero__media > .bm-el-hit {\n\twidth: var(--bm-mf-img-width, var(--bm-image-width, 100%)) !important;\n\tmax-width: 100% !important;\n\theight: auto !important;\n\tmin-height: 0 !important;\n\tdisplay: block !important;\n\tgrid-area: auto !important;\n\tmargin-left: var(--bm-mf-media-margin-start, var(--bm-media-margin-inline-start, 0)) !important;\n\tmargin-right: var(--bm-mf-media-margin-end, var(--bm-media-margin-inline-end, 0)) !important;\n\tbox-sizing: border-box !important;\n}\n";
		$body .= "{$selector} .bm-hero__media .bm-hero__image:not(.bm-hero__embed),\n{$selector} .bm-hero__media > img,\n{$selector} .bm-hero__media > video,\n{$selector} .bm-hero__media > iframe,\n{$selector} .bm-hero__media > .bm-media-frame > img,\n{$selector} .bm-hero__media > .bm-media-frame > video,\n{$selector} .bm-hero__media > .bm-media-frame > iframe,\n{$selector} .bm-hero__media > .bm-media-frame > .bm-hero__embed,\n{$selector} .bm-hero__media > .bm-el-hit > img,\n{$selector} .bm-hero__media > .bm-el-hit > video,\n{$selector} .bm-hero__media > .bm-el-hit > iframe {\n\tposition: static !important;\n\tinset: auto !important;\n\tgrid-area: auto !important;\n\twidth: 100% !important;\n\tmax-width: 100% !important;\n\theight: var(--bm-image-height, auto) !important;\n\tmin-height: 0 !important;\n\taspect-ratio: var(--bm-image-aspect-ratio, 16/9) !important;\n\tborder-top-left-radius: calc(var(--bm-mf-img-radius-tl, var(--bm-mf-img-radius, 0px)) * (1 - {$bleed})) !important;\n\tborder-top-right-radius: calc(var(--bm-mf-img-radius-tr, var(--bm-mf-img-radius, 0px)) * (1 - {$bleed})) !important;\n\tborder-bottom-right-radius: calc(var(--bm-mf-img-radius-br, var(--bm-mf-img-radius, 0px)) * (1 - {$bleed})) !important;\n\tborder-bottom-left-radius: calc(var(--bm-mf-img-radius-bl, var(--bm-mf-img-radius, 0px)) * (1 - {$bleed})) !important;\n\tobject-fit: var(--bm-image-object-fit, cover) !important;\n\tobject-position: center !important;\n\tmargin: 0 !important;\n}";
		$body .= "{$selector} .bm-hero__media .bm-hero__embed,\n{$selector} .bm-hero__media > .bm-media-frame > .bm-hero__embed {\n\theight: auto !important;\n\tmin-height: 0 !important;\n\taspect-ratio: auto !important;\n\tobject-fit: unset !important;\n\toverflow: hidden !important;\n}\n";
		$body .= "{$selector} .bm-hero__media .bm-hero__embed .wp-block-embed__wrapper {\n\tposition: relative !important;\n\tdisplay: block !important;\n\twidth: 100% !important;\n\theight: auto !important;\n\taspect-ratio: auto !important;\n\toverflow: hidden !important;\n\tline-height: 0 !important;\n}\n";
		$body .= "{$selector} .bm-hero__media .bm-hero__embed iframe,\n{$selector} .bm-hero__media .bm-hero__embed embed,\n{$selector} .bm-hero__media .bm-hero__embed object {\n\tposition: absolute !important;\n\tinset: 0 !important;\n\twidth: 100% !important;\n\tmax-width: none !important;\n\theight: 100% !important;\n\tmin-height: 0 !important;\n\taspect-ratio: auto !important;\n\tobject-fit: unset !important;\n\tborder: 0 !important;\n\tmargin: 0 !important;\n}\n";
		return $body;
	}

	/**
	 * Truthy CSS flag (toggle vars like --bm-tab-nav-responsive).
	 *
	 * @param mixed $value Raw value.
	 */
	public static function is_css_flag_on( $value ): bool {
		$raw = strtolower( trim( (string) $value ) );
		return in_array( $raw, array( '1', 'on', 'true', 'yes' ), true );
	}

	/**
	 * Whether a border-width value is effectively non-zero.
	 *
	 * @param mixed $value Raw width.
	 */
	private static function border_side_has_width( $value ): bool {
		$raw = strtolower( trim( (string) $value ) );
		if ( '' === $raw || in_array( $raw, array( 'none', 'initial', 'unset', '0', '0px', '0rem', '0em' ), true ) ) {
			return false;
		}
		if ( is_numeric( $raw ) || preg_match( '/^-?\d+(\.\d+)?/', $raw ) ) {
			return (float) $raw > 0;
		}
		return true;
	}

	/**
	 * Normalize a width token so CSS max() always gets comparable lengths.
	 *
	 * @param mixed $value Raw width.
	 */
	private static function normalize_border_width_token( $value ): string {
		$raw = trim( (string) $value );
		if ( '' === $raw || 'none' === strtolower( $raw ) ) {
			return '0px';
		}
		if ( '0' === $raw ) {
			return '0px';
		}
		if ( preg_match( '/^\d+(\.\d+)?$/', $raw ) ) {
			return $raw . 'px';
		}
		return $raw;
	}

	/**
	 * Resolve accordion item border side width from flat css vars.
	 * Explicit side keys win (including "0").
	 *
	 * @param array<string, string> $flat Flat vars.
	 * @param string                $side top|right|bottom|left.
	 */
	private static function accordion_item_border_side_width( array $flat, string $side ): string {
		$side_key = "--bm-item-border-{$side}-width";
		if ( array_key_exists( $side_key, $flat ) ) {
			return trim( (string) $flat[ $side_key ] );
		}
		if ( isset( $flat['--bm-item-border-width'] ) && '' !== trim( (string) $flat['--bm-item-border-width'] ) ) {
			return trim( (string) $flat['--bm-item-border-width'] );
		}
		return '';
	}

	/**
	 * Close the open end of separator-style accordion borders.
	 *
	 * Bakes resolved top/bottom widths from the effective cssVars map (same as
	 * the JBM block editor) so library stylesheets cannot inflate computed
	 * widths via cascaded `var()` resolution. Sparse Gutenberg instance
	 * overrides must include border width context via prepare_instance_css_vars().
	 *
	 * @param string                $selector Block selector.
	 * @param array<string, string> $flat     Effective css vars.
	 */
	public static function accordion_closing_border_rules( string $selector, array $flat ): string {
		if ( ! self::is_css_flag_on( $flat['--bm-item-closing-border'] ?? '' ) ) {
			return '';
		}
		$top    = self::normalize_border_width_token( self::accordion_item_border_side_width( $flat, 'top' ) );
		$bottom = self::normalize_border_width_token( self::accordion_item_border_side_width( $flat, 'bottom' ) );
		if ( ! self::border_side_has_width( $top ) && ! self::border_side_has_width( $bottom ) ) {
			return '';
		}
		$style = 'var(--bm-item-border-style, solid)';
		$color = 'var(--bm-item-border-color, currentColor)';
		return "{$selector} .bm-accordion__item:last-of-type {\n"
			. "\tborder-bottom-width: max({$bottom}, {$top}) !important;\n"
			. "\tborder-bottom-style: {$style} !important;\n"
			. "\tborder-bottom-color: {$color} !important;\n"
			. "}\n"
			. "{$selector} .bm-accordion__item:first-of-type {\n"
			. "\tborder-top-width: max({$top}, {$bottom}) !important;\n"
			. "\tborder-top-style: {$style} !important;\n"
			. "\tborder-top-color: {$color} !important;\n"
			. '}';
	}

	/**
	 * @param array<int, string>                       $parts    CSS parts.
	 * @param string                                   $selector Selector.
	 * @param array<string, array<string, string>>     $n        Normalized vars.
	 */
	private static function append_accordion_closing_border_css( array &$parts, string $selector, array $n ): void {
		$desktop_rules = self::accordion_closing_border_rules(
			$selector,
			self::effective( $n, 'desktop' )
		);
		if ( '' !== $desktop_rules ) {
			$parts[] = $desktop_rules;
		}
		foreach ( array( 'tablet', 'mobile' ) as $bp ) {
			if ( empty( $n[ $bp ] ) ) {
				continue;
			}
			$rules = self::accordion_closing_border_rules(
				$selector,
				self::effective( $n, $bp )
			);
			if ( '' === $rules || $rules === $desktop_rules ) {
				continue;
			}
			$parts[] = '@media ' . self::MEDIA[ $bp ] . " {\n" . $rules . "\n}";
		}
	}

	/**
	 * Bake accordion caret display / position / size into scoped CSS.
	 *
	 * @param string                $selector Block selector.
	 * @param array<string, string> $flat     Effective css vars.
	 */
	public static function accordion_arrow_rules( string $selector, array $flat ): string {
		$s = trim( $selector );
		if ( '' === $s ) {
			return '';
		}

		$flag        = $flat['--bm-accordion-arrow'] ?? null;
		$display_raw = trim( (string) ( $flat['--bm-accordion-arrow-display'] ?? '' ) );
		if ( '' !== $display_raw ) {
			$display = $display_raw;
		} else {
			$display = ( null === $flag || '' === (string) $flag || self::is_css_flag_on( $flag ) )
				? 'inline-flex'
				: 'none';
		}
		if ( 'block' === $display ) {
			$display = 'inline-flex';
		}

		$position = 'left' === strtolower( trim( (string) ( $flat['--bm-accordion-arrow-position'] ?? '' ) ) )
			? 'left'
			: 'right';

		$order = trim( (string) ( $flat['--bm-accordion-arrow-order'] ?? '' ) );
		$ml    = trim( (string) ( $flat['--bm-accordion-arrow-ml'] ?? '' ) );
		$mr    = trim( (string) ( $flat['--bm-accordion-arrow-mr'] ?? '' ) );
		if ( '' === $order ) {
			$order = 'left' === $position ? '-1' : '0';
		}
		if ( '' === $ml ) {
			$ml = 'left' === $position ? '0' : 'auto';
		}
		if ( '' === $mr ) {
			$mr = '0';
		}

		$size_presets = array(
			'small'  => array( 'boxSize' => '1.5rem', 'width' => '0.35rem', 'height' => '0.35rem', 'border' => '1.5px', 'plusMinusStroke' => '2px', 'plusMinusSize' => '8px', 'fontSize' => '0.75rem', 'openOffset' => '30%' ),
			'medium' => array( 'boxSize' => '2rem', 'width' => '0.45rem', 'height' => '0.45rem', 'border' => '2px', 'plusMinusStroke' => '2px', 'plusMinusSize' => '10px', 'fontSize' => '1rem', 'openOffset' => '45%' ),
			'large'  => array( 'boxSize' => '2.5rem', 'width' => '0.55rem', 'height' => '0.55rem', 'border' => '2.5px', 'plusMinusStroke' => '2px', 'plusMinusSize' => '14px', 'fontSize' => '1.25rem', 'openOffset' => '30%' ),
			'xlarge' => array( 'boxSize' => '3rem', 'width' => '0.7rem', 'height' => '0.7rem', 'border' => '3px', 'plusMinusStroke' => '2px', 'plusMinusSize' => '16px', 'fontSize' => '1.5rem', 'openOffset' => '30%' ),
		);
		$size_key = strtolower( trim( (string) ( $flat['--bm-accordion-arrow-size'] ?? '' ) ) );
		$preset   = $size_presets[ $size_key ] ?? null;

		$box_size  = trim( (string) ( $flat['--bm-accordion-arrow-box-size'] ?? '' ) );
		$width     = trim( (string) ( $flat['--bm-accordion-arrow-width'] ?? '' ) );
		$height    = trim( (string) ( $flat['--bm-accordion-arrow-height'] ?? '' ) );
		$border    = trim( (string) ( $flat['--bm-accordion-arrow-border'] ?? '' ) );
		$plus_minus_stroke = trim( (string) ( $flat['--bm-accordion-plus-minus-stroke'] ?? '' ) );
		$plus_minus_size   = trim( (string) ( $flat['--bm-accordion-plus-minus-size'] ?? '' ) );
		$font_size = trim( (string) ( $flat['--bm-accordion-arrow-font-size'] ?? '' ) );
		$open_offset = trim( (string) ( $flat['--bm-accordion-arrow-open-offset'] ?? '' ) );
		if ( '' === $box_size ) {
			$box_size = is_array( $preset ) ? $preset['boxSize'] : '2rem';
		}
		if ( '' === $width ) {
			$width = is_array( $preset ) ? $preset['width'] : '0.45rem';
		}
		if ( '' === $height ) {
			$height = is_array( $preset ) ? $preset['height'] : '0.45rem';
		}
		if ( '' === $border ) {
			$border = is_array( $preset ) ? $preset['border'] : '2px';
		}
		if ( '' === $plus_minus_stroke ) {
			$plus_minus_stroke = is_array( $preset ) ? $preset['plusMinusStroke'] : '2px';
		}
		if ( '' === $plus_minus_size ) {
			$plus_minus_size = is_array( $preset ) ? $preset['plusMinusSize'] : '10px';
		}
		if ( '' === $font_size ) {
			$font_size = is_array( $preset ) ? $preset['fontSize'] : '1rem';
		}
		if ( '' === $open_offset ) {
			$open_offset = is_array( $preset ) ? $preset['openOffset'] : '45%';
		}

		// Caret sizing only applies to the rotating-arrow style; other styles draw
		// their own indicator from the same pseudo-elements.
		$arrow_style = strtolower( trim( (string) ( $flat['--bm-accordion-arrow-style'] ?? '' ) ) );
		if ( '' === $arrow_style ) {
			$arrow_style = self::is_css_flag_on( $flat['--bm-accordion-arrow-custom'] ?? null ) ? 'custom' : 'arrow';
		}
		$is_caret_style = 'arrow' === $arrow_style;

		// theme-agnostic.css already defines this exact default arrow rule.
		if (
			'inline-flex' === $display &&
			'right' === $position &&
			'0' === $order &&
			'auto' === $ml &&
			'0' === $mr &&
			'2rem' === $box_size &&
			'0.45rem' === $width &&
			'0.45rem' === $height &&
			'2px' === $border &&
			'2px' === $plus_minus_stroke &&
			'10px' === $plus_minus_size &&
			'1rem' === $font_size &&
			'45%' === $open_offset
		) {
			return '';
		}

		$css = "{$s} .bm-accordion__arrow {\n"
			. "\tdisplay: {$display} !important;\n"
			. "\tflex: 0 0 auto !important;\n"
			. "\torder: {$order} !important;\n"
			. "\tmargin-left: {$ml} !important;\n"
			. "\tmargin-right: {$mr} !important;\n"
			. "\twidth: {$box_size} !important;\n"
			. "\theight: {$box_size} !important;\n"
			. "\tpadding: 0 !important;\n"
			. "\tfont-size: {$font_size} !important;\n"
			. "\t--bm-accordion-arrow-open-offset: {$open_offset};\n"
			. "\t--bm-accordion-plus-minus-stroke: {$plus_minus_stroke};\n"
			. "\t--bm-accordion-plus-minus-size: {$plus_minus_size};\n"
			. '}';

		if ( $is_caret_style ) {
			$css .= "\n{$s} .bm-accordion__arrow::after {\n"
				. "\twidth: {$width} !important;\n"
				. "\theight: {$height} !important;\n"
				. "\tborder-right: {$border} solid currentColor !important;\n"
				. "\tborder-bottom: {$border} solid currentColor !important;\n"
				. '}';
		}

		return $css;
	}

	/**
	 * @param array<int, string>                   $parts    CSS parts.
	 * @param string                               $selector Selector.
	 * @param array<string, array<string, string>> $n        Normalized vars.
	 */
	private static function append_accordion_arrow_css( array &$parts, string $selector, array $n ): void {
		$desktop_rules = self::accordion_arrow_rules(
			$selector,
			self::effective( $n, 'desktop' )
		);
		if ( '' !== $desktop_rules ) {
			$parts[] = $desktop_rules;
		}
		foreach ( array( 'tablet', 'mobile' ) as $bp ) {
			if ( empty( $n[ $bp ] ) ) {
				continue;
			}
			$rules = self::accordion_arrow_rules(
				$selector,
				self::effective( $n, $bp )
			);
			if ( '' === $rules || $rules === $desktop_rules ) {
				continue;
			}
			$parts[] = '@media ' . self::MEDIA[ $bp ] . " {\n" . $rules . "\n}";
		}
	}

	/**
	 * Tablet/below burger tab nav rules (scoped to block selector).
	 *
	 * @param string $selector Block selector.
	 */
	public static function tabs_responsive_nav_rules( string $selector ): string {
		$s = $selector;
		return "{$s} .bm-tabs__nav {\n"
			. "\tflex-direction: column;\n"
			. "\tflex-wrap: nowrap;\n"
			. "\talign-items: stretch;\n"
			. "\tposition: relative;\n"
			. "\tz-index: 5;\n"
			. "\toverflow: visible;\n"
			. "}\n"
			. "{$s} .bm-tabs__nav-bar {\n"
			. "\tdisplay: flex !important;\n"
			. "\tposition: relative;\n"
			. "\tz-index: 2;\n"
			. "}\n"
			. "{$s} .bm-tabs__list {\n"
			. "\tposition: absolute;\n"
			. "\ttop: 100%;\n"
			. "\tleft: 0;\n"
			. "\tright: 0;\n"
			. "\tz-index: 30;\n"
			. "\tdisplay: flex;\n"
			. "\tflex-direction: column;\n"
			. "\tflex-wrap: nowrap;\n"
			. "\talign-items: stretch;\n"
			. "\tgap: 0;\n"
			. "\twidth: 100%;\n"
			. "\tmax-height: 0;\n"
			. "\topacity: 0;\n"
			. "\tvisibility: hidden;\n"
			. "\toverflow: hidden;\n"
			. "\tpointer-events: none;\n"
			. "\tbox-sizing: border-box;\n"
			. "\tmargin: 0;\n"
			. "\tpadding: 0.35rem 0;\n"
			. "\tbackground: var(--bm-bg, #fff);\n"
			. "\tcolor: var(--bm-text, inherit);\n"
			. "\tborder: 1px solid var(--bm-tab-list-border-color, rgba(0, 0, 0, 0.12));\n"
			. "\tborder-radius: 8px;\n"
			. "\tbox-shadow: 0 10px 28px rgba(15, 23, 42, 0.14);\n"
			. "\ttransform: translateY(-6px);\n"
			. "\ttransition: max-height 0.28s ease, opacity 0.2s ease, transform 0.2s ease, visibility 0.2s;\n"
			. "}\n"
			. "{$s} .bm-tabs__menu-check:checked ~ .bm-tabs__list {\n"
			. "\tmax-height: min(70vh, 32rem);\n"
			. "\topacity: 1;\n"
			. "\tvisibility: visible;\n"
			. "\tpointer-events: auto;\n"
			. "\toverflow: auto;\n"
			. "\ttransform: translateY(0);\n"
			. "}\n"
			. "{$s} .bm-tabs__label {\n"
			. "\tdisplay: block;\n"
			. "\twidth: 100%;\n"
			. "}\n"
			. "{$s} .bm-tabs__menu-check:checked ~ .bm-tabs__nav-bar .bm-tabs__burger {\n"
			. "\tbackground: transparent;\n"
			. "}\n"
			. "{$s} .bm-tabs__menu-check:checked ~ .bm-tabs__nav-bar .bm-tabs__burger::before {\n"
			. "\ttransform: translateY(var(--bm-tab-burger-gap, 6px)) rotate(45deg);\n"
			. "}\n"
			. "{$s} .bm-tabs__menu-check:checked ~ .bm-tabs__nav-bar .bm-tabs__burger::after {\n"
			. "\ttransform: translateY(calc(-1 * var(--bm-tab-burger-gap, 6px))) rotate(-45deg);\n"
			. '}';
	}

	/**
	 * When Tab bar → Responsive is on, emit collapse/burger rules for real viewports.
	 *
	 * @param array<int, string>           $parts    CSS parts.
	 * @param string                       $selector Selector.
	 * @param array<string, array<string, string>> $n Normalized vars.
	 */
	private static function append_tabs_responsive_nav_css( array &$parts, string $selector, array $n ): void {
		$flag = $n['desktop']['--bm-tab-nav-responsive'] ?? '';
		if ( ! self::is_css_flag_on( $flag ) ) {
			return;
		}
		$parts[] = '@media ' . self::MEDIA['tablet'] . " {\n" . self::tabs_responsive_nav_rules( $selector ) . "\n}";
	}

	/**
	 * Append side-by-side fullscreen rules for non-stacked viewports.
	 *
	 * @param array<int, string> $parts    CSS parts.
	 * @param string             $selector Selector.
	 * @param array              $n        Normalized vars.
	 */
	private static function append_media_frame_side_css( array &$parts, string $selector, array $n ): void {
		$stack_px = self::has_columns_breakpoint( $n['desktop'] )
			? self::resolve_columns_breakpoint_px( (string) $n['desktop']['--bm-columns-breakpoint'] )
			: null;
		$side     = self::media_frame_side_rules( $selector );

		$desktop_frame = self::effective( $n, 'desktop' )['--bm-media-frame'] ?? '';
		if ( self::is_media_fullscreen( (string) $desktop_frame ) ) {
			if ( null !== $stack_px ) {
				$min     = (int) $stack_px + 1;
				$parts[] = "@media (min-width: {$min}px) {\n{$side}\n}";
			} else {
				$parts[] = $side;
			}
		}

		foreach ( array( 'tablet', 'mobile' ) as $bp ) {
			$frame = self::effective( $n, $bp )['--bm-media-frame'] ?? '';
			if ( ! self::is_media_fullscreen( (string) $frame ) ) {
				continue;
			}
			$device_px = (int) self::DEVICE_WIDTH_PX[ $bp ];
			$stacked   = null !== $stack_px && $device_px <= $stack_px;
			if ( $stacked ) {
				continue;
			}
			$parts[] = '@media ' . self::MEDIA[ $bp ] . " {\n{$side}\n}";
		}
	}

	/**
	 * Force .bm-hero__grid into a stacked flex column (works over inline display:grid).
	 * Resets side-by-side fullscreen !important rules that must not apply when stacked.
	 *
	 * @param string $selector CSS selector.
	 * @return string
	 */
	private static function stacked_grid_rules( string $selector ): string {
		$cols = self::GRID_COLUMNS_ONE;
		return "{$selector} .bm-hero__grid {\n\tdisplay: flex !important;\n\tflex-direction: var(--bm-stacked-direction, column) !important;\n\tflex-wrap: nowrap !important;\n\tgrid-template-columns: {$cols} !important;\n\tgrid-template-rows: none !important;\n\twidth: 100% !important;\n\tmax-width: var(--bm-mf-stack-grid-max-width, var(--bm-content-max-width, 72rem)) !important;\n\tmargin: var(--bm-inner-margin, 0 auto) !important;\n\tgap: var(--bm-columns-gap, var(--bm-gap)) !important;\n\tcolumn-gap: var(--bm-columns-gap, var(--bm-gap)) !important;\n\trow-gap: var(--bm-columns-gap, var(--bm-gap)) !important;\n\talign-items: stretch !important;\n}";
	}

	/**
	 * @param array<string, string> $desktop Desktop flat vars.
	 */
	private static function has_columns_breakpoint( array $desktop ): bool {
		return isset( $desktop['--bm-columns-breakpoint'] ) && '' !== trim( (string) $desktop['--bm-columns-breakpoint'] );
	}

	/**
	 * Whether the map looks like a nested responsive structure.
	 *
	 * @param mixed $vars Vars.
	 */
	public static function is_responsive( $vars ): bool {
		if ( ! is_array( $vars ) ) {
			return false;
		}
		foreach ( self::BREAKPOINTS as $bp ) {
			if ( array_key_exists( $bp, $vars ) && is_array( $vars[ $bp ] ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Normalize flat or nested maps to { desktop, tablet, mobile }.
	 *
	 * @param mixed $vars Raw vars.
	 * @return array{desktop: array<string, string>, tablet: array<string, string>, mobile: array<string, string>}
	 */
	public static function normalize( $vars ): array {
		$empty = array(
			'desktop' => array(),
			'tablet'  => array(),
			'mobile'  => array(),
		);

		if ( ! is_array( $vars ) || array() === $vars ) {
			return $empty;
		}

		if ( self::is_responsive( $vars ) ) {
			foreach ( self::BREAKPOINTS as $bp ) {
				$flat = self::sanitize_flat( isset( $vars[ $bp ] ) && is_array( $vars[ $bp ] ) ? $vars[ $bp ] : array() );
				$healed = 'desktop' === $bp ? $flat : self::heal_sparse_bg_pollution( $flat );
				$empty[ $bp ] = self::normalize_author_image_align_vars(
					self::backfill_content_align_media_margins( $healed )
				);
			}
			return $empty;
		}

		// Flat legacy map → desktop only.
		$empty['desktop'] = self::normalize_author_image_align_vars(
			self::backfill_content_align_media_margins( self::sanitize_flat( $vars ) )
		);
		return $empty;
	}

	/**
	 * Rewrite legacy row-/column-reverse image placement to order-based vars.
	 *
	 * @param array<string, string> $flat Flat vars.
	 * @return array<string, string>
	 */
	private static function normalize_author_image_align_vars( array $flat ): array {
		$dir   = trim( (string) ( $flat['--bm-author-image-flex-direction'] ?? '' ) );
		$align = trim( (string) ( $flat['--bm-author-image-align'] ?? '' ) );
		$order = trim( (string) ( $flat['--bm-author-image-order'] ?? '' ) );

		$patches = array(
			'left'   => array(
				'--bm-author-image-align'           => 'left',
				'--bm-author-image-flex-direction'  => 'row',
				'--bm-author-image-align-items'     => 'center',
				'--bm-author-image-order'           => '0',
			),
			'right'  => array(
				'--bm-author-image-align'           => 'right',
				'--bm-author-image-flex-direction'  => 'row',
				'--bm-author-image-align-items'     => 'center',
				'--bm-author-image-order'           => '1',
			),
			'top'    => array(
				'--bm-author-image-align'           => 'top',
				'--bm-author-image-flex-direction'  => 'column',
				'--bm-author-image-align-items'     => 'var(--bm-author-align-self, flex-start)',
				'--bm-author-image-order'           => '0',
			),
			'bottom' => array(
				'--bm-author-image-align'           => 'bottom',
				'--bm-author-image-flex-direction'  => 'column',
				'--bm-author-image-align-items'     => 'var(--bm-author-align-self, flex-start)',
				'--bm-author-image-order'           => '1',
			),
		);

		if ( 'row-reverse' === $dir ) {
			return array_merge( $flat, $patches['right'] );
		}
		if ( 'column-reverse' === $dir ) {
			return array_merge( $flat, $patches['bottom'] );
		}
		if ( isset( $patches[ $align ] ) && '' === $order ) {
			return array_merge( $flat, $patches[ $align ] );
		}
		return $flat;
	}

	/**
	 * Fill media inline margins from content alignment when missing.
	 * Narrow images need these (hit-wrap is the flex child; img margins alone do not center).
	 *
	 * @param array<string, string> $flat Flat vars.
	 * @return array<string, string>
	 */
	private static function backfill_content_align_media_margins( array $flat ): array {
		$has_start = '' !== trim( (string) ( $flat['--bm-media-margin-inline-start'] ?? '' ) );
		$has_end   = '' !== trim( (string) ( $flat['--bm-media-margin-inline-end'] ?? '' ) );
		if ( $has_start && $has_end ) {
			return $flat;
		}

		$align = strtolower( trim( (string) ( $flat['--bm-content-align'] ?? '' ) ) );
		if ( ! in_array( $align, array( 'center', 'left', 'right' ), true ) ) {
			$items = trim( (string) ( $flat['--bm-align-items'] ?? '' ) );
			if ( 'center' === $items ) {
				$align = 'center';
			} elseif ( 'flex-end' === $items ) {
				$align = 'right';
			} elseif ( 'flex-start' === $items ) {
				$align = 'left';
			} else {
				// Sparse breakpoint maps must not invent center margins.
				return $flat;
			}
		}

		$patch = array(
			'center' => array( 'auto', 'auto' ),
			'left'   => array( '0', 'auto' ),
			'right'  => array( 'auto', '0' ),
		);
		$margins = $patch[ $align ] ?? $patch['center'];
		if ( ! $has_start ) {
			$flat['--bm-media-margin-inline-start'] = $margins[0];
		}
		if ( ! $has_end ) {
			$flat['--bm-media-margin-inline-end'] = $margins[1];
		}
		return $flat;
	}

	/**
	 * Sanitize a flat custom-property map.
	 *
	 * @param array<string, mixed> $vars Flat vars.
	 * @return array<string, string>
	 */
	public static function sanitize_flat( array $vars ): array {
		$out = array();
		foreach ( $vars as $key => $value ) {
			if ( is_array( $value ) ) {
				continue;
			}
			$key = sanitize_text_field( (string) $key );
			if ( '' === $key ) {
				continue;
			}
			if ( 0 !== strpos( $key, '--' ) ) {
				$key = '--' . ltrim( $key, '-' );
			}
			// Custom property names: letters, digits, underscore, hyphen only.
			if ( ! preg_match( '/^--[a-zA-Z0-9_-]+$/', $key ) ) {
				continue;
			}
			$out[ $key ] = self::sanitize_css_value( (string) $value );
		}
		return self::migrate_tab_active_bg(
			self::migrate_bg_gradient( self::migrate_overlay_opacity( $out ) )
		);
	}

	/**
	 * Seeded `--bm-tab-active-bg: transparent` blocked Default bg on the checked tab.
	 *
	 * @param array<string, string> $flat Flat vars.
	 * @return array<string, string>
	 */
	private static function migrate_tab_active_bg( array $flat ): array {
		$active = strtolower( trim( (string) ( $flat['--bm-tab-active-bg'] ?? '' ) ) );
		if ( 'transparent' !== $active ) {
			return $flat;
		}
		unset( $flat['--bm-tab-active-bg'] );
		return $flat;
	}

	/**
	 * Sanitize a CSS custom-property value (no declaration breakouts).
	 *
	 * @param string $value Raw value.
	 * @return string
	 */
	public static function sanitize_css_value( string $value ): string {
		$value = wp_strip_all_tags( $value );
		$value = str_replace( array( "\0", "\r", "\n" ), '', $value );
		// Block escaping a declaration or injecting extra rules.
		$value = str_replace( array( ';', '{', '}', '*/', '<!--', '-->' ), '', $value );
		$value = trim( $value );
		if ( '' === $value ) {
			return '';
		}
		// Reject expression()/javascript: and unbalanced quotes used for breakout.
		$lower = strtolower( $value );
		if (
			str_contains( $lower, 'expression(' )
			|| str_contains( $lower, 'javascript:' )
			|| str_contains( $lower, 'behavior:' )
			|| str_contains( $lower, '-moz-binding' )
		) {
			return '';
		}
		if ( substr_count( $value, '"' ) % 2 !== 0 || substr_count( $value, "'" ) % 2 !== 0 ) {
			return '';
		}
		// Allow common CSS tokens: colors, lengths, gradients, url("…"), fonts, keywords.
		if ( ! preg_match(
			'/^[a-zA-Z0-9#%.,\s_\\/+*()\\[\\]:%"\'\\-]+$/',
			$value
		) ) {
			return '';
		}
		return sanitize_text_field( $value );
	}

	/**
	 * Bake legacy --bm-overlay-opacity into --bm-overlay color alpha.
	 *
	 * @param array<string, string> $flat Flat vars.
	 * @return array<string, string>
	 */
	private static function migrate_overlay_opacity( array $flat ): array {
		if ( ! array_key_exists( '--bm-overlay-opacity', $flat ) ) {
			return $flat;
		}

		$opacity = (float) $flat['--bm-overlay-opacity'];
		$overlay = isset( $flat['--bm-overlay'] ) ? (string) $flat['--bm-overlay'] : '';

		// Keep at 1 so older markup that still references the var stays visible.
		$flat['--bm-overlay-opacity'] = '1';

		if ( $opacity >= 1.0 ) {
			return $flat;
		}

		if ( '' === $overlay || false !== stripos( $overlay, 'gradient' ) ) {
			return $flat;
		}

		$flat['--bm-overlay'] = self::apply_alpha_to_color( $overlay, $opacity );
		return $flat;
	}

	/**
	 * Move gradient fills off --bm-bg onto --bm-bg-gradient so background-image stays valid with an image URL.
	 *
	 * Do not invent --bm-bg-image / --bm-bg-gradient on sparse breakpoint maps — writing `none`
	 * there overrides desktop inheritance and hides the image.
	 *
	 * @param array<string, string> $flat Flat vars.
	 * @return array<string, string>
	 */
	private static function migrate_bg_gradient( array $flat ): array {
		$has_bg       = array_key_exists( '--bm-bg', $flat );
		$has_gradient = array_key_exists( '--bm-bg-gradient', $flat );
		$has_image    = array_key_exists( '--bm-bg-image', $flat );
		$bg           = $has_bg ? trim( (string) $flat['--bm-bg'] ) : '';
		$existing     = $has_gradient ? trim( (string) $flat['--bm-bg-gradient'] ) : '';

		if ( $has_bg && false !== stripos( $bg, 'gradient' ) ) {
			if (
				! $has_gradient
				|| '' === $existing
				|| 0 === strcasecmp( $existing, 'none' )
				|| false === stripos( $existing, 'gradient' )
			) {
				$flat['--bm-bg-gradient'] = $bg;
			}
			$flat['--bm-bg'] = '#3525cd';
		}

		if ( $has_image && '' === trim( (string) $flat['--bm-bg-image'] ) ) {
			$flat['--bm-bg-image'] = 'none';
		}

		if ( $has_gradient && '' === trim( (string) ( $flat['--bm-bg-gradient'] ?? '' ) ) ) {
			$flat['--bm-bg-gradient'] = 'none';
		}

		return $flat;
	}

	/**
	 * Drop paired `none` defaults wrongly injected onto tablet/mobile sparse maps.
	 *
	 * @param array<string, string> $flat Flat vars.
	 * @return array<string, string>
	 */
	private static function heal_sparse_bg_pollution( array $flat ): array {
		$has_img  = array_key_exists( '--bm-bg-image', $flat );
		$has_grad = array_key_exists( '--bm-bg-gradient', $flat );
		$img      = $has_img ? strtolower( trim( (string) $flat['--bm-bg-image'] ) ) : '';
		$grad     = $has_grad ? strtolower( trim( (string) $flat['--bm-bg-gradient'] ) ) : '';

		if ( $has_img && $has_grad && 'none' === $img && 'none' === $grad ) {
			unset( $flat['--bm-bg-image'], $flat['--bm-bg-gradient'] );
		}

		return $flat;
	}

	/**
	 * Apply an alpha channel to a hex/rgb color.
	 *
	 * @param string $color Color.
	 * @param float  $alpha Alpha 0–1.
	 */
	private static function apply_alpha_to_color( string $color, float $alpha ): string {
		$alpha = max( 0.0, min( 1.0, $alpha ) );
		$raw   = trim( $color );

		if ( '' === $raw || false !== stripos( $raw, 'gradient' ) ) {
			return $raw;
		}
		if ( $alpha <= 0.0 ) {
			return 'transparent';
		}
		if ( $alpha >= 1.0 ) {
			return $raw;
		}

		if ( preg_match( '/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i', $raw, $m ) ) {
			return sprintf(
				'rgba(%d, %d, %d, %s)',
				(int) round( (float) $m[1] ),
				(int) round( (float) $m[2] ),
				(int) round( (float) $m[3] ),
				rtrim( rtrim( number_format( $alpha, 3, '.', '' ), '0' ), '.' )
			);
		}

		$hex = ltrim( $raw, '#' );
		if ( 3 === strlen( $hex ) ) {
			$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
		}
		if ( 8 === strlen( $hex ) ) {
			$hex = substr( $hex, 0, 6 );
		}
		if ( ! preg_match( '/^[0-9a-fA-F]{6}$/', $hex ) ) {
			return $raw;
		}

		return sprintf(
			'rgba(%d, %d, %d, %s)',
			hexdec( substr( $hex, 0, 2 ) ),
			hexdec( substr( $hex, 2, 2 ) ),
			hexdec( substr( $hex, 4, 2 ) ),
			rtrim( rtrim( number_format( $alpha, 3, '.', '' ), '0' ), '.' )
		);
	}

	/**
	 * Effective vars for a breakpoint (desktop → tablet → mobile cascade).
	 *
	 * @param mixed  $vars       Raw or nested vars.
	 * @param string $breakpoint desktop|tablet|mobile.
	 * @return array<string, string>
	 */
	public static function effective( $vars, string $breakpoint = 'desktop' ): array {
		$n = self::normalize( $vars );
		$out = $n['desktop'];
		if ( 'tablet' === $breakpoint || 'mobile' === $breakpoint ) {
			$out = array_merge( $out, $n['tablet'] );
		}
		if ( 'mobile' === $breakpoint ) {
			$out = array_merge( $out, $n['mobile'] );
		}
		return $out;
	}

	/**
	 * Parent cascade for a breakpoint (what a key inherits from if unset).
	 *
	 * @param array{desktop: array, tablet: array, mobile: array} $normalized Normalized.
	 * @param string                                               $breakpoint Breakpoint.
	 * @return array<string, string>
	 */
	public static function parent_effective( array $normalized, string $breakpoint ): array {
		if ( 'desktop' === $breakpoint ) {
			return array();
		}
		if ( 'tablet' === $breakpoint ) {
			return $normalized['desktop'];
		}
		return array_merge( $normalized['desktop'], $normalized['tablet'] );
	}

	/**
	 * Set a var on a breakpoint; sparse for tablet/mobile (omit if equals parent).
	 *
	 * @param mixed  $vars       Current vars.
	 * @param string $breakpoint Breakpoint.
	 * @param string $key        Custom property.
	 * @param string $value      Value.
	 * @return array{desktop: array<string, string>, tablet: array<string, string>, mobile: array<string, string>}
	 */
	public static function set_breakpoint_var( $vars, string $breakpoint, string $key, string $value ): array {
		$n = self::normalize( $vars );
		if ( ! in_array( $breakpoint, self::BREAKPOINTS, true ) ) {
			$breakpoint = 'desktop';
		}
		$key = sanitize_text_field( $key );
		if ( 0 !== strpos( $key, '--' ) ) {
			$key = '--' . ltrim( $key, '-' );
		}
		$value = sanitize_text_field( $value );

		if ( 'desktop' === $breakpoint ) {
			$n['desktop'][ $key ] = $value;
			return $n;
		}

		$parent = self::parent_effective( $n, $breakpoint );
		$parent_val = $parent[ $key ] ?? '';
		if ( (string) $parent_val === (string) $value ) {
			unset( $n[ $breakpoint ][ $key ] );
		} else {
			$n[ $breakpoint ][ $key ] = $value;
		}
		return $n;
	}

	/**
	 * Count stored CSS custom properties across breakpoints.
	 *
	 * @param mixed $vars Css vars map.
	 * @return int
	 */
	public static function count_keys( $vars ): int {
		$n     = self::normalize( $vars );
		$count = 0;
		foreach ( self::BREAKPOINTS as $bp ) {
			$count += count( $n[ $bp ] );
		}
		return $count;
	}

	/**
	 * True when placement cssVars look like a full library bake (attribute default
	 * / old snapshot), not sparse intentional overrides.
	 *
	 * @param mixed $instance Instance cssVars.
	 * @param mixed $library  Library / saved cssVars.
	 * @param int   $min_keys Minimum keys before treating as a snapshot.
	 * @param float $ratio    Shared-key ratio vs library shape.
	 * @return bool
	 */
	public static function looks_like_library_snapshot( $instance, $library, int $min_keys = 4, float $ratio = 0.75 ): bool {
		$inst   = self::normalize( $instance );
		$lib    = self::normalize( $library );
		$inst_n = self::count_keys( $inst );
		$lib_n  = self::count_keys( $lib );
		if ( $inst_n < $min_keys || $lib_n < $min_keys ) {
			return false;
		}
		$shared = 0;
		foreach ( self::BREAKPOINTS as $bp ) {
			foreach ( $inst[ $bp ] as $key => $_value ) {
				if ( array_key_exists( $key, $lib[ $bp ] ) ) {
					++$shared;
				}
			}
		}
		return $shared >= ( $lib_n * $ratio ) && $inst_n >= ( $lib_n * $ratio );
	}

	/**
	 * Diff instance overrides vs saved defaults (per breakpoint, sparse).
	 *
	 * @param mixed $instance Instance cssVars.
	 * @param mixed $saved    Saved block cssVars.
	 * @return array{desktop: array<string, string>, tablet: array<string, string>, mobile: array<string, string>}
	 */
	public static function overrides( $instance, $saved ): array {
		$inst = self::normalize( $instance );
		$base = self::normalize( $saved );
		$out  = array(
			'desktop' => array(),
			'tablet'  => array(),
			'mobile'  => array(),
		);

		foreach ( self::BREAKPOINTS as $bp ) {
			foreach ( $inst[ $bp ] as $key => $value ) {
				$saved_val = $base[ $bp ][ $key ] ?? null;
				// For desktop, also compare against effective saved if key only on desktop saved.
				if ( 'desktop' === $bp && null === $saved_val ) {
					$saved_val = $base['desktop'][ $key ] ?? null;
				}
				if ( (string) $saved_val !== (string) $value ) {
					$out[ $bp ][ $key ] = $value;
				}
			}
		}

		return $out;
	}

	/**
	 * Ensure instance override CSS can emit a columns stack media query and
	 * wrap fullscreen side rules above the stack breakpoint.
	 * Sparse overrides often include --bm-media-frame or --bm-columns-stack-order
	 * without the breakpoint from the library.
	 *
	 * Also pulls accordion item border width/style/color from the saved library
	 * when Closing border is present in overrides, so baked closing rules match
	 * the JBM block editor (full effective cssVars).
	 *
	 * @param mixed $overrides Instance override vars.
	 * @param mixed $saved     Saved block cssVars.
	 * @return array{desktop: array<string, string>, tablet: array<string, string>, mobile: array<string, string>}
	 */
	public static function prepare_instance_css_vars( $overrides, $saved ) {
		$over = self::normalize( $overrides );
		$base = self::normalize( $saved );

		$touches_media_frame = false;
		$touches_closing_border = false;
		foreach ( self::BREAKPOINTS as $bp ) {
			if ( array_key_exists( '--bm-media-frame', $over[ $bp ] ) ) {
				$touches_media_frame = true;
			}
			if ( array_key_exists( '--bm-item-closing-border', $over[ $bp ] ) ) {
				$touches_closing_border = true;
			}
		}

		$needs_stack_context =
			$touches_media_frame ||
			array_key_exists( '--bm-columns-stack-order', $over['desktop'] ) ||
			array_key_exists( '--bm-columns-breakpoint', $over['desktop'] );

		if ( ! $needs_stack_context && ! $touches_closing_border ) {
			return $over;
		}

		$context = array(
			'desktop' => array(),
			'tablet'  => array(),
			'mobile'  => array(),
		);

		if ( $needs_stack_context ) {
			if ( empty( $over['desktop']['--bm-columns-breakpoint'] ) && ! empty( $base['desktop']['--bm-columns-breakpoint'] ) ) {
				$context['desktop']['--bm-columns-breakpoint'] = $base['desktop']['--bm-columns-breakpoint'];
			}
			if ( empty( $over['desktop']['--bm-columns-stack-order'] ) && ! empty( $base['desktop']['--bm-columns-stack-order'] ) ) {
				$context['desktop']['--bm-columns-stack-order'] = $base['desktop']['--bm-columns-stack-order'];
			}
			if ( empty( $over['desktop']['--bm-columns-dom-order'] ) && ! empty( $base['desktop']['--bm-columns-dom-order'] ) ) {
				$context['desktop']['--bm-columns-dom-order'] = $base['desktop']['--bm-columns-dom-order'];
			} elseif ( empty( $over['desktop']['--bm-columns-dom-order'] ) && empty( $context['desktop']['--bm-columns-dom-order'] ) ) {
				// Infer from saved markup-driven defaults when missing on older blocks.
				$context['desktop']['--bm-columns-dom-order'] = 'media-first';
			}
		}

		if ( $touches_closing_border ) {
			$border_keys = array(
				'--bm-item-border-width',
				'--bm-item-border-style',
				'--bm-item-border-color',
				'--bm-item-border-top-width',
				'--bm-item-border-right-width',
				'--bm-item-border-bottom-width',
				'--bm-item-border-left-width',
			);
			foreach ( self::BREAKPOINTS as $bp ) {
				if ( ! array_key_exists( '--bm-item-closing-border', $over[ $bp ] ) && 'desktop' !== $bp ) {
					// Still fill desktop when closing is only on a lower breakpoint.
					if ( empty( $over[ $bp ] ) ) {
						continue;
					}
				}
				$need_bp = array_key_exists( '--bm-item-closing-border', $over[ $bp ] )
					|| ( 'desktop' === $bp && $touches_closing_border );
				if ( ! $need_bp ) {
					continue;
				}
				foreach ( $border_keys as $key ) {
					if ( array_key_exists( $key, $over[ $bp ] ) ) {
						continue;
					}
					if ( array_key_exists( $key, $base[ $bp ] ) ) {
						$context[ $bp ][ $key ] = $base[ $bp ][ $key ];
					} elseif ( 'desktop' !== $bp && array_key_exists( $key, $base['desktop'] ) ) {
						$context[ $bp ][ $key ] = $base['desktop'][ $key ];
					}
				}
			}
		}

		$has_context = false;
		foreach ( self::BREAKPOINTS as $bp ) {
			if ( ! empty( $context[ $bp ] ) ) {
				$has_context = true;
				break;
			}
		}
		if ( ! $has_context ) {
			return $over;
		}

		return self::merge( $context, $over );
	}

	/**
	 * Merge two var maps (override wins per breakpoint).
	 *
	 * @param mixed $base     Base vars.
	 * @param mixed $override Override vars.
	 * @return array{desktop: array<string, string>, tablet: array<string, string>, mobile: array<string, string>}
	 */
	public static function merge( $base, $override ): array {
		$a = self::normalize( $base );
		$b = self::normalize( $override );
		return array(
			'desktop' => array_merge( $a['desktop'], $b['desktop'] ),
			'tablet'  => array_merge( $a['tablet'], $b['tablet'] ),
			'mobile'  => array_merge( $a['mobile'], $b['mobile'] ),
		);
	}

	/**
	 * Whether any breakpoint has vars.
	 *
	 * @param array{desktop: array, tablet: array, mobile: array} $normalized Normalized.
	 */
	public static function has_any( array $normalized ): bool {
		foreach ( self::BREAKPOINTS as $bp ) {
			if ( ! empty( $normalized[ $bp ] ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Build CSS with media queries for a selector.
	 *
	 * @param string $selector CSS selector (may contain commas).
	 * @param mixed  $vars     Raw or nested vars.
	 */
	public static function to_css( string $selector, $vars ): string {
		$n     = self::normalize( $vars );
		$parts = array();

		$desktop = $n['desktop'];
		if ( self::has_columns_breakpoint( $desktop ) ) {
			$desktop['--bm-grid-template-columns'] = self::GRID_COLUMNS_TWO;
		}
		$desktop = self::expand_layout_vars( $desktop );

		$base = self::declarations_block( $selector, $desktop );
		if ( '' !== $base ) {
			$parts[] = $base;
		}

		foreach ( array( 'tablet', 'mobile' ) as $bp ) {
			if ( empty( $n[ $bp ] ) ) {
				continue;
			}
			$inner = self::declarations_block( $selector, self::layout_flat_for_breakpoint( $n, $bp ) );
			if ( '' === $inner ) {
				continue;
			}
			$parts[] = '@media ' . self::MEDIA[ $bp ] . " {\n" . $inner . "\n}";
		}

		// Side fullscreen, framed resets, then stack MQ.
		self::append_media_frame_side_css( $parts, $selector, $n );
		self::append_media_frame_framed_css( $parts, $selector, $n );
		self::append_tabs_responsive_nav_css( $parts, $selector, $n );
		self::append_accordion_closing_border_css( $parts, $selector, $n );
		self::append_accordion_arrow_css( $parts, $selector, $n );

		if ( self::has_columns_breakpoint( $n['desktop'] ) ) {
			$px   = self::resolve_columns_breakpoint_px( (string) $n['desktop']['--bm-columns-breakpoint'] );
			$body = "\t--bm-grid-template-columns: " . self::GRID_COLUMNS_ONE . ";\n";
			$body .= "\t--bm-media-order: var(--bm-stacked-media-order, 0);\n";
			$body .= "\t--bm-content-order: var(--bm-stacked-content-order, 0);\n";
			$body .= "\t--bm-mf-img-height: var(--bm-image-height, auto);\n";
			$body .= "\t--bm-mf-img-aspect: var(--bm-image-aspect-ratio, 16/9);\n";
			$stack = "{$selector} {\n{$body}}\n" . self::stacked_grid_rules( $selector );
			$stack .= "\n" . self::media_frame_bleed_rules( $selector );
			$parts[] = "@media (max-width: {$px}px) {\n{$stack}\n}";
		} elseif ( array_key_exists( '--bm-columns-stack-order', $n['desktop'] ) ) {
			$expanded  = self::expand_layout_vars( $n['desktop'] );
			$order_only = array(
				'--bm-stacked-media-order'   => $expanded['--bm-stacked-media-order'] ?? '0',
				'--bm-stacked-content-order' => $expanded['--bm-stacked-content-order'] ?? '0',
				'--bm-stacked-direction'     => $expanded['--bm-stacked-direction'] ?? 'column',
			);
			$inner = self::declarations_block( $selector, $order_only );
			if ( '' !== $inner ) {
				$parts[] = $inner;
			}
		}

		$hover = self::element_hover_css_from_vars( $selector, $vars );
		if ( '' !== $hover ) {
			$parts[] = $hover;
		}

		$fonts = self::element_font_family_css_from_vars( $selector, $vars );
		if ( '' !== $fonts ) {
			$parts[] = $fonts;
		}

		$spacing = self::element_spacing_css_from_vars( $selector, $vars );
		if ( '' !== $spacing ) {
			$parts[] = $spacing;
		}

		return implode( "\n\n", $parts );
	}

	/**
	 * Fields that opt into per-element style toolbars.
	 *
	 * @param mixed $field_schema Field schema.
	 * @return array<int, array<string, mixed>>
	 */
	public static function styleable_fields( $field_schema ): array {
		if ( ! is_array( $field_schema ) ) {
			return array();
		}
		$out = array();
		foreach ( $field_schema as $field ) {
			if ( ! is_array( $field ) || empty( $field['key'] ) ) {
				continue;
			}
			if ( empty( $field['elementStyle'] ) ) {
				continue;
			}
			$out[] = $field;
		}
		return $out;
	}

	/**
	 * Sanitize element id for CSS attribute selectors.
	 *
	 * @param string $id Raw id.
	 */
	private static function sanitize_el_id( string $id ): string {
		return (string) preg_replace( '/[^a-zA-Z0-9_-]/', '', $id );
	}

	/**
	 * Whether a cssVars key stores HTML class/id (not emitted as CSS).
	 *
	 * @param string $key Var name.
	 */
	public static function is_element_html_attr_var( string $key ): bool {
		return (bool) preg_match( '/^--bm-.+-(class|id)$/i', $key );
	}

	/**
	 * Parse `--bm-{el}-class` / `--bm-{el}-id`.
	 *
	 * @param string $key Var name.
	 * @return array{el: string, attr: string}|null
	 */
	public static function parse_element_html_attr_key( string $key ): ?array {
		if ( ! preg_match( '/^--bm-(.+)-(class|id)$/i', $key, $m ) ) {
			return null;
		}
		$el = self::sanitize_el_id( (string) $m[1] );
		if ( '' === $el ) {
			return null;
		}
		return array(
			'el'   => $el,
			'attr' => 'id' === strtolower( (string) $m[2] ) ? 'id' : 'class',
		);
	}

	/**
	 * Sanitize a space-separated HTML class list.
	 *
	 * @param string $value Raw value.
	 */
	public static function sanitize_element_class_name( string $value ): string {
		$parts = preg_split( '/\s+/', trim( $value ) ) ?: array();
		$out   = array();
		foreach ( $parts as $part ) {
			$clean = sanitize_html_class( (string) $part );
			if ( '' !== $clean ) {
				$out[] = $clean;
			}
		}
		return implode( ' ', array_unique( $out ) );
	}

	/**
	 * Sanitize a single HTML id.
	 *
	 * @param string $value Raw value.
	 */
	public static function sanitize_element_html_id( string $value ): string {
		$raw = preg_replace( '/[^a-zA-Z0-9_:-]/', '', trim( $value ) ) ?? '';
		if ( '' === $raw || ! preg_match( '/^[A-Za-z]/', $raw ) ) {
			return '';
		}
		return $raw;
	}

	/**
	 * Chrome / shared surface var namespaces that are not data-bm-el targets.
	 * `--bm-tab-padding-*` must not emit `[data-bm-el="tab"]` (labels use tabTitle).
	 *
	 * @param string $id Sanitized el id.
	 */
	private static function is_chrome_spacing_el_id( string $id ): bool {
		return in_array( $id, array( 'tab' ), true );
	}

	/**
	 * Hover rules for button-type styleable fields.
	 *
	 * @param string               $block_selector Block root selector.
	 * @param array<int, mixed>    $fields         Styleable fields.
	 */
	public static function element_hover_css( string $block_selector, array $fields ): string {
		$parts = array();
		foreach ( $fields as $field ) {
			if ( ! is_array( $field ) || empty( $field['key'] ) ) {
				continue;
			}
			if ( empty( $field['elementStyle'] ) || ( $field['elementType'] ?? '' ) !== 'button' ) {
				continue;
			}
			$id = self::sanitize_el_id( (string) $field['key'] );
			if ( '' === $id ) {
				continue;
			}
			$el_sel = ! empty( $field['selector'] )
				? (string) $field['selector']
				: '[data-bm-el="' . $id . '"]';
			$parts[] = "{$block_selector} {$el_sel} {\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[] = "{$block_selector} {$el_sel}:hover {\n\tbackground: var(--bm-{$id}-bg-hover) !important;\n\tbackground-color: var(--bm-{$id}-bg-hover) !important;\n\tcolor: var(--bm-{$id}-color-hover, inherit) !important;\n\tbox-shadow: var(--bm-{$id}-shadow-hover, none);\n}";
		}
		return implode( "\n\n", $parts );
	}

	/**
	 * Hover rules derived from `--bm-{id}-bg-hover` keys.
	 *
	 * @param string $block_selector Block root selector.
	 * @param mixed  $vars           Vars map.
	 */
	public static function element_hover_css_from_vars( string $block_selector, $vars ): string {
		$n    = self::normalize( $vars );
		$keys = array_unique(
			array_merge(
				array_keys( $n['desktop'] ),
				array_keys( $n['tablet'] ),
				array_keys( $n['mobile'] )
			)
		);
		$parts = array();
		foreach ( $keys as $key ) {
			if ( ! preg_match( '/^--bm-(.+)-bg-hover$/', (string) $key, $m ) ) {
				continue;
			}
			$id = self::sanitize_el_id( $m[1] );
			if ( '' === $id ) {
				continue;
			}
			$parts[] = "{$block_selector} [data-bm-el=\"{$id}\"] {\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[] = "{$block_selector} [data-bm-el=\"{$id}\"]:hover {\n\tbackground: var(--bm-{$id}-bg-hover) !important;\n\tbackground-color: var(--bm-{$id}-bg-hover) !important;\n\tcolor: var(--bm-{$id}-color-hover, inherit) !important;\n\tbox-shadow: var(--bm-{$id}-shadow-hover, none);\n}";
		}
		return implode( "\n\n", $parts );
	}

	/**
	 * Typography longhands from ElementToolbar (bake with !important).
	 *
	 * @var array<int, string>
	 */
	private const ELEMENT_TYPOGRAPHY_PROPS = array(
		'font-family',
		'font-size',
		'font-weight',
		'line-height',
		'letter-spacing',
		'color',
	);

	/**
	 * Per-element typography when `--bm-{id}-{prop}` is set.
	 * !important so card/hero titles beat theme-agnostic heading scale.
	 *
	 * @param string $block_selector Block root selector.
	 * @param mixed  $vars           Vars map.
	 */
	public static function element_font_family_css_from_vars( string $block_selector, $vars ): string {
		$n    = self::normalize( $vars );
		$keys = array_unique(
			array_merge(
				array_keys( $n['desktop'] ),
				array_keys( $n['tablet'] ),
				array_keys( $n['mobile'] )
			)
		);
		$parts = array();
		$by_id = array();
		foreach ( $keys as $key ) {
			$key = (string) $key;
			foreach ( self::ELEMENT_TYPOGRAPHY_PROPS as $prop ) {
				$prefix = '--bm-';
				$suffix = '-' . $prop;
				if ( ! str_starts_with( $key, $prefix ) || ! str_ends_with( $key, $suffix ) ) {
					continue;
				}
				$id = self::sanitize_el_id( substr( $key, strlen( $prefix ), -strlen( $suffix ) ) );
				if ( '' === $id ) {
					continue;
				}
				if ( ! isset( $by_id[ $id ] ) ) {
					$by_id[ $id ] = array();
				}
				$by_id[ $id ][ $prop ] = true;
				break;
			}
		}
		foreach ( $by_id as $id => $props ) {
			$decls = array();
			foreach ( self::ELEMENT_TYPOGRAPHY_PROPS as $prop ) {
				if ( empty( $props[ $prop ] ) ) {
					continue;
				}
				$fallback = 'font-family' === $prop ? ', inherit' : '';
				$decls[]  = "\t{$prop}: var(--bm-{$id}-{$prop}{$fallback}) !important;";
			}
			if ( ! $decls ) {
				continue;
			}
			$parts[] = "{$block_selector} [data-bm-el=\"{$id}\"] {\n" . implode( "\n", $decls ) . "\n}";
		}
		return implode( "\n\n", $parts );
	}

	/**
	 * Per-element margin/padding when `--bm-{id}-{prop}` is set.
	 *
	 * @param string $block_selector Block root selector.
	 * @param mixed  $vars           Vars map.
	 */
	public static function element_spacing_css_from_vars( string $block_selector, $vars ): string {
		$n    = self::normalize( $vars );
		$keys = array_unique(
			array_merge(
				array_keys( $n['desktop'] ),
				array_keys( $n['tablet'] ),
				array_keys( $n['mobile'] )
			)
		);
		$by_id = array();
		foreach ( $keys as $key ) {
			if ( ! preg_match( '/^--bm-(.+)-(margin|padding)-(top|right|bottom|left)$/', (string) $key, $m ) ) {
				continue;
			}
			$id = self::sanitize_el_id( $m[1] );
			// Skip chrome namespaces (e.g. --bm-tab-padding-* → "tab"); labels use tabTitle.
			if ( '' === $id || self::is_chrome_spacing_el_id( $id ) ) {
				continue;
			}
			$prop = $m[2] . '-' . $m[3];
			if ( ! isset( $by_id[ $id ] ) ) {
				$by_id[ $id ] = array();
			}
			$by_id[ $id ][ $prop ] = true;
		}

		$order = array(
			'margin-top',
			'margin-right',
			'margin-bottom',
			'margin-left',
			'padding-top',
			'padding-right',
			'padding-bottom',
			'padding-left',
		);
		$parts = array();
		foreach ( $by_id as $id => $props ) {
			$decls = array();
			foreach ( $order as $prop ) {
				if ( empty( $props[ $prop ] ) ) {
					continue;
				}
				$decls[] = "\t{$prop}: var(--bm-{$id}-{$prop});";
			}
			if ( empty( $decls ) ) {
				continue;
			}
			$stretch = ( 'header' === $id || 'subheader' === $id )
				? "\talign-self: stretch;\n\twidth: 100%;\n\tbox-sizing: border-box;\n"
				: '';
			$parts[] = "{$block_selector} [data-bm-el=\"{$id}\"] {\n{$stretch}" . implode( "\n", $decls ) . "\n}";
		}
		return implode( "\n\n", $parts );
	}

	/**
	 * Declarations block for a selector.
	 *
	 * @param string                $selector Selector.
	 * @param array<string, string> $vars     Flat vars.
	 */
	private static function declarations_block( string $selector, array $vars ): string {
		$flat = self::sanitize_flat( $vars );
		if ( empty( $flat ) ) {
			return '';
		}
		if ( empty( $flat['--bm-font-family'] ) ) {
			$flat['--bm-font-family'] = self::DEFAULT_FONT_FAMILY;
		}
		// Beat stale library `--bm-tab-active-bg: transparent` so Default bg applies.
		if ( isset( $flat['--bm-tab-bg'] ) || array_key_exists( '--bm-tab-active-bg', $flat ) ) {
			$active = strtolower( trim( (string) ( $flat['--bm-tab-active-bg'] ?? '' ) ) );
			if ( '' === $active || 'transparent' === $active ) {
				$flat['--bm-tab-active-bg'] = 'initial';
			}
		}
		$lines   = array( "{$selector} {" );
		$lines[] = "\tfont-family: var(--bm-font-family, " . self::DEFAULT_FONT_FAMILY . ");";
		foreach ( $flat as $key => $value ) {
			if ( '' === $value || self::is_element_html_attr_var( (string) $key ) ) {
				continue;
			}
			$lines[] = "\t{$key}: {$value};";
		}
		$lines[] = '}';
		return implode( "\n", $lines );
	}

	/**
	 * @deprecated 0.2.1 Prefer to_css() with wp_add_inline_style(). Kept for BC callers.
	 *
	 * @param string $selector Selector.
	 * @param mixed  $vars     Vars.
	 * @param string $id       Unused (was style element id).
	 * @return string Always empty — do not emit hard-coded style elements.
	 */
	public static function to_style_tag( string $selector, $vars, string $id = '' ): string {
		unset( $selector, $vars, $id );
		return '';
	}

	/** @var array<int, string> Layout + spacing keys stored per slide. */
	public const SLIDE_LAYOUT_KEYS = array(
		'--bm-content-align',
		'--bm-text-align',
		'--bm-align-items',
		'--bm-justify-content',
		'--bm-container-align',
		'--bm-inner-margin',
		'--bm-media-margin-inline-start',
		'--bm-media-margin-inline-end',
		'--bm-content-max-width',
		'--bm-min-height',
		'--bm-columns-breakpoint',
		'--bm-columns-stack-order',
		'--bm-media-frame',
		// Container Spacing
		'--bm-padding',
		'--bm-padding-y',
		'--bm-padding-x',
		'--bm-padding-top',
		'--bm-padding-right',
		'--bm-padding-bottom',
		'--bm-padding-left',
		'--bm-margin',
		'--bm-margin-y',
		'--bm-margin-x',
		'--bm-margin-top',
		'--bm-margin-right',
		'--bm-margin-bottom',
		'--bm-margin-left',
		// Elements Spacing
		'--bm-gap',
		'--bm-columns-gap',
	);

	/**
	 * Pick Layout keys from a css vars map.
	 *
	 * @param mixed $vars Vars.
	 * @return array{desktop: array<string, string>, tablet: array<string, string>, mobile: array<string, string>}
	 */
	public static function pick_slide_layout_vars( $vars ): array {
		$n   = self::normalize( $vars );
		$out = array(
			'desktop' => array(),
			'tablet'  => array(),
			'mobile'  => array(),
		);
		foreach ( self::BREAKPOINTS as $bp ) {
			foreach ( self::SLIDE_LAYOUT_KEYS as $key ) {
				if ( isset( $n[ $bp ][ $key ] ) && '' !== trim( (string) $n[ $bp ][ $key ] ) ) {
					$out[ $bp ][ $key ] = $n[ $bp ][ $key ];
				}
			}
		}
		return $out;
	}

	/**
	 * Resolve Layout cssVars for one slide (own → fill missing from block → empty).
	 *
	 * @param array<string, mixed> $slide         Slide.
	 * @param mixed                $block_css_vars Block css vars.
	 * @return array{desktop: array<string, string>, tablet: array<string, string>, mobile: array<string, string>}
	 */
	public static function resolve_slide_layout_vars( array $slide, $block_css_vars = array() ): array {
		$own        = self::normalize( isset( $slide['cssVars'] ) ? $slide['cssVars'] : array() );
		$from_block = self::pick_slide_layout_vars( $block_css_vars );
		if ( self::has_any( $own ) ) {
			return self::backfill_slide_justify_content( self::merge( $from_block, $own ) );
		}
		return self::backfill_slide_justify_content( $from_block );
	}

	/**
	 * Derive --bm-justify-content when Content alignment is set but justify is missing.
	 *
	 * @param array{desktop?: array<string, string>, tablet?: array<string, string>, mobile?: array<string, string>} $vars Vars.
	 * @return array{desktop: array<string, string>, tablet: array<string, string>, mobile: array<string, string>}
	 */
	private static function backfill_slide_justify_content( array $vars ): array {
		$vars = self::normalize( $vars );
		$map  = array(
			'center' => 'center',
			'left'   => 'flex-start',
			'right'  => 'flex-end',
		);
		foreach ( array( 'desktop', 'tablet', 'mobile' ) as $bp ) {
			if ( empty( $vars[ $bp ] ) || ! is_array( $vars[ $bp ] ) ) {
				continue;
			}
			if ( '' !== trim( (string) ( $vars[ $bp ]['--bm-justify-content'] ?? '' ) ) ) {
				continue;
			}
			$align = strtolower( trim( (string) ( $vars[ $bp ]['--bm-content-align'] ?? '' ) ) );
			if ( isset( $map[ $align ] ) ) {
				$vars[ $bp ]['--bm-justify-content'] = $map[ $align ];
				continue;
			}
			$items = trim( (string) ( $vars[ $bp ]['--bm-align-items'] ?? '' ) );
			if ( '' !== $items ) {
				$vars[ $bp ]['--bm-justify-content'] = $items;
			}
		}
		return $vars;
	}

	/**
	 * Emit Layout CSS for each slide under a block selector.
	 *
	 * @param string               $block_selector Block CSS selector.
	 * @param array<int, mixed>    $slides         Slides.
	 * @param mixed                $block_css_vars Fallback block vars.
	 */
	public static function to_css_for_slides( string $block_selector, array $slides, $block_css_vars = array() ): string {
		if ( '' === $block_selector || ! $slides ) {
			return '';
		}
		$parts = array();
		foreach ( $slides as $index => $slide ) {
			if ( ! is_array( $slide ) ) {
				continue;
			}
			$vars = self::resolve_slide_layout_vars( $slide, $block_css_vars );
			if ( ! self::has_any( $vars ) ) {
				continue;
			}
			$sel = $block_selector . ' [data-bm-slide-index="' . absint( $index ) . '"]';
			$css = self::to_css( $sel, $vars );
			if ( '' !== trim( $css ) ) {
				$parts[] = $css;
			}
		}
		return implode( "\n\n", $parts );
	}
}
