/**
 * Shared design renderer — turns a builder design model (rows/columns/items +
 * container) into DOM. Imported by BOTH the admin preview (GridPreview) and the
 * public frontend runtime so a list_item / infobox design renders identically in
 * the builder and on the live site.
 */
import { useState, useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { IconGlyph } from './frontendIcons';
import { applyFilters } from './hooks';

// Accept either a number (treated as px) or a unit string like "12px".
const dim = ( v ) => ( typeof v === 'number' ? `${ v }px` : v );

// Neutralise an authored CSS *value* so it cannot break out of its declaration /
// rule / the <style> element when emitted into the generated stylesheet string.
// Strips backslash (CSS unicode escapes like \7d → }) and the structural chars
// ; { } < > — no legitimate single value needs them, so gradients/shadows/fonts
// (parens/commas/quotes) survive. PHP twin: DesignRenderer::cssSafe().
const cssSafe = ( v ) => String( v == null ? '' : v ).replace( /[\\;{}<>]/g, '' ).trim();

// Sanitise rich-text HTML before it is injected via dangerouslySetInnerHTML —
// the client-side counterpart of PHP wp_kses_post(). Live data is already
// server-sanitised; this also covers the builder preview (admin-entered HTML)
// and any host filter returning a { type:'html' } value. Strips dangerous
// elements + on*/javascript: attributes while preserving normal markup
// (<p>/<a>/<strong>/… and the renderer's own <div class="cliq-hours">).
// Rich-text HTML is reduced to an allowlist before it is injected via
// dangerouslySetInnerHTML — the client-side counterpart of wp_kses_post(), and
// the mirror of PHP's SafeHtml. An allowlist rather than a denylist: a list of
// things to remove is only ever as good as its author's imagination, and this
// runs on markup an editor authored.
const SANITIZE_ALLOWED = {
	// Text and structure.
	P: [], BR: [], HR: [], SPAN: [], DIV: [], STRONG: [], EM: [], B: [], I: [],
	U: [], S: [], SMALL: [], SUP: [], SUB: [], ABBR: [], MARK: [], CODE: [],
	PRE: [], BLOCKQUOTE: [], UL: [], OL: [], LI: [], DL: [], DT: [], DD: [],
	H1: [], H2: [], H3: [], H4: [], H5: [], H6: [],
	FIGURE: [], FIGCAPTION: [], SECTION: [], ARTICLE: [],
	TABLE: [], THEAD: [], TBODY: [], TFOOT: [], TR: [], CAPTION: [],
	// Tags carrying attributes of their own, on top of the shared set below.
	A: [ 'href', 'target', 'rel' ],
	IMG: [ 'src', 'alt', 'width', 'height', 'loading', 'decoding' ],
	TIME: [ 'datetime' ],
	TH: [ 'colspan', 'rowspan', 'scope' ],
	TD: [ 'colspan', 'rowspan' ],
};

// Permitted on any allowed element. `style` is deliberately absent: design
// values become generated stylesheet rules, never style attributes.
const SANITIZE_GLOBAL_ATTRS = [ 'class', 'id', 'title', 'lang', 'dir' ];

// Everything else is a script vector (javascript:, data:text/html, vbscript:).
const SANITIZE_SAFE_URL = /^(?:https?:|mailto:|tel:|[/#?]|\.{1,2}\/)/i;

const sanitizeHtml = ( html ) => {
	const str = String( html == null ? '' : html );
	if ( str === '' || typeof window === 'undefined' || ! window.DOMParser ) {
		return str;
	}
	const doc = new window.DOMParser().parseFromString( str, 'text/html' );
	// Static NodeList: an element unwrapped below still gets visited, and its
	// children are re-parented rather than lost.
	doc.body.querySelectorAll( '*' ).forEach( ( el ) => {
		const allowed = SANITIZE_ALLOWED[ el.tagName ];
		if ( ! allowed ) {
			// Unwrapped, not deleted, so an unknown wrapper keeps its text.
			el.replaceWith( ...el.childNodes );
			return;
		}
		[ ...el.attributes ].forEach( ( attr ) => {
			const name = attr.name.toLowerCase();
			const permitted =
				allowed.includes( name ) ||
				SANITIZE_GLOBAL_ATTRS.includes( name ) ||
				name.startsWith( 'aria-' ) ||
				name.startsWith( 'data-' );
			const unsafeUrl =
				( name === 'href' || name === 'src' || name === 'xlink:href' ) &&
				! SANITIZE_SAFE_URL.test( attr.value.trim() );
			if ( ! permitted || unsafeUrl ) {
				el.removeAttribute( attr.name );
			}
		} );
	} );
	return doc.body.innerHTML;
};

// Where each css control applies — control-name → { property, selector }, defined
// in the host's PHP (BuilderControls::cssMap). In the admin builder it's read from
// the CLIQTHEMES_UI global; on the public frontend the host registers it once via
// setFrontendCssMap() so the framework never hardcodes a consumer's global name. A
// control listed here emits `property: value` onto the element's scoped selector +
// `selector` ('' = the element/wrapper, ' svg' = a child). Anything not listed is
// composed by the cssToStyle() mapping (shadow / border / spacing / font / …).
let frontendCssMap = null;

/** Host frontend registers its builder css-map here (replaces the old CLIQ_SL global read). */
export const setFrontendCssMap = ( map ) => {
	frontendCssMap = map && typeof map === 'object' ? map : null;
};

const resolveCssMap = () =>
	( typeof window !== 'undefined' && window.CLIQTHEMES_UI?.builder?.controls?.cssMap ) ||
	frontendCssMap ||
	{};

// Per-type "look" defaults + responsive breakpoint are single-sourced in the
// host's PHP (BuilderControls) and shipped to JS: in the admin builder via the
// CLIQTHEMES_UI global, on the frontend via setRenderDefaults/setBreakpoint.
// JS keeps NO copy of the values (only an empty / numeric last-resort), so the
// PHP renderer (DesignRenderer) and this one share one source and cannot drift.
let frontendDefaults = null;
let frontendBreakpoint = null;

/** Host frontend registers the per-type default css map (heading/button/logo). */
export const setRenderDefaults = ( defaults ) => {
	frontendDefaults = defaults && typeof defaults === 'object' ? defaults : null;
};

/** Host frontend registers the responsive breakpoint (px). */
export const setBreakpoint = ( px ) => {
	frontendBreakpoint = Number( px ) > 0 ? Number( px ) : null;
};

// Last-resort breakpoint if the host injected nothing (matches the PHP default
// in BuilderControls::tabletBreakpoint()). The real value is always supplied by
// PHP — admin via CLIQTHEMES_UI, frontend via setBreakpoint().
const FALLBACK_BREAKPOINT = 768;

const resolveDefaults = () =>
	( typeof window !== 'undefined' && window.CLIQTHEMES_UI?.builder?.controls?.defaults ) ||
	frontendDefaults ||
	{};

const resolveBreakpoint = () =>
	( typeof window !== 'undefined' && window.CLIQTHEMES_UI?.builder?.controls?.breakpoint ) ||
	frontendBreakpoint ||
	FALLBACK_BREAKPOINT;

// Apply a BoxControl value ({top,right,bottom,left}) or a scalar to a property.
const applyBox = ( s, prop, v ) => {
	if ( v === undefined || v === '' || v === null ) {
		return;
	}
	if ( typeof v === 'object' ) {
		if ( v.top ) s[ `${ prop }Top` ] = v.top;
		if ( v.right ) s[ `${ prop }Right` ] = v.right;
		if ( v.bottom ) s[ `${ prop }Bottom` ] = v.bottom;
		if ( v.left ) s[ `${ prop }Left` ] = v.left;
	} else {
		s[ prop ] = dim( v );
	}
};

/** Map a stored css object to a React inline-style object. */
export const cssToStyle = ( css = {} ) => {
	const s = {};
	if ( css.background ) s.background = css.background;
	if ( css.color ) s.color = css.color;
	if ( css.textAlign ) s.textAlign = css.textAlign;
	if ( css.fontWeight ) s.fontWeight = css.fontWeight;
	if ( css.fontFamily ) s.fontFamily = `'${ css.fontFamily }', sans-serif`;
	if ( css.fontSize ) s.fontSize = dim( css.fontSize );
	// Leading and tracking — the PHP twin (DesignRenderer::itemStyle) emits these
	// too, so SSR and hydration must agree. Unitless line-height stays a ratio;
	// a bare number for tracking is treated as px (negative allowed).
	if ( css.lineHeight !== undefined && css.lineHeight !== '' ) {
		s.lineHeight = css.lineHeight;
	}
	if ( css.letterSpacing !== undefined && css.letterSpacing !== '' ) {
		s.letterSpacing = /^-?\d*\.?\d+$/.test( String( css.letterSpacing ) )
			? `${ css.letterSpacing }px`
			: css.letterSpacing;
	}
	if ( css.width ) s.width = dim( css.width );
	if ( css.height ) s.height = dim( css.height );
	if ( css.maxWidth ) s.maxWidth = dim( css.maxWidth );
	if ( css.maxHeight ) { s.maxHeight = dim( css.maxHeight ); s.overflow = 'auto'; }
	applyBox( s, 'padding', css.padding );
	applyBox( s, 'margin', css.margin );
	if ( css.borderRadius !== undefined && css.borderRadius !== '' ) s.borderRadius = dim( css.borderRadius );
	// Border: BorderControl object {color,style,width} or separate width/color.
	if ( css.border && typeof css.border === 'object' && ( css.border.width || css.border.color ) ) {
		s.border = `${ css.border.width || '1px' } ${ css.border.style || 'solid' } ${ css.border.color || '#e2e8f0' }`;
	} else if ( css.borderWidth !== undefined && css.borderWidth !== '' ) {
		s.border = `${ dim( css.borderWidth ) } solid ${ css.borderColor || '#e2e8f0' }`;
	}
	// Box shadow from x/y/blur/spread/colour.
	if ( css.shadowX || css.shadowY || css.shadowBlur || css.shadowSpread ) {
		s.boxShadow = `${ dim( css.shadowX || 0 ) } ${ dim( css.shadowY || 0 ) } ${ dim( css.shadowBlur || 0 ) } ${ dim( css.shadowSpread || 0 ) } ${ css.shadowColor || 'rgba(15,23,42,.15)' }`;
	}
	if ( css.overflow ) s.overflow = css.overflow;
	if ( css.objectFit ) s.objectFit = css.objectFit;
	// Entrance animation.
	if ( css.animType ) {
		s.animationName = `cliq-widget-kf-${ css.animType }`;
		s.animationDuration = `${ css.animDuration || 400 }ms`;
		s.animationFillMode = 'both';
		s.animationTimingFunction = 'ease';
	}
	return s;
};

/** Extract a URL string from a WP media object, URL string, or ID. */
export const mediaUrl = ( v ) => {
	if ( ! v ) return '';
	if ( typeof v === 'string' ) return v;
	if ( typeof v === 'object' ) return v.url || v.source_url || v.guid?.rendered || '';
	return '';
};

// Normalise a gallery image (WP media object, REST attachment, or URL string) to
// a predictable shape carrying its size variants.
const normaliseImage = ( img ) => {
	if ( typeof img === 'string' ) return img ? { full: img, sizes: {}, w: 1600, h: 1200, alt: '' } : null;
	if ( ! img || typeof img !== 'object' ) return null;
	const full = img.url || img.source_url || '';
	if ( ! full ) return null;
	return {
		full,
		sizes: img.sizes || img.media_details?.sizes || {},
		w: parseInt( img.width ) || 1600,
		h: parseInt( img.height ) || 1200,
		alt: img.alt || '',
	};
};

// Resolve a gallery image URL for a chosen WP size name, with graceful fallbacks
// (next-largest available → original). `imageSize` is e.g. 'medium' | 'large' | 'full'.
const pickSize = ( img, imageSize ) => {
	const sizes = img.sizes || {};
	if ( ! imageSize || imageSize === 'full' ) return img.full;
	const order = [ imageSize, 'large', 'medium_large', 'medium' ];
	for ( const s of order ) {
		if ( sizes[ s ]?.url ) return sizes[ s ].url;
		if ( sizes[ s ]?.source_url ) return sizes[ s ].source_url;
	}
	return img.full;
};

// Read a (possibly dotted) source path off a record, e.g. 'meta.parking'. The live
// payload nests custom fields under `meta`, while the admin/builder record flattens
// them to top-level keys — so a dotted path falls back to its trailing segment at
// top level, letting the same design resolve in both the preview and on the site.
const readPath = ( record, path ) => {
	if ( ! path ) return undefined;
	if ( path.indexOf( '.' ) !== -1 ) {
		const [ a, b ] = path.split( '.' );
		const nested = record[ a ] ? record[ a ][ b ] : undefined;
		return nested !== undefined ? nested : record[ b ];
	}
	return record[ path ];
};

/**
 * Resolve a layout token to a value, driven entirely by the token's metadata
 * (`format`/`source`) supplied by the host — the renderer has NO domain knowledge
 * of specific field names. The framework resolves only the generic content formats
 * (text / media / gallery / button); any other format (address, distance, rating,
 * hours, a host's own…) is produced by `cliqui.token_value` filter callbacks, which
 * may also override a generic value. `tokens` is the host-provided token list.
 */
export const tokenValue = ( token, record, tokens = [], item = null ) => {
	if ( ! record ) return null;
	const meta = tokens.find( ( t ) => t.value === token ) || {};
	const format = meta.format || 'text';
	const raw = readPath( record, meta.source ) ?? record[ token ];

	let value;
	switch ( format ) {
		case 'media': {
			// Carry the resolved media object (not just its URL) as `raw` so the
			// caller (LayoutItem, which has the placing item's chosen
			// item.css.imageSize in scope) can pick a specific WP size variant —
			// tokenValue() itself only has the token's metadata, not the item.
			let mediaRaw = raw;
			let src = mediaUrl( raw );
			if ( ! src ) {
				mediaRaw = readPath( record, meta.fallback );
				src = mediaUrl( mediaRaw ) || '';
			}
			value = { type: 'logo', src, raw: mediaRaw, name: record.name || record.title || record.label || '' };
			break;
		}
		case 'gallery': {
			let imgs = raw;
			if ( typeof imgs === 'string' ) {
				try { imgs = JSON.parse( imgs ); } catch ( e ) { imgs = []; }
			}
			value = { type: 'gallery', images: Array.isArray( imgs ) ? imgs : [] };
			break;
		}
		case 'button':
			value = {
				type: 'button',
				label: item?.buttonLabel?.trim() || meta.label || token,
			};
			break;
		case 'html':
		case 'richtext':
			value = { type: 'html', html: raw == null ? '' : String( raw ) };
			break;
		case 'toggle':
			// A typed marker, not the final text — the layout ITEM (not this
			// token-only context) owns the "text when true"/"text when false"
			// wording (item.toggleOnText/toggleOffText, since the same token
			// can be placed on multiple designs wanting different wording).
			// Stored value can be "1"/"0"/""/a real bool depending on write
			// path (admin form vs. import) — loose-truthy check handles all.
			value = { type: 'bool', on: !! raw && raw !== '0' };
			break;
		default: {
			// A multi-value field (checkboxes / multiselect) arrives as an array
			// (admin record) or a JSON-encoded list string (live payload). Render it
			// as comma-joined scalars rather than dumping `["dining"]` verbatim.
			let v = raw;
			if ( typeof v === 'string' && v.trim().startsWith( '[' ) ) {
				try {
					const arr = JSON.parse( v.trim() );
					if ( Array.isArray( arr ) ) v = arr;
				} catch ( e ) {} // eslint-disable-line no-empty
			}
			if ( Array.isArray( v ) ) {
				value = v
					.map( ( x ) => ( x && typeof x === 'object' ? ( x.name || x.label || '' ) : x ) )
					.filter( ( s ) => s != null && s !== '' )
					.join( ', ' );
			} else if ( v && typeof v === 'object' ) {
				value = v.name || v.label || mediaUrl( v ) || '';
			} else {
				value = v ?? '—';
			}
		}
	}

	return applyFilters( 'cliqui.token_value', value, { token, record, meta, format, raw, tokens, item } );
};

// Text/heading elements may contain store merge tags such as
// "{distance} away". They use the exact same token resolver as Store field
// elements, so domain formats and host filters remain single-sourced.
export const interpolateTokens = ( template, record, tokens = [] ) => {
	const text = String( template || '' );
	if ( ! record ) return text;
	let missing = false;
	const rendered = text.replace( /\{([a-zA-Z0-9_.-]+)\}/g, ( match, token ) => {
		const value = tokenValue( token, record, tokens );
		if ( value === null || value === undefined || value === '' || typeof value === 'object' ) {
			missing = true;
			return '';
		}
		return String( value );
	} );
	// An authored phrase containing an unavailable dynamic value is incomplete.
	// Hide the phrase until its data exists instead of showing fragments such as
	// "About away" while distance is still unknown.
	return missing ? '' : rendered;
};

// Logo with graceful fallback: if the image fails to load (bad/empty URL),
// show an initial-letter placeholder instead of a broken-image icon.
const LogoImg = ( { src, name, className = '' } ) => {
	const [ broken, setBroken ] = useState( ! src );
	if ( broken || ! src ) {
		return <div className={ `cliq-widget-logo cliq-widget-logo--ph ${ className }` }>{ ( name || '?' ).slice( 0, 1 ).toUpperCase() }</div>;
	}
	return <img className={ `cliq-widget-logo ${ className }` } src={ src } alt={ name } onError={ () => setBroken( true ) } />;
};

// Gallery preview/render: mirrors the live frontend's chosen LAYOUT so the builder
// canvas matches the site (the frontend additionally upgrades it to a Swiper +
// PhotoSwipe lightbox; here we render the same visual structure, dependency-free).
// `item.gallery` carries the per-item options (layout, image size, columns, nav).
const GalleryView = ( { item, images } ) => {
	const list = Array.isArray( images ) ? images.map( normaliseImage ).filter( Boolean ) : [];
	if ( ! list.length ) {
		return <div className="cliq-widget-gallery cliq-widget-gallery--empty">{ __( 'Gallery', 'cliq-store-locator' ) }</div>;
	}
	const g = item.gallery || {};
	const layout = g.layout || 'slider-thumbs';

	// Grid layout: a responsive thumbnail grid. Columns/gap arrive as custom
	// properties in the item's GENERATED rule (buildDesignCss emits them on
	// this element's own n-hook), consumed by design.scss's base rule — no
	// style attribute anywhere.
	if ( layout === 'thumbs' ) {
		return (
			<div className={ nodeClass( 'cliq-widget-gallery cliq-widget-gallery--grid', item ) }>
				{ list.map( ( img, i ) => (
					<img key={ i } className="cliq-widget-gallery__img" src={ pickSize( img, g.imageSize ) } alt={ img.alt } loading="lazy" />
				) ) }
			</div>
		);
	}

	// Slider / slider+thumbs: a large lead image (with nav affordances) and, for
	// 'slider-thumbs', a thumbnail strip beneath — the same structure Swiper builds.
	return (
		<div className={ nodeClass( `cliq-widget-gallery cliq-widget-gallery--${ layout }`, item ) }>
			<div className="cliq-widget-gallery__main">
				<img className="cliq-widget-gallery__img" src={ pickSize( list[ 0 ], g.imageSize ) } alt={ list[ 0 ].alt } loading="lazy" />
				{ g.nav !== false && (
					<>
						<span className="cliq-widget-gallery__nav cliq-widget-gallery__nav--prev">‹</span>
						<span className="cliq-widget-gallery__nav cliq-widget-gallery__nav--next">›</span>
					</>
				) }
			</div>
			{ layout === 'slider-thumbs' && list.length > 1 && (
				<div className="cliq-widget-gallery__thumbs">
					{ list.slice( 0, 8 ).map( ( img, i ) => (
						<img key={ i } className={ `cliq-widget-gallery__thumb${ i === 0 ? ' is-active' : '' }` } src={ pickSize( img, 'thumbnail' ) } alt={ img.alt } loading="lazy" />
					) ) }
				</div>
			) }
		</div>
	);
};

// Icon + label in the chosen order. iconSize sizes the glyph; iconColor is applied
// by the scoped stylesheet via the control's `selector: ' svg'` (css-driven), so
// the glyph just inherits currentColor here.
export const WithIcon = ( { item, children } ) => {
	if ( ! item.icon ) return children;
	const iconSize = item.css?.iconSize ? ( parseInt( item.css.iconSize ) || 15 ) : 15;
	const ic = <IconGlyph name={ item.icon } size={ iconSize } />;
	return item.iconPos === 'right'
		? <>{ children }{ ' ' }{ ic }</>
		: <>{ ic }{ ' ' }{ children }</>;
};

// Convert a cssToStyle() object into a CSS declaration string (camelCase → kebab).
const declString = ( obj ) =>
	Object.entries( obj )
		.map( ( [ k, v ] ) => `${ k.replace( /[A-Z]/g, ( m ) => `-${ m.toLowerCase() }` ) }:${ cssSafe( v ) }` )
		.join( ';' );

// A node's class list: the shared `cliq-widget` base (carries the reset + marks it
// as builder output), its type class, a scope-bound id hook (cliq-widget-n-<id>)
// the generated stylesheet targets, and an optional :hover hook.
// Sanitise a user-supplied class string to space-separated valid CSS identifiers.
export const sanitizeClasses = ( raw ) =>
	String( raw || '' )
		.split( /\s+/ )
		.map( ( c ) => c.replace( /[^a-zA-Z0-9_-]/g, '' ) )
		.filter( Boolean )
		.join( ' ' );

export const nodeClass = ( base, node ) => {
	const css = node.css || node.settings?.css || {};
	const hover = css.hoverBackground || css.hoverColor ? ` cliq-widget-h-${ node.id }` : '';
	const custom = sanitizeClasses( css.cssClass );
	return `cliq-widget ${ base } cliq-widget-n-${ node.id }${ hover } ${ custom }`.replace( /\s+/g, ' ' ).trim();
};

// Only normal web schemes and relative/hash URLs reach an href. PHP's twin uses
// esc_url(), so authored javascript:/data: values cannot become navigation.
const SAFE_HREF = /^(?:https?:|mailto:|tel:|\/|#|\?|\.{1,2}\/)/i;
export const safeHref = ( value ) => {
	const href = String( value == null ? '' : value ).trim();
	return href && SAFE_HREF.test( href ) ? href : '';
};

// Resolve the generic link controls stored in an item's/container's css bag.
export const resolveNodeLink = ( node, record = null ) => {
	const css = node?.css || {};
	let href = '';
	if ( css.linkType === 'details' ) {
		href = record?.detail_url || record?.url || record?.permalink || '';
	} else if ( css.linkType === 'website' ) {
		href = String( record?.website || '' ).trim();
		if ( href && ! /^https?:\/\//i.test( href ) ) {
			href = `https://${ href.replace( /^\/+/, '' ) }`;
		}
	} else if ( css.linkType === 'email' ) {
		const email = String( record?.email || '' ).trim();
		href = email ? `mailto:${ email }` : '';
	} else if ( css.linkType === 'phone' ) {
		const phone = String( record?.phone || '' ).replace( /[^0-9+]/g, '' );
		href = phone ? `tel:${ phone }` : '';
	} else if ( css.linkType === 'custom' ) {
		href = String( css.linkUrl || '' ).trim();
	}
	href = safeHref( href );
	return href ? { href, target: css.linkNewTab ? '_blank' : undefined } : null;
};

const LinkedItem = ( { item, record, disabled = false, children } ) => {
	const link = resolveNodeLink( item, record );
	if ( ! link ) return children;
	return (
		<a
			className="cliq-widget cliq-widget-link"
			href={ link.href }
			target={ link.target }
			rel={ link.target === '_blank' ? 'noopener noreferrer' : undefined }
			onClick={ ( event ) => {
				event.stopPropagation();
				if ( disabled ) event.preventDefault();
			} }
		>
			{ children }
		</a>
	);
};

// Per-item wrapper layout — structural, so it stays inline. `fit:'auto'` makes the
// cell hug its content (so a chip/background wraps the text instead of the whole
// column); `align` then positions that hugged element. `fit:'full'` (default)
// keeps the default full-width behaviour. Cells are CSS-grid items, hence
// justify-self (not align-self).
export const cellLayoutStyle = ( item ) => {
	const css = item.css || {};
	if ( css.overlay ) {
		// Detach from the column's grid flow entirely and anchor to the nearest
		// positioned ancestor — .cliq-widget-col (position:relative, see
		// design.scss) — so this item overlays a SIBLING cell in the same column
		// (e.g. a badge over a hero image). `gridColumn:'auto'` cancels
		// .cliq-widget-cell's base "span 6" fallback, which would otherwise still
		// snap this abspos item to a grid track instead of the given insets.
		const s = { position: 'absolute', gridColumn: 'auto' };
		[ 'Top', 'Right', 'Bottom', 'Left' ].forEach( ( side ) => {
			const v = css[ `inset${ side }` ];
			if ( v !== undefined && v !== null && v !== '' ) {
				s[ side.toLowerCase() ] = dim( v );
			}
		} );
		if ( css.zIndex !== undefined && css.zIndex !== null && css.zIndex !== '' ) {
			s.zIndex = css.zIndex;
		}
		return s;
	}
	const s = { gridColumn: `span ${ item.colSpan || 12 }` };
	if ( ( css.fit || 'full' ) === 'auto' ) {
		s.justifySelf = css.align === 'center' ? 'center' : css.align === 'right' ? 'end' : 'start';
	} else {
		s.justifySelf = 'stretch';
	}
	if ( css.cellJustifyContent ) s.justifyContent = css.cellJustifyContent;
	if ( css.cellAlignItems ) s.alignItems = css.cellAlignItems;
	return s;
};

/**
 * Build the scoped stylesheet for one design. Visual CSS (container / row / col /
 * item `css`) becomes rules under `scope` (a class selector, e.g. ".cliq-widget-d-x") so
 * the same look renders in the builder and on the live site without inline styles.
 * `cssMobile` overrides go in a max-width media query so responsive actually works
 * on the frontend — except in the builder's mobile-device preview, where they're
 * applied directly (the preview frame isn't a real viewport). Hover rules included.
 */
// Per-type "look" defaults (heading/button/logo) are single-sourced in PHP
// (BuilderControls::itemDefaults()) and injected — admin via CLIQTHEMES_UI,
// frontend via setRenderDefaults() — so they're never hardcoded here. Resolved
// per item below by element type / token format (format 'button'
// fields like Directions/Details render as buttons; 'media' as a logo).
// Search-form input types (everything that isn't a button/heading/media field)
// get the shared 'input' default look — same mechanism as heading/button/logo,
// not a hardcoded stylesheet rule per field type. 'text' is unambiguous for
// every type here EXCEPT itself: a layout design's plain text LABEL item is
// ALSO type==='text' (see DesignRenderer::renderItem()) — a real collision,
// not a hypothetical one (list_item/infobox/marker designs use it constantly).
// Disambiguate via `name`: a search-form field always has one from creation
// (newItemFor() in GridBuilderField.js — HTML form-field convention), a
// layout text/heading/field item never does.
const INPUT_DEFAULT_TYPES = [ 'email', 'tel', 'number', 'textarea', 'date', 'select', 'chips', 'accordion', 'popup', 'options_list', 'checkbox', 'radio', 'toggle' ];
const GLIDE_INPUT_TYPES = [ 'select', 'chips', 'accordion', 'popup', 'options_list' ];
const FORM_CONTROL_HEIGHTS = { small: '38px', medium: '46px', large: '54px' };

const formContractVars = ( settings = {} ) => {
	const preset = settings.controlHeightPreset || 'medium';
	const height = preset === 'custom'
		? ( cssSafe( dim( settings.controlHeightCustom || '46px' ) ) || '46px' )
		: ( FORM_CONTROL_HEIGHTS[ preset ] || FORM_CONTROL_HEIGHTS.medium );
	const labelFamily = cssSafe( settings.labelFontFamily || '' ).replace( /['"]/g, '' );
	const labelSize = cssSafe( dim( settings.labelFontSize || '12px' ) ) || '12px';
	const labelWeight = [ '400', '500', '600', '700' ].includes( String( settings.labelFontWeight ) )
		? String( settings.labelFontWeight )
		: '600';
	const labelColor = cssSafe( settings.labelColor || '' ) || 'var(--cliq-sl-label)';
	const labelGap = cssSafe( dim( settings.labelGap ?? '6px' ) ) || '6px';
	const labelLineHeight = cssSafe( settings.labelLineHeight || '1.25' ) || '1.25';
	const wraps = settings.labelOverflow === 'wrap';

	return `--cliq-sl-field-height:${ height };--cliq-sl-label-font-family:${ labelFamily ? `'${ labelFamily }',sans-serif` : 'inherit' };--cliq-sl-label-size:${ labelSize };--cliq-sl-label-weight:${ labelWeight };--cliq-sl-label-color:${ labelColor };--cliq-sl-label-gap:${ labelGap };--cliq-sl-label-line-height:${ labelLineHeight };--cliq-sl-label-white-space:${ wraps ? 'normal' : 'nowrap' };--cliq-sl-label-overflow:${ wraps ? 'visible' : 'hidden' };--cliq-sl-label-text-overflow:${ wraps ? 'clip' : 'ellipsis' }`;
};

const isButtonItem = ( item, tokens = [] ) => {
	const type = item.type || 'field';
	if ( [ 'button', 'submit', 'geocode' ].includes( type ) ) return true;
	return type === 'field' && tokens.find( ( token ) => token.value === item.token )?.format === 'button';
};

export const defaultItemCss = ( item, tokens = [] ) => {
	const defs = resolveDefaults();
	const type = item.type || 'field';
	if ( type === 'heading' ) return defs.heading;
	// submit/geocode are placeable BUTTON fields (see buildTypes.js) — same
	// default look as a type==='button' item.
	if ( type === 'button' || type === 'submit' || type === 'geocode' ) return defs.button;
	if ( INPUT_DEFAULT_TYPES.includes( type ) ) return defs.input;
	if ( type === 'text' && item.name !== undefined ) return defs.input;
	if ( type === 'field' ) {
		const fmt = tokens.find( ( t ) => t.value === item.token )?.format;
		if ( fmt === 'button' ) return defs.button;
		if ( fmt === 'media' ) return defs.logo;
	}
	return null;
};

// A tint is a real overlay layer (::after) painted ON TOP of the item's own
// content — not a `background` sitting behind it — so it visibly washes a hero
// image (an <img>'s own pixels would otherwise hide a plain background). The
// cell wrapper (.cliq-widget-cl-<id>) is reused as the positioning/clipping
// context since every item already has one; `position` is skipped when the
// item is in overlay-position mode (cellLayoutStyle already made it absolute —
// still a valid containing block, so nothing else is needed there).
// `mergedCss` is the item's EFFECTIVE css (type defaults + overrides, same bag
// buildDesignCss already computed for the item node) so a rounded hero image's
// default border-radius carries onto the overlay too — otherwise a square
// ::after visibly pokes past the image's rounded corners once opaque enough.
// Image-hover effects: zoom is a transform; the rest compose ONE filter()
// list on hover. The hover* toggles are the ONLY source — an unchecked
// toggle means no effect, full stop. Filter amounts are the framework's
// picks; the effects are on/off toggles, not parameterised.
const HOVER_FILTERS = {
	hoverBlur: 'blur(4px)',
	hoverGrayscale: 'grayscale(1)',
	hoverBrightness: 'brightness(1.12)',
	hoverSepia: 'sepia(1)',
	hoverSaturate: 'saturate(1.4)',
};
const hoverEffectsOf = ( css ) => ( {
	zoom: !! css.hoverZoom,
	filters: Object.keys( HOVER_FILTERS ).filter( ( k ) => css[ k ] ).map( ( k ) => HOVER_FILTERS[ k ] ),
} );

export const tintOverlayCss = ( item, scope, mergedCss ) => {
	const css = mergedCss || item.css || {};
	const { tint, tintHover, borderRadius } = css;
	const fx = hoverEffectsOf( css );
	if ( ! tint && ! tintHover && ! fx.zoom && ! fx.filters.length ) return [];
	const cellSel = `${ scope } .cliq-widget-cl-${ item.id }`;
	const radiusValue = borderRadius !== undefined && borderRadius !== null && borderRadius !== '' ? cssSafe( dim( borderRadius ) ) : '';

	// Positioning/clipping box shared by both features: the cell wrapper
	// becomes the ::after's containing block and clips the zoomed image.
	const boxDecls = [];
	if ( ! css.overlay ) boxDecls.push( 'position:relative' );
	boxDecls.push( 'overflow:hidden' );
	if ( radiusValue ) boxDecls.push( `border-radius:${ radiusValue }` );
	const boxRule = `${ cellSel }{${ boxDecls.join( ';' ) }}`;

	const rules = [];
	if ( tint || tintHover ) {
		const afterDecls = [ 'content:""', 'position:absolute', 'inset:0', 'pointer-events:none', 'z-index:1' ];
		if ( radiusValue ) afterDecls.push( `border-radius:${ radiusValue }` );
		afterDecls.push( 'transition:background var(--cliq-transition-duration, .4s) var(--cliq-transition-easing, ease-in-out)' );
		afterDecls.push( `background:${ cssSafe( tint || 'transparent' ) }` );

		rules.push( boxRule );
		rules.push( `${ cellSel }::after{${ afterDecls.join( ';' ) }}` );
		if ( tintHover ) {
			rules.push( `${ cellSel }:hover::after{background:${ cssSafe( tintHover ) }}` );
		}
	}
	if ( fx.zoom || fx.filters.length ) {
		// No per-effect transition — the BASE layer (.cliq-widget in
		// design.scss) transitions transform/filter with the shared
		// --cliq-transition-* timing.
		rules.push( boxRule );
		const hoverDecls = [];
		if ( fx.zoom ) hoverDecls.push( 'transform:scale(1.08)' );
		if ( fx.filters.length ) hoverDecls.push( `filter:${ fx.filters.join( ' ' ) }` );
		rules.push( `${ cellSel }:hover .cliq-widget-n-${ item.id }{${ hoverDecls.join( ';' ) }}` );
	}
	return rules;
};

/**
 * A marker's pointer tail is driven by ONE "size" number (not separate
 * width/height/gap knobs) — width and the under-the-shape overlap both scale
 * with it at fixed ratios, so "bigger tail" is a single slider. `netOffset` is
 * how far past the container's own bottom edge the tail's visible tip sits
 * (height minus the overlap tucked back under the shape) — the host uses this
 * to push the marker's map-anchor point past the box onto the tail's tip
 * (see resources/frontend/src/widgets.js renderMarker() in cliq-store-locator).
 * PHP twin: DesignRenderer::tailGeometry().
 */
export const tailGeometry = ( size ) => {
	const s = Math.min( 60, Math.max( 6, Number( size ) || 14 ) );
	const height = Math.round( s );
	const halfWidth = Math.round( s * 0.8 );
	const overlap = Math.round( s * 0.35 );
	return { height, halfWidth, overlap, netOffset: height - overlap };
};

// A Glide choice field's "Dropdown panel" controls plus the field's generic
// typography controls →
// Glide's own --glide-* theming variables, as a CSS declaration string.
// PHP twin: DesignRenderer::glidePanelVars().
const GLIDE_PANEL_VARS = {
	color: [ '--glide-color' ],
	fontSize: [ '--glide-font-size' ],
	fontWeight: [ '--glide-font-weight' ],
	textAlign: [ '--glide-text-align' ],
	glidePanelBg: [ '--glide-bg', '--glide-chip-bg' ],
	glideHover: [ '--glide-hover' ],
	glideActive: [ '--glide-active', '--glide-chip-selected-bg', '--glide-tag-bg' ],
	glideActiveColor: [ '--glide-active-color', '--glide-chip-selected-color', '--glide-tag-color' ],
	glidePlaceholder: [ '--glide-placeholder' ],
	glideFocusBorder: [ '--glide-border-focus', '--glide-chip-selected-border' ],
	glideScrollbar: [ '--glide-scrollbar-thumb' ],
	glidePanelRadius: [ '--glide-radius', '--glide-popup-radius', '--glide-chip-radius' ],
};
const glidePanelVars = ( css = {} ) => {
	const decls = [];
	if ( css.fontFamily ) {
		decls.push( `--glide-font-family:'${ cssSafe( css.fontFamily ) }',sans-serif` );
	}
	Object.keys( GLIDE_PANEL_VARS ).forEach( ( k ) => {
		if ( css[ k ] === undefined || css[ k ] === null || css[ k ] === '' ) return;
		let v = css[ k ];
		if ( k === 'glidePanelRadius' || k === 'fontSize' ) v = dim( v );
		GLIDE_PANEL_VARS[ k ].forEach( ( variable ) => {
			decls.push( `${ variable }:${ cssSafe( v ) }` );
		} );
	} );
	return decls.join( ';' );
};

// A range field's "Slider settings" controls (host schema, data controls) →
// @cliqthemes/cliq-slider-control's own --cui-slider-* theming variables, as a
// CSS declaration string. PHP twin: DesignRenderer::sliderVars().
const SLIDER_VARS = {
	sliderTrack: '--cui-slider-track-bg',
	sliderFill: '--cui-slider-selection-bg',
	sliderHandle: '--cui-slider-handle-bg',
	sliderHandleBorder: '--cui-slider-handle-border',
};
const sliderVars = ( css = {} ) => {
	const decls = [];
	Object.keys( SLIDER_VARS ).forEach( ( k ) => {
		if ( css[ k ] === undefined || css[ k ] === null || css[ k ] === '' ) return;
		decls.push( `${ SLIDER_VARS[ k ] }:${ cssSafe( css[ k ] ) }` );
	} );
	return decls.join( ';' );
};

export const buildDesignCss = ( model, scope, device = 'desktop', breakpoint = null, tokens = [] ) => {
	const desk = [];
	const tablet = [];
	const mob = [];
	const bp = breakpoint != null ? breakpoint : resolveBreakpoint();
	const mobileBp = Number(
		( typeof window !== 'undefined' && ( window.CLIQTHEMES_UI?.builder?.controls?.mobileBreakpoint || window.CLIQ_SL?.mobileOnlyBreakpoint ) ) ||
		Math.min( 480, bp )
	) || 480;
	const map = resolveCssMap();
	// Split a css bag into per-selector mapped declarations + the leftover
	// (unmapped) bag that cssToStyle() composes. A mapped key is removed from the
	// unmapped bag so a property is never emitted twice.
	// Returns { bySel: { selector: [decls] }, unmapped }.
	// Compose one declaration for a mapped property, choosing by VALUE type: a
	// {top,right,bottom,left} object expands to longhands (padding-top: …), a scalar
	// emits the shorthand. `isUnit` adds a px fallback to bare numbers (length
	// controls only) — keyword/number controls (font-weight) never get px.
	const compose = ( prop, val, isUnit ) => {
		const d = ( v ) => cssSafe( isUnit && /^-?\d*\.?\d+$/.test( String( v ) ) ? `${ v }px` : String( v ) );
		if ( val && typeof val === 'object' && ! Array.isArray( val ) ) {
			return [ 'top', 'right', 'bottom', 'left' ]
				.filter( ( side ) => val[ side ] )
				.map( ( side ) => `${ prop }-${ side }:${ d( val[ side ] ) }` );
		}
		return [ `${ prop }:${ d( val ) }` ];
	};
	const split = ( css ) => {
		const bySel = {};
		const unmapped = {};
		Object.keys( css || {} ).forEach( ( k ) => {
			const val = css[ k ];
			if ( val === undefined || val === '' || val === null ) return;
			if ( k === 'cssClass' || k === 'backgroundAuto' ) return; // backgroundAuto is data, handled below
			const m = map[ k ];
			if ( m && m.property ) {
				const s = m.selector || '';
				( bySel[ s ] = bySel[ s ] || [] ).push( ...compose( m.property, val, !! m.unit ) );
			} else {
				unmapped[ k ] = val;
			}
		} );
		// "Automatic" background (Colour group) overrides the static value above
		// with a per-record CSS custom property the caller sets on each record's
		// own wrapper — see CategoryBrowser's per-category colour rule (JS) /
		// DesignRenderer::renderList()'s autoColorVar option (PHP twin). Pushed
		// last so it wins the cascade whether or not a static background is set;
		// the static value (if any) becomes the var()'s own fallback, so a
		// record with no colour of its own still shows something instead of
		// going transparent.
		if ( css?.backgroundAuto && map.background && map.background.property ) {
			const s = map.background.selector || '';
			const fallback = css.background ? `, ${ cssSafe( css.background ) }` : '';
			( bySel[ s ] = bySel[ s ] || [] ).push( ...compose( map.background.property, `var(--cliq-auto-color${ fallback })`, false ) );
		}
		return { bySel, unmapped };
	};
	// Emit rule(s) for one element: structural layout + mapped css on the element
	// selector, plus a separate rule per non-empty child selector (e.g. ' svg').
	const emitInto = ( target, sel, structural, css ) => {
		const { bySel, unmapped } = split( css );
		const unmappedDecls = Object.keys( unmapped ).length ? declString( cssToStyle( unmapped ) ) : '';
		const base = [ structural, ...( bySel[ '' ] || [] ), unmappedDecls ].filter( Boolean ).join( ';' );
		if ( base ) target.push( `${ sel }{${ base }}` );
		Object.keys( bySel ).forEach( ( s ) => {
			if ( s === '' ) return;
			const d = bySel[ s ].join( ';' );
			if ( d ) target.push( `${ sel }${ s }{${ d }}` );
		} );
	};
	const emit = ( sel, structural, css, cssMobile ) => {
		emitInto( desk, sel, structural, css );
		if ( cssMobile && Object.keys( cssMobile ).length ) {
			emitInto( device === 'mobile' || device === 'tablet' ? desk : tablet, sel, '', cssMobile );
		}
	};
	// The container is flex+column BY DEFAULT so its own Justify/Align controls
	// take effect out of the box. flex-column + the browser's default
	// align:stretch / justify:flex-start is layout-equivalent to the old block
	// flow (rows stack full-width), so existing designs don't shift. Stored css
	// (incl. an explicit display:grid) overrides these defaults.
	emit( scope, '', { display: 'flex', flexDirection: 'column', ...( model?.container?.css || {} ) }, model?.container?.cssMobile );
	if ( model?.settings ) desk.push( `${ scope }{${ formContractVars( model.settings ) }}` );
	if ( model?.container?.activeBackground ) {
		desk.push( `.cliq-sl-result.is-active>${ scope }{background:${ cssSafe( model.container.activeBackground ) }}` );
	}
	// Pointer tail (map-marker use): absolutely positioned below the container so
	// it shows correctly whether the container auto-sizes to its content or (as a
	// pin/icon-style marker often does) has an explicit fixed width/height — a
	// real-flow element would get absorbed into a fixed-height box instead of
	// appearing below it. `position:relative` on the scope makes it the tail's
	// containing block regardless of the container's own layout.
	if ( model?.container?.tail ) {
		const g = tailGeometry( model.container.tailSize );
		const tailColor = cssSafe( model.container.tailColor || model.container.css?.background || '#1d4ed8' );
		// --cliq-tail-offset: how far past the design's own box the tail's tip
		// sits — read by the HOST's marker-anchor rule (a child-selector rule in
		// its stylesheet, e.g. .cliq-sl-marker-body > .cliq-widget-design) so
		// the pin anchor lands on the tip. Emitted here (generated stylesheet),
		// never as an element style.
		desk.push( `${ scope }{position:relative;--cliq-tail-offset:${ g.netOffset }px}` );
		// Pulled up by `overlap` so its flat top edge tucks under a rounded/circular
		// container instead of floating below it with a visible curved gap.
		desk.push( `${ scope } .cliq-widget-marker-tail{position:absolute;left:50%;top:calc(100% - ${ g.overlap }px);transform:translateX(-50%);width:0;height:0;border-left:${ g.halfWidth }px solid transparent;border-right:${ g.halfWidth }px solid transparent;border-top:${ g.height }px solid ${ tailColor };pointer-events:none}` );
	}
	( model?.rows || [] ).forEach( ( row ) => {
		const rowStruct = declString( { gap: dim( row.settings?.gap ?? 16 ), ...( row.settings?.align ? { alignItems: row.settings.align } : {} ) } );
		const rowHook = `${ scope } .cliq-widget-n-${ row.id }`;
		emit( rowHook, rowStruct, row.settings?.css, row.settings?.cssMobile );
		// Equalise the row's column boxes. Map fills a dedicated column. A Store
		// List fills the remaining track beneath preceding tools (for example the
		// Result Count widget), while unrelated widgets retain their own height.
		if ( row.settings?.equalColumnHeight ) {
			const rawHeight = row.settings.equalColumnHeightValue ?? '600px';
			const height = cssSafe( dim( rawHeight ) ) || '600px';
			desk.push(
				`${ rowHook }{grid-auto-rows:${ height };align-items:stretch}` +
				`${ rowHook }>.cliq-widget-col{height:100%;min-height:0}`
			);
			( row.columns || [] ).forEach( ( column ) => {
				const items = column.items || [];
				const flexibleIndex = items.findIndex( ( item ) => item.widget === 'results' );
				const mapIndex = items.findIndex( ( item ) => item.widget === 'map' );
				const itemIndex = flexibleIndex >= 0 ? flexibleIndex : mapIndex;
				if ( itemIndex < 0 ) return;
				const item = items[ itemIndex ];
				const columnHook = `${ scope } .cliq-widget-n-${ column.id }`;
				const cellHook = `${ scope } .cliq-widget-cl-${ item.id }`;
				const tracks = items.map( ( unused, index ) => index === itemIndex ? 'minmax(0,1fr)' : 'auto' ).join( ' ' );
				desk.push( `${ columnHook }{grid-template-rows:${ tracks };align-content:stretch}${ cellHook }{height:100%;min-height:0}` );
				if ( item.widget === 'map' ) {
					desk.push( `${ cellHook }>.cliq-sl-w--map{height:100%;min-height:0}` );
				} else {
					desk.push(
						`${ cellHook }>.cliq-sl-w--results{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden}` +
						`${ cellHook }>.cliq-sl-w--results>:is(.cliq-sl-results,.cliq-sl-acc-list,.cliq-sl-group-list,.cliq-sl-catbrowse,.cliq-sl-filter-drawer){flex:1 1 auto;min-height:0;max-height:none;overflow:auto;align-content:start}`
					);
				}
			} );
		}
		( row.columns || [] ).forEach( ( col ) => {
			// Column "Item gap" (settings.gap) feeds the --cliq-col-gap variable the
			// structural .cliq-widget-col rule consumes. Always define it on this
			// column so a nested design cannot inherit an unrelated outer column's
			// value (for example, a locator column with gap 0 collapsing card items).
			const colGap = col.settings?.gap !== undefined && col.settings?.gap !== null && col.settings?.gap !== ''
				? col.settings.gap
				: 6;
			const colStruct = declString( {
				gridColumn: `span ${ col.span || 12 }`,
				...( col.settings?.align ? { alignContent: col.settings.align } : {} ),
				'--cliq-col-gap': dim( colGap ),
			} );
			emit( `${ scope } .cliq-widget-n-${ col.id }`, colStruct, col.settings?.css, col.settings?.cssMobile );
			// Equal-items mode is the escape hatch for counts that cannot be expressed
			// exactly by a 12-track span grid (most notably five fields). "auto"
			// means one equal track per current item; breakpoint values can instead
			// provide a fixed wrapping count such as 3 on tablet and 1 on mobile.
			if ( col.settings?.itemLayout === 'equal' ) {
				const equalRule = ( raw ) => {
					const count = raw === 'auto'
						? Math.max( 1, ( col.items || [] ).filter( ( item ) => ! item.css?.overlay ).length )
						: Math.max( 1, Math.min( 12, parseInt( raw, 10 ) || 1 ) );
					return `${ scope } .cliq-widget-n-${ col.id }{grid-template-columns:repeat(${ count },minmax(0,1fr))}` +
						`${ scope } .cliq-widget-n-${ col.id }>.cliq-widget-cell{grid-column:auto}`;
				};
				const desktopColumns = col.settings.itemColumns || 'auto';
				const tabletColumns = col.settings.itemColumnsTablet || desktopColumns;
				const mobileColumns = col.settings.itemColumnsMobile || 1;
				if ( device === 'mobile' ) desk.push( equalRule( mobileColumns ) );
				else if ( device === 'tablet' ) desk.push( equalRule( tabletColumns ) );
				else {
					desk.push( equalRule( desktopColumns ) );
					tablet.push( equalRule( tabletColumns ) );
					mob.push( equalRule( mobileColumns ) );
				}
			}
			// Optional fixed, equal-height widget rows. The cell and its widget root
			// stretch to the authored height; result content becomes the scroll owner
			// so toolbars, filters and Load more stay inside the same-height panel.
			if ( col.settings?.equalHeight ) {
				const rawHeight = col.settings.equalHeightValue ?? '600px';
				const height = cssSafe( dim( rawHeight ) ) || '600px';
				const hook = `${ scope } .cliq-widget-n-${ col.id }`;
				desk.push(
					`${ hook }{grid-auto-rows:${ height };align-items:stretch}` +
					`${ hook }>.cliq-widget-cell{height:100%;min-height:0}` +
					`${ hook }>.cliq-widget-cell>:is(.cliq-w,.cliq-sl-w){height:100%;min-height:0}` +
					`${ hook }>.cliq-widget-cell>.cliq-sl-w--results{display:flex;flex-direction:column;overflow:hidden}` +
					`${ hook }>.cliq-widget-cell>.cliq-sl-w--results>:is(.cliq-sl-results,.cliq-sl-acc-list,.cliq-sl-group-list,.cliq-sl-catbrowse,.cliq-sl-filter-drawer){flex:1 1 auto;min-height:0;max-height:none;overflow:auto;align-content:start}`
				);
			}
			// Responsive column width: on phones a column defaults to FULL width
			// (span 6 → stacks), unless an explicit mobile span is set. Applied at
			// the breakpoint (or directly in the builder's mobile-device preview).
			const spanMobile = ( col.spanMobile === undefined || col.spanMobile === null || col.spanMobile === '' )
				? 12
				: col.spanMobile;
			const mRule = `${ scope } .cliq-widget-n-${ col.id }{grid-column:span ${ cssSafe( spanMobile ) }}`;
			( device === 'mobile' || device === 'tablet' ? desk : tablet ).push( mRule );
			( col.items || [] ).forEach( ( item ) => {
				// Cell wrapper (own hook) carries the structural layout only.
				emit( `${ scope } .cliq-widget-cl-${ item.id }`, declString( cellLayoutStyle( item ) ), null, null );
				// Responsive item width: on phones an item keeps its desktop span
				// unless an explicit mobile span (colSpanMobile) is set — additive,
				// so existing designs render unchanged. Mirrors the column rule above.
				const itemSpanMobile = ( item.colSpanMobile === undefined || item.colSpanMobile === null || item.colSpanMobile === '' )
					? ( item.colSpan || 12 )
					: item.colSpanMobile;
				const itemMRule = `${ scope } .cliq-widget-cl-${ item.id }{grid-column:span ${ cssSafe( itemSpanMobile ) }}`;
				( device === 'mobile' || device === 'tablet' ? desk : tablet ).push( itemMRule );
				const dft = defaultItemCss( item, tokens );
				const css = dft ? { ...dft, ...( item.css || {} ) } : item.css;
				// Gallery layout settings (columns/gap) become custom properties in
				// THIS generated rule — consumed by the gallery base rules
				// (design.scss / the host's Swiper gallery css) — never a style attr.
				let galleryVars = '';
				if ( item.gallery && typeof item.gallery === 'object' ) {
					galleryVars = `--cliq-gallery-cols:${ parseInt( item.gallery.columns ) || 4 };`;
					if ( item.gallery.gap !== undefined && item.gallery.gap !== null && item.gallery.gap !== '' ) {
						galleryVars += `--cliq-gallery-gap:${ cssSafe( dim( item.gallery.gap ) ) };`;
					}
				}
				// Include the actual action element (SSR button; hydrated linked action).
				// This adds one
				// element of specificity without strengthening the neutral `.cliq-widget`
				// reset, so theme `[type=button]:hover` rules cannot erase Builder styles.
				const itemHook = `${ scope } ${ isButtonItem( item, tokens ) ? ':is(button,a).' : '.' }cliq-widget-n-${ item.id }`;
				emit( itemHook, galleryVars, css, item.cssMobile );
				if ( item.controlHeightMode === 'custom' ) {
					const fieldHeight = cssSafe( dim( item.controlHeightCustom || '46px' ) ) || '46px';
					desk.push( `${ scope } .cliq-sl-field-shell-${ item.id },${ scope } .cliq-widget-n-${ item.id }{--cliq-sl-field-height:${ fieldHeight }}` );
				} else if ( item.controlHeightMode === 'auto' ) {
					desk.push( `${ scope } .cliq-widget-n-${ item.id }{--cliq-sl-field-height:auto}` );
				}
				// A select field's Dropdown-panel settings → --glide-* variables:
				// on the field's own scoped rule (the control carries the n-hook)
				// AND an UNSCOPED rule for the panel — Glide portals its dropdown
				// to <body> (outside the design scope), and the host tags it with
				// the same n-hook classes at init (see GlideSelect in the
				// consumer's widgets.js). Item ids are globally-unique uids, so
				// the unscoped hook is collision-safe.
				const gpv = GLIDE_INPUT_TYPES.includes( item.type ) ? glidePanelVars( item.css ) : '';
				if ( gpv ) {
					desk.push( `${ scope } .cliq-widget-n-${ item.id }{${ gpv }}` );
					desk.push( `.glide-dropdown.cliq-widget-panel-n-${ item.id }{${ gpv }}` );
				}
				// A range field's Slider-settings → --cui-slider-* vars: scoped rule
				// only (the slider control doesn't portal outside the item's own
				// DOM subtree the way Glide's dropdown panel does).
				const sv = sliderVars( item.css );
				if ( sv ) desk.push( `${ scope } .cliq-widget-n-${ item.id }{${ sv }}` );
				const h = item.css || {};
				if ( h.hoverBackground || h.hoverColor ) {
					const decls = [];
					if ( h.hoverBackground ) decls.push( `background:${ cssSafe( h.hoverBackground ) }` );
					if ( h.hoverColor ) decls.push( `color:${ cssSafe( h.hoverColor ) }` );
					desk.push( `${ scope } .cliq-widget-h-${ item.id }:hover{${ decls.join( ';' ) } !important}` );
				}
				const statusHook = `${ scope } .cliq-widget-n-${ item.id }.cliq-widget-open-status`;
				if ( h.openBackground || h.openColor ) {
					const declarations = [];
					if ( h.openBackground ) declarations.push( `background:${ cssSafe( h.openBackground ) }` );
					if ( h.openColor ) declarations.push( `color:${ cssSafe( h.openColor ) }` );
					desk.push( `${ statusHook }.is-open{${ declarations.join( ';' ) }}` );
				}
				if ( h.closedBackground || h.closedColor ) {
					const declarations = [];
					if ( h.closedBackground ) declarations.push( `background:${ cssSafe( h.closedBackground ) }` );
					if ( h.closedColor ) declarations.push( `color:${ cssSafe( h.closedColor ) }` );
					desk.push( `${ statusHook }.is-closed{${ declarations.join( ';' ) }}` );
				}
				// A toggle field's Toggle-settings → plain declarations against its
				// own track/thumb classes; the "on" colour needs a :checked rule, so
				// this is a bespoke pass, not the generic css.property pipeline.
				if ( h.toggleTrackOff || h.toggleTrackOn || h.toggleThumb || h.toggleInlineColor || h.toggleBorderColor ) {
					const hook = `${ scope } .cliq-widget-n-${ item.id }`;
					if ( h.toggleTrackOff ) desk.push( `${ hook } .cliq-sl-toggle__track{background:${ cssSafe( h.toggleTrackOff ) }}` );
					if ( h.toggleTrackOn ) desk.push( `${ hook } .cliq-sl-toggle__input:checked + .cliq-sl-toggle__track{background:${ cssSafe( h.toggleTrackOn ) }}` );
					if ( h.toggleThumb ) desk.push( `${ hook } .cliq-sl-toggle__thumb{background:${ cssSafe( h.toggleThumb ) }}` );
					if ( h.toggleInlineColor ) desk.push( `${ hook } .cliq-sl-toggle__inline{color:${ cssSafe( h.toggleInlineColor ) }}` );
					if ( h.toggleBorderColor ) desk.push( `${ hook } .cliq-sl-toggle__track{--cliq-sl-toggle-border:${ cssSafe( h.toggleBorderColor ) }}` );
				}
				desk.push( ...tintOverlayCss( item, scope, css ) );
			} );
		} );
	} );
	if ( tablet.length ) desk.push( `@media (max-width:${ bp }px){${ tablet.join( '' ) }}` );
	if ( mob.length ) desk.push( `@media (max-width:${ mobileBp }px){${ mob.join( '' ) }}` );
	// Extension seam: hosts can append/transform the generated stylesheet.
	return applyFilters( 'cliqui.design_css', desk.join( '\n' ), { model, scope, device, breakpoint: bp } );
};

/** Render a single layout item (record field / text / heading / button / icon). */
export const LayoutItem = ( { item, record, tokens = [], linksDisabled = false } ) => {
	const type = item.type || 'field';
	const linked = ( content ) => <LinkedItem item={ item } record={ record } disabled={ linksDisabled }>{ content }</LinkedItem>;

	if ( type === 'icon' ) {
		const iconSize = item.css?.iconSize ? ( parseInt( item.css.iconSize ) || 24 ) : 24;
		if ( ! item.icon ) return linked( <span className={ nodeClass( 'cliq-widget-icon cliq-widget-icon--empty', item ) } /> );
		// iconColor is applied by the scoped `svg` rule (css-driven, see cssMap).
		return linked(
			<span className={ nodeClass( 'cliq-widget-icon', item ) }>
				<IconGlyph name={ item.icon } size={ iconSize } />
			</span>
		);
	}

	if ( type === 'button' ) {
		const link = resolveNodeLink( item, record );
		return (
			<a
				className={ nodeClass( 'cliq-widget-btn', item ) }
				href={ link?.href || safeHref( item.url ) || undefined }
				target={ link?.target }
				rel={ link?.target === '_blank' ? 'noopener noreferrer' : undefined }
				onClick={ link ? ( event ) => { event.stopPropagation(); if ( linksDisabled ) event.preventDefault(); } : undefined }
			>
				<WithIcon item={ item }>{ item.text || __( 'Button', 'cliq-store-locator' ) }</WithIcon>
			</a>
		);
	}
	if ( type === 'heading' ) {
		return linked( <span className={ nodeClass( 'cliq-widget-text cliq-widget-text--heading', item ) }><WithIcon item={ item }>{ interpolateTokens( item.text || __( 'Heading', 'cliq-store-locator' ), record, tokens ) }</WithIcon></span> );
	}
	if ( type === 'text' ) {
		return linked( <span className={ nodeClass( 'cliq-widget-text', item ) }><WithIcon item={ item }>{ interpolateTokens( item.text || __( 'Text', 'cliq-store-locator' ), record, tokens ) }</WithIcon></span> );
	}

	// Dynamic record field.
	const val = tokenValue( item.token, record, tokens, item );
	const label = tokens.find( ( t ) => t.value === item.token )?.label || item.token;
	if ( val && typeof val === 'object' && val.type === 'logo' ) {
		// item.css.imageSize (the "Image size" control, media tokens only) picks
		// which WP-registered size variant to display — same pickSize()/
		// normaliseImage() machinery the Gallery field already uses.
		const imageSize = item.css?.imageSize || 'large';
		const sized = val.src ? pickSize( normaliseImage( val.raw ) || { full: val.src, sizes: {} }, imageSize ) : val.src;
		return linked( <LogoImg src={ sized } name={ val.name } className={ nodeClass( '', item ).trim() } /> );
	}
	if ( val && typeof val === 'object' && val.type === 'gallery' ) {
		return linked( <GalleryView item={ item } images={ val.images } /> );
	}
	if ( val && typeof val === 'object' && val.type === 'html' ) {
		// Rich-text field (e.g. a WYSIWYG): render its markup, don't escape it.
		// When the whole field has an authored link, unwrap links already present
		// in the rich text so the resulting HTML never contains nested anchors.
		const html = sanitizeHtml( val.html );
		const linkedHtml = resolveNodeLink( item, record ) ? html.replace( /<\/?a\b[^>]*>/gi, '' ) : html;
		return linked(
			<div
				className={ nodeClass( 'cliq-widget-text cliq-widget-html', item ) }
				dangerouslySetInnerHTML={ { __html: linkedHtml } }
			/>
		);
	}
	if ( val && typeof val === 'object' && val.type === 'button' ) {
		// A resolved action button links somewhere when the host supplies an href
		// (e.g. Details → detail page, Directions → maps); otherwise it's inert.
		// `val.data` (e.g. { 'cliq-action':'directions', … }) becomes data-* attrs so
		// the host can intercept the click (in-house route) while the href stays a
		// no-JS fallback.
		const authoredLink = resolveNodeLink( item, record );
		const valHref = safeHref( val.href );
		if ( authoredLink || valHref ) {
			const dataAttrs = val.data
				? Object.fromEntries( Object.entries( val.data ).map( ( [ k, v ] ) => [ `data-${ k }`, v ] ) )
				: {};
			return (
				<a
					className={ nodeClass( 'cliq-widget-btn', item ) }
					href={ authoredLink?.href || valHref }
					target={ authoredLink?.target || val.target || undefined }
					rel={ ( authoredLink?.target || val.target ) === '_blank' ? 'noopener noreferrer' : undefined }
					onClick={ authoredLink ? ( event ) => { event.stopPropagation(); if ( linksDisabled ) event.preventDefault(); } : undefined }
					{ ...dataAttrs }
				>
					<WithIcon item={ item }>{ val.label }</WithIcon>
				</a>
			);
		}
		return <button type="button" className={ nodeClass( 'cliq-widget-btn', item ) }><WithIcon item={ item }>{ val.label }</WithIcon></button>;
	}
	if ( val && typeof val === 'object' && val.type === 'bool' ) {
		const on = !! val.on;
		const text = on ? ( item.toggleOnText || '' ) : ( item.toggleOffText || '' );
		if ( ! text ) {
			// Nothing configured for this state (typically "off" with no
			// off-text) — render nothing at all, not an empty badge/icon.
			return null;
		}
		// The icon decorates the "on"/true state only (e.g. a checkmark on a
		// "Featured" badge) — showing it for "off" too would leave a bare
		// icon-only badge on every non-featured card.
		return linked(
			<span className={ nodeClass( 'cliq-widget-text', item ) }>
				{ on ? <WithIcon item={ item }>{ text }</WithIcon> : text }
			</span>
		);
	}
	if ( val && typeof val === 'object' && val.type === 'open_status' ) {
		if ( val.open === null || val.open === undefined ) return null;
		const open = !! val.open;
		const label = open
			? ( item.openText || __( 'Open now', 'cliq-store-locator' ) )
			: ( item.closedText || __( 'Closed', 'cliq-store-locator' ) );
		return (
			<span className={ `${ nodeClass( 'cliq-widget-text cliq-widget-open-status', item ) } ${ open ? 'is-open' : 'is-closed' }` }>
				<WithIcon item={ item }>{ label }</WithIcon>
			</span>
		);
	}
	// A missing dynamic value must not leave only its authored wrapper behind
	// (for example, "About " + {distance} + " away" becoming "About away"
	// before a visitor location is known).
	if ( record && ( val === null || val === undefined || val === '' ) ) return null;
	// item.prefix/suffix wrap the resolved value (e.g. "(" + count + " stores)")
	// — only once there's a real value; the builder's unbound "{Label}"
	// placeholder is left bare so it still reads as a token, not literal text.
	const displayValue = record && String( item.displayLabel || '' ).trim()
		? String( item.displayLabel ).trim()
		: val;
	const display = record ? `${ item.prefix || '' }${ displayValue }${ item.suffix || '' }` : `{${ label }}`;
	return linked(
		<span className={ nodeClass( 'cliq-widget-text', item ) }>
			<WithIcon item={ item }>{ display }</WithIcon>
		</span>
	);
};

/**
 * Compute the results-list container style from a results widget's settings.
 * Supports flex (column/row, wrapping) and responsive CSS grid (auto-fill /
 * auto-fit with a min column width). Shared by the builder preview + frontend
 * so they render identically.
 */
/**
 * Results-list container, split across the two halves of the styling
 * contract — NOTHING is ever set via a style attribute:
 *   - resultsContainerClass(): the element's classes (discrete layout modes
 *     as modifiers, consumed by shared/design.scss's structural base rules).
 *   - resultsContainerVars(): a CSS declaration string of custom properties
 *     (continuous values: gap / scroll height / grid columns) for the caller
 *     to emit into its GENERATED scoped stylesheet, keyed on the widget
 *     item's own hook (e.g. `.cliq-sl-l-t20 .cliq-widget-n-<itemId>{…}`) —
 *     the variables inherit down to the container element.
 * PHP SSR twin: LocatorSsrRenderer (resultsContainerClassAttr +
 * widgetSettingsCss) must emit the same classes/variables.
 */
export const resultsContainerClass = ( ws = {}, extraClass = '' ) => {
	const classes = [ 'cliq-widget-results' ];
	if ( ws.scroll ) classes.push( 'cliq-widget-results--scroll' );
	if ( ws.layout === 'grid' ) classes.push( 'cliq-widget-results--grid' );
	else if ( ws.direction === 'row' ) classes.push( 'cliq-widget-results--row' );
	if ( extraClass ) classes.push( extraClass );
	return classes.join( ' ' );
};

export const resultsContainerVars = ( ws = {} ) => {
	const decls = [];
	if ( ws.gap !== undefined && ws.gap !== null && ws.gap !== '' ) decls.push( `--cliq-results-gap:${ cssSafe( dim( ws.gap ) ) }` );
	if ( ws.cardWidth ) decls.push( `--cliq-results-card-w:${ cssSafe( dim( ws.cardWidth ) ) || '100%' }` );
	if ( ws.scroll && ws.maxHeight ) decls.push( `--cliq-results-max-h:${ cssSafe( dim( ws.maxHeight ) ) }` );
	if ( ws.scrollbarColor ) decls.push( `--cliq-results-scroll-thumb:${ cssSafe( ws.scrollbarColor ) }` );
	if ( ws.scrollbarTrackColor ) decls.push( `--cliq-results-scroll-track:${ cssSafe( ws.scrollbarTrackColor ) }` );
	if ( ws.layout === 'grid' ) {
		const min = ws.minColWidth ?? 300;
		const mode = ws.colMode === 'auto-fit' ? 'auto-fit' : 'auto-fill';
		const requestedCount = Number.parseInt( ws.gridColumns ?? 4, 10 );
		const count = Math.min( 12, Math.max( 1, Number.isFinite( requestedCount ) ? requestedCount : 4 ) );
		const gap = Number.isFinite( Number( ws.gap ) ) ? Math.max( 0, Number( ws.gap ) ) : 14;
		const reservedGap = gap * ( count - 1 );
		// A capped auto grid: the calculated share prevents more than `count`
		// tracks, while minColWidth makes tracks collapse responsively according
		// to the locator's container (not the viewport). min(100%, …) prevents a
		// single track overflowing containers narrower than minColWidth.
		const trackMin = `min(100%, max(${ min }px, calc((100% - ${ reservedGap }px) / ${ count })))`;
		decls.push( `--cliq-results-cols:${ cssSafe( `repeat(${ mode }, minmax(${ trackMin }, 1fr))` ) }` );
	}
	// The category TILE GRID has its own settings + variables, independent of the
	// store list's layout above. Used by BOTH the Results widget's categories
	// drill-down (displayMode) and the standalone Category filter's tiles mode
	// (display). Older widgets without tile* settings inherit the shared values.
	if ( ws.displayMode === 'categories' || ws.display === 'tiles' ) {
		const tMin = ws.tileMinWidth ?? ws.minColWidth ?? 160;
		const tMode = ( ws.tileColMode ?? ws.colMode ) === 'auto-fit' ? 'auto-fit' : 'auto-fill';
		decls.push( `--cliq-cat-cols:${ cssSafe( `repeat(${ tMode }, minmax(${ tMin }px, 1fr))` ) }` );
		const tGap = ws.tileGap ?? ws.gap;
		if ( tGap !== undefined && tGap !== null && tGap !== '' ) decls.push( `--cliq-cat-gap:${ cssSafe( dim( tGap ) ) }` );
	}
	return decls.join( ';' );
};

/**
 * Load More button style, same half-of-the-contract split as
 * resultsContainerVars() above: the button's typography, colors and box style
 * settings become custom properties for the caller's generated stylesheet
 * (keyed on the results widget item's own hook) — never a style attribute.
 * `.cliq-sl-loadmore` (style.scss) consumes these with fallbacks.
 * PHP SSR twin: LocatorSsrRenderer::widgetSettingsCss().
 */
export const loadMoreButtonVars = ( ws = {} ) => {
	const decls = [];
	if ( ws.loadMoreFontFamily ) decls.push( `--cliq-loadmore-font-family:${ cssSafe( ws.loadMoreFontFamily ) }` );
	if ( ws.loadMoreFontSize ) decls.push( `--cliq-loadmore-font-size:${ cssSafe( dim( ws.loadMoreFontSize ) ) }` );
	if ( ws.loadMoreFontWeight ) decls.push( `--cliq-loadmore-font-weight:${ cssSafe( ws.loadMoreFontWeight ) }` );
	if ( ws.loadMoreColor ) decls.push( `--cliq-loadmore-color:${ cssSafe( ws.loadMoreColor ) }` );
	if ( ws.loadMoreBackground ) decls.push( `--cliq-loadmore-background:${ cssSafe( ws.loadMoreBackground ) }` );
	if ( ws.loadMoreBorderColor ) decls.push( `--cliq-loadmore-border-color:${ cssSafe( ws.loadMoreBorderColor ) }` );
	if ( ws.loadMoreBorderWidth ) decls.push( `--cliq-loadmore-border-width:${ cssSafe( dim( ws.loadMoreBorderWidth ) ) }` );
	if ( ws.loadMoreBorderRadius ) decls.push( `--cliq-loadmore-border-radius:${ cssSafe( dim( ws.loadMoreBorderRadius ) ) }` );
	if ( ws.loadMorePaddingVertical || ws.loadMorePaddingHorizontal ) {
		decls.push( `--cliq-loadmore-padding:${ cssSafe( dim( ws.loadMorePaddingVertical || '9px' ) ) } ${ cssSafe( dim( ws.loadMorePaddingHorizontal || '22px' ) ) }` );
	}
	if ( ws.loadMoreHoverColor ) decls.push( `--cliq-loadmore-hover-color:${ cssSafe( ws.loadMoreHoverColor ) }` );
	if ( ws.loadMoreHoverBackground ) decls.push( `--cliq-loadmore-hover-background:${ cssSafe( ws.loadMoreHoverBackground ) }` );
	if ( ws.loadMoreHoverBorderColor ) decls.push( `--cliq-loadmore-hover-border-color:${ cssSafe( ws.loadMoreHoverBorderColor ) }` );
	return decls.join( ';' );
};

/** Collect per-item hover rules into a scoped <style> string. */
export const hoverStyles = ( rows ) => {
	const rules = [];
	( rows || [] ).forEach( ( row ) =>
		( row.columns || [] ).forEach( ( col ) =>
			( col.items || [] ).forEach( ( item ) => {
				const h = item.css || {};
				if ( h.hoverBackground || h.hoverColor ) {
					const decls = [];
					if ( h.hoverBackground ) decls.push( `background:${ cssSafe( h.hoverBackground ) }` );
					if ( h.hoverColor ) decls.push( `color:${ cssSafe( h.hoverColor ) }` );
					rules.push( `.cliq-widget-h-${ item.id }:hover{${ decls.join( ';' ) } !important}` );
				}
			} )
		)
	);
	return rules.join( '\n' );
};

/** Render a single design row (columns → cells → items). ALL styling — visual and
 * structural (grid span / gap / alignment) — comes from the scoped stylesheet via
 * the cliq-widget-n-<id> / cliq-widget-cl-<id> hooks; no inline style attributes. */
const DesignRow = ( { row, record, tokens, linksDisabled } ) => (
	<div className={ nodeClass( 'cliq-widget-row', row ) }>
		{ ( row.columns || [] ).map( ( col ) => (
			<div className={ nodeClass( 'cliq-widget-col', col ) } key={ col.id }>
				{ ( col.items || [] ).map( ( item ) => (
					<div className={ `cliq-widget cliq-widget-cell cliq-widget-cl-${ item.id }` } key={ item.id }>
						<LayoutItem item={ item } record={ record } tokens={ tokens } linksDisabled={ linksDisabled } />
					</div>
				) ) }
			</div>
		) ) }
	</div>
);

/** True when a row is marked to reveal only when an accordion card is expanded. */
export const isCollapsibleRow = ( row ) => !! row?.settings?.collapsible;

/** Whether a design has any expand-on-click rows (i.e. works as an accordion). */
export const hasCollapsibleRows = ( model ) =>
	Array.isArray( model?.rows ) && model.rows.some( isCollapsibleRow );

/**
 * Render a full layout-mode design (a list_item or infobox) for one record.
 * Used by the frontend results list + map infobox, and reusable by previews.
 *
 * `accordion`: when passed `{ open }`, rows flagged `settings.collapsible` are
 * grouped into a single smoothly-animating reveal region inside the SAME card —
 * collapsed shows only the header rows, expanded reveals the rest.
 */
const randomScope = () => `cliq-widget-d-${ Math.random().toString( 36 ).slice( 2, 9 ) }`;

/**
 * One scope + one stylesheet for a design rendered against MANY records.
 *
 * A design's generated css depends only on the design, never on the record, so
 * a list of N cards needs one stylesheet rather than N copies of the same rules
 * (20 store cards was ~60KB of duplicate css). Render the returned `css` once,
 * pass `scope` to every {@see DesignView}, and each card reuses that stylesheet.
 * This is the client twin of the PHP renderList(), which shares a scope the
 * same way.
 *
 * @param {Object} model             Design model.
 * @param {Object} [options]         device / breakpoint / tokens, as DesignView takes them.
 * @return {{scope: string, css: string}} Scope class and its stylesheet.
 */
export const useSharedDesignStyles = ( model, { device = 'desktop', breakpoint = null, tokens = [] } = {} ) => {
	const scope = useMemo( randomScope, [] );
	const css = useMemo(
		() => ( model && Array.isArray( model.rows ) && model.rows.length
			? buildDesignCss( model, `.${ scope }`, device, breakpoint, tokens )
			: '' ),
		[ model, scope, device, breakpoint, tokens ]
	);

	return { scope, css };
};

export const DesignView = ( { model, record = null, tokens = [], device = 'desktop', breakpoint = null, className = '', accordion = null, linksDisabled = false, scope: sharedScope = null } ) => {
	const rows = model && Array.isArray( model.rows ) ? model.rows : [];
	const ownScope = useMemo( randomScope, [] );
	// A caller rendering many records passes the scope whose stylesheet it has
	// already emitted (see useSharedDesignStyles); this card then adds no css of
	// its own. Standalone cards keep owning their stylesheet.
	const scope = sharedScope || ownScope;
	if ( ! rows.length ) return null;

	const css = sharedScope ? '' : buildDesignCss( model, `.${ scope }`, device, breakpoint, tokens );

	let body;
	if ( accordion ) {
		const always = rows.filter( ( r ) => ! isCollapsibleRow( r ) );
		const reveal = rows.filter( isCollapsibleRow );
		body = (
			<>
				{ always.map( ( row ) => <DesignRow key={ row.id } row={ row } record={ record } tokens={ tokens } linksDisabled={ linksDisabled } /> ) }
				{ reveal.length > 0 && (
					<div className={ `cliq-widget cliq-widget-reveal${ accordion.open ? ' is-open' : '' }` }>
						<div className="cliq-widget cliq-widget-reveal__inner">
							{ reveal.map( ( row ) => <DesignRow key={ row.id } row={ row } record={ record } tokens={ tokens } linksDisabled={ linksDisabled } /> ) }
						</div>
					</div>
				) }
			</>
		);
	} else {
		body = rows.map( ( row ) => <DesignRow key={ row.id } row={ row } record={ record } tokens={ tokens } linksDisabled={ linksDisabled } /> );
	}
	const containerLink = resolveNodeLink( model?.container, record );
	const navigate = ( event ) => {
		const interactive = event.target.closest( 'a,button,input,select,textarea,label,[role="button"]' );
		if ( ! containerLink || ( interactive && event.currentTarget.contains( interactive ) ) ) return;
		event.stopPropagation();
		if ( linksDisabled ) return;
		if ( containerLink.target === '_blank' ) window.open( containerLink.href, '_blank', 'noopener,noreferrer' );
		else window.location.assign( containerLink.href );
	};

	return (
		<div
			className={ `cliq-widget cliq-widget-design cliq-widget-design--layout${ containerLink ? ' cliq-widget-design--linked' : '' } ${ scope } ${ className }` }
			role={ containerLink ? 'link' : undefined }
			tabIndex={ containerLink ? 0 : undefined }
			onClick={ navigate }
			onKeyDown={ ( event ) => {
				if ( containerLink && ( event.key === 'Enter' || event.key === ' ' ) ) {
					event.preventDefault();
				navigate( event );
				}
			} }
		>
			{ css ? <style>{ css }</style> : null }
			{ body }
			{ model?.container?.tail && <div className="cliq-widget-marker-tail" /> }
		</div>
	);
};
