/**
 * Responsive CSS custom-property helpers (desktop / tablet / mobile).
 */

import {
	effectiveElementsOrder,
	elementsOrderDefaultsFromSchema,
} from './shared/elementsOrder';
import {
	backfillContentAlignMediaMargins,
	normalizeAuthorImageAlignVars,
} from './styleFieldDefaults';

export const BREAKPOINTS = [ 'desktop', 'tablet', 'mobile' ];

export const MEDIA = {
	tablet: '(max-width: 1024px)',
	mobile: '(max-width: 480px)',
};

/** Nominal device widths used for baked preview (Gutenberg / admin viewport). */
export const DEVICE_WIDTH_PX = {
	tablet: 1024,
	mobile: 480,
};

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

/**
 * Resolve --bm-columns-breakpoint to a pixel number (default tablet 1024).
 *
 * @param {string} value
 * @return {number}
 */
export function resolveColumnsBreakpointPx( value ) {
	const raw = String( value || '' ).trim().toLowerCase();
	if ( ! raw || raw === 'tablet' ) {
		return DEVICE_WIDTH_PX.tablet;
	}
	if ( raw === 'mobile' ) {
		return DEVICE_WIDTH_PX.mobile;
	}
	const match = raw.match( /^(\d+(?:\.\d+)?)\s*px$/i );
	if ( match ) {
		return Math.max( 1, Math.round( parseFloat( match[ 1 ] ) ) );
	}
	const bare = raw.match( /^(\d+(?:\.\d+)?)$/ );
	if ( bare ) {
		return Math.max( 1, Math.round( parseFloat( bare[ 1 ] ) ) );
	}
	return DEVICE_WIDTH_PX.tablet;
}

/**
 * Whether stack-order mode requests a reverse of the DOM sequence.
 *
 * @param {string} mode
 * @return {boolean}
 */
export function isStackReverse( mode ) {
	const m = String( mode || 'default' ).trim().toLowerCase();
	return m === 'reverse' || m === 'image-last' || m === '1' || m === 'true';
}

/**
 * Order vars applied only when columns are stacked.
 *
 * `domOrder` matches the markup sequence:
 * - media-first → image-left (media then content)
 * - content-first → image-right (content then media)
 *
 * @param {string} mode     default|image-first|image-last|reverse
 * @param {string} [domOrder] media-first|content-first
 * @return {Record<string, string>}
 */
export function stackOrderVars( mode, domOrder = 'media-first' ) {
	const m = String( mode || 'default' ).trim().toLowerCase();
	const mediaFirst =
		String( domOrder || 'media-first' ).trim().toLowerCase() !==
		'content-first';

	if ( m === 'image-first' ) {
		return { '--bm-media-order': '1', '--bm-content-order': '2' };
	}
	if ( m === 'image-last' ) {
		return { '--bm-media-order': '2', '--bm-content-order': '1' };
	}
	if ( isStackReverse( m ) ) {
		// Swap relative to the template’s DOM order.
		return mediaFirst
			? { '--bm-media-order': '2', '--bm-content-order': '1' }
			: { '--bm-media-order': '1', '--bm-content-order': '2' };
	}
	return { '--bm-media-order': '0', '--bm-content-order': '0' };
}

/**
 * Bake stack-order into inheritable vars used by the stacked media query.
 * Visual reverse is handled by --bm-stacked-direction (flex column-reverse),
 * not by CSS order — combining both would cancel out.
 *
 * @param {Record<string, string>} flat
 * @return {Record<string, string>}
 */
function expandStackedOrderVars( flat ) {
	if ( ! flat || typeof flat !== 'object' ) {
		return flat;
	}
	const hasOrder = Object.prototype.hasOwnProperty.call(
		flat,
		'--bm-columns-stack-order'
	);
	const hasBp = hasColumnsBreakpoint( flat );
	if ( ! hasOrder && ! hasBp ) {
		return flat;
	}
	const mode = flat[ '--bm-columns-stack-order' ] || 'default';
	return {
		...flat,
		'--bm-stacked-media-order': '0',
		'--bm-stacked-content-order': '0',
		'--bm-stacked-direction': isStackReverse( mode )
			? 'column-reverse'
			: 'column',
	};
}

/**
 * @param {string} value
 * @return {boolean}
 */
export function isMediaFullscreen( value ) {
	const v = String( value || '' )
		.trim()
		.toLowerCase();
	return (
		v === 'fullscreen' || v === 'full' || v === '1' || v === 'true'
	);
}

/**
 * Bake --bm-media-frame into layout vars consumed by markup.
 *
 * @param {Record<string, string>} flat
 * @return {Record<string, string>}
 */
function expandMediaFrameVars( flat ) {
	if ( ! flat || typeof flat !== 'object' ) {
		return flat;
	}
	if (
		! Object.prototype.hasOwnProperty.call( flat, '--bm-media-frame' )
	) {
		return flat;
	}
	if ( ! isMediaFullscreen( flat[ '--bm-media-frame' ] ) ) {
		return {
			...flat,
			'--bm-mf-bleed': '0',
			'--bm-mf-grid-align': 'center',
			'--bm-mf-media-align-self': 'auto',
			'--bm-mf-media-height': 'auto',
			'--bm-mf-media-align-items': 'var(--bm-align-items, flex-start)',
			'--bm-mf-img-width': 'var(--bm-image-width, 100%)',
			'--bm-mf-img-max-width': '100%',
			'--bm-mf-img-height': 'var(--bm-image-height, auto)',
			'--bm-mf-img-aspect': 'var(--bm-image-aspect-ratio, 16/9)',
			'--bm-mf-img-radius': 'var(--bm-image-border-radius, 8px)',
			'--bm-mf-img-radius-tl':
				'var(--bm-image-border-top-left-radius, var(--bm-image-border-radius, 8px))',
			'--bm-mf-img-radius-tr':
				'var(--bm-image-border-top-right-radius, var(--bm-image-border-radius, 8px))',
			'--bm-mf-img-radius-br':
				'var(--bm-image-border-bottom-right-radius, var(--bm-image-border-radius, 8px))',
			'--bm-mf-img-radius-bl':
				'var(--bm-image-border-bottom-left-radius, var(--bm-image-border-radius, 8px))',
			'--bm-mf-media-margin-start':
				'var(--bm-media-margin-inline-start, 0)',
			'--bm-mf-media-margin-end': 'var(--bm-media-margin-inline-end, 0)',
			'--bm-mf-stack-grid-max-width':
				'var(--bm-content-max-width, 72rem)',
		};
	}
	return {
		...flat,
		'--bm-mf-bleed': '1',
		'--bm-mf-grid-align': 'stretch',
		'--bm-mf-media-align-self': 'stretch',
		'--bm-mf-media-height': 'auto',
		'--bm-mf-media-align-items': 'stretch',
		'--bm-mf-img-width': '100%',
		'--bm-mf-img-max-width': 'none',
		'--bm-mf-img-height': '100%',
		'--bm-mf-img-aspect': 'auto',
		'--bm-mf-img-radius': '0',
		'--bm-mf-img-radius-tl': '0',
		'--bm-mf-img-radius-tr': '0',
		'--bm-mf-img-radius-br': '0',
		'--bm-mf-img-radius-bl': '0',
		'--bm-mf-media-margin-start': '0',
		'--bm-mf-media-margin-end': '0',
		'--bm-mf-stack-grid-max-width': 'none',
	};
}

/**
 * Expand layout helper vars before emitting declarations.
 *
 * @param {Record<string, string>} flat
 * @return {Record<string, string>}
 */
function expandLayoutVars( flat ) {
	return expandMediaFrameVars( expandStackedOrderVars( flat ) );
}

/**
 * Flat for one breakpoint, inheriting --bm-media-frame so each viewport can
 * choose fullscreen vs framed independently (with its own gap / padding).
 *
 * @param {object} n Normalized vars
 * @param {string} bp
 * @return {Record<string, string>}
 */
function layoutFlatForBreakpoint( n, bp ) {
	const flat = { ...( n[ bp ] || {} ) };
	if ( ! Object.prototype.hasOwnProperty.call( flat, '--bm-media-frame' ) ) {
		const frame = effective( n, bp )[ '--bm-media-frame' ];
		if ( frame != null && String( frame ).trim() !== '' ) {
			flat[ '--bm-media-frame' ] = frame;
		}
	}
	return expandLayoutVars( flat );
}

/**
 * Side-by-side fullscreen: fill the media half of the grid and bleed to the
 * section edge on the outer side (true split-screen media).
 *
 * Keep the image in-flow (nested 1×1 grid) — absolute positioning collapses the
 * media cell to 0×0 when it is the only child.
 *
 * @param {string} selector
 * @return {string}
 */
function mediaFrameSideRules( selector ) {
	const padL = 'var(--bm-padding-left, var(--bm-padding-x, 0px))';
	const padR = 'var(--bm-padding-right, var(--bm-padding-x, 0px))';
	const padT = 'var(--bm-padding-top, var(--bm-padding-y, 0px))';
	const padB = 'var(--bm-padding-bottom, var(--bm-padding-y, 0px))';
	const gap = 'var(--bm-columns-gap, var(--bm-gap))';
	return [
		// Full-bleed grid: media reaches section edges; columns gap stays editable.
		`${ selector } .bm-hero__grid {`,
		`\tmax-width: none !important;`,
		`\twidth: calc(100% + ${ padL } + ${ padR }) !important;`,
		`\tmargin-left: calc(-1 * ${ padL }) !important;`,
		`\tmargin-right: calc(-1 * ${ padR }) !important;`,
		`\tmargin-top: calc(-1 * ${ padT }) !important;`,
		`\tmargin-bottom: calc(-1 * ${ padB }) !important;`,
		`\tflex: 1 1 auto;`,
		`\talign-self: stretch;`,
		`\tmin-height: 0;`,
		`\talign-items: stretch !important;`,
		`\tgrid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important;`,
		`\tgap: ${ gap } !important;`,
		`\tcolumn-gap: ${ gap } !important;`,
		`\trow-gap: ${ gap } !important;`,
		`}`,
		`${ selector } .bm-hero__media {`,
		`\tposition: relative !important;`,
		`\talign-self: stretch !important;`,
		`\tjustify-self: stretch !important;`,
		`\tbox-sizing: border-box !important;`,
		`\twidth: 100% !important;`,
		`\tmax-width: none !important;`,
		`\theight: auto !important;`,
		`\tmin-width: 0 !important;`,
		`\tmin-height: 0 !important;`,
		`\tmargin: 0 !important;`,
		`\toverflow: hidden !important;`,
		`\tdisplay: grid !important;`,
		`\tgrid-template: minmax(0, 1fr) / minmax(0, 1fr) !important;`,
		`\talign-items: stretch !important;`,
		`}`,
		// Container spacing + max width live on content — media stays edge-to-edge.
		`${ selector } .bm-hero__content,`,
		`${ selector } .bm-hero--image-left .bm-hero__content,`,
		`${ selector } .bm-hero--image-right .bm-hero__content {`,
		`\tjustify-content: center;`,
		`\talign-self: stretch !important;`,
		`\tbox-sizing: border-box !important;`,
		`\tmin-height: 0;`,
		`\twidth: 100% !important;`,
		`\tmax-width: min(100%, var(--bm-content-max-width, 72rem)) !important;`,
		`\tmargin-left: auto !important;`,
		`\tmargin-right: auto !important;`,
		`\tpadding-top: ${ padT } !important;`,
		`\tpadding-right: ${ padR } !important;`,
		`\tpadding-bottom: ${ padB } !important;`,
		`\tpadding-left: ${ padL } !important;`,
		`}`,
		// Media frame / editor hit-wrap is the grid child — stretch it or fill CSS never reaches the media.
		`${ selector } .bm-hero__media > .bm-media-frame,`,
		`${ selector } .bm-hero__media > .bm-el-hit {`,
		`\tgrid-area: 1 / 1 !important;`,
		`\tposition: relative !important;`,
		`\twidth: 100% !important;`,
		`\tmax-width: none !important;`,
		`\theight: 100% !important;`,
		`\tmin-width: 0 !important;`,
		`\tmin-height: 100% !important;`,
		`\talign-self: stretch !important;`,
		`\tjustify-self: stretch !important;`,
		`\tdisplay: grid !important;`,
		`\tgrid-template: minmax(0, 1fr) / minmax(0, 1fr) !important;`,
		`\talign-items: stretch !important;`,
		`\tbox-sizing: border-box !important;`,
		`\tline-height: 0 !important;`,
		`}`,
		`${ selector } .bm-hero__media .bm-hero__image:not(.bm-hero__embed),`,
		`${ selector } .bm-hero__media > img,`,
		`${ selector } .bm-hero__media > video,`,
		`${ selector } .bm-hero__media > iframe,`,
		`${ selector } .bm-hero__media > .bm-media-frame > img,`,
		`${ selector } .bm-hero__media > .bm-media-frame > video,`,
		`${ selector } .bm-hero__media > .bm-media-frame > iframe,`,
		`${ selector } .bm-hero__media > .bm-el-hit > img,`,
		`${ selector } .bm-hero__media > .bm-el-hit > video,`,
		`${ selector } .bm-hero__media > .bm-el-hit > iframe {`,
		`\tposition: static !important;`,
		`\tinset: auto !important;`,
		`\tgrid-area: 1 / 1 !important;`,
		`\twidth: 100% !important;`,
		`\tmax-width: none !important;`,
		`\theight: 100% !important;`,
		`\tmin-width: 0 !important;`,
		`\tmin-height: 100% !important;`,
		`\taspect-ratio: unset !important;`,
		`\tborder-radius: 0 !important;`,
		`\tobject-fit: var(--bm-image-object-fit, cover) !important;`,
		`\tobject-position: center !important;`,
		`\tmargin: 0 !important;`,
		`\tdisplay: block !important;`,
		`}`,
		// Fullscreen embeds: fill the media pane (wrapper owns the box; iframe absolute).
		`${ selector } .bm-hero__media .bm-hero__embed,`,
		`${ selector } .bm-hero__media > .bm-media-frame > .bm-hero__embed,`,
		`${ selector } .bm-hero__media > .bm-el-hit > .bm-hero__embed {`,
		`\tposition: relative !important;`,
		`\tgrid-area: 1 / 1 !important;`,
		`\twidth: 100% !important;`,
		`\tmax-width: none !important;`,
		`\theight: 100% !important;`,
		`\tmin-width: 0 !important;`,
		`\tmin-height: 100% !important;`,
		`\taspect-ratio: unset !important;`,
		`\tborder-radius: 0 !important;`,
		`\tobject-fit: unset !important;`,
		`\toverflow: hidden !important;`,
		`\tmargin: 0 !important;`,
		`\tdisplay: block !important;`,
		`}`,
		`${ selector } .bm-hero__media .bm-hero__embed .wp-block-embed__wrapper {`,
		`\tposition: absolute !important;`,
		`\tinset: 0 !important;`,
		`\tdisplay: block !important;`,
		`\twidth: 100% !important;`,
		`\theight: 100% !important;`,
		`\taspect-ratio: auto !important;`,
		`\toverflow: hidden !important;`,
		`\tline-height: 0 !important;`,
		`}`,
		`${ selector } .bm-hero__media .bm-hero__embed .bm-embed-ratio,`,
		`${ selector } .bm-hero__media .bm-hero__embed .wp-block-embed__wrapper > div[style*="padding-bottom"] {`,
		`\tposition: absolute !important;`,
		`\tinset: 0 !important;`,
		`\twidth: 100% !important;`,
		`\theight: 100% !important;`,
		`\tpadding-bottom: 0 !important;`,
		`}`,
		`${ selector } .bm-hero__media .bm-hero__embed iframe,`,
		`${ selector } .bm-hero__media .bm-hero__embed embed,`,
		`${ selector } .bm-hero__media .bm-hero__embed object {`,
		`\tposition: absolute !important;`,
		`\tinset: 0 !important;`,
		`\twidth: 100% !important;`,
		`\tmax-width: none !important;`,
		`\theight: 100% !important;`,
		`\tmin-height: 0 !important;`,
		`\taspect-ratio: auto !important;`,
		`\tobject-fit: unset !important;`,
		`\tborder: 0 !important;`,
		`\tmargin: 0 !important;`,
		`}`,
	].join( '\n' );
}

/**
 * When columns are stacked, fullscreen media bleeds to the block edges.
 * Gated by --bm-mf-bleed (0|1) so tablet/mobile can differ inside one stack MQ.
 *
 * Fullscreen moves section padding onto .bm-hero__content only — the image
 * stays edge-to-edge (no padded “frame” above/beside the media).
 *
 * @param {string} selector
 * @return {string}
 */
function mediaFrameBleedRules( selector ) {
	const padL = 'var(--bm-padding-left, var(--bm-padding-x, 0px))';
	const padR = 'var(--bm-padding-right, var(--bm-padding-x, 0px))';
	const padT = 'var(--bm-padding-top, var(--bm-padding-y, 0px))';
	const padB = 'var(--bm-padding-bottom, var(--bm-padding-y, 0px))';
	const bleed = 'var(--bm-mf-bleed, 0)';
	return [
		`${ selector } {`,
		// Section padding handles framed inset; fullscreen zeros it (pad moves to content).
		`\t--bm-mf-margin-left: 0;`,
		`\t--bm-mf-margin-right: 0;`,
		`\t--bm-mf-media-width: 100%;`,
		`\t--bm-mf-media-max-width: none;`,
		`\t--bm-mf-media-height: auto;`,
		`\t--bm-mf-img-height: var(--bm-image-height, auto);`,
		`\t--bm-mf-img-aspect: var(--bm-image-aspect-ratio, 16/9);`,
		`}`,
		// bleed=1 → section pad off; bleed=0 → keep section pad (framed).
		`${ selector } .bm-hero,`,
		`${ selector }.bm-hero {`,
		`\tpadding-top: calc(${ padT } * (1 - ${ bleed })) !important;`,
		`\tpadding-right: calc(${ padR } * (1 - ${ bleed })) !important;`,
		`\tpadding-bottom: calc(${ padB } * (1 - ${ bleed })) !important;`,
		`\tpadding-left: calc(${ padL } * (1 - ${ bleed })) !important;`,
		`}`,
		// Fullscreen: content carries inset + max width; framed: no extra content pad/cap.
		`${ selector } .bm-hero__content,`,
		`${ selector } .bm-hero--image-left .bm-hero__content,`,
		`${ selector } .bm-hero--image-right .bm-hero__content {`,
		`\tbox-sizing: border-box !important;`,
		`\talign-self: auto !important;`,
		`\twidth: 100% !important;`,
		`\tmax-width: min(100%, calc(${ bleed } * var(--bm-content-max-width, 72rem) + (1 - ${ bleed }) * 100%)) !important;`,
		`\tmargin-left: auto !important;`,
		`\tmargin-right: auto !important;`,
		`\tpadding-top: calc(${ padT } * ${ bleed }) !important;`,
		`\tpadding-right: calc(${ padR } * ${ bleed }) !important;`,
		`\tpadding-bottom: calc(${ padB } * ${ bleed }) !important;`,
		`\tpadding-left: calc(${ padL } * ${ bleed }) !important;`,
		`}`,
		`${ selector } .bm-hero__media {`,
		`\tposition: relative !important;`,
		`\toverflow: hidden !important;`,
		`\tdisplay: flex !important;`,
		`\tflex-direction: column !important;`,
		`\twidth: 100% !important;`,
		`\tmax-width: none !important;`,
		`\theight: auto !important;`,
		`\tmin-height: 0 !important;`,
		`\talign-self: auto !important;`,
		`\tjustify-self: auto !important;`,
		`\tmargin-left: 0 !important;`,
		`\tmargin-right: 0 !important;`,
		`}`,
		`${ selector } .bm-hero__media > .bm-media-frame,`,
		`${ selector } .bm-hero__media > .bm-el-hit {`,
		`\twidth: var(--bm-mf-img-width, var(--bm-image-width, 100%)) !important;`,
		`\tmax-width: 100% !important;`,
		`\theight: auto !important;`,
		`\tmin-height: 0 !important;`,
		`\tdisplay: block !important;`,
		`\tgrid-area: auto !important;`,
		`\tmargin-left: var(--bm-mf-media-margin-start, var(--bm-media-margin-inline-start, 0)) !important;`,
		`\tmargin-right: var(--bm-mf-media-margin-end, var(--bm-media-margin-inline-end, 0)) !important;`,
		`\tbox-sizing: border-box !important;`,
		`}`,
		`${ selector } .bm-hero__media .bm-hero__image:not(.bm-hero__embed),`,
		`${ selector } .bm-hero__media > img,`,
		`${ selector } .bm-hero__media > video,`,
		`${ selector } .bm-hero__media > iframe,`,
		`${ selector } .bm-hero__media > .bm-media-frame > img,`,
		`${ selector } .bm-hero__media > .bm-media-frame > video,`,
		`${ selector } .bm-hero__media > .bm-media-frame > iframe,`,
		`${ selector } .bm-hero__media > .bm-media-frame > .bm-hero__embed,`,
		`${ selector } .bm-hero__media > .bm-el-hit > img,`,
		`${ selector } .bm-hero__media > .bm-el-hit > video,`,
		`${ selector } .bm-hero__media > .bm-el-hit > iframe {`,
		`\tposition: static !important;`,
		`\tinset: auto !important;`,
		`\tgrid-area: auto !important;`,
		`\twidth: 100% !important;`,
		`\tmax-width: 100% !important;`,
		`\theight: var(--bm-image-height, auto) !important;`,
		`\tmin-height: 0 !important;`,
		`\taspect-ratio: var(--bm-image-aspect-ratio, 16/9) !important;`,
		`\tborder-top-left-radius: calc(var(--bm-mf-img-radius-tl, var(--bm-mf-img-radius, 0px)) * (1 - ${ bleed })) !important;`,
		`\tborder-top-right-radius: calc(var(--bm-mf-img-radius-tr, var(--bm-mf-img-radius, 0px)) * (1 - ${ bleed })) !important;`,
		`\tborder-bottom-right-radius: calc(var(--bm-mf-img-radius-br, var(--bm-mf-img-radius, 0px)) * (1 - ${ bleed })) !important;`,
		`\tborder-bottom-left-radius: calc(var(--bm-mf-img-radius-bl, var(--bm-mf-img-radius, 0px)) * (1 - ${ bleed })) !important;`,
		`\tobject-fit: var(--bm-image-object-fit, cover) !important;`,
		`\tobject-position: center !important;`,
		`\tmargin: 0 !important;`,
		`}`,
		`${ selector } .bm-hero__media .bm-hero__embed,`,
		`${ selector } .bm-hero__media > .bm-media-frame > .bm-hero__embed {`,
		`\theight: auto !important;`,
		`\tmin-height: 0 !important;`,
		`\taspect-ratio: auto !important;`,
		`\tobject-fit: unset !important;`,
		`\toverflow: hidden !important;`,
		`}`,
		`${ selector } .bm-hero__media .bm-hero__embed .wp-block-embed__wrapper {`,
		`\tposition: relative !important;`,
		`\tdisplay: block !important;`,
		`\twidth: 100% !important;`,
		`\theight: auto !important;`,
		`\taspect-ratio: auto !important;`,
		`\toverflow: hidden !important;`,
		`\tline-height: 0 !important;`,
		`}`,
		`${ selector } .bm-hero__media .bm-hero__embed iframe,`,
		`${ selector } .bm-hero__media .bm-hero__embed embed,`,
		`${ selector } .bm-hero__media .bm-hero__embed object {`,
		`\tposition: absolute !important;`,
		`\tinset: 0 !important;`,
		`\twidth: 100% !important;`,
		`\tmax-width: none !important;`,
		`\theight: 100% !important;`,
		`\tmin-height: 0 !important;`,
		`\taspect-ratio: auto !important;`,
		`\tobject-fit: unset !important;`,
		`\tborder: 0 !important;`,
		`\tmargin: 0 !important;`,
		`}`,
	].join( '\n' );
}

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

/**
 * Truthy CSS flag (toggle vars like --bm-tab-nav-responsive).
 *
 * @param {string} value
 * @return {boolean}
 */
export function isCssFlagOn( value ) {
	const raw = String( value || '' )
		.trim()
		.toLowerCase();
	return raw === '1' || raw === 'on' || raw === 'true' || raw === 'yes';
}

/**
 * Whether a border-width value is effectively non-zero.
 *
 * @param {string|number|null|undefined} value
 * @return {boolean}
 */
function borderSideHasWidth( value ) {
	const raw = String( value ?? '' )
		.trim()
		.toLowerCase();
	if (
		! raw ||
		raw === 'none' ||
		raw === 'initial' ||
		raw === 'unset' ||
		raw === '0' ||
		raw === '0px' ||
		raw === '0rem' ||
		raw === '0em'
	) {
		return false;
	}
	const n = parseFloat( raw );
	if ( Number.isFinite( n ) ) {
		return n > 0;
	}
	return true;
}

/**
 * Normalize a width token so CSS max() always gets comparable lengths.
 *
 * @param {string} value
 * @return {string}
 */
function normalizeBorderWidthToken( value ) {
	const raw = String( value ?? '' ).trim();
	if ( ! raw || raw === 'none' ) {
		return '0px';
	}
	if ( raw === '0' ) {
		return '0px';
	}
	if ( /^\d+(\.\d+)?$/.test( raw ) ) {
		return `${ raw }px`;
	}
	return raw;
}

/**
 * Resolve accordion item border side width from flat css vars.
 * Explicit side keys win (including "0") — do not fall back to linked width.
 *
 * @param {object} flat
 * @param {'top'|'right'|'bottom'|'left'} side
 * @return {string}
 */
function accordionItemBorderSideWidth( flat, side ) {
	const sideKey = `--bm-item-border-${ side }-width`;
	if ( flat && Object.prototype.hasOwnProperty.call( flat, sideKey ) ) {
		return String( flat[ sideKey ] ?? '' ).trim();
	}
	const linked = flat?.[ '--bm-item-border-width' ];
	if ( linked != null && String( linked ).trim() !== '' ) {
		return String( linked ).trim();
	}
	return '';
}

/**
 * Close the open end of separator-style accordion borders.
 * Uses max(top, bottom) on the first/last item so a top-only or bottom-only
 * separator also closes the stack. Harmless when both sides already match.
 *
 * Widths are baked from the effective cssVars map (same as the JBM block
 * editor) so Gutenberg canvas / library stylesheets cannot inflate the
 * computed top/bottom via cascaded `var()` resolution.
 *
 * @param {string} selector
 * @param {object} flat Effective css vars for one breakpoint.
 * @return {string}
 */
export function accordionClosingBorderRules( selector, flat = {} ) {
	if ( ! isCssFlagOn( flat[ '--bm-item-closing-border' ] ) ) {
		return '';
	}
	const top = normalizeBorderWidthToken(
		accordionItemBorderSideWidth( flat, 'top' )
	);
	const bottom = normalizeBorderWidthToken(
		accordionItemBorderSideWidth( flat, 'bottom' )
	);
	// Nothing to close when neither vertical edge is drawn.
	if ( ! borderSideHasWidth( top ) && ! borderSideHasWidth( bottom ) ) {
		return '';
	}
	const s = selector;
	const style = 'var(--bm-item-border-style, solid)';
	const color = 'var(--bm-item-border-color, currentColor)';
	return [
		`${ s } .bm-accordion__item:last-of-type {`,
		`\tborder-bottom-width: max(${ bottom }, ${ top }) !important;`,
		`\tborder-bottom-style: ${ style } !important;`,
		`\tborder-bottom-color: ${ color } !important;`,
		`}`,
		`${ s } .bm-accordion__item:first-of-type {`,
		`\tborder-top-width: max(${ top }, ${ bottom }) !important;`,
		`\tborder-top-style: ${ style } !important;`,
		`\tborder-top-color: ${ color } !important;`,
		`}`,
	].join( '\n' );
}

/**
 * @param {string[]} parts
 * @param {string}   selector
 * @param {object}   vars
 */
function appendAccordionClosingBorderCss( parts, selector, vars ) {
	const n = normalize( vars );
	const desktopRules = accordionClosingBorderRules(
		selector,
		effective( n, 'desktop' )
	);
	if ( desktopRules ) {
		parts.push( desktopRules );
	}
	[ 'tablet', 'mobile' ].forEach( ( bp ) => {
		if ( ! Object.keys( n[ bp ] || {} ).length ) {
			return;
		}
		const rules = accordionClosingBorderRules(
			selector,
			effective( n, bp )
		);
		if ( ! rules ) {
			return;
		}
		if ( rules === desktopRules ) {
			return;
		}
		parts.push( `@media ${ MEDIA[ bp ] } {\n${ rules }\n}` );
	} );
}

const ACCORDION_ARROW_SIZE_CSS = {
	small: {
		boxSize: '1.5rem',
		width: '0.35rem',
		height: '0.35rem',
		border: '1.5px',
		plusMinusStroke: '2px',
		plusMinusSize: '8px',
		fontSize: '0.75rem',
		openOffset: '30%',
	},
	medium: {
		boxSize: '2rem',
		width: '0.45rem',
		height: '0.45rem',
		border: '2px',
		plusMinusStroke: '2px',
		plusMinusSize: '10px',
		fontSize: '1rem',
		openOffset: '45%',
	},
	large: {
		boxSize: '2.5rem',
		width: '0.55rem',
		height: '0.55rem',
		border: '2.5px',
		plusMinusStroke: '2px',
		plusMinusSize: '14px',
		fontSize: '1.25rem',
		openOffset: '30%',
	},
	xlarge: {
		boxSize: '3rem',
		width: '0.7rem',
		height: '0.7rem',
		border: '3px',
		plusMinusStroke: '2px',
		plusMinusSize: '16px',
		fontSize: '1.5rem',
		openOffset: '30%',
	},
};

/**
 * Bake accordion caret display / position / size into scoped CSS so preview
 * updates even when a cached theme-agnostic stylesheet still has hardcoded rules.
 *
 * @param {string} selector
 * @param {Record<string, string>} flat
 * @return {string}
 */
export function accordionArrowRules( selector, flat = {} ) {
	const s = String( selector || '' ).trim();
	if ( ! s ) {
		return '';
	}
	const flag = flat[ '--bm-accordion-arrow' ];
	const displayRaw = String(
		flat[ '--bm-accordion-arrow-display' ] ?? ''
	).trim();
	let display = displayRaw;
	if ( ! display ) {
		// Default on when unset (matches block.json seed).
		display =
			flag == null || flag === '' || isCssFlagOn( flag )
				? 'inline-flex'
				: 'none';
	}
	// Legacy "block" display still means visible.
	if ( display === 'block' ) {
		display = 'inline-flex';
	}

	const position =
		String( flat[ '--bm-accordion-arrow-position' ] || '' )
			.trim()
			.toLowerCase() === 'left'
			? 'left'
			: 'right';

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

	const sizeKey = String( flat[ '--bm-accordion-arrow-size' ] || '' )
		.trim()
		.toLowerCase();
	const sizePreset = ACCORDION_ARROW_SIZE_CSS[ sizeKey ] || null;
	const boxSize =
		String( flat[ '--bm-accordion-arrow-box-size' ] || '' ).trim() ||
		sizePreset?.boxSize ||
		'2rem';
	const width =
		String( flat[ '--bm-accordion-arrow-width' ] || '' ).trim() ||
		sizePreset?.width ||
		'0.45rem';
	const height =
		String( flat[ '--bm-accordion-arrow-height' ] || '' ).trim() ||
		sizePreset?.height ||
		'0.45rem';
	const border =
		String( flat[ '--bm-accordion-arrow-border' ] || '' ).trim() ||
		sizePreset?.border ||
		'2px';
	const plusMinusStroke =
		String( flat[ '--bm-accordion-plus-minus-stroke' ] || '' ).trim() ||
		sizePreset?.plusMinusStroke ||
		'2px';
	const plusMinusSize =
		String( flat[ '--bm-accordion-plus-minus-size' ] || '' ).trim() ||
		sizePreset?.plusMinusSize ||
		'10px';
	const fontSize =
		String( flat[ '--bm-accordion-arrow-font-size' ] || '' ).trim() ||
		sizePreset?.fontSize ||
		'1rem';
	const openOffset =
		String( flat[ '--bm-accordion-arrow-open-offset' ] || '' ).trim() ||
		sizePreset?.openOffset ||
		'45%';

	// Caret sizing only applies to the rotating-arrow style; other styles draw
	// their own indicator from the same pseudo-elements.
	let arrowStyle = String( flat[ '--bm-accordion-arrow-style' ] || '' )
		.trim()
		.toLowerCase();
	if ( ! arrowStyle ) {
		arrowStyle = isCssFlagOn( flat[ '--bm-accordion-arrow-custom' ] )
			? 'custom'
			: 'arrow';
	}
	const isCaretStyle = arrowStyle === 'arrow';

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

	const rules = [
		`${ s } .bm-accordion__arrow {`,
		`\tdisplay: ${ display } !important;`,
		`\tflex: 0 0 auto !important;`,
		`\torder: ${ order } !important;`,
		`\tmargin-left: ${ ml } !important;`,
		`\tmargin-right: ${ mr } !important;`,
		`\twidth: ${ boxSize } !important;`,
		`\theight: ${ boxSize } !important;`,
		`\tpadding: 0 !important;`,
		`\tfont-size: ${ fontSize } !important;`,
		`\t--bm-accordion-arrow-open-offset: ${ openOffset };`,
		`\t--bm-accordion-plus-minus-stroke: ${ plusMinusStroke };`,
		`\t--bm-accordion-plus-minus-size: ${ plusMinusSize };`,
		`}`,
	];

	if ( isCaretStyle ) {
		rules.push(
			`${ s } .bm-accordion__arrow::after {`,
			`\twidth: ${ width } !important;`,
			`\theight: ${ height } !important;`,
			`\tborder-right: ${ border } solid currentColor !important;`,
			`\tborder-bottom: ${ border } solid currentColor !important;`,
			`}`
		);
	}

	return rules.join( '\n' );
}

/**
 * @param {string[]} parts
 * @param {string}   selector
 * @param {object}   vars
 */
function appendAccordionArrowCss( parts, selector, vars ) {
	const n = normalize( vars );
	const desktopRules = accordionArrowRules(
		selector,
		effective( n, 'desktop' )
	);
	if ( desktopRules ) {
		parts.push( desktopRules );
	}
	[ 'tablet', 'mobile' ].forEach( ( bp ) => {
		if ( ! Object.keys( n[ bp ] || {} ).length ) {
			return;
		}
		const rules = accordionArrowRules( selector, effective( n, bp ) );
		if ( ! rules || rules === desktopRules ) {
			return;
		}
		parts.push( `@media ${ MEDIA[ bp ] } {\n${ rules }\n}` );
	} );
}

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

/**
 * When Tab bar → Responsive is on, emit collapse/burger rules for real viewports.
 *
 * @param {string[]} parts
 * @param {string}   selector
 * @param {object}   vars
 */
function appendTabsResponsiveNavCss( parts, selector, vars ) {
	const n = normalize( vars );
	if ( ! isCssFlagOn( n.desktop[ '--bm-tab-nav-responsive' ] ) ) {
		return;
	}
	parts.push(
		`@media ${ MEDIA.tablet } {\n${ tabsResponsiveNavRules( selector ) }\n}`
	);
}

/**
 * Append framed reset rules when media is not fullscreen (beats stale library CSS).
 *
 * @param {string[]} parts
 * @param {string}   selector
 * @param {object}   vars
 */
function appendMediaFrameFramedCss( parts, selector, vars ) {
	const n = normalize( vars );
	const frame = effective( n, 'desktop' )[ '--bm-media-frame' ];
	if ( frame == null || String( frame ).trim() === '' ) {
		return;
	}
	const stackPx = hasColumnsBreakpoint( n.desktop )
		? resolveColumnsBreakpointPx( n.desktop[ '--bm-columns-breakpoint' ] )
		: null;
	const framed = mediaFrameFramedRules( selector );

	if ( ! isMediaFullscreen( effective( n, 'desktop' )[ '--bm-media-frame' ] ) ) {
		if ( stackPx ) {
			parts.push(
				`@media (min-width: ${ stackPx + 1 }px) {\n${ framed }\n}`
			);
		} else {
			parts.push( framed );
		}
	}

	[ 'tablet', 'mobile' ].forEach( ( bp ) => {
		if ( isMediaFullscreen( effective( n, bp )[ '--bm-media-frame' ] ) ) {
			return;
		}
		// Only emit when this bp can show side-by-side (not stacked).
		const devicePx = DEVICE_WIDTH_PX[ bp ];
		const stacked = stackPx !== null && devicePx <= stackPx;
		if ( stacked ) {
			return;
		}
		// Skip if desktop already framed and this bp doesn't override frame
		// (desktop MQ/min-width already covers it) — still emit for clarity when
		// tablet/mobile set framed while desktop is fullscreen.
		if (
			! Object.prototype.hasOwnProperty.call(
				n[ bp ] || {},
				'--bm-media-frame'
			) &&
			! isMediaFullscreen( effective( n, 'desktop' )[ '--bm-media-frame' ] )
		) {
			return;
		}
		parts.push( `@media ${ MEDIA[ bp ] } {\n${ framed }\n}` );
	} );
}

/**
 * Append side-by-side fullscreen rules for viewports that are not stacked.
 *
 * @param {string[]} parts
 * @param {string}   selector
 * @param {object}   vars
 */
function appendMediaFrameSideCss( parts, selector, vars ) {
	const n = normalize( vars );
	const stackPx = hasColumnsBreakpoint( n.desktop )
		? resolveColumnsBreakpointPx( n.desktop[ '--bm-columns-breakpoint' ] )
		: null;
	const side = mediaFrameSideRules( selector );

	if ( isMediaFullscreen( effective( n, 'desktop' )[ '--bm-media-frame' ] ) ) {
		if ( stackPx ) {
			parts.push(
				`@media (min-width: ${ stackPx + 1 }px) {\n${ side }\n}`
			);
		} else {
			parts.push( side );
		}
	}

	[ 'tablet', 'mobile' ].forEach( ( bp ) => {
		if ( ! isMediaFullscreen( effective( n, bp )[ '--bm-media-frame' ] ) ) {
			return;
		}
		const devicePx = DEVICE_WIDTH_PX[ bp ];
		const stacked = stackPx !== null && devicePx <= stackPx;
		if ( stacked ) {
			return;
		}
		parts.push( `@media ${ MEDIA[ bp ] } {\n${ side }\n}` );
	} );
}

/**
 * Extra rules that force the hero grid to stack (and optionally reverse).
 * Targets .bm-hero__grid so it works even when markup still has display:grid.
 * Also resets side-by-side fullscreen !important rules that must not leak into stack.
 *
 * @param {string} selector
 * @param {string} [direction] column|column-reverse — omit to use CSS var
 * @return {string}
 */
function stackedGridRules( selector, direction ) {
	const dir =
		direction === 'column' || direction === 'column-reverse'
			? direction
			: 'var(--bm-stacked-direction, column)';
	return [
		`${ selector } .bm-hero__grid {`,
		`\tdisplay: flex !important;`,
		`\tflex-direction: ${ dir } !important;`,
		`\tflex-wrap: nowrap !important;`,
		`\tgrid-template-columns: ${ GRID_COLUMNS_ONE } !important;`,
		`\tgrid-template-rows: none !important;`,
		`\twidth: 100% !important;`,
		`\tmax-width: var(--bm-mf-stack-grid-max-width, var(--bm-content-max-width, 72rem)) !important;`,
		`\tmargin: var(--bm-inner-margin, 0 auto) !important;`,
		`\tgap: var(--bm-columns-gap, var(--bm-gap)) !important;`,
		`\tcolumn-gap: var(--bm-columns-gap, var(--bm-gap)) !important;`,
		`\trow-gap: var(--bm-columns-gap, var(--bm-gap)) !important;`,
		`\talign-items: stretch !important;`,
		`}`,
	].join( '\n' );
}

/**
 * @param {Record<string, string>} desktop
 * @return {boolean}
 */
function hasColumnsBreakpoint( desktop ) {
	return (
		desktop &&
		Object.prototype.hasOwnProperty.call( desktop, '--bm-columns-breakpoint' ) &&
		String( desktop[ '--bm-columns-breakpoint' ] ?? '' ).trim() !== ''
	);
}

/**
 * Bake --bm-grid-template-columns (+ stack order) for editor/admin device previews.
 *
 * @param {Record<string, string>} flat
 * @param {object}                 vars
 * @param {string}                 breakpoint
 */
function applyGridColumnsForPreview( flat, vars, breakpoint ) {
	const n = normalize( vars );
	if ( ! hasColumnsBreakpoint( n.desktop ) ) {
		return;
	}
	const px = resolveColumnsBreakpointPx( n.desktop[ '--bm-columns-breakpoint' ] );
	const devicePx =
		breakpoint === 'mobile'
			? DEVICE_WIDTH_PX.mobile
			: breakpoint === 'tablet'
			? DEVICE_WIDTH_PX.tablet
			: null;
	const stacked = devicePx !== null && devicePx <= px;
	flat[ '--bm-grid-template-columns' ] = stacked
		? GRID_COLUMNS_ONE
		: GRID_COLUMNS_TWO;
	const mode = stacked
		? n.desktop[ '--bm-columns-stack-order' ] || 'default'
		: 'default';
	// Keep order at default; reverse uses flex-direction column-reverse instead.
	Object.assign( flat, {
		'--bm-media-order': '0',
		'--bm-content-order': '0',
		'--bm-stacked-direction': isStackReverse( mode )
			? 'column-reverse'
			: 'column',
	} );
}

export function isResponsive( vars ) {
	if ( ! vars || typeof vars !== 'object' || Array.isArray( vars ) ) {
		return false;
	}
	return BREAKPOINTS.some( ( bp ) => bp in vars && vars[ bp ] && typeof vars[ bp ] === 'object' );
}

function sanitizeFlat( vars ) {
	const out = {};
	if ( ! vars || typeof vars !== 'object' ) {
		return out;
	}
	Object.keys( vars ).forEach( ( key ) => {
		const value = vars[ key ];
		if ( value !== null && typeof value === 'object' ) {
			return;
		}
		let k = String( key );
		if ( ! k.startsWith( '--' ) ) {
			k = '--' + k.replace( /^-+/, '' );
		}
		out[ k ] = String( value ?? '' );
	} );
	return migrateTabActiveBg(
		migrateBgGradient( migrateOverlayOpacity( out ) )
	);
}

/**
 * Seeded `--bm-tab-active-bg: transparent` blocked Default bg on the checked tab.
 * Drop that seed so Active inherits Default until an Active color is chosen.
 *
 * @param {Record<string, string>} flat
 * @return {Record<string, string>}
 */
function migrateTabActiveBg( flat ) {
	if ( ! flat || typeof flat !== 'object' ) {
		return flat;
	}
	const active = String( flat[ '--bm-tab-active-bg' ] ?? '' )
		.trim()
		.toLowerCase();
	if ( active !== 'transparent' ) {
		return flat;
	}
	const next = { ...flat };
	delete next[ '--bm-tab-active-bg' ];
	return next;
}

/**
 * Bake legacy --bm-overlay-opacity into --bm-overlay (alpha in the color).
 *
 * @param {Record<string, string>} flat
 * @return {Record<string, string>}
 */
function migrateOverlayOpacity( flat ) {
	if ( ! flat || ! Object.prototype.hasOwnProperty.call( flat, '--bm-overlay-opacity' ) ) {
		return flat;
	}
	const opacity = parseFloat( flat[ '--bm-overlay-opacity' ] );
	const overlay = flat[ '--bm-overlay' ] || '';
	const next = { ...flat };

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

	if ( ! Number.isFinite( opacity ) || opacity >= 1 ) {
		return next;
	}
	if ( ! overlay || String( overlay ).includes( 'gradient' ) ) {
		return next;
	}
	next[ '--bm-overlay' ] = applyAlphaToColor( overlay, opacity );
	return next;
}

/**
 * Move gradient fills off --bm-bg onto --bm-bg-gradient so background-image
 * can stay valid when an image URL is also set.
 *
 * Do not invent --bm-bg-image / --bm-bg-gradient on sparse breakpoint maps —
 * writing `none` there overrides desktop inheritance and hides the image.
 *
 * @param {Record<string, string>} flat
 * @return {Record<string, string>}
 */
function migrateBgGradient( flat ) {
	if ( ! flat ) {
		return flat;
	}
	const next = { ...flat };
	const hasBg = Object.prototype.hasOwnProperty.call( flat, '--bm-bg' );
	const hasGradient = Object.prototype.hasOwnProperty.call(
		flat,
		'--bm-bg-gradient'
	);
	const hasImage = Object.prototype.hasOwnProperty.call( flat, '--bm-bg-image' );
	const bg = String( flat[ '--bm-bg' ] || '' ).trim();
	const existing = String( flat[ '--bm-bg-gradient' ] || '' ).trim();

	if ( hasBg && bg.toLowerCase().includes( 'gradient' ) ) {
		if (
			! hasGradient ||
			! existing ||
			existing.toLowerCase() === 'none' ||
			! existing.toLowerCase().includes( 'gradient' )
		) {
			next[ '--bm-bg-gradient' ] = bg;
		}
		next[ '--bm-bg' ] = '#3525cd';
	}

	if ( hasImage && ! String( next[ '--bm-bg-image' ] || '' ).trim() ) {
		next[ '--bm-bg-image' ] = 'none';
	}

	if ( hasGradient && ! String( next[ '--bm-bg-gradient' ] || '' ).trim() ) {
		next[ '--bm-bg-gradient' ] = 'none';
	}

	return next;
}

/**
 * Drop the paired `none` defaults that empty-map migration used to inject onto
 * tablet/mobile (they override desktop background image/gradient).
 *
 * @param {Record<string, string>} flat
 * @return {Record<string, string>}
 */
function healSparseBgPollution( flat ) {
	if ( ! flat ) {
		return flat;
	}
	const hasImg = Object.prototype.hasOwnProperty.call( flat, '--bm-bg-image' );
	const hasGrad = Object.prototype.hasOwnProperty.call(
		flat,
		'--bm-bg-gradient'
	);
	const img = String( flat[ '--bm-bg-image' ] || '' )
		.trim()
		.toLowerCase();
	const grad = String( flat[ '--bm-bg-gradient' ] || '' )
		.trim()
		.toLowerCase();
	if ( hasImg && hasGrad && img === 'none' && grad === 'none' ) {
		const next = { ...flat };
		delete next[ '--bm-bg-image' ];
		delete next[ '--bm-bg-gradient' ];
		return next;
	}
	return flat;
}

/**
 * Parse rgb/rgba channels from a CSS color string (legacy + space-separated).
 *
 * @param {string} color
 * @return {{ r: number, g: number, b: number, a: number }|null}
 */
function parseRgbChannels( color ) {
	const raw = String( color || '' ).trim();
	if ( ! raw ) {
		return null;
	}
	const legacy = raw.match(
		/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i
	);
	if ( legacy ) {
		return {
			r: Math.round( Number( legacy[ 1 ] ) ),
			g: Math.round( Number( legacy[ 2 ] ) ),
			b: Math.round( Number( legacy[ 3 ] ) ),
			a:
				legacy[ 4 ] != null
					? Math.min( 1, Math.max( 0, Number( legacy[ 4 ] ) ) )
					: 1,
		};
	}
	const modern = raw.match(
		/^rgba?\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/i
	);
	if ( modern ) {
		let a = 1;
		if ( modern[ 4 ] != null ) {
			const tok = String( modern[ 4 ] );
			a = tok.endsWith( '%' )
				? Math.min( 1, Math.max( 0, parseFloat( tok ) / 100 ) )
				: Math.min( 1, Math.max( 0, Number( tok ) ) );
		}
		return {
			r: Math.round( Number( modern[ 1 ] ) ),
			g: Math.round( Number( modern[ 2 ] ) ),
			b: Math.round( Number( modern[ 3 ] ) ),
			a,
		};
	}
	return null;
}

/**
 * Resolve any CSS color (hex / rgb / hsl / var(…)) to rgba channels via the DOM.
 *
 * @param {string} color
 * @return {{ r: number, g: number, b: number, a: number }|null}
 */
export function resolveColorChannels( color ) {
	const raw = String( color || '' ).trim();
	if ( ! raw || raw.toLowerCase().includes( 'gradient' ) ) {
		return null;
	}
	if ( raw.toLowerCase() === 'transparent' ) {
		return { r: 0, g: 0, b: 0, a: 0 };
	}

	const fromRgb = parseRgbChannels( raw );
	if ( fromRgb ) {
		return fromRgb;
	}

	let hex = raw.replace( /^#/, '' );
	if ( hex.length === 3 ) {
		hex = hex
			.split( '' )
			.map( ( c ) => c + c )
			.join( '' );
	}
	if ( hex.length === 8 && /^[0-9a-fA-F]{8}$/.test( hex ) ) {
		return {
			r: parseInt( hex.slice( 0, 2 ), 16 ),
			g: parseInt( hex.slice( 2, 4 ), 16 ),
			b: parseInt( hex.slice( 4, 6 ), 16 ),
			a:
				Math.round(
					( parseInt( hex.slice( 6, 8 ), 16 ) / 255 ) * 1000
				) / 1000,
		};
	}
	if ( hex.length === 6 && /^[0-9a-fA-F]{6}$/.test( hex ) ) {
		return {
			r: parseInt( hex.slice( 0, 2 ), 16 ),
			g: parseInt( hex.slice( 2, 4 ), 16 ),
			b: parseInt( hex.slice( 4, 6 ), 16 ),
			a: 1,
		};
	}

	if ( typeof document === 'undefined' ) {
		return null;
	}
	try {
		const host =
			document.getElementById( 'joc-blockora-morph-app' ) ||
			document.body;
		const probe = document.createElement( 'span' );
		probe.style.cssText =
			'position:absolute;left:-9999px;top:0;visibility:hidden;color:' +
			raw;
		host.appendChild( probe );
		const computed = getComputedStyle( probe ).color;
		host.removeChild( probe );
		return parseRgbChannels( computed );
	} catch ( e ) {
		return null;
	}
}

/**
 * @param {string} color
 * @return {number} Alpha 0–1
 */
export function getColorAlpha( color ) {
	const channels = resolveColorChannels( color );
	if ( ! channels ) {
		const raw = String( color || '' ).trim().toLowerCase();
		if ( ! raw || raw === 'transparent' ) {
			return raw === 'transparent' ? 0 : 1;
		}
		return 1;
	}
	return channels.a;
}

/**
 * True when a color control intentionally has no value (Clear), not when alpha is 0.
 *
 * @param {unknown} color
 * @return {boolean}
 */
export function isAbsentColor( color ) {
	if ( color == null ) {
		return true;
	}
	return String( color ).trim() === '';
}

/**
 * @param {string} color
 * @param {number} alpha
 * @return {string}
 */
export function applyAlphaToColor( color, alpha ) {
	const a = Math.min( 1, Math.max( 0, Number( alpha ) ) );
	const raw = String( color || '' ).trim();
	if ( ! raw || raw.toLowerCase().includes( 'gradient' ) ) {
		return raw;
	}

	const channels = resolveColorChannels( raw );
	if ( ! channels ) {
		return raw;
	}
	const { r, g, b } = channels;
	// Keep RGB when fully transparent so opacity can be raised again without
	// collapsing to the `transparent` keyword (which loses the swatch hue).
	if ( a < 1 ) {
		const rounded = Number( a.toFixed( 3 ) );
		return `rgba(${ r }, ${ g }, ${ b }, ${ rounded })`;
	}
	const toHex = ( n ) => {
		const h = Math.max( 0, Math.min( 255, n ) ).toString( 16 );
		return h.length === 1 ? `0${ h }` : h;
	};
	return `#${ toHex( r ) }${ toHex( g ) }${ toHex( b ) }`;
}

/**
 * Normalize flat or nested maps to { desktop, tablet, mobile }.
 */
export function normalize( vars ) {
	const empty = { desktop: {}, tablet: {}, mobile: {} };
	if ( ! vars || typeof vars !== 'object' ) {
		return empty;
	}
	if ( isResponsive( vars ) ) {
		BREAKPOINTS.forEach( ( bp ) => {
			const flat = sanitizeFlat( vars[ bp ] || {} );
			const healed =
				bp === 'desktop' ? flat : healSparseBgPollution( flat );
			empty[ bp ] = normalizeAuthorImageAlignVars(
				backfillContentAlignMediaMargins( healed )
			);
		} );
		return empty;
	}
	empty.desktop = normalizeAuthorImageAlignVars(
		backfillContentAlignMediaMargins( sanitizeFlat( vars ) )
	);
	return empty;
}

/**
 * Effective vars for a breakpoint (desktop → tablet → mobile).
 */
export function effective( vars, breakpoint = 'desktop' ) {
	const n = normalize( vars );
	let out = { ...n.desktop };
	if ( breakpoint === 'tablet' || breakpoint === 'mobile' ) {
		out = { ...out, ...n.tablet };
	}
	if ( breakpoint === 'mobile' ) {
		out = { ...out, ...n.mobile };
	}
	return out;
}

function parentEffective( normalized, breakpoint ) {
	if ( breakpoint === 'desktop' ) {
		return {};
	}
	if ( breakpoint === 'tablet' ) {
		return { ...normalized.desktop };
	}
	return { ...normalized.desktop, ...normalized.tablet };
}

/**
 * Set a var on a breakpoint; tablet/mobile stay sparse when equal to parent.
 */
export function setBreakpointVar( vars, breakpoint, key, value ) {
	const n = normalize( vars );
	const bp = BREAKPOINTS.includes( breakpoint ) ? breakpoint : 'desktop';
	let k = String( key );
	if ( ! k.startsWith( '--' ) ) {
		k = '--' + k.replace( /^-+/, '' );
	}
	const v = String( value ?? '' );

	if ( bp === 'desktop' ) {
		return {
			...n,
			desktop: { ...n.desktop, [ k ]: v },
		};
	}

	const parent = parentEffective( n, bp );
	const nextBp = { ...n[ bp ] };
	if ( String( parent[ k ] ?? '' ) === v ) {
		delete nextBp[ k ];
	} else {
		nextBp[ k ] = v;
	}
	return { ...n, [ bp ]: nextBp };
}

/**
 * Store a shared (global) var on desktop and clear tablet/mobile overrides.
 *
 * @param {object} vars
 * @param {string} key
 * @param {string} value
 */
export function setSharedDesktopVar( vars, key, value ) {
	const n = setBreakpointVar( vars, 'desktop', key, value );
	let k = String( key );
	if ( ! k.startsWith( '--' ) ) {
		k = '--' + k.replace( /^-+/, '' );
	}
	const tablet = { ...n.tablet };
	const mobile = { ...n.mobile };
	delete tablet[ k ];
	delete mobile[ k ];
	return { ...n, tablet, mobile };
}

function declarationsBlock( selector, flat ) {
	const sanitized = { ...sanitizeFlat( flat ) };
	if ( ! String( sanitized[ '--bm-font-family' ] || '' ).trim() ) {
		sanitized[ '--bm-font-family' ] = '"Inter", sans-serif';
	}
	// Stale library CSS may still set --bm-tab-active-bg: transparent, which
	// blocks Default bg on the checked tab. Force inherit via `initial`.
	if (
		Object.prototype.hasOwnProperty.call( sanitized, '--bm-tab-bg' ) ||
		Object.prototype.hasOwnProperty.call( sanitized, '--bm-tab-active-bg' )
	) {
		const active = String( sanitized[ '--bm-tab-active-bg' ] ?? '' )
			.trim()
			.toLowerCase();
		if ( ! active || active === 'transparent' ) {
			sanitized[ '--bm-tab-active-bg' ] = 'initial';
		}
	}
	const entries = Object.entries( sanitized ).filter(
		( [ k, v ] ) =>
			String( v ?? '' ) !== '' && ! isElementHtmlAttrVar( k )
	);
	if ( ! entries.length ) {
		return '';
	}
	const body = [
		'\tfont-family: var(--bm-font-family, "Inter", sans-serif);',
		...entries.map( ( [ k, v ] ) => `\t${ k }: ${ v };` ),
	].join( '\n' );
	return `${ selector } {\n${ body }\n}`;
}

/**
 * Build CSS string with media queries (frontend / real viewports).
 */
export function toCss( selector, vars ) {
	const n = normalize( vars );
	const parts = [];
	let desktop = { ...n.desktop };
	if ( hasColumnsBreakpoint( desktop ) ) {
		desktop[ '--bm-grid-template-columns' ] = GRID_COLUMNS_TWO;
	}
	desktop = expandLayoutVars( desktop );
	const base = declarationsBlock( selector, desktop );
	if ( base ) {
		parts.push( base );
	}
	[ 'tablet', 'mobile' ].forEach( ( bp ) => {
		if ( ! Object.keys( n[ bp ] ).length ) {
			return;
		}
		const inner = declarationsBlock(
			selector,
			layoutFlatForBreakpoint( n, bp )
		);
		if ( ! inner ) {
			return;
		}
		parts.push( `@media ${ MEDIA[ bp ] } {\n${ inner }\n}` );
	} );
	// Side fullscreen first, then framed resets, then stack MQ.
	// Framed resets beat a stale library stylesheet still enqueued in Gutenberg.
	appendMediaFrameSideCss( parts, selector, vars );
	appendMediaFrameFramedCss( parts, selector, vars );
	appendTabsResponsiveNavCss( parts, selector, vars );
	appendAccordionClosingBorderCss( parts, selector, vars );
	appendAccordionArrowCss( parts, selector, vars );

	if ( hasColumnsBreakpoint( n.desktop ) ) {
		const px = resolveColumnsBreakpointPx(
			n.desktop[ '--bm-columns-breakpoint' ]
		);
		const stackParts = [
			`${ selector } {\n\t--bm-grid-template-columns: ${ GRID_COLUMNS_ONE };\n\t--bm-media-order: var(--bm-stacked-media-order, 0);\n\t--bm-content-order: var(--bm-stacked-content-order, 0);\n\t--bm-mf-img-height: var(--bm-image-height, auto);\n\t--bm-mf-img-aspect: var(--bm-image-aspect-ratio, 16/9);\n}`,
			stackedGridRules( selector ),
			mediaFrameBleedRules( selector ),
		];
		parts.push(
			`@media (max-width: ${ px }px) {\n${ stackParts.join( '\n' ) }\n}`
		);
	} else if (
		Object.prototype.hasOwnProperty.call(
			n.desktop,
			'--bm-columns-stack-order'
		)
	) {
		// Instance-only override without breakpoint context.
		const expanded = expandLayoutVars( { ...n.desktop } );
		const orderOnly = {
			'--bm-stacked-media-order':
				expanded[ '--bm-stacked-media-order' ] || '0',
			'--bm-stacked-content-order':
				expanded[ '--bm-stacked-content-order' ] || '0',
			'--bm-stacked-direction':
				expanded[ '--bm-stacked-direction' ] || 'column',
		};
		const inner = declarationsBlock( selector, orderOnly );
		if ( inner ) {
			parts.push( inner );
		}
	}
	return parts.join( '\n\n' );
}

/**
 * Flat CSS for one breakpoint (editor canvas — WP device preview does not
 * change the real viewport, so media queries do not apply there).
 */
export function toCssForBreakpoint( selector, vars, breakpoint = 'desktop' ) {
	const flat = expandLayoutVars( { ...effective( vars, breakpoint ) } );
	applyGridColumnsForPreview( flat, vars, breakpoint );
	const parts = [ declarationsBlock( selector, flat ) ];

	const n = normalize( vars );
	if ( hasColumnsBreakpoint( n.desktop ) ) {
		const px = resolveColumnsBreakpointPx(
			n.desktop[ '--bm-columns-breakpoint' ]
		);
		const devicePx =
			breakpoint === 'mobile'
				? DEVICE_WIDTH_PX.mobile
				: breakpoint === 'tablet'
				? DEVICE_WIDTH_PX.tablet
				: null;
		const stacked = devicePx !== null && devicePx <= px;
		if ( stacked ) {
			parts.push(
				stackedGridRules(
					selector,
					isStackReverse(
						n.desktop[ '--bm-columns-stack-order' ] || 'default'
					)
						? 'column-reverse'
						: 'column'
				)
			);
			parts.push( mediaFrameBleedRules( selector ) );
		} else if ( isMediaFullscreen( flat[ '--bm-media-frame' ] ) ) {
			parts.push( mediaFrameSideRules( selector ) );
		} else if (
			flat[ '--bm-media-frame' ] != null &&
			String( flat[ '--bm-media-frame' ] ).trim() !== ''
		) {
			parts.push( mediaFrameFramedRules( selector ) );
		}
	} else if ( isMediaFullscreen( flat[ '--bm-media-frame' ] ) ) {
		parts.push( mediaFrameSideRules( selector ) );
	} else if (
		flat[ '--bm-media-frame' ] != null &&
		String( flat[ '--bm-media-frame' ] ).trim() !== ''
	) {
		parts.push( mediaFrameFramedRules( selector ) );
	}

	if (
		( breakpoint === 'tablet' || breakpoint === 'mobile' ) &&
		isCssFlagOn( effective( vars, 'desktop' )[ '--bm-tab-nav-responsive' ] )
	) {
		parts.push( tabsResponsiveNavRules( selector ) );
	}

	const closing = accordionClosingBorderRules( selector, flat );
	if ( closing ) {
		parts.push( closing );
	}

	const arrow = accordionArrowRules( selector, flat );
	if ( arrow ) {
		parts.push( arrow );
	}

	return parts.filter( Boolean ).join( '\n\n' );
}

/**
 * Map WordPress preview device type → Blockora Morph breakpoint id.
 */
export function wpDeviceToBreakpoint( deviceType ) {
	const d = String( deviceType || 'Desktop' ).toLowerCase();
	if ( d === 'tablet' ) {
		return 'tablet';
	}
	if ( d === 'mobile' ) {
		return 'mobile';
	}
	return 'desktop';
}

/**
 * Map Blockora Morph breakpoint id → WordPress preview device type.
 */
export function breakpointToWpDevice( breakpoint ) {
	if ( breakpoint === 'tablet' ) {
		return 'Tablet';
	}
	if ( breakpoint === 'mobile' ) {
		return 'Mobile';
	}
	return 'Desktop';
}

/**
 * Style tag HTML for scoped responsive vars.
 */
export function toStyleTag( selector, vars, id = '' ) {
	const css = toCss( selector, vars );
	if ( ! css.trim() ) {
		return '';
	}
	const idAttr = id ? ` id="${ id }"` : '';
	return `<style${ idAttr }>${ css }</style>`;
}

/**
 * Whether any breakpoint has vars.
 *
 * @param {object} vars Flat or responsive map
 * @return {boolean}
 */
export function hasAny( vars ) {
	const n = normalize( vars );
	return BREAKPOINTS.some( ( bp ) => Object.keys( n[ bp ] || {} ).length > 0 );
}

/**
 * Merge two var maps (override wins per breakpoint).
 */
export function merge( base, override ) {
	const a = normalize( base );
	const b = normalize( override );
	return {
		desktop: { ...a.desktop, ...b.desktop },
		tablet: { ...a.tablet, ...b.tablet },
		mobile: { ...a.mobile, ...b.mobile },
	};
}

/**
 * Keys in `current` that differ from `baseline` (true instance overrides).
 * Used so a full copied library default on the block does not mask live library edits.
 *
 * @param {object} current
 * @param {object} baseline
 * @return {object}
 */
export function diffCssVars( current, baseline ) {
	const a = normalize( current );
	const b = normalize( baseline );
	const out = { desktop: {}, tablet: {}, mobile: {} };
	BREAKPOINTS.forEach( ( bp ) => {
		Object.keys( a[ bp ] || {} ).forEach( ( key ) => {
			const cur = String( a[ bp ][ key ] ?? '' );
			const base = String( b[ bp ]?.[ key ] ?? '' );
			if ( cur !== base ) {
				out[ bp ][ key ] = a[ bp ][ key ];
			}
		} );
	} );
	return out;
}

/**
 * Count stored CSS custom properties across breakpoints.
 *
 * @param {object} vars
 * @return {number}
 */
export function countCssVarKeys( vars ) {
	const n = normalize( vars || {} );
	return BREAKPOINTS.reduce(
		( sum, bp ) => sum + Object.keys( n[ bp ] || {} ).length,
		0
	);
}

/**
 * True when placement cssVars look like a full library bake (attribute default /
 * old snapshot), not sparse intentional overrides.
 *
 * @param {object} instance
 * @param {object} library
 * @param {{ minKeys?: number, ratio?: number }} [opts]
 * @return {boolean}
 */
export function looksLikeLibrarySnapshot(
	instance,
	library,
	{ minKeys = 4, ratio = 0.75 } = {}
) {
	const inst = normalize( instance || {} );
	const lib = normalize( library || {} );
	const instN = countCssVarKeys( inst );
	const libN = countCssVarKeys( lib );
	if ( instN < minKeys || libN < minKeys ) {
		return false;
	}
	let shared = 0;
	BREAKPOINTS.forEach( ( bp ) => {
		Object.keys( inst[ bp ] || {} ).forEach( ( key ) => {
			if (
				Object.prototype.hasOwnProperty.call( lib[ bp ] || {}, key )
			) {
				shared += 1;
			}
		} );
	} );
	return shared >= libN * ratio && instN >= libN * ratio;
}

/**
 * Preview map: live library + only real instance overrides vs a frozen baseline.
 * Ignores baked full-library attribute snapshots so linked placements follow live My Blocks.
 *
 * @param {object} library
 * @param {object} instance
 * @param {object} baseline
 * @return {object}
 */
export function mergeLibraryPreview( library, instance, baseline ) {
	const lib = library || {};
	if (
		looksLikeLibrarySnapshot( instance, lib ) ||
		looksLikeLibrarySnapshot( instance, baseline || lib )
	) {
		return normalize( lib );
	}
	return merge( lib, diffCssVars( instance || {}, baseline || {} ) );
}

/**
 * Shared preview selector used in admin iframes.
 */
export const PREVIEW_SELECTOR = '.bm-preview-root';

/**
 * Fields that opt into per-element style toolbars.
 *
 * @param {Array} fieldSchema
 * @return {Array}
 */
export function styleableFields( fieldSchema ) {
	return ( fieldSchema || [] ).filter(
		( f ) => f && f.key && f.elementStyle === true
	);
}

/**
 * Resolve the content-stack element order from attrs / schema defaults.
 *
 * @param {Array}  fieldSchema
 * @param {string[]} [orderValue]
 * @param {object} [defaultAttrs]
 * @return {string[]}
 */
export function resolveElementsOrder(
	fieldSchema,
	orderValue,
	defaultAttrs = {},
	breakpoint = 'desktop'
) {
	const defaults = elementsOrderDefaultsFromSchema(
		fieldSchema,
		defaultAttrs
	);
	const source =
		orderValue != null && orderValue !== ''
			? orderValue
			: defaultAttrs?.elementsOrder;
	return effectiveElementsOrder( source, breakpoint, defaults );
}

/**
 * Styleable fields sorted to match the block’s visual / content-stack order.
 * Uses `elementsOrder` for stack items; layout media outside the stack is
 * placed before/after based on markup when available.
 *
 * @param {Array}  fieldSchema
 * @param {string[]} [orderValue]
 * @param {{ defaultAttrs?: object, markup?: string }} [opts]
 * @return {Array}
 */
export function styleableFieldsInOrder(
	fieldSchema,
	orderValue,
	{ defaultAttrs = {}, markup = '', breakpoint = 'desktop' } = {}
) {
	const fields = styleableFields( fieldSchema );
	if ( ! fields.length ) {
		return fields;
	}

	const hasOrderField = ( fieldSchema || [] ).some(
		( f ) => f?.type === 'elementsOrder'
	);
	const defaults = elementsOrderDefaultsFromSchema(
		fieldSchema,
		defaultAttrs
	);

	// Cards / FAQ / tabs etc. have no content-stack order field — use schema
	// (or default_attrs.elementsOrder) directly so hero defaults cannot reshuffle.
	if ( ! hasOrderField ) {
		const byKey = new Map( fields.map( ( f ) => [ f.key, f ] ) );
		const ordered = [];
		const used = new Set();
		const push = ( key ) => {
			const field = byKey.get( key );
			if ( ! field || used.has( key ) ) {
				return;
			}
			ordered.push( field );
			used.add( key );
		};
		( Array.isArray( defaults ) ? defaults : [] ).forEach( push );
		fields.forEach( ( field ) => push( field.key ) );
		return ordered;
	}

	const stackOrder = resolveElementsOrder(
		fieldSchema,
		orderValue,
		defaultAttrs,
		breakpoint
	);
	const byKey = new Map( fields.map( ( f ) => [ f.key, f ] ) );
	const inStack = new Set( stackOrder.filter( ( k ) => byKey.has( k ) ) );
	const extras = fields.filter( ( f ) => ! inStack.has( f.key ) );

	const markPos = ( field ) => {
		const candidates = [
			`{{${ field.key }.html}}`,
			`data-bm-el="${ field.key }"`,
		];
		if ( field.key === 'image' || field.elementType === 'image' ) {
			candidates.unshift( '{{image.html}}' );
		}
		if ( field.key === 'video' ) {
			candidates.push( '{{video.html}}', '{{video.url}}' );
		}
		let best = -1;
		candidates.forEach( ( token ) => {
			const idx = markup.indexOf( token );
			if ( idx >= 0 && ( best < 0 || idx < best ) ) {
				best = idx;
			}
		} );
		return best;
	};

	const prepend = [];
	const append = [];
	const stackIdx =
		markup && markup.includes( '{{contentStack}}' )
			? markup.indexOf( '{{contentStack}}' )
			: -1;

	extras.forEach( ( field ) => {
		if ( stackIdx >= 0 ) {
			const pos = markPos( field );
			if ( pos >= 0 && pos < stackIdx ) {
				prepend.push( field );
				return;
			}
			append.push( field );
			return;
		}
		// No markup hint — keep schema-relative order after the stack.
		append.push( field );
	} );

	const ordered = [];
	const used = new Set();

	const push = ( field ) => {
		if ( ! field?.key || used.has( field.key ) ) {
			return;
		}
		ordered.push( field );
		used.add( field.key );
	};

	prepend.forEach( push );
	stackOrder.forEach( ( key ) => push( byKey.get( key ) ) );
	append.forEach( push );
	fields.forEach( push );

	return ordered;
}

/**
 * Sanitize element id for CSS attribute selectors.
 *
 * @param {string} id
 * @return {string}
 */
function sanitizeElId( id ) {
	return String( id || '' ).replace( /[^a-zA-Z0-9_-]/g, '' );
}

/**
 * Chrome namespaces that are not data-bm-el targets.
 * `--bm-tab-padding-*` must not emit `[data-bm-el="tab"]` (labels use tabTitle).
 *
 * @param {string} id
 * @return {boolean}
 */
function isChromeSpacingElId( id ) {
	return id === 'tab';
}

/**
 * Hover rules for button-type styleable fields.
 *
 * @param {string} blockSelector
 * @param {Array}  fields
 * @return {string}
 */
export function elementHoverCss( blockSelector, fields ) {
	const parts = [];
	( fields || [] ).forEach( ( field ) => {
		if (
			! field?.key ||
			field.elementStyle !== true ||
			field.elementType !== 'button'
		) {
			return;
		}
		const id = sanitizeElId( field.key );
		if ( ! id ) {
			return;
		}
		const elSel = field.selector
			? String( field.selector )
			: `[data-bm-el="${ id }"]`;
		parts.push(
			`${ blockSelector } ${ elSel } {\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}`,
			`${ blockSelector } ${ elSel }:hover {\n\tbackground: var(--bm-${ id }-bg-hover) !important;\n\tbackground-color: var(--bm-${ id }-bg-hover) !important;\n\tcolor: var(--bm-${ id }-color-hover, inherit) !important;\n\tbox-shadow: var(--bm-${ id }-shadow-hover, none);\n}`
		);
	} );
	return parts.join( '\n\n' );
}

/**
 * Hover rules derived from `--bm-{id}-bg-hover` keys (no field schema needed).
 *
 * @param {string} blockSelector
 * @param {object} vars
 * @return {string}
 */
export function elementHoverCssFromVars( blockSelector, vars ) {
	const n = normalize( vars );
	const keys = new Set( [
		...Object.keys( n.desktop ),
		...Object.keys( n.tablet ),
		...Object.keys( n.mobile ),
	] );
	const parts = [];
	keys.forEach( ( key ) => {
		const match = key.match( /^--bm-(.+)-bg-hover$/ );
		if ( ! match ) {
			return;
		}
		const id = sanitizeElId( match[ 1 ] );
		if ( ! id ) {
			return;
		}
		parts.push(
			`${ blockSelector } [data-bm-el="${ id }"] {\n\ttransition: background 0.2s ease, background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, border 0.2s ease, border-color 0.2s ease;\n}`,
			`${ blockSelector } [data-bm-el="${ id }"]:hover {\n\tbackground: var(--bm-${ id }-bg-hover) !important;\n\tbackground-color: var(--bm-${ id }-bg-hover) !important;\n\tcolor: var(--bm-${ id }-color-hover, inherit) !important;\n\tbox-shadow: var(--bm-${ id }-shadow-hover, none);\n}`
		);
	} );
	return parts.join( '\n\n' );
}

/** Typography longhands written by ElementToolbar → bake with !important. */
const ELEMENT_TYPOGRAPHY_PROPS = [
	'font-family',
	'font-size',
	'font-weight',
	'line-height',
	'letter-spacing',
	'color',
];

/**
 * Per-element typography when `--bm-{id}-{prop}` is set.
 * Uses !important so card/hero titles (often h1–h6) beat theme-agnostic
 * heading scale rules that force font-size / font-family.
 *
 * @param {string} blockSelector
 * @param {object} vars
 * @return {string}
 */
export function elementFontFamilyCssFromVars( blockSelector, vars ) {
	const n = normalize( vars );
	const keys = new Set( [
		...Object.keys( n.desktop ),
		...Object.keys( n.tablet ),
		...Object.keys( n.mobile ),
	] );
	const parts = [];
	/** @type {Map<string, Set<string>>} */
	const byId = new Map();
	keys.forEach( ( key ) => {
		for ( const prop of ELEMENT_TYPOGRAPHY_PROPS ) {
			const prefix = `--bm-`;
			const suffix = `-${ prop }`;
			if ( ! key.startsWith( prefix ) || ! key.endsWith( suffix ) ) {
				continue;
			}
			const id = sanitizeElId(
				key.slice( prefix.length, key.length - suffix.length )
			);
			if ( ! id ) {
				continue;
			}
			if ( ! byId.has( id ) ) {
				byId.set( id, new Set() );
			}
			byId.get( id ).add( prop );
			break;
		}
	} );
	byId.forEach( ( props, id ) => {
		const decls = [];
		ELEMENT_TYPOGRAPHY_PROPS.forEach( ( prop ) => {
			if ( ! props.has( prop ) ) {
				return;
			}
			const fallback = prop === 'font-family' ? ', inherit' : '';
			decls.push(
				`\t${ prop }: var(--bm-${ id }-${ prop }${ fallback }) !important;`
			);
		} );
		if ( ! decls.length ) {
			return;
		}
		parts.push(
			`${ blockSelector } [data-bm-el="${ id }"] {\n${ decls.join( '\n' ) }\n}`
		);
	} );
	return parts.join( '\n\n' );
}

const ELEMENT_SPACING_PROPS = [
	'margin-top',
	'margin-right',
	'margin-bottom',
	'margin-left',
	'padding-top',
	'padding-right',
	'padding-bottom',
	'padding-left',
];

/**
 * Per-element margin/padding when `--bm-{id}-{prop}` is set.
 * Ensures spacing applies even if saved markup lacks the inline longhands
 * (e.g. older hero titles).
 *
 * @param {string} blockSelector
 * @param {object} vars
 * @return {string}
 */
export function elementSpacingCssFromVars( blockSelector, vars ) {
	const n = normalize( vars );
	const keys = new Set( [
		...Object.keys( n.desktop ),
		...Object.keys( n.tablet ),
		...Object.keys( n.mobile ),
	] );
	/** @type {Map<string, Set<string>>} */
	const byId = new Map();

	keys.forEach( ( key ) => {
		const match = String( key ).match(
			/^--bm-(.+)-(margin|padding)-(top|right|bottom|left)$/
		);
		if ( ! match ) {
			return;
		}
		const id = sanitizeElId( match[ 1 ] );
		// Skip chrome namespaces (e.g. --bm-tab-padding-* → "tab").
		if ( ! id || isChromeSpacingElId( id ) ) {
			return;
		}
		const prop = `${ match[ 2 ] }-${ match[ 3 ] }`;
		if ( ! byId.has( id ) ) {
			byId.set( id, new Set() );
		}
		byId.get( id ).add( prop );
	} );

	const parts = [];
	byId.forEach( ( props, id ) => {
		const decls = [];
		ELEMENT_SPACING_PROPS.forEach( ( p ) => {
			if ( ! props.has( p ) ) {
				return;
			}
			decls.push( `\t${ p }: var(--bm-${ id }-${ p });` );
		} );
		if ( ! decls.length ) {
			return;
		}
		// Stretch text elements so horizontal padding insets content visibly
		// inside centered flex columns (hero titles/subheads shrink-wrap otherwise).
		const stretch =
			id === 'header' || id === 'subheader'
				? '\talign-self: stretch;\n\twidth: 100%;\n\tbox-sizing: border-box;\n'
				: '';
		parts.push(
			`${ blockSelector } [data-bm-el="${ id }"] {\n${ stretch }${ decls.join(
				'\n'
			) }\n}`
		);
	} );
	return parts.join( '\n\n' );
}

/**
 * CSS custom property name for an element style prop.
 *
 * @param {string} fieldKey
 * @param {string} prop     font-size | width | border-radius | shadow | margin-top | …
 * @return {string}
 */
export function elementVarName( fieldKey, prop ) {
	const id = sanitizeElId( fieldKey );
	return `--bm-${ id }-${ prop }`;
}

/**
 * Whether a cssVars key is an HTML class/id attr (not a real CSS custom property).
 *
 * @param {string} key
 * @return {boolean}
 */
export function isElementHtmlAttrVar( key ) {
	return /^--bm-.+-(class|id)$/i.test( String( key || '' ) );
}

/**
 * Parse `--bm-{el}-class` / `--bm-{el}-id`.
 *
 * @param {string} key
 * @return {{ el: string, attr: 'class'|'id' }|null}
 */
export function parseElementHtmlAttrKey( key ) {
	const m = String( key || '' ).match( /^--bm-(.+)-(class|id)$/i );
	if ( ! m ) {
		return null;
	}
	const el = sanitizeElId( m[ 1 ] );
	if ( ! el ) {
		return null;
	}
	return { el, attr: m[ 2 ].toLowerCase() === 'id' ? 'id' : 'class' };
}

/**
 * Sanitize a space-separated HTML class list.
 *
 * @param {string} value
 * @return {string}
 */
export function sanitizeElementClassName( value ) {
	return String( value || '' )
		.split( /\s+/ )
		.map( ( part ) =>
			String( part || '' )
				.replace( /[^a-zA-Z0-9_-]/g, '' )
				.replace( /^[^a-zA-Z_]+/, '' )
		)
		.filter( Boolean )
		.join( ' ' );
}

/**
 * Sanitize a single HTML id.
 *
 * @param {string} value
 * @return {string}
 */
export function sanitizeElementHtmlId( value ) {
	const raw = String( value || '' )
		.trim()
		.replace( /[^a-zA-Z0-9_:-]/g, '' );
	if ( ! raw ) {
		return '';
	}
	// HTML ids should start with a letter.
	if ( ! /^[A-Za-z]/.test( raw ) ) {
		return '';
	}
	return raw;
}
