/**
 * Markup token interpolation (flat, nested, repeaters, content stack).
 * Mirrors PHP MarkupInterpolator for editor / admin preview.
 */

import {
	composeButtonInlineStyleVars,
	normalizeButtonStyleItem,
} from './shared/buttonStyleBaseline';
import { buildButtonIconTokens } from './shared/buttonIconTokens';
import {
	normalizeIconRef,
	renderIconHtml,
	ensureIconStylesheets,
} from './shared/iconRegistry';
import { flattenSlideItem, sliderConfigAttr, buildSliderConfig, attrsHaveHeroButtons, sliderEffectClass, sliderArrowSizeClass, sliderArrowBehaviorClass } from './shared/sliderHelpers';
import {
	defaultsForPartials,
	effectiveElementsOrder,
	serializeElementsOrder,
} from './shared/elementsOrder';
import {
	isElementVisibleAt,
	isElementVisibleOnAnyBreakpoint,
	serializeElementsVisibility,
	defaultVisibilityMap,
	normalizeElementsVisibility,
} from './shared/elementsVisibility';
import {
	effective,
	parseElementHtmlAttrKey,
	sanitizeElementClassName,
	sanitizeElementHtmlId,
} from './cssVars';

/**
 * Escape a string for use inside a RegExp.
 *
 * @param {string} value
 * @return {string}
 */
function escapeRegExp( value ) {
	return String( value || '' ).replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
}

/**
 * Escape a value for an HTML attribute.
 *
 * @param {string} value
 * @return {string}
 */
function escapeAttr( value ) {
	return String( value || '' )
		.replace( /&/g, '&amp;' )
		.replace( /"/g, '&quot;' )
		.replace( /</g, '&lt;' )
		.replace( />/g, '&gt;' );
}

/**
 * Apply per-element Class name / ID from cssVars onto `[data-bm-el]` tags.
 *
 * @param {string} html
 * @param {object} cssVars
 * @return {string}
 */
export function applyElementHtmlAttrs( html, cssVars = {} ) {
	if ( ! html || typeof html !== 'string' ) {
		return html || '';
	}
	const flat = effective( cssVars, 'desktop' ) || {};
	/** @type {Record<string, { class?: string, id?: string }>} */
	const byEl = {};
	Object.entries( flat ).forEach( ( [ key, raw ] ) => {
		const parsed = parseElementHtmlAttrKey( key );
		if ( ! parsed ) {
			return;
		}
		const value = String( raw ?? '' ).trim();
		if ( ! value ) {
			return;
		}
		const clean =
			parsed.attr === 'id'
				? sanitizeElementHtmlId( value )
				: sanitizeElementClassName( value );
		if ( ! clean ) {
			return;
		}
		if ( ! byEl[ parsed.el ] ) {
			byEl[ parsed.el ] = {};
		}
		byEl[ parsed.el ][ parsed.attr ] = clean;
	} );

	let out = html;
	Object.entries( byEl ).forEach( ( [ el, attrs ] ) => {
		const re = new RegExp(
			`(<[^>]*\\bdata-bm-el="${ escapeRegExp( el ) }"[^>]*)(>)`,
			'gi'
		);
		out = out.replace( re, ( full, open, close ) => {
			let tag = open;
			if ( attrs.class ) {
				if ( /\bclass\s*=\s*"/i.test( tag ) ) {
					tag = tag.replace(
						/\bclass\s*=\s*"([^"]*)"/i,
						( m, existing ) => {
							const merged = `${ existing } ${ attrs.class }`
								.replace( /\s+/g, ' ' )
								.trim();
							return `class="${ escapeAttr( merged ) }"`;
						}
					);
				} else {
					tag += ` class="${ escapeAttr( attrs.class ) }"`;
				}
			}
			if ( attrs.id ) {
				if ( /\bid\s*=\s*"/i.test( tag ) ) {
					tag = tag.replace(
						/\bid\s*=\s*"[^"]*"/i,
						`id="${ escapeAttr( attrs.id ) }"`
					);
				} else {
					tag += ` id="${ escapeAttr( attrs.id ) }"`;
				}
			}
			return `${ tag }${ close }`;
		} );
	} );
	return out;
}

/**
 * @param {object} link
 * @return {{ title: string, url: string, opensInNewTab: boolean }}
 */
export function normalizeLink( link = {} ) {
	return {
		title: link?.title != null ? String( link.title ) : '',
		url: link?.url != null ? String( link.url ) : '',
		opensInNewTab: Boolean( link?.opensInNewTab ),
	};
}

/**
 * Resolve plugin-bundled media URLs (`bm-asset:path`).
 *
 * @param {string} url
 * @return {string}
 */
export function resolveMediaUrl( url ) {
	const raw = String( url || '' ).trim();
	if ( ! raw.startsWith( 'bm-asset:' ) ) {
		return raw;
	}
	const rel = raw.slice( 'bm-asset:'.length ).replace( /^\//, '' );
	const base = String(
		( typeof window !== 'undefined' &&
			( window.jocbmEditor?.pluginUrl ||
				window.jocbmAdmin?.pluginUrl ||
				window.jocbmFront?.pluginUrl ) ) ||
			''
	).replace( /\/?$/, '/' );
	return base && base !== '/' ? `${ base }${ rel }` : rel;
}

/**
 * @param {object} media
 * @param {object} [field]
 */
export function normalizeMedia( media = {}, field = {} ) {
	let kind = media?.type || field?.mediaType || 'image';
	if ( kind !== 'image' && kind !== 'video' && kind !== 'iframe' ) {
		kind = 'image';
	}
	const embedHtml =
		kind === 'iframe' && media?.embedHtml != null
			? String( media.embedHtml )
			: '';
	return {
		id: media?.id ? Number( media.id ) : 0,
		url: resolveMediaUrl( media?.url != null ? String( media.url ) : '' ),
		alt: media?.alt != null ? String( media.alt ) : '',
		mime: media?.mime != null ? String( media.mime ) : '',
		type: kind,
		embedHtml,
		providerName:
			kind === 'iframe' && media?.providerName != null
				? String( media.providerName )
				: '',
	};
}

/**
 * Convert watch URLs to embeddable iframe src when possible.
 *
 * @param {string} url
 * @return {string}
 */
export function normalizeEmbedUrl( url ) {
	const raw = String( url || '' ).trim();
	if ( ! raw ) {
		return '';
	}
	try {
		const u = new URL( raw );
		const host = u.hostname.replace( /^www\./, '' );
		if (
			host === 'youtube.com' ||
			host === 'm.youtube.com' ||
			host === 'music.youtube.com' ||
			host === 'youtube-nocookie.com'
		) {
			const v = u.searchParams.get( 'v' );
			if ( v ) {
				return `https://www.youtube.com/embed/${ encodeURIComponent( v ) }`;
			}
			const shorts = u.pathname.match( /\/shorts\/([^/]+)/ );
			if ( shorts?.[ 1 ] ) {
				return `https://www.youtube.com/embed/${ encodeURIComponent(
					shorts[ 1 ]
				) }`;
			}
			const live = u.pathname.match( /\/live\/([^/]+)/ );
			if ( live?.[ 1 ] ) {
				return `https://www.youtube.com/embed/${ encodeURIComponent(
					live[ 1 ]
				) }`;
			}
			const embed = u.pathname.match( /\/embed\/([^/?]+)/ );
			if ( embed?.[ 1 ] ) {
				return `https://www.youtube.com/embed/${ encodeURIComponent(
					embed[ 1 ]
				) }`;
			}
		}
		if ( host === 'youtu.be' ) {
			const id = u.pathname.replace( /^\//, '' ).split( '/' )[ 0 ];
			if ( id ) {
				return `https://www.youtube.com/embed/${ encodeURIComponent( id ) }`;
			}
		}
		if ( host === 'vimeo.com' || host === 'player.vimeo.com' ) {
			const parts = u.pathname.split( '/' ).filter( Boolean );
			// player.vimeo.com/video/123 or vimeo.com/123
			const id =
				parts[ 0 ] === 'video' && parts[ 1 ]
					? parts[ 1 ]
					: parts[ parts.length - 1 ];
			if ( id && /^\d+$/.test( id ) ) {
				return `https://player.vimeo.com/video/${ id }`;
			}
		}
	} catch ( e ) {
		return raw;
	}
	return raw;
}

/**
 * Providers we can render as a plain iframe (more reliable in wp-admin than raw oEmbed HTML).
 *
 * @param {string} url
 * @return {boolean}
 */
export function isDirectVideoEmbedProvider( url ) {
	try {
		const host = new URL( String( url || '' ).trim() ).hostname.replace(
			/^www\./,
			''
		);
		return (
			host === 'youtube.com' ||
			host === 'm.youtube.com' ||
			host === 'music.youtube.com' ||
			host === 'youtube-nocookie.com' ||
			host === 'youtu.be' ||
			host === 'vimeo.com' ||
			host === 'player.vimeo.com'
		);
	} catch ( e ) {
		return false;
	}
}

const EMBED_IFRAME_ALLOW =
	'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share';

/** Host styles for .bm-hero__embed — ratio box lives inside .wp-block-embed__wrapper. */
const 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';

/** Classic oEmbed ratio shell (padding-bottom) — stable in admin srcdoc + Gutenberg. */
const EMBED_RATIO_STYLE =
	'left:0;width:100%;height:0;position:relative;padding-bottom:56.25%;';
const EMBED_IFRAME_FILL_STYLE =
	'top:0;left:0;width:100%;height:100%;position:absolute;border:0;';

/**
 * YouTube Error 153: embeds need a usable Referer. Ensure iframe attrs for admin/editor.
 *
 * @param {string} html
 * @return {string}
 */
export function ensureEmbedIframeAttrs( html ) {
	return String( html || '' ).replace(
		/<iframe\b([^>]*)>/gi,
		( full, attrs ) => {
			let next = String( attrs || '' );
			if ( ! /\breferrerpolicy\s*=/i.test( next ) ) {
				next +=
					' referrerpolicy="strict-origin-when-cross-origin"';
			} else {
				next = next.replace(
					/\breferrerpolicy\s*=\s*(["']?)[^"'>\s]*\1/i,
					'referrerpolicy="strict-origin-when-cross-origin"'
				);
			}
			if ( ! /\ballow\s*=/i.test( next ) ) {
				next += ` allow="${ EMBED_IFRAME_ALLOW }"`;
			}
			if ( ! /\ballowfullscreen\b/i.test( next ) ) {
				next += ' allowfullscreen';
			}
			// Prefer fill styles when present; drop fixed HTML width/height attrs.
			next = next.replace( /\s*\b(?:width|height)\s*=\s*(["']?)[^"'\s>]*\1/gi, '' );
			return `<iframe${ next }>`;
		}
	);
}

/**
 * @param {string} html
 * @return {boolean}
 */
function hasPaddingBottomRatioShell( html ) {
	return /padding-bottom\s*:\s*[\d.]+%/i.test( String( html || '' ) );
}

/**
 * Wrap a bare iframe in the classic padding-bottom ratio shell.
 *
 * @param {string} iframeHtml
 * @return {string}
 */
export function wrapEmbedRatioShell( iframeHtml ) {
	const inner = String( iframeHtml || '' ).trim();
	if ( ! inner ) {
		return '';
	}
	if ( hasPaddingBottomRatioShell( inner ) ) {
		return inner;
	}
	// Ensure absolute fill styles on bare iframes (oEmbed-compatible).
	const withFill = inner.replace(
		/<iframe\b([^>]*)>/i,
		( full, attrs ) => {
			let next = String( attrs || '' );
			if ( /\bstyle\s*=/i.test( next ) ) {
				next = next.replace(
					/\bstyle\s*=\s*(["'])([\s\S]*?)\1/i,
					( _m, q, style ) => {
						const s = String( style || '' );
						if ( /position\s*:\s*absolute/i.test( s ) ) {
							return `style=${ q }${ s }${ q }`;
						}
						return `style=${ q }${ EMBED_IFRAME_FILL_STYLE }${ s ? ';' + s : '' }${ q }`;
					}
				);
			} else {
				next += ` style="${ EMBED_IFRAME_FILL_STYLE }"`;
			}
			return `<iframe${ next }>`;
		}
	);
	return `<div class="bm-embed-ratio" style="${ EMBED_RATIO_STYLE }">${ withFill }</div>`;
}

/**
 * Normalize embed markup for .wp-block-embed__wrapper (ratio shell + iframe attrs).
 *
 * @param {string} html
 * @return {string}
 */
export function prepareEmbedWrapperInner( html ) {
	return wrapEmbedRatioShell( ensureEmbedIframeAttrs( html ) );
}

/**
 * WordPress-style responsive embed shell.
 * Sizing uses the classic padding-bottom ratio box inside the wrapper.
 *
 * @param {string} iframeHtml Inner iframe / oEmbed markup.
 * @return {string}
 */
export function wrapEmbedBlock( iframeHtml ) {
	const inner = prepareEmbedWrapperInner( iframeHtml );
	if ( ! inner ) {
		return '';
	}
	return `<div class="bm-hero__embed wp-block-embed" style="${ EMBED_HOST_STYLE }"><div class="wp-block-embed__wrapper">${ inner }</div></div>`;
}

/**
 * @param {string} src
 * @param {string} title
 * @return {string}
 */
function buildDirectEmbedIframe( src, title ) {
	const safeTitle = String( title || 'Embedded media' ).replace( /"/g, '&quot;' );
	return `<iframe class="bm-hero__iframe" src="${ src }" title="${ safeTitle }" style="${ EMBED_IFRAME_FILL_STYLE }" allow="${ EMBED_IFRAME_ALLOW }" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`;
}

/** Replaced media fills the frame; width/alignment live on .bm-media-frame. */
const IMAGE_MEDIA_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';

/** Video / iframe: contain so the frame is not cropped; always centered inside the box. */
const EMBED_MEDIA_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';

/**
 * Host for image width + content-align margins + element padding L/R.
 * Width must live here so % sizes resolve against the content column, not a
 * circular fit-content box — then margin auto / flex align-items can center it.
 * @type {string}
 */
const MEDIA_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';

/**
 * Wrap replaced media so padding/margin L/R apply to a div, not the img/video/iframe.
 *
 * @param {string} innerHtml
 * @param {string} elKey
 * @return {string}
 */
function wrapMediaFrame( innerHtml, elKey ) {
	if ( ! innerHtml ) {
		return '';
	}
	const el = String( elKey || 'image' ).replace( /[^a-zA-Z0-9_-]/g, '' ) || 'image';
	return `<div class="bm-media-frame" data-bm-el="${ el }" style="${ MEDIA_FRAME_STYLE }">${ innerHtml }</div>`;
}

/**
 * @param {object} media
 * @param {string} [elKey]
 * @return {string}
 */
export function mediaToHtml( media = {}, elKey = 'image' ) {
	const m = normalizeMedia( media );
	const el = String( elKey || 'image' ).replace( /[^a-zA-Z0-9_-]/g, '' ) || 'image';

	if ( m.type === 'video' ) {
		if ( ! m.url ) {
			return '';
		}
		return wrapMediaFrame(
			`<video class="bm-hero__image bm-hero__video" src="${ m.url }" controls playsinline style="${ EMBED_MEDIA_STYLE }"></video>`,
			el
		);
	}

	if ( m.type === 'iframe' ) {
		const title = ( m.alt || 'Embedded media' ).replace( /"/g, '&quot;' );
		// YouTube/Vimeo: prefer a direct iframe with referrerpolicy (oEmbed HTML
		// often omits it → Error 153 in wp-admin / editor canvases).
		if ( isDirectVideoEmbedProvider( m.url ) ) {
			const src = normalizeEmbedUrl( m.url );
			if ( ! src ) {
				return '';
			}
			return wrapMediaFrame(
				wrapEmbedBlock( buildDirectEmbedIframe( src, title ) ),
				el
			);
		}
		const embedInner = String( m.embedHtml || '' ).trim();
		if ( embedInner ) {
			return wrapMediaFrame( wrapEmbedBlock( embedInner ), el );
		}
		const src = normalizeEmbedUrl( m.url );
		if ( ! src ) {
			return '';
		}
		return wrapMediaFrame(
			wrapEmbedBlock( buildDirectEmbedIframe( src, title ) ),
			el
		);
	}

	const alt = ( m.alt || '' ).replace( /"/g, '&quot;' );
	return wrapMediaFrame(
		`<img class="bm-hero__image" src="${ m.url || '' }" alt="${ alt }" style="${ IMAGE_MEDIA_STYLE }" />`,
		el
	);
}

/**
 * Attach `.html` on media-shaped attrs for {{image.html}} tokens.
 *
 * @param {object} attrs
 * @return {object}
 */
export function enrichMediaHtml( attrs = {} ) {
	const next = { ...attrs };
	Object.keys( next ).forEach( ( key ) => {
		const val = next[ key ];
		if (
			val &&
			typeof val === 'object' &&
			! Array.isArray( val ) &&
			typeof val.type === 'string' &&
			[ 'image', 'video', 'iframe' ].includes( val.type ) &&
			( 'url' in val || 'alt' in val || 'id' in val )
		) {
			const normalized = normalizeMedia( val );
			next[ key ] = {
				...normalized,
				html: mediaToHtml( normalized, key ),
			};
		}
	} );
	return next;
}

const HEADING_TAGS = [ 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ];
const SUB_TAGS = [ 'div', 'p' ];

/** Fallback bm-asset when a hero image field has no URL yet. */
const HERO_IMAGE_PLACEHOLDER_ASSET =
	'bm-asset:assets/images/image-placeholder.jpg';

/**
 * Optional placeholder fill — unused by default now that empty media is omitted.
 * Kept for PreviewModal opt-in if needed later.
 *
 * @param {string} html
 * @return {string}
 */
export function applyHeroImagePlaceholders( html ) {
	const placeholder = resolveMediaUrl( HERO_IMAGE_PLACEHOLDER_ASSET );
	return String( html || '' ).replace(
		/<img\b([^>]*\bclass="[^"]*\bbm-hero__image\b[^"]*"[^>]*)>/gi,
		( full, attrs ) => {
			if ( /\bsrc="https?:\/\//i.test( attrs ) ) {
				return full;
			}
			if ( /\bsrc="[^"]+"/i.test( attrs ) ) {
				return full.replace(
					/\bsrc="[^"]*"/i,
					`src="${ placeholder }"`
				);
			}
			return `<img${ attrs } src="${ placeholder }">`;
		}
	);
}

function sanitizeTag( tag, path ) {
	const clean = String( tag || '' )
		.toLowerCase()
		.replace( /[^a-z0-9]/g, '' );
	const allow = /sub/i.test( path ) ? SUB_TAGS : HEADING_TAGS;
	return allow.includes( clean ) ? clean : 'div';
}

function isAssocArray( arr ) {
	if ( ! Array.isArray( arr ) ) {
		return typeof arr === 'object' && arr !== null;
	}
	return false;
}

function extractPartials( markup ) {
	const partials = {};
	const body = String( markup || '' ).replace(
		/<!--\s*bm-partial:([a-zA-Z0-9_-]+)\s*-->([\s\S]*?)<!--\s*\/bm-partial:\1\s*-->/g,
		( _, key, inner ) => {
			partials[ key ] = inner;
			return '';
		}
	);
	return { body, partials };
}

/**
 * Whether an element should show for a viewport (default visible).
 * Legacy flat maps are treated as desktop.
 *
 * @param {string} key
 * @param {object} attrs
 * @param {string} [breakpoint]
 * @return {boolean}
 */
export function isElementVisible( key, attrs = {}, breakpoint = 'desktop' ) {
	return isElementVisibleAt( key, attrs, breakpoint );
}

/**
 * Whether a hero/repeater button is hidden for this placement only
 * (Gutenberg / frontend). Not synced to My Blocks.
 *
 * @param {object} item
 * @return {boolean}
 */
export function isButtonItemHidden( item = {} ) {
	const v = item?.hidden;
	return v === true || v === 1 || v === '1' || v === 'true';
}

/**
 * Whether a hero/repeater button has a visible label or icon.
 *
 * @param {object} item
 * @return {boolean}
 */
export function buttonItemHasContent( item = {} ) {
	if ( isButtonItemHidden( item ) ) {
		return false;
	}
	const title = String( item?.link?.title ?? '' ).trim();
	const icon = String( item?.icon ?? '' ).trim();
	if ( title !== '' || /^[a-z0-9-]+$/i.test( icon ) ) {
		return true;
	}
	// Library styles may supply an icon even when the item icon field is empty.
	return (
		String( item?.styleMode || '' ).toLowerCase() === 'library' &&
		Number( item?.libraryBlockId ) !== 0 &&
		Number.isFinite( Number( item?.libraryBlockId ) )
	);
}

/**
 * Whether element content is empty (omit from output).
 *
 * @param {string} key
 * @param {object} attrs
 * @return {boolean}
 */
export function isElementEmpty( key, attrs = {} ) {
	if ( key === 'buttons' ) {
		if ( ! Array.isArray( attrs.buttons ) || attrs.buttons.length === 0 ) {
			return true;
		}
		return ! attrs.buttons.some( ( item ) => buttonItemHasContent( item ) );
	}
	if ( key === 'logos' ) {
		if ( ! Array.isArray( attrs.logos ) || attrs.logos.length === 0 ) {
			return true;
		}
		return ! attrs.logos.some( ( item ) => {
			const url = item?.image?.url || item?.url || '';
			return String( url ).trim() !== '';
		} );
	}
	if ( key === 'image' || key === 'video' || key === 'media' ) {
		const media = attrs[ key ];
		if ( ! media || typeof media !== 'object' ) {
			return true;
		}
		return ! String( media.url || '' ).trim();
	}
	const val = attrs[ key ];
	if ( val == null ) {
		return true;
	}
	if ( typeof val === 'string' ) {
		return val.replace( /<[^>]*>/g, ' ' ).replace( /&nbsp;/gi, ' ' ).trim() === '';
	}
	return false;
}

/**
 * Include in DOM when not empty and visible on any breakpoint (CSS hides per BP).
 *
 * @param {string} key
 * @param {object} attrs
 * @return {boolean}
 */
export function shouldRenderElement( key, attrs = {} ) {
	return (
		! isElementEmpty( key, attrs ) &&
		isElementVisibleOnAnyBreakpoint( key, attrs )
	);
}

/**
 * Default visibility map (all true) for the given keys.
 *
 * @param {string[]} keys
 * @return {Record<string, boolean>}
 */
export function defaultElementsVisibility( keys = [] ) {
	return defaultVisibilityMap( keys );
}

/**
 * Keys that can be shown/hidden for an elementsOrder field.
 *
 * @param {object} field
 * @param {object} [defaultAttrs]
 * @return {string[]}
 */
export function visibilityKeysForField( field = {}, defaultAttrs = {} ) {
	const fromField = Array.isArray( field.visibilityKeys )
		? field.visibilityKeys.filter( ( k ) => typeof k === 'string' && k )
		: [];
	// Explicit visibilityKeys win (e.g. cards: title/description/buttons, not icon/image).
	if ( fromField.length ) {
		return [ ...new Set( fromField ) ];
	}
	const fromDefault = Array.isArray( field.default )
		? field.default.filter( ( k ) => typeof k === 'string' && k )
		: [];
	let fromAttrs = [];
	if (
		defaultAttrs?.elementsVisibility &&
		typeof defaultAttrs.elementsVisibility === 'object' &&
		! Array.isArray( defaultAttrs.elementsVisibility )
	) {
		fromAttrs = Object.keys(
			normalizeElementsVisibility( defaultAttrs.elementsVisibility )
				.desktop
		);
	}
	return [ ...new Set( [ ...fromDefault, ...fromAttrs ] ) ];
}

function clearHiddenMediaHtml( attrs ) {
	const next = { ...attrs };
	[ 'image', 'video', 'media' ].forEach( ( key ) => {
		if ( ! shouldRenderElement( key, next ) ) {
			const media = next[ key ];
			if ( media && typeof media === 'object' ) {
				next[ key ] = { ...media, html: '' };
			}
		}
	} );
	return next;
}

function stripEmptyHeroMedia( html ) {
	return String( html || '' ).replace(
		/<div\b([^>]*\bclass="[^"]*\bbm-hero__media\b[^"]*"[^>]*)>\s*<\/div>/gi,
		''
	);
}

function resolveButtonItemStyles( item, libraryStyles = {} ) {
	const custom = normalizeButtonStyleItem( item || {} );

	if ( String( item?.styleMode || 'custom' ).toLowerCase() !== 'library' ) {
		return custom;
	}

	const id = item?.libraryBlockId ? Number( item.libraryBlockId ) : 0;
	const lib = id !== 0 && libraryStyles[ id ] ? libraryStyles[ id ] : null;
	if ( ! lib ) {
		return custom;
	}

	// Catalog owns Design while Style reference is linked.
	// Icon glyph + iconPosition stay on the placement (content), not My Blocks.
	return normalizeButtonStyleItem( {
		...lib,
		icon:
			String( item?.icon || '' ).trim() !== ''
				? String( item.icon )
				: String( lib.icon || '' ),
		iconPosition:
			String( item?.iconPosition || custom.iconPosition || 'left' ),
		styleMode: 'library',
		libraryBlockId: id,
	} );
}

function flattenButtonItem( item, index, libraryStyles ) {
	const link = normalizeLink( item?.link || {} );
	const styles = resolveButtonItemStyles( item || {}, libraryStyles );
	const iconTokens = buildButtonIconTokens( {
		icon: styles.icon,
		iconPosition: styles.iconPosition,
		iconSize: styles.iconSize,
		iconColor: styles.iconColor,
	} );
	const title = String( link.title || '' ).trim();
	return {
		...( item || {} ),
		link,
		'link.title': link.title,
		'link.url': link.url,
		'link.target': link.opensInNewTab ? '_blank' : '',
		'link.rel': link.opensInNewTab ? 'noopener noreferrer' : '',
		'link.targetAttr': link.opensInNewTab
			? ' target="_blank" rel="noopener noreferrer"'
			: '',
		btnStyle: composeButtonInlineStyleVars( styles ),
		btnHoverBg: styles.bgHover,
		btnHoverColor: styles.colorHover,
		btnHoverShadow: styles.shadow,
		btnIndex: String( index ),
		'@index': String( index ),
		labelHtml: title
			? `<span class="bm-hero__btn-label">${ title
					.replace( /&/g, '&amp;' )
					.replace( /</g, '&lt;' )
					.replace( />/g, '&gt;' )
					.replace( /"/g, '&quot;' ) }</span>`
			: '',
		...iconTokens,
	};
}

function resolvePath( path, attrs ) {
	if ( Object.prototype.hasOwnProperty.call( attrs, path ) ) {
		return attrs[ path ];
	}
	const lower = path.toLowerCase();
	const keys = Object.keys( attrs || {} );
	for ( let i = 0; i < keys.length; i++ ) {
		if ( keys[ i ].toLowerCase() === lower ) {
			return attrs[ keys[ i ] ];
		}
	}
	if ( path.includes( '.' ) ) {
		const [ root, rest ] = path.split( '.', 2 );
		const base = attrs[ root ];
		if ( base && typeof base === 'object' && ! Array.isArray( base ) ) {
			if ( Object.prototype.hasOwnProperty.call( base, rest ) ) {
				return base[ rest ];
			}
		}
	}
	return null;
}

function stringifyToken( path, val ) {
	if ( val == null ) {
		return '';
	}
	if ( typeof val === 'object' ) {
		if ( val.url != null && val.title != null ) {
			return String( val.title );
		}
		if ( val.url != null ) {
			return String( val.url );
		}
		return '';
	}
	const str = String( val );
	if ( path === 'link.targetAttr' || path.includes( 'targetAttr' ) || path === 'radioChecked' || path === 'detailsOpen' ) {
		return str;
	}
	if (
		path === 'headerTag' ||
		path === 'subheaderTag' ||
		// Element HTML tag keys (headerTag); not content fields named "tag".
		/(?:^|\.)[a-zA-Z0-9_]*[a-z0-9]Tag$/.test( path )
	) {
		return sanitizeTag( str, path );
	}
	if (
		path.includes( 'btnStyle' ) ||
		path === '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' ||
		/\.html$/i.test( path )
	) {
		return str;
	}
	return str;
}

function processTokens( markup, attrs ) {
	return String( markup || '' ).replace(
		/\{\{\s*([a-zA-Z0-9_@]+(?:\.[a-zA-Z0-9_]+)?)\s*\}\}/g,
		( _, path ) => stringifyToken( path, resolvePath( path, attrs ) )
	);
}

function flattenLogoItem( item, index ) {
	const media = normalizeMedia(
		item?.image && typeof item.image === 'object' ? item.image : {}
	);
	return {
		...item,
		image: media,
		'image.url': media.url,
		'image.alt': media.alt || '',
		'@index': String( index ),
	};
}

/**
 * Escape plain text for embedding inside a feature <li>.
 *
 * @param {string} text
 * @return {string}
 */
function escapeFeatureText( text ) {
	return String( text ?? '' )
		.replace( /&/g, '&amp;' )
		.replace( /</g, '&lt;' )
		.replace( />/g, '&gt;' )
		.replace( /"/g, '&quot;' );
}

/**
 * Normalize plan features to HTML list items (legacy repeater → string).
 *
 * @param {unknown} features
 * @return {string}
 */
export function normalizeFeaturesHtml( features ) {
	if ( typeof features === 'string' ) {
		const raw = features.trim();
		if ( ! raw ) {
			return '<li><br></li>';
		}
		// Already list markup (with or without check spans from older markup).
		if ( /<li\b/i.test( raw ) ) {
			return raw.replace(
				/<span\b[^>]*\bbm-pricing__feature-check\b[^>]*>[\s\S]*?<\/span>/gi,
				''
			);
		}
		return `<li>${ escapeFeatureText( raw ) }</li>`;
	}
	if ( ! Array.isArray( features ) || ! features.length ) {
		return '<li><br></li>';
	}
	return features
		.map( ( item ) => {
			if ( typeof item === 'string' ) {
				return `<li>${ escapeFeatureText( item ) }</li>`;
			}
			if ( item && typeof item === 'object' ) {
				const text =
					item.text != null
						? item.text
						: item.title != null
						? item.title
						: '';
				return `<li>${ escapeFeatureText( text ) }</li>`;
			}
			return '<li><br></li>';
		} )
		.join( '' );
}

function flattenItemHelpers( item, index ) {
	const next = { ...( item || {} ), '@index': String( index ) };
	if ( next.image && typeof next.image === 'object' && ! Array.isArray( next.image ) ) {
		const media = normalizeMedia( next.image );
		next.image = media;
		next['image.url'] = media.url;
		next['image.alt'] = media.alt || '';
	}
	// Legacy single link → buttons[] so nested {{#buttons}} markup works.
	const hasButtons =
		Array.isArray( next.buttons ) && next.buttons.length > 0;
	if (
		! hasButtons &&
		next.link &&
		typeof next.link === 'object' &&
		! Array.isArray( next.link )
	) {
		next.buttons = [ { link: next.link } ];
		delete next.link;
	}
	if (
		next.button &&
		typeof next.button === 'object' &&
		! Array.isArray( next.button )
	) {
		const link = normalizeLink( next.button );
		next.button = link;
		next['button.title'] = link.title;
		next['button.url'] = link.url;
		next['button.target'] = link.opensInNewTab ? '_blank' : '';
		next['button.rel'] = link.opensInNewTab ? 'noopener noreferrer' : '';
		next['button.targetAttr'] = link.opensInNewTab
			? ' target="_blank" rel="noopener noreferrer"'
			: '';
	}
	if (
		next.link &&
		typeof next.link === 'object' &&
		! Array.isArray( next.link )
	) {
		const link = normalizeLink( next.link );
		next.link = link;
		next['link.title'] = link.title;
		next['link.url'] = link.url;
		next['link.target'] = link.opensInNewTab ? '_blank' : '';
		next['link.rel'] = link.opensInNewTab ? 'noopener noreferrer' : '';
		next['link.targetAttr'] = link.opensInNewTab
			? ' target="_blank" rel="noopener noreferrer"'
			: '';
	}
	// Card / feature / tab / accordion icons (namespaced refs via IconRegistry).
	if ( Object.prototype.hasOwnProperty.call( next, 'icon' ) ) {
		const iconRef = normalizeIconRef( String( next.icon || '' ).trim() );
		if ( iconRef && ! iconRef.startsWith( 'dashicons:' ) ) {
			ensureIconStylesheets();
		}
		const rawSize = String( next.iconSize || '' ).trim();
		const safeSize = /^[\d.]+\s*(px|rem|em|%|ch|vh|vw)?$/i.test( rawSize )
			? rawSize
			: '';
		const fontSize = safeSize
			? safeSize
			: 'var(--bm-icon-font-size, var(--bm-card-icon-size, 2rem))';
		const rawColor = String( next.iconColor || '' ).trim();
		const safeColor =
			rawColor &&
			/^(#([0-9a-f]{3,8})|rgba?\([^)]+\)|hsla?\([^)]+\)|currentcolor|inherit|transparent|[a-z]+)$/i.test(
				rawColor
			)
				? rawColor
				: '';
		const iconColorCss = safeColor
			? safeColor
			: 'var(--bm-icon-color, var(--bm-title-icon-color, var(--bm-card-icon-color, currentColor)))';
		const iconStyle = `font-size:${ fontSize };width:1em;height:1em;color:${ iconColorCss }`;
		next.icon = iconRef;
		next.iconSize = safeSize || rawSize || '1.25rem';
		next.iconColor = safeColor || rawColor;
		next.iconHtml = iconRef
			? renderIconHtml( iconRef, iconStyle, 'bm-card__icon' )
			: '';
		next.iconWrapperStyle = iconRef ? '' : 'display:none;';
	}
	if ( Object.prototype.hasOwnProperty.call( next, 'features' ) ) {
		next.features = normalizeFeaturesHtml( next.features );
	}
	return next;
}

function processSections( markup, attrs, libraryStyles ) {
	return String( markup || '' ).replace(
		/\{\{#\s*([a-zA-Z0-9_]+)\s*\}\}([\s\S]*?)\{\{\/\s*\1\s*\}\}/g,
		( _, key, inner ) => {
			const items = attrs[ key ];
			if ( ! Array.isArray( items ) ) {
				return '';
			}
			return items
				.map( ( item, index ) => {
					let ctx;
					if (
						item &&
						typeof item === 'object' &&
						! Array.isArray( item )
					) {
						if ( key === 'buttons' ) {
							if ( isButtonItemHidden( item ) ) {
								return '';
							}
							ctx = flattenButtonItem( item, index, libraryStyles );
							if ( ! ctx.labelHtml && ! ctx.iconHtml ) {
								return '';
							}
						} else if ( key === 'logos' ) {
							ctx = flattenLogoItem( item, index );
						} else if ( key === 'slides' ) {
							ctx = flattenSlideItem( item, index, mediaToHtml, {
								flattenButton: ( btn, btnIndex ) =>
									flattenButtonItem(
										btn,
										btnIndex,
										libraryStyles
									),
								elementsOrder: effectiveElementsOrder(
									item?.elementsOrder ?? attrs.elementsOrder,
									'desktop'
								),
							} );
						} else {
							const isAccordionItems =
								key === 'items' &&
								( Object.prototype.hasOwnProperty.call(
									item || {},
									'accordionTitle'
								) ||
									Object.prototype.hasOwnProperty.call(
										item || {},
										'accordionContent'
									) );
							const itemForFlat =
								key === 'tabs' || isAccordionItems
									? {
											icon: '',
											iconSize: '1.25rem',
											...( item || {} ),
									  }
									: item;
							ctx = flattenItemHelpers( itemForFlat, index );
							if ( key === 'tabs' || isAccordionItems ) {
								ctx.titleIconDisplay = resolveTitleIconDisplay(
									attrs.cssVars || attrs
								);
							}
							if ( key === 'tabs' ) {
								const activeRaw = Number( attrs.activeTab );
								const activeIndex = Number.isFinite( activeRaw )
									? Math.max(
											0,
											Math.min(
												activeRaw,
												Math.max( 0, items.length - 1 )
											)
									  )
									: 0;
								ctx.radioChecked =
									index === activeIndex ? ' checked' : '';
								ctx.tabsId = String( attrs.tabsId || 'bm-tabs' );
							}
							if ( isAccordionItems ) {
								const mode = accordionOpenMode( attrs );
								ctx.detailsOpen =
									mode === 'all' ||
									( mode === 'first' && index === 0 )
										? ' open'
										: '';
								ctx.arrowClosedHtml = String(
									attrs.arrowClosedHtml || ''
								);
								ctx.arrowOpenHtml = String(
									attrs.arrowOpenHtml || ''
								);
							}
						}
					} else {
						ctx = { value: item, '@index': String( index ) };
					}
					return processTokens(
						processSections( inner, ctx, libraryStyles ),
						ctx
					);
				} )
				.join( '' );
		}
	);
}

/**
 * Expand legacy card `border:1px solid var(--bm-card-border-color)` so Surface
 * boxBorder width / style / per-side controls apply in admin + Gutenberg preview.
 *
 * @param {string} markup
 * @return {string}
 */
export function upgradeCardBorderMarkup( markup ) {
	let html = String( markup || '' );
	if ( ! html.includes( 'bm-card' ) ) {
		return html;
	}

	const ensureCardShadow = ( source ) => {
		if ( source.includes( 'box-shadow:var(--bm-card-shadow' ) ) {
			return source;
		}
		return source.replace(
			/(<article\b[^>]*\bclass="[^"]*\bbm-card\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
			( full, open, style, close ) => {
				if ( style.includes( 'box-shadow:var(--bm-card-shadow' ) ) {
					return full;
				}
				let next = style;
				if ( /box-sizing\s*:/i.test( next ) ) {
					next = next.replace(
						/box-sizing\s*:/i,
						'box-shadow:var(--bm-card-shadow, none);box-sizing:'
					);
				} else {
					next += 'box-shadow:var(--bm-card-shadow, none);';
				}
				return `${ open }${ next }${ close }`;
			}
		);
	};

	const ensureCardColumns = ( source ) => {
		if ( source.includes( 'grid-template-columns:repeat(var(--bm-card-columns' ) ) {
			return source;
		}
		return source.replace(
			/grid-template-columns\s*:\s*repeat\(\s*auto-fit\s*,\s*minmax\(\s*16rem\s*,\s*1fr\s*\)\s*\)/gi,
			'grid-template-columns:repeat(var(--bm-card-columns, 4), minmax(0, 1fr))'
		);
	};

	if ( ! html.includes( 'border-top-width:var(--bm-card-border-top-width' ) ) {
		const 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));';

		html = html.replace(
			/border\s*:\s*1px\s+solid\s+var\(\s*--bm-card-border-color(?:\s*,[^)]*)?\s*\)\s*;?/gi,
			longhands
		);
	}

	return ensureCardColumns( ensureCardShadow( html ) );
}

/**
 * Ensure feature-card icon wrappers honor padding / border / radius / shadow vars.
 *
 * @param {string} markup
 * @return {string}
 */
export function upgradeCardIconSurfaceMarkup( markup ) {
	let html = String( markup || '' );
	if ( ! html.includes( 'bm-card__icon-wrapper' ) ) {
		return html;
	}

	const surfaceCss =
		'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);';

	return html.replace(
		/(<div\b[^>]*\bclass="[^"]*\bbm-card__icon-wrapper\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if ( ! next.includes( '--bm-icon-bg' ) ) {
				next = `background:var(--bm-icon-bg, transparent);${ next }`;
			}
			if (
				next.includes( 'margin-top:var(--bm-icon-margin-top' ) &&
				! next.includes( 'margin-right:var(--bm-icon-margin-right' )
			) {
				next = next.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);'
				);
			}
			if (
				next.includes( 'margin-bottom:var(--bm-icon-margin-bottom' ) &&
				! next.includes( 'margin-left:var(--bm-icon-margin-left' )
			) {
				next = next.replace(
					/(margin-bottom:var\(--bm-icon-margin-bottom,[^)]+\);?)/i,
					'$1margin-left:var(--bm-icon-margin-left, 0);'
				);
			}
			if ( next.includes( 'padding-top:var(--bm-icon-padding-top' ) ) {
				return `${ open }${ next }${ close }`;
			}
			if ( next.includes( '{{iconWrapperStyle}}' ) ) {
				next = next.replace(
					'{{iconWrapperStyle}}',
					`${ surfaceCss }{{iconWrapperStyle}}`
				);
			} else {
				next += surfaceCss;
			}
			return `${ open }${ next }${ close }`;
		}
	);
}

/**
 * Ensure card bodies / button rows honor Content alignment (left / center / right).
 *
 * @param {string} markup
 * @return {string}
 */
export function upgradeCardContentAlignMarkup( markup ) {
	let html = String( markup || '' );
	if ( ! html.includes( 'bm-card__body' ) ) {
		return html;
	}

	html = html.replace(
		/(<div\b[^>]*\bclass="[^"]*\bbm-card__body\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if ( ! next.includes( '--bm-text-align' ) ) {
				next = `text-align:var(--bm-text-align, left);${ next }`;
			}
			if (
				/display\s*:\s*flex/i.test( next ) &&
				/flex-direction\s*:\s*column/i.test( next ) &&
				! /align-items\s*:/i.test( next )
			) {
				next = next.replace(
					/flex-direction\s*:\s*column/i,
					'flex-direction:column;align-items:var(--bm-align-items, flex-start)'
				);
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<div\b[^>]*\bclass="[^"]*\bbm-card__buttons\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			if ( style.includes( '--bm-justify-content' ) ) {
				return full;
			}
			let next = style;
			if ( /display\s*:\s*flex/i.test( next ) ) {
				next = next.replace(
					/display\s*:\s*flex/i,
					'display:flex;justify-content:var(--bm-justify-content, flex-start)'
				);
			} else {
				next = `justify-content:var(--bm-justify-content, flex-start);${ next }`;
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<span\b[^>]*\bclass="[^"]*\bbm-pricing__tag\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			if ( style.includes( '--bm-align-items' ) ) {
				return full;
			}
			let next = style;
			if ( /align-self\s*:\s*flex-start/i.test( next ) ) {
				next = next.replace(
					/align-self\s*:\s*flex-start/i,
					'align-self:var(--bm-align-items, flex-start)'
				);
			} else {
				next = `align-self:var(--bm-align-items, flex-start);${ next }`;
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<a\b[^>]*\bclass="[^"]*\bbm-pricing__button\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			if ( style.includes( '--bm-align-items' ) ) {
				return full;
			}
			let next = style;
			if ( /display\s*:\s*inline-flex/i.test( next ) ) {
				next = next.replace(
					/display\s*:\s*inline-flex/i,
					'display:inline-flex;align-self:var(--bm-align-items, flex-start)'
				);
			} else {
				next = `align-self:var(--bm-align-items, flex-start);${ next }`;
			}
			return `${ open }${ next }${ close }`;
		}
	);

	return html;
}

/**
 * Testimonials: surface padding off, content padding + gap on the body,
 * author meta pinned to the bottom, field margins default to 0.
 *
 * @param {string} markup
 * @return {string}
 */
export function upgradeTestimonialCardLayoutMarkup( markup ) {
	let html = String( markup || '' );
	if (
		! html.includes( 'bm-testimonial' ) &&
		! html.includes( 'bm-testimonials' )
	) {
		return html;
	}

	html = html.replace(
		/(<(?:blockquote)\b[^>]*\bclass="[^"]*\bbm-testimonial\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if ( /padding\s*:\s*var\(\s*--bm-card-padding/i.test( next ) ) {
				next = next.replace(
					/padding\s*:\s*var\(\s*--bm-card-padding[^)]*\)\s*;?/gi,
					'padding:0;'
				);
			} else if ( ! /padding\s*:/i.test( next ) ) {
				next = `padding:0;${ next }`;
			}
			if ( ! /display\s*:\s*flex/i.test( next ) ) {
				next = next.replace( /display\s*:\s*var\([^)]+\)\s*;?/gi, '' );
				next = `display:flex;flex-direction:column;align-self:stretch;height:auto;${ next }`;
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<div\b[^>]*\bclass="[^"]*\bbm-card__body\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if ( ! next.includes( '--bm-card-body-gap' ) ) {
				next = `gap:var(--bm-card-body-gap, 2.5rem);${ next }`;
			}
			if ( /padding\s*:\s*var\(\s*--bm-card-body-padding/i.test( next ) ) {
				next = next.replace(
					/padding\s*:\s*var\(\s*--bm-card-body-padding[^)]*\)\s*;?/gi,
					'padding:var(--bm-card-body-padding, 1.75rem);'
				);
			} else if ( ! /padding\s*:/i.test( next ) ) {
				next = `padding:var(--bm-card-body-padding, 1.75rem);${ next }`;
			}
			if ( ! /display\s*:\s*flex/i.test( next ) ) {
				next = next.replace( /display\s*:\s*var\([^)]+\)\s*;?/gi, '' );
				next = `display:flex;flex-direction:column;flex:1 1 auto;align-items:stretch;${ next }`;
			} else if ( ! /flex\s*:\s*1/i.test( next ) ) {
				next = `flex:1 1 auto;${ next }`;
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<(?:div)\b[^>]*\bclass="[^"]*\bbm-testimonial__quote\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			next = next.replace(
				/margin-bottom\s*:\s*var\(\s*--bm-icon-margin-bottom[^)]*\)\s*;?/gi,
				'margin-bottom:var(--bm-icon-margin-bottom, 0);'
			);
			if ( ! next.includes( '--bm-quote-align-self' ) ) {
				if ( /align-self\s*:/i.test( next ) ) {
					next = next.replace(
						/align-self\s*:[^;]+;?/gi,
						'align-self:var(--bm-quote-align-self, var(--bm-align-items, flex-start));'
					);
				} else {
					next = `align-self:var(--bm-quote-align-self, var(--bm-align-items, flex-start));${ next }`;
				}
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<div\b[^>]*\bclass="[^"]*\bbm-testimonial__content\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			next = next.replace(
				/margin-bottom\s*:\s*var\(\s*--bm-content-margin-bottom[^)]*\)\s*;?/gi,
				'margin-bottom:var(--bm-content-margin-bottom, 0);'
			);
			next = next.replace(
				/margin-top\s*:\s*var\(\s*--bm-content-margin-top[^)]*\)\s*;?/gi,
				'margin-top:var(--bm-content-margin-top, 0);'
			);
			if ( ! next.includes( '--bm-message-text-align' ) ) {
				if ( /text-align\s*:/i.test( next ) ) {
					next = next.replace(
						/text-align\s*:[^;]+;?/gi,
						'text-align:var(--bm-message-text-align, var(--bm-text-align, left));'
					);
				} else {
					next = `text-align:var(--bm-message-text-align, var(--bm-text-align, left));${ next }`;
				}
			}
			if ( ! /flex\s*:\s*1/i.test( next ) ) {
				next = `${ next }flex:1 1 auto;`;
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<(?:footer)\b[^>]*\bclass="[^"]*\bbm-testimonial__meta\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if ( ! /margin-top\s*:\s*auto/i.test( next ) ) {
				next = next.replace( /margin-top\s*:[^;]+;?/gi, '' );
				next = `margin-top:auto;${ next }`;
			}
			if ( ! next.includes( '--bm-author-image-flex-direction' ) ) {
				if ( /flex-direction\s*:/i.test( next ) ) {
					next = next.replace(
						/flex-direction\s*:[^;]+;?/gi,
						'flex-direction:var(--bm-author-image-flex-direction, row);'
					);
				} else {
					next = `flex-direction:var(--bm-author-image-flex-direction, row);${ next }`;
				}
			}
			if ( ! next.includes( '--bm-author-image-align-items' ) ) {
				if ( /align-items\s*:/i.test( next ) ) {
					next = next.replace(
						/align-items\s*:[^;]+;?/gi,
						'align-items:var(--bm-author-image-align-items, center);'
					);
				} else {
					next = `align-items:var(--bm-author-image-align-items, center);${ next }`;
				}
			}
			if ( ! next.includes( '--bm-author-justify-content' ) ) {
				if ( /justify-content\s*:/i.test( next ) ) {
					next = next.replace(
						/justify-content\s*:[^;]+;?/gi,
						'justify-content:var(--bm-author-justify-content, var(--bm-justify-content, flex-start));'
					);
				} else {
					next = `justify-content:var(--bm-author-justify-content, var(--bm-justify-content, flex-start));${ next }`;
				}
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<(?:img)\b[^>]*\bclass="[^"]*\bbm-testimonial__avatar\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if ( ! next.includes( '--bm-author-image-order' ) ) {
				if ( /(?:^|;)\s*order\s*:/i.test( next ) ) {
					next = next.replace(
						/(?:^|;)\s*order\s*:[^;]+;?/gi,
						( m ) => {
							const prefix = m.startsWith( ';' ) ? ';' : '';
							return `${ prefix }order:var(--bm-author-image-order, 0);`;
						}
					);
				} else {
					next = `order:var(--bm-author-image-order, 0);${ next }`;
				}
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<(?:div)\b[^>]*\bclass="[^"]*\bbm-testimonial__author\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if ( ! /(?:^|;)\s*order\s*:/i.test( next ) ) {
				next = `order:0;${ next }`;
			}
			if ( ! next.includes( '--bm-author-text-align' ) ) {
				if ( /text-align\s*:/i.test( next ) ) {
					next = next.replace(
						/text-align\s*:[^;]+;?/gi,
						'text-align:var(--bm-author-text-align, var(--bm-text-align, left));'
					);
				} else {
					next = `text-align:var(--bm-author-text-align, var(--bm-text-align, left));${ next }`;
				}
			}
			return `${ open }${ next }${ close }`;
		}
	);

	return html;
}

/**
 * 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
 * @return {string}
 */
function pinButtonOrderFallback( style ) {
	let next = String( style || '' ).replace(
		/order\s*:\s*var\(\s*--bm-buttons-flex-order\s*\)/gi,
		'order:var(--bm-buttons-flex-order, 999)'
	);
	if ( ! next.includes( '--bm-buttons-flex-order' ) ) {
		next = `order:var(--bm-buttons-flex-order, 999);${ next }`;
	}
	return next;
}

/**
 * Pricing matrix: card columns grid, pin CTA, features selectable, card surface vars.
 *
 * @param {string} markup
 * @return {string}
 */
export function upgradePricingMatrixMarkup( markup ) {
	let html = String( markup || '' );
	if ( ! html.includes( 'bm-pricing' ) ) {
		return html;
	}

	// Nested features repeater → single HTML token inside the features <ul>.
	html = html.replace(
		/\{\{#\s*features\s*\}\}[\s\S]*?\{\{\/\s*features\s*\}\}/gi,
		'{{features}}'
	);

	if (
		html.includes( 'bm-pricing__grid' ) &&
		! html.includes( '--bm-card-columns' )
	) {
		html = html.replace(
			/grid-template-columns\s*:\s*repeat\(\s*auto-fit\s*,\s*minmax\([^)]+\)\s*\)\s*;?/gi,
			'grid-template-columns:repeat(var(--bm-card-columns, 4), minmax(0, 1fr));grid-auto-rows:minmax(min-content, auto);align-items:stretch;'
		);
	}

	html = html.replace(
		/(<(?:article)\b)([^>]*\bclass=")([^"]*\bbm-pricing__card\b[^"]*)(")/gi,
		( full, open, classAttr, classes, close ) => {
			if ( /\bbm-card\b/.test( classes ) ) {
				return full;
			}
			return `${ open }${ classAttr }${ classes } bm-card${ close }`;
		}
	);

	html = html.replace(
		/(<(?:article)\b[^>]*\bclass="[^"]*\bbm-pricing__card\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if ( ! next.includes( '--bm-card-display' ) ) {
				next = next.replace( /display\s*:\s*flex\s*;?/gi, '' );
				next =
					'display:var(--bm-card-display, flex);flex-direction:column;align-self:stretch;height:var(--bm-card-height, auto);' +
					next;
			}
			if ( /padding\s*:\s*var\(\s*--bm-card-padding/i.test( next ) ) {
				next = next.replace(
					/padding\s*:\s*var\(\s*--bm-card-padding[^)]*\)\s*;?/gi,
					'padding:var(--bm-card-padding, 0);'
				);
			}
			if ( ! next.includes( '--bm-card-shadow' ) && ! /box-shadow\s*:/i.test( next ) ) {
				next = `box-shadow:var(--bm-card-shadow, none);${ next }`;
			}
			if ( ! next.includes( '--bm-card-border-top-width' ) && /border\s*:\s*1px\s+solid/i.test( next ) ) {
				next = next.replace(
					/border\s*:\s*1px\s+solid\s+var\(--bm-card-border-color\)\s*;?/gi,
					'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));'
				);
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<div\b[^>]*\bclass="[^"]*\bbm-card__body\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			if ( ! html.includes( 'bm-pricing' ) ) {
				return full;
			}
			let next = style;
			if ( ! next.includes( '--bm-card-body-gap' ) ) {
				next = `gap:var(--bm-card-body-gap, 1rem);${ next }`;
			} else {
				next = next.replace(
					/gap\s*:\s*var\(\s*--bm-card-body-gap[^)]*\)\s*;?/gi,
					'gap:var(--bm-card-body-gap, 1rem);'
				);
			}
			if ( /padding\s*:\s*var\(\s*--bm-card-body-padding/i.test( next ) ) {
				next = next.replace(
					/padding\s*:\s*var\(\s*--bm-card-body-padding[^)]*\)\s*;?/gi,
					'padding:var(--bm-card-body-padding, 1.75rem);'
				);
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<(?:ul)\b[^>]*\bclass="[^"]*\bbm-pricing__features\b[^"]*")([^>]*>)/gi,
		( full, open, rest ) => {
			if ( /\bdata-bm-el\s*=/.test( full ) ) {
				return full;
			}
			return `${ open } data-bm-el="features"${ rest }`;
		}
	);

	html = html.replace(
		/(<(?:span)\b[^>]*\bclass="[^"]*\bbm-pricing__tag\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if ( ! next.includes( '--bm-tag-shadow' ) ) {
				next = next.replace(
					/border-radius\s*:\s*var\(\s*--bm-tag-border-radius[^;]*;?/gi,
					''
				);
				next +=
					'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 `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<(?:ul)\b[^>]*\bclass="[^"]*\bbm-pricing__features\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if ( ! next.includes( '--bm-align-items' ) ) {
				if ( /align-items\s*:/i.test( next ) ) {
					next = next.replace(
						/align-items\s*:[^;]+;?/gi,
						'align-items:var(--bm-align-items, flex-start);'
					);
				} else if ( /flex-direction\s*:\s*column/i.test( next ) ) {
					next = next.replace(
						/flex-direction\s*:\s*column/i,
						'flex-direction:column;align-items:var(--bm-align-items, flex-start)'
					);
				} else {
					next = `align-items:var(--bm-align-items, flex-start);${ next }`;
				}
			}
			if ( ! next.includes( '--bm-text-align' ) ) {
				if ( /text-align\s*:/i.test( next ) ) {
					next = next.replace(
						/text-align\s*:[^;]+;?/gi,
						'text-align:var(--bm-text-align, left);'
					);
				} else {
					next = `text-align:var(--bm-text-align, left);${ next }`;
				}
			}
			// Growing the list is what pushes the CTA down, so it has to follow
			// the pin toggle instead of being hardcoded.
			if ( ! next.includes( '--bm-features-flex' ) ) {
				if ( /(^|[;\s])flex\s*:/i.test( next ) ) {
					next = next.replace(
						/(^|[;\s])flex\s*:[^;]+;?/gi,
						'$1flex:var(--bm-features-flex, 1 1 auto);'
					);
				} else {
					next = `flex:var(--bm-features-flex, 1 1 auto);${ next }`;
				}
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<(?:li)\b[^>]*\bclass="[^"]*\bbm-pricing__feature\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if ( ! /width\s*:\s*fit-content/i.test( next ) ) {
				next = `width:fit-content;max-width:100%;${ next }`;
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<(?:a)\b[^>]*\bclass="[^"]*\bbm-pricing__button\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = pinButtonOrderFallback( style );
			if ( ! next.includes( '--bm-buttons-pin-top' ) ) {
				if ( /margin-top\s*:/i.test( next ) ) {
					next = next.replace(
						/margin-top\s*:[^;]+;?/gi,
						'margin-top:var(--bm-buttons-pin-top, var(--bm-button-margin-top, 0));'
					);
				} else {
					next = `margin-top:var(--bm-buttons-pin-top, var(--bm-button-margin-top, 0));${ next }`;
				}
			}
			return `${ open }${ next }${ close }`;
		}
	);

	return html;
}

/**
 * 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
 * @return {string}
 */
export function upgradeCardButtonsAlignMarkup( markup ) {
	let html = String( markup || '' );
	if (
		! html.includes( 'bm-card__buttons' ) ||
		( ! html.includes( 'bm-card' ) && ! html.includes( 'bm-cards' ) )
	) {
		return html;
	}

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

	html = html.replace(
		/(<(?:article)\b[^>]*\bclass="[^"]*\bbm-card\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if ( ! next.includes( '--bm-card-display' ) ) {
				next = next.replace(
					/display\s*:\s*flex\s*;?/i,
					''
				);
				next = next.replace(
					/height\s*:\s*100%\s*;?/i,
					''
				);
				next =
					'display:var(--bm-card-display, block);flex-direction:column;align-self:stretch;height:var(--bm-card-height, auto);' +
					next;
			} else {
				if ( ! /align-self\s*:/i.test( next ) ) {
					next = `align-self:stretch;${ next }`;
				}
				next = next.replace( /min-height\s*:\s*0\s*;?/i, '' );
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<div\b[^>]*\bclass="[^"]*\bbm-card__body\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if ( ! next.includes( '--bm-card-body-display' ) ) {
				// Pricing bodies keep align-items for content alignment.
				if ( /align-items\s*:\s*var\(\s*--bm-align-items/i.test( next ) ) {
					if ( ! /order\s*:/i.test( next ) ) {
						next = `order:1;${ next }`;
					}
					if ( ! next.includes( '--bm-card-body-gap' ) ) {
						next = `gap:var(--bm-card-body-gap, 1rem);${ next }`;
					}
					return `${ open }${ next }${ close }`;
				}
				next = next.replace( /display\s*:\s*flex\s*;?/i, '' );
				next = next.replace( /flex\s*:\s*1\s*1\s*auto\s*;?/i, '' );
				next = next.replace( /flex\s*:\s*1\s*;?/i, '' );
				next =
					'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;' +
					next;
			} else {
				next = next.replace( /min-height\s*:\s*0\s*;?/i, 'min-height:auto;' );
				if ( ! /order\s*:/i.test( next ) ) {
					next = `order:1;${ next }`;
				}
				if ( ! next.includes( '--bm-card-body-gap' ) ) {
					next = `gap:var(--bm-card-body-gap, 1rem);${ next }`;
				}
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<img\b[^>]*\bclass="[^"]*\bbm-card__image\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if ( ! /order\s*:\s*-1/i.test( next ) ) {
				next = `flex-shrink:0;order:-1;${ next }`;
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<div\b[^>]*\bclass="[^"]*\bbm-card__icon-wrapper\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			if ( style.includes( '--bm-align-items' ) ) {
				return full;
			}
			let next = style;
			if ( /align-self\s*:/i.test( next ) ) {
				next = next.replace(
					/align-self\s*:[^;]+;?/i,
					'align-self:var(--bm-align-items, flex-start);'
				);
			} else {
				next = `align-self:var(--bm-align-items, flex-start);${ next }`;
			}
			return `${ open }${ next }${ close }`;
		}
	);

	html = html.replace(
		/(<div\b[^>]*\bclass="[^"]*\bbm-card__buttons\b[^"]*"[^>]*?\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if ( ! next.includes( '--bm-buttons-pin-top' ) ) {
				if (
					/margin\s*:\s*var\(\s*--bm-buttons-margin-top/i.test( next )
				) {
					next = next.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));'
					);
				} else if ( /margin-top\s*:/i.test( next ) ) {
					next = next.replace(
						/margin-top\s*:[^;]+;?/i,
						'margin-top:var(--bm-buttons-pin-top, var(--bm-buttons-margin-top, 0));'
					);
					if ( ! /margin\s*:\s*var\(\s*--bm-buttons-margin-top/i.test( next ) ) {
						next =
							'margin:var(--bm-buttons-margin-top, 0) 0 var(--bm-buttons-margin-bottom, 0);' +
							next;
					}
				} else {
					next =
						'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));' +
						next;
				}
			} else if (
				/margin\s*:\s*var\(\s*--bm-buttons-pin-top/i.test( next ) &&
				! /margin-top\s*:\s*var\(\s*--bm-buttons-pin-top/i.test( next )
			) {
				// Older single-shorthand pin margin → layered margin + margin-top.
				next = next.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));'
				);
			}
			if ( ! /width\s*:\s*100%/i.test( next ) ) {
				next = `width:100%;box-sizing:border-box;${ next }`;
			}
			next = pinButtonOrderFallback( next );
			return `${ open }${ next }${ close }`;
		}
	);

	return html;
}

/**
 * Expand legacy 2-value padding/margin so per-side vars (esp. bottom/left) apply.
 * Also normalizes 4-value shorthand to longhands (safer if one side var is empty).
 *
 * @param {string} markup
 * @return {string}
 */
export function upgradeBoxSpacingMarkup( markup ) {
	let html = String( markup || '' );

	const padLong =
		'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))';
	const marLong =
		'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))';

	html = html.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*\)/gi,
		padLong
	);
	html = html.replace(
		/padding\s*:\s*var\(\s*--bm-padding-y\s*\)\s+var\(\s*--bm-padding-x\s*\)/gi,
		padLong
	);
	html = html.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*\)/gi,
		marLong
	);
	html = html.replace(
		/margin\s*:\s*var\(\s*--bm-margin-y\s*\)\s+var\(\s*--bm-margin-x\s*\)/gi,
		marLong
	);

	// Background: image + optional gradient layers (never put solid --bm-bg in background-image).
	html = html.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*;/gi,
		'background-color:var(--bm-bg);background-image:var(--bm-bg-image, none),var(--bm-bg-gradient, none);'
	);
	html = html.replace(
		/background-color\s*:\s*var\(\s*--bm-bg\s*\)\s*;\s*background-image\s*:\s*var\(\s*--bm-bg-image\s*\)\s*;/gi,
		'background-color:var(--bm-bg);background-image:var(--bm-bg-image, none),var(--bm-bg-gradient, none);'
	);
	html = html.replace(
		/background-color\s*:\s*var\(\s*--bm-bg\s*\)\s*;\s*background-image\s*:\s*var\(\s*--bm-bg\s*\)\s*;/gi,
		'background-color:var(--bm-bg);background-image:var(--bm-bg-gradient, none);'
	);

	// Badge: spacing padding + surface vars (radius/border/shadow) for older markup.
	html = html.replace(
		/(<[^>]*\bdata-bm-el="badge"[^>]*\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			const badgePad =
				'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)';
			const badgeSurface =
				'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 ( /padding\s*:\s*0\.35rem\s+0\.75rem/i.test( next ) ) {
				next = next.replace(
					/padding\s*:\s*0\.35rem\s+0\.75rem/gi,
					badgePad
				);
			}
			// Undo earlier inset mapping (padding vars on margin).
			next = next.replace(
				/margin-left\s*:\s*var\(\s*--bm-badge-padding-left[^)]*\)\s*;?/gi,
				''
			);
			next = next.replace(
				/margin-right\s*:\s*var\(\s*--bm-badge-padding-right[^)]*\)\s*;?/gi,
				''
			);
			if ( ! /padding-left\s*:/i.test( next ) ) {
				next += `;${ badgePad }`;
			}
			// Replace hardcoded chip radius so inspector --bm-badge-border-* vars apply.
			if ( /border-radius\s*:\s*4px/i.test( next ) ) {
				next = next.replace( /border-radius\s*:\s*4px\s*;?/gi, '' );
			}
			if ( ! /--bm-badge-border-radius/i.test( next ) ) {
				next += `;${ badgeSurface }`;
			} else if ( ! /box-shadow\s*:\s*var\(\s*--bm-badge-shadow/i.test( next ) ) {
				next += ';box-shadow:var(--bm-badge-shadow, none)';
			}
			return `${ open }${ next }${ close }`;
		}
	);

	// Inject header/subheader horizontal padding + stretch for older saved markup.
	html = html.replace(
		/(<[^>]*\bdata-bm-el="(header|subheader)"[^>]*\bstyle=")([^"]*)(")/gi,
		( full, open, el, style, close ) => {
			let next = style;
			if ( ! /padding-left\s*:/i.test( next ) ) {
				next += `;padding-left:var(--bm-${ el }-padding-left, 0)`;
			}
			if ( ! /padding-right\s*:/i.test( next ) ) {
				next += `;padding-right:var(--bm-${ el }-padding-right, 0)`;
			}
			// Drop the old hardcoded subhead max-width so width matches the content column.
			if ( el === 'subheader' ) {
				next = next.replace( /;?\s*max-width\s*:\s*40rem/gi, '' );
				next = next.replace( /align-self\s*:\s*center/gi, 'align-self:stretch' );
				next = next.replace( /;?\s*margin-left\s*:\s*auto/gi, '' );
				next = next.replace( /;?\s*margin-right\s*:\s*auto/gi, '' );
			}
			if ( ! /align-self\s*:/i.test( next ) ) {
				next += ';align-self:stretch';
			}
			if ( ! /(?:^|;)\s*width\s*:/i.test( next ) ) {
				next += ';width:100%';
			}
			if ( ! /box-sizing\s*:/i.test( next ) ) {
				next += ';box-sizing:border-box';
			}
			return `${ open }${ next }${ close }`;
		}
	);

	return html;
}

/**
 * Ensure tab labels expose surface CSS vars + active Default/Active chrome.
 * Older saved markup may omit background/border/radius or use box-shadow only.
 *
 * @param {string} markup
 * @return {string}
 */
export function upgradeTabsLabelMarkup( markup ) {
	let html = String( markup || '' );

	// Strip surface props from label inline styles — stylesheet owns Default/Active.
	const stripProps = [
		'color',
		'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',
	];

	html = html.replace(
		/(<label\b[^>]*(?:data-bm-el="tabTitle"|class="[^"]*\bbm-tabs__label\b[^"]*")[^>]*\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			stripProps.forEach( ( prop ) => {
				const re = new RegExp(
					`(^|;)\\s*${ prop.replace( /-/g, '\\-' ) }\\s*:[^;]*`,
					'gi'
				);
				next = next.replace( re, '$1' );
			} );
			next = next.replace( /;;+/g, ';' ).replace( /^;|;$/g, '' );
			if ( ! /(?:^|;)\s*display\s*:/i.test( next ) ) {
				next = `display:inline-flex;align-items:center;gap:0.45em;${ next }`;
			} else if ( /display\s*:\s*inline-block/i.test( next ) ) {
				next = next.replace(
					/display\s*:\s*inline-block/gi,
					'display:inline-flex;align-items:center;gap:0.45em'
				);
			}
			return `${ open }${ next }${ close }`;
		}
	);

	const surfaceRules = `.bm-tabs .bm-tabs__label {
      color: var(--bm-tabTitle-color, var(--bm-tab-color, #64748b));
      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-tab-color, var(--bm-tabTitle-color, #3525cd))) !important;
      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);
    }`;

	// Collapse stale saved tab surface rules to one shared block.
	if ( /<style[\s>]/i.test( html ) && /bm-tabs/i.test( html ) ) {
		html = html.replace(
			/\.bm-tabs\s+\.bm-tabs__label\s*\{[^}]*\}\s*|\.bm-tabs\s+\.bm-tabs__input:checked\s*\+\s*\.bm-tabs__label\s*\{[^}]*\}\s*/gi,
			''
		);
		html = html.replace( /(<style[^>]*>)/i, `$1\n    ${ surfaceRules }\n` );
	}

	// Tab panels: longhand padding vars (replace legacy --bm-panel-padding shorthand).
	const panelPad =
		'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)';
	html = html.replace(
		/(<[^>]*\b(?:data-bm-el="tabContent"|class="[^"]*\bbm-tabs__panel\b[^"]*")[^>]*\bstyle=")([^"]*)(")/gi,
		( full, open, style, close ) => {
			let next = style;
			if (
				/padding\s*:\s*var\(\s*--bm-panel-padding/i.test( next ) ||
				/padding\s*:\s*[^;]+/i.test( next )
			) {
				next = next.replace( /(?:^|;)\s*padding\s*:[^;]*/gi, '' );
			}
			if ( ! /padding-top\s*:/i.test( next ) ) {
				next = `${ next };${ panelPad }`.replace( /^;/, '' );
			} else if ( ! /--bm-tabContent-padding-top/i.test( next ) ) {
				next = next
					.replace(
						/padding-top\s*:[^;]*/gi,
						'padding-top:var(--bm-tabContent-padding-top, 1.25rem)'
					)
					.replace(
						/padding-right\s*:[^;]*/gi,
						'padding-right:var(--bm-tabContent-padding-right, 0)'
					)
					.replace(
						/padding-bottom\s*:[^;]*/gi,
						'padding-bottom:var(--bm-tabContent-padding-bottom, 0)'
					)
					.replace(
						/padding-left\s*:[^;]*/gi,
						'padding-left:var(--bm-tabContent-padding-left, 0)'
					);
			}
			next = next.replace( /;;+/g, ';' ).replace( /^;|;$/g, '' );
			return `${ open }${ next }${ close }`;
		}
	);

	html = upgradeTabsNavChrome( html );

	return html;
}

const TABS_NAV_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)";

/**
 * Ensure burger menu chrome exists for older tabs markup.
 *
 * @param {string} markup
 * @return {string}
 */
function upgradeTabsNavChrome( markup ) {
	let html = String( markup || '' );
	if ( ! /bm-tabs__nav/i.test( html ) ) {
		return html;
	}

	if ( ! /bm-tabs__menu-check/i.test( html ) ) {
		const 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;
    ">`;
		html = html.replace(
			/(<div\b[^>]*\bclass="[^"]*\bbm-tabs__nav\b[^"]*"[^>]*>)/i,
			`$1\n    ${ chrome }`
		);
		// Close list after the first tabs loop inside nav (label radios).
		let closed = false;
		html = html.replace( /\{\{\/tabs\}\}/gi, ( m ) => {
			if ( closed ) {
				return m;
			}
			closed = true;
			return `${ m }\n    </div>`;
		} );
	} else if ( ! /bm-tabs__list/i.test( html ) ) {
		html = html.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;">`
		);
		let closed = false;
		html = html.replace( /\{\{\/tabs\}\}/gi, ( m ) => {
			if ( closed ) {
				return m;
			}
			closed = true;
			return `${ m }\n    </div>`;
		} );
	}

	html = html.replace(
		/<label(?![^>]*\bonclick\s*=)(\s[^>]*\bbm-tabs__label\b[^>]*)>/gi,
		`<label$1 onclick="${ TABS_NAV_CLOSE_ONCLICK }">`
	);

	// Prefer dedicated burger color over shared tab title color.
	html = html.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*\)/gi,
		'$1color:var(--bm-tab-burger-color, #4b5563)'
	);

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

	return html;
}

/**
 * Resolve baked title-icon display from cssVars (flat or breakpoint map).
 *
 * @param {object} cssVars
 * @return {'inline-flex'|'none'}
 */
export function resolveTitleIconDisplay( cssVars = {} ) {
	const vars =
		cssVars && typeof cssVars === 'object' && ! Array.isArray( cssVars )
			? cssVars
			: {};
	let raw = '';
	if ( vars.desktop && typeof vars.desktop === 'object' ) {
		raw =
			vars.desktop[ '--bm-title-icons-display' ] ??
			vars.desktop[ '--bm-title-icons' ] ??
			'';
	}
	if ( ! raw ) {
		raw = vars[ '--bm-title-icons-display' ] ?? '';
	}
	if ( String( raw ).trim().toLowerCase() === 'inline-flex' ) {
		return 'inline-flex';
	}
	if ( ! raw ) {
		let flag = '';
		if ( vars.desktop && typeof vars.desktop === 'object' ) {
			flag = vars.desktop[ '--bm-title-icons' ] ?? '';
		}
		if ( ! flag ) {
			flag = vars[ '--bm-title-icons' ] ?? '';
		}
		if ( isCssFlagOn( flag ) ) {
			return 'inline-flex';
		}
	}
	return 'none';
}

const TITLE_ICON_SPAN_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>';
const TITLE_ICON_SPAN_ACCORDION =
	'<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>';

/**
 * Inject left icon + editable title text wrappers for tabs / accordion.
 *
 * @param {string} markup
 * @return {string}
 */
export function upgradeTitleIconMarkup( markup ) {
	let html = String( markup || '' );

	// Prefer baked display token over CSS-var gate (var inheritance is unreliable in preview).
	html = html.replace(
		/style="(display\s*:\s*)var\(\s*--bm-title-icons-display\s*,\s*none\s*\)/gi,
		'style="$1{{titleIconDisplay}}'
	);

	html = html.replace(
		/(<label\b[^>]*\bbm-tabs__label\b[^>]*>)([\s\S]*?)(<\/label>)/gi,
		( full, open, inner, close ) => {
			if ( /\bbm-tabs__icon\b/i.test( inner ) ) {
				return full;
			}
			if ( /\bbm-edit-text\b/i.test( inner ) ) {
				return `${ open }${ TITLE_ICON_SPAN_TABS }${ inner }${ close }`;
			}
			const trimmed = String( inner || '' ).trim();
			return `${ open }${ TITLE_ICON_SPAN_TABS }<span class="bm-tabs__label-text bm-edit-text">${ trimmed }</span>${ close }`;
		}
	);

	html = html.replace(
		/(<summary\b[^>]*\bbm-accordion__title\b[^>]*>)([\s\S]*?)(<\/summary>)/gi,
		( full, open, inner, close ) => {
			if ( /\bbm-accordion__icon\b/i.test( inner ) ) {
				return full;
			}
			if ( /\bbm-edit-text\b/i.test( inner ) ) {
				return `${ open }${ TITLE_ICON_SPAN_ACCORDION }${ inner }${ close }`;
			}
			const trimmed = String( inner || '' ).trim();
			return `${ open }${ TITLE_ICON_SPAN_ACCORDION }<span class="bm-accordion__title-text bm-edit-text">${ trimmed }</span>${ close }`;
		}
	);

	return html;
}

/**
 * Inject selectable accordion arrow host (closed/open icon slots).
 *
 * @param {string} markup
 * @return {string}
 */
export function upgradeAccordionArrowMarkup( markup ) {
	let html = String( markup || '' );
	if ( ! html.includes( 'bm-accordion' ) ) {
		return html;
	}

	if ( ! html.includes( 'accordionArrowCustomClass' ) && ! html.includes( 'accordionArrowStyleClass' ) ) {
		html = html.replace(
			/(<section\b[^>]*\bclass="[^"]*\bbm-accordion\b[^"]*)(")/i,
			'$1{{accordionArrowCustomClass}}$2'
		);
	}

	const 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>';

	html = html.replace(
		/(<summary\b[^>]*\bbm-accordion__title\b[^>]*>)([\s\S]*?)(<\/summary>)/gi,
		( full, open, inner, close ) => {
			if ( /\bbm-accordion__arrow\b/i.test( inner ) ) {
				return full;
			}
			return `${ open }${ inner }${ arrow }${ close }`;
		}
	);

	return html;
}

/**
 * Bake accordion arrow open/closed icon HTML + style-mode class token.
 *
 * @param {object} attrs
 * @return {object}
 */
export function enrichAccordionArrowTokens( attrs = {} ) {
	const next = { ...( attrs || {} ) };
	const vars =
		next.cssVars && typeof next.cssVars === 'object' ? next.cssVars : next;

	const readVar = ( key ) => {
		if ( vars.desktop && typeof vars.desktop === 'object' ) {
			const fromDesktop = String( vars.desktop[ key ] ?? '' ).trim();
			if ( fromDesktop ) {
				return fromDesktop;
			}
		}
		return String( vars[ key ] ?? '' ).trim();
	};

	let style = String( readVar( '--bm-accordion-arrow-style' ) || '' )
		.trim()
		.toLowerCase()
		.replace( /[^a-z0-9-]/g, '' );
	if ( ! style ) {
		style = isCssFlagOn( readVar( '--bm-accordion-arrow-custom' ) )
			? 'custom'
			: 'arrow';
	}
	if ( style === 'arrow' ) {
		next.accordionArrowCustomClass = '';
		next.accordionArrowStyleClass = '';
	} else if ( style === 'custom' ) {
		next.accordionArrowCustomClass =
			' bm-accordion--arrow-style-custom bm-accordion--arrow-custom';
		next.accordionArrowStyleClass = next.accordionArrowCustomClass;
	} else {
		// Built-in styles: plus-minus today; more later via CSS `.bm-accordion--arrow-style-{id}`.
		next.accordionArrowCustomClass = ` bm-accordion--arrow-style-${ style }`;
		next.accordionArrowStyleClass = next.accordionArrowCustomClass;
	}

	let fontSize = '1em';
	const rawFont = readVar( '--bm-accordion-arrow-font-size' );
	if ( rawFont ) {
		fontSize = rawFont;
	}
	const iconStyle = `font-size:${ fontSize };width:1em;height:1em;color:inherit`;

	const closedRef = normalizeIconRef(
		String( next.arrowIconClosed || '' ).trim()
	);
	const openRef = normalizeIconRef( String( next.arrowIconOpen || '' ).trim() );
	next.arrowClosedHtml =
		style === 'custom' && closedRef
			? renderIconHtml( closedRef, iconStyle, 'bm-accordion__arrow-glyph' )
			: '';
	next.arrowOpenHtml =
		style === 'custom' && openRef
			? renderIconHtml( openRef, iconStyle, 'bm-accordion__arrow-glyph' )
			: '';

	return next;
}

/**
 * Prefer {{labelHtml}} so empty labels omit the .bm-hero__btn-label span (icon-only).
 *
 * @param {string} markup
 * @return {string}
 */
export function upgradeHeroButtonLabelMarkup( markup ) {
	let html = String( markup || '' );
	html = html.replace(
		/\{\{iconHtml\}\}\s*<span\b[^>]*\bbm-hero__btn-label\b[^>]*>\s*\{\{\s*link\.title\s*\}\}\s*<\/span>/gi,
		'{{iconHtml}}{{labelHtml}}'
	);
	html = html.replace(
		/<span\b[^>]*\bbm-hero__btn-label\b[^>]*>\s*\{\{\s*link\.title\s*\}\}\s*<\/span>/gi,
		'{{labelHtml}}'
	);
	return html;
}

/**
 * Remove empty label spans left by stale markup or whitespace-only titles.
 *
 * @param {string} html
 * @return {string}
 */
function stripEmptyHeroButtonLabels( html ) {
	return String( html || '' ).replace(
		/<span\b([^>]*\bbm-hero__btn-label\b[^>]*)>\s*<\/span>/gi,
		''
	);
}

/**
 * Unique radio/group id for one tabs instance.
 * Prefer an explicit tabsId / instanceId (Gutenberg clientId, bm-i-* uid) so
 * sibling blocks with identical content do not share document-wide name/id.
 *
 * @param {object} attrs
 * @return {string}
 */
export function resolveTabsInstanceId( attrs = {} ) {
	const explicit = String( attrs.tabsId || '' ).trim();
	if ( explicit ) {
		return explicit.replace( /[^a-zA-Z0-9_-]/g, '' ).slice( 0, 64 ) || explicit;
	}
	const saltRaw = String(
		attrs.instanceId || attrs.clientId || attrs.blockInstanceId || ''
	).trim();
	const salt = saltRaw.replace( /[^a-zA-Z0-9_-]/g, '' ).slice( 0, 20 );
	const payload = JSON.stringify( attrs.tabs || [] );
	let hash = 0;
	for ( let i = 0; i < payload.length; i++ ) {
		hash = ( hash * 31 + payload.charCodeAt( i ) ) >>> 0;
	}
	const content = hash.toString( 16 );
	return salt ? `bm-tabs-${ salt }-${ content }` : `bm-tabs-${ content }`;
}

/**
 * @param {string} markup
 * @param {object} attrs
 * @param {object} [libraryStyles] map of userBlockId → style bag
 * @return {string}
 */
export function interpolate( markup, attrs = {}, libraryStyles = {} ) {
	let enriched = enrichMediaHtml( attrs );
	enriched = clearHiddenMediaHtml( enriched );
	enriched = {
		...enriched,
		...buildButtonIconTokens( enriched, enriched.cssVars || {} ),
		sliderConfigAttr: sliderConfigAttr( enriched ),
		sliderEffectClass: sliderEffectClass( enriched.effect ),
		sliderArrowSizeClass: sliderArrowSizeClass( enriched.arrowSize ),
		sliderArrowBehaviorClass: sliderArrowBehaviorClass(
			enriched.arrowBehavior
		),
	};
	if ( Array.isArray( enriched.tabs ) ) {
		enriched.tabsId = resolveTabsInstanceId( enriched );
	}
	enriched.accordionMultiple = resolveAccordionMultipleFlag( enriched )
		? '1'
		: '0';
	enriched = enrichAccordionArrowTokens( enriched );
	const { body, partials } = extractPartials(
		upgradeAccordionMultipleMarkup(
			upgradeAccordionArrowMarkup(
				upgradeTitleIconMarkup(
					upgradeTabsLabelMarkup(
						upgradeHeroButtonLabelMarkup(
							upgradeCardContentAlignMarkup(
								upgradePricingMatrixMarkup(
									upgradeTestimonialCardLayoutMarkup(
										upgradeCardButtonsAlignMarkup(
											upgradeCardIconSurfaceMarkup(
												upgradeCardBorderMarkup(
													upgradeBoxSpacingMarkup( markup )
												)
											)
										)
									)
								)
							)
						)
					)
				)
			)
		)
	);
	const partialKeys = Object.keys( partials );
	const orderDefaults = defaultsForPartials( partialKeys );
	let order = effectiveElementsOrder(
		enriched.elementsOrder,
		'desktop',
		orderDefaults
	);
	if ( ! order.length && partialKeys.length ) {
		order = partialKeys;
	}
	const stack = order
		.filter( ( key ) => partials[ key ] && shouldRenderElement( key, enriched ) )
		.map( ( key ) => partials[ key ] )
		.join( '' );
	let html = body.split( '{{contentStack}}' ).join( stack );
	html = processSections( html, enriched, libraryStyles );
	html = processTokens( html, enriched );
	html = stripEmptyHeroMedia( html );
	html = stripEmptyHeroButtonLabels( html );
	html = applyAccordionOpenAttr( html, enriched.cssVars || {} );
	html = applyElementHtmlAttrs( html, enriched.cssVars || {} );
	return html;
}

/**
 * Whether a css-flag style value is on.
 *
 * @param {unknown} value
 * @return {boolean}
 */
export function isCssFlagOn( value ) {
	const v = String( value ?? '' )
		.trim()
		.toLowerCase();
	return v === '1' || v === 'true' || v === 'yes' || v === 'on';
}

/**
 * Resolve accordion initially-open mode from attrs / cssVars.
 * Supports legacy toggle values (`1`/`true` → `all`, `0`/`false` → `none`).
 *
 * @param {object} attrs
 * @return {'first'|'all'|'none'}
 */
export function accordionOpenMode( attrs = {} ) {
	const vars = attrs?.cssVars;
	let raw = '';
	if ( vars && typeof vars === 'object' && ! Array.isArray( vars ) ) {
		if ( vars.desktop && typeof vars.desktop === 'object' ) {
			raw = vars.desktop[ '--bm-accordion-open-default' ] ?? '';
		}
		if ( ! raw ) {
			raw = vars[ '--bm-accordion-open-default' ] ?? '';
		}
	}
	if ( ! raw && attrs && typeof attrs === 'object' ) {
		if ( attrs.desktop && typeof attrs.desktop === 'object' ) {
			raw = attrs.desktop[ '--bm-accordion-open-default' ] ?? '';
		}
		if ( ! raw ) {
			raw = attrs[ '--bm-accordion-open-default' ] ?? '';
		}
	}
	const v = String( raw || '' )
		.trim()
		.toLowerCase();
	if ( v === 'all' || isCssFlagOn( v ) ) {
		return 'all';
	}
	if (
		v === 'none' ||
		v === '0' ||
		v === 'false' ||
		v === 'off' ||
		v === 'closed'
	) {
		return 'none';
	}
	return 'first';
}

/**
 * Whether multiple accordion items may stay open together.
 * Forced on when Initially open is "all".
 *
 * @param {object} attrs Attrs or cssVars map.
 * @return {boolean}
 */
export function resolveAccordionMultipleFlag( attrs = {} ) {
	if ( accordionOpenMode( attrs ) === 'all' ) {
		return true;
	}
	const vars = attrs?.cssVars;
	let raw = '';
	if ( vars && typeof vars === 'object' && ! Array.isArray( vars ) ) {
		if ( vars.desktop && typeof vars.desktop === 'object' ) {
			raw = vars.desktop[ '--bm-accordion-multiple' ] ?? '';
		}
		if ( raw === '' || raw == null ) {
			raw = vars[ '--bm-accordion-multiple' ] ?? '';
		}
	}
	if ( ( raw === '' || raw == null ) && attrs && typeof attrs === 'object' ) {
		if ( attrs.desktop && typeof attrs.desktop === 'object' ) {
			raw = attrs.desktop[ '--bm-accordion-multiple' ] ?? '';
		}
		if ( raw === '' || raw == null ) {
			raw = attrs[ '--bm-accordion-multiple' ] ?? '';
		}
	}
	if ( raw === '' || raw == null ) {
		return true;
	}
	return isCssFlagOn( raw );
}

/**
 * Ensure accordion roots expose data-bm-accordion-multiple for exclusive JS.
 *
 * @param {string} markup
 * @return {string}
 */
export function upgradeAccordionMultipleMarkup( markup ) {
	return String( markup || '' ).replace(
		/<section\b([^>]*\bbm-accordion\b[^>]*)>/gi,
		( full, attrs ) => {
			if ( /\bdata-bm-accordion-multiple\s*=/i.test( attrs ) ) {
				return full;
			}
			return `<section${ attrs } data-bm-accordion-multiple="{{accordionMultiple}}">`;
		}
	);
}

/** @deprecated Use accordionOpenMode — kept for call-site compat. */
export function accordionOpenDefaultOn( attrs = {} ) {
	return accordionOpenMode( attrs ) === 'all';
}

/**
 * Ensure accordion `<details>` have / lack the `open` attribute from css vars.
 * `first` opens only the first item; `all` opens every item; `none` opens none.
 *
 * @param {string} html
 * @param {object} cssVars Nested or flat css vars map.
 * @return {string}
 */
export function applyAccordionOpenAttr( html, cssVars = {} ) {
	const mode = accordionOpenMode( { cssVars } );
	let index = 0;
	return String( html || '' ).replace(
		/<details([^>]*\bbm-accordion__item\b[^>]*)>/gi,
		( _, attrs ) => {
			let next = String( attrs )
				.replace( /\{\{\s*detailsOpen\s*\}\}/gi, '' )
				.replace( /\s+\bopen\b(?=(?:\s|>|$))/gi, '' );
			const shouldOpen =
				mode === 'all' || ( mode === 'first' && index === 0 );
			index += 1;
			if ( shouldOpen ) {
				next += ' open';
			}
			return `<details${ next }>`;
		}
	);
}

/**
 * @param {object} attrs
 * @param {string} [repeater]
 */
function blockHasLabelButton( attrs = {} ) {
	return String( attrs?.label ?? '' ).trim() !== '';
}

/**
 * @param {object} attrs
 * @param {string} [repeater]
 */
function blockHasIndexedButtons( attrs = {}, repeater = 'buttons' ) {
	const items = attrs?.[ repeater ];
	if ( Array.isArray( items ) && items.length ) {
		return true;
	}
	return repeater === 'buttons' && attrsHaveHeroButtons( attrs );
}

/**
 * @param {string} selector
 * @param {object} attrs
 * @param {string} [repeater]
 * @param {object} [libraryStyles]
 */
export function buttonHoverCss(
	selector,
	attrs = {},
	repeater = 'buttons',
	libraryStyles = {}
) {
	void libraryStyles;
	const hasLabel = repeater === 'buttons' && blockHasLabelButton( attrs );
	const hasIndexed = blockHasIndexedButtons( attrs, repeater );
	if ( ! hasLabel && ! hasIndexed ) {
		return '';
	}

	const parts = [];
	if ( hasLabel ) {
		parts.push(
			`${ 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}`,
			`${ 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 ( hasIndexed ) {
		parts.push(
			`${ 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}`,
			`${ 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 parts.join( '\n\n' );
}

/**
 * Build Gutenberg attribute config for a field schema entry.
 *
 * @param {object} field
 * @param {object} defaults
 */
export function attributeForField( field, defaults = {} ) {
	const key = field?.key;
	if ( ! key ) {
		return null;
	}
	const type = field.type || 'text';
	const raw = defaults[ key ];

	switch ( type ) {
		case 'link':
			return {
				type: 'object',
				default: normalizeLink( raw && typeof raw === 'object' ? raw : {} ),
			};
		case 'media':
			return {
				type: 'object',
				default: normalizeMedia(
					raw && typeof raw === 'object' ? raw : {},
					field
				),
			};
		case 'repeater':
			return {
				type: 'array',
				default: Array.isArray( raw ) ? raw : [],
			};
		case 'elementsOrder':
			return {
				type: 'object',
				default: serializeElementsOrder(
					raw && typeof raw === 'object'
						? raw
						: Array.isArray( field.default )
						  ? field.default
						  : []
				),
			};
		case 'elementsVisibility':
			return {
				type: 'object',
				default: serializeElementsVisibility(
					raw && typeof raw === 'object' ? raw : {},
					visibilityKeysForField( field, defaults )
				),
			};
		case 'tag':
			return {
				type: 'string',
				default:
					typeof raw === 'string' && raw
						? raw
						: String( field.default || 'div' ),
			};
		case 'toggle':
		case 'boolean':
			return {
				type: 'boolean',
				default:
					raw === true ||
					raw === 1 ||
					raw === '1' ||
					raw === 'true' ||
					Boolean( field.default ),
			};
		case 'number':
			return {
				type: 'number',
				default:
					raw !== '' && raw != null && Number.isFinite( Number( raw ) )
						? Number( raw )
						: field.default != null && field.default !== ''
						? Number( field.default )
						: 0,
			};
		case 'select':
			return {
				type: 'string',
				default:
					raw != null && raw !== ''
						? String( raw )
						: String(
								field.default ??
									( Array.isArray( field.options ) &&
									field.options[ 0 ]
										? typeof field.options[ 0 ] === 'string'
											? field.options[ 0 ]
											: field.options[ 0 ].value || ''
										: '' )
						  ),
			};
		default:
			return {
				type: 'string',
				default:
					raw != null && typeof raw !== 'object' ? String( raw ) : '',
			};
	}
}

export { resolveButtonItemStyles, isAssocArray, buildSliderConfig, sliderConfigAttr };
