<?php
/**
 * PHP twin of `src/shared/chrome/StylingRepeater/cssBuilder.js`.
 *
 * Mirrors the JS aggregator architecture so frontend page loads render the
 * same sparse-storage CSS the editor preview shows. JS owns the editor
 * `<style>` (per Content Loop `edit.js`); PHP owns the frontend `<style>`
 * (via the existing CSS pipeline in `Blocksify_CSS_Pipeline`).
 *
 * Per FOUNDATION §9.1, declarations match byte-for-byte for the same input.
 * Selectors intentionally differ: editor uses bumped specificity
 * (`.editor-styles-wrapper .is-root-container .{uid}.{uid}{tail}`) to
 * defeat block-theme `*` rules; frontend uses single-class (0,1,0)
 * `.bsy-{shorthand}-{uid}{tail}` and wins via source order (per-instance
 * inline `<style>` lands AFTER head `<link>` stylesheets in the pipeline
 * output).
 *
 * @package Blocksify
 */

defined( 'ABSPATH' ) || exit;

if ( ! class_exists( 'Blocksify_Styling_Repeater' ) ) :

class Blocksify_Styling_Repeater {

	/** @see Blocksify_CSS_Generator (same constants — kept local for clarity). */
	const TABLET_MAX = 1023;
	const MOBILE_MAX = 767;

	/**
	 * Mirror of `BLOCK_CLASS_SHORTHAND` in `src/shared/helpers.js`. Long block
	 * names compress to a shorter selector base. Three sources must stay in
	 * sync (JS helpers, this map, the CSS pipeline output) so JS preview and
	 * PHP frontend resolve identical selectors.
	 */
	const BLOCK_CLASS_SHORTHAND = array(
		'content-loop' => 'cl',
		'icon-list'    => 'iconlist',
	);

	/**
	 * Build the aggregated sparse-storage preview CSS for one element's
	 * styling slice. Mirrors JS `buildSparsePreviewCss`.
	 *
	 * @param array $sparse Sparse value `{ [prop]: { base, hover, tablet, mobile } }`.
	 * @param array $args   { block_name, uid, part_selector, consumer_name?, exclude_groups?, exclude_props?, selector_overrides? }.
	 * @return string CSS string (no `<style>` wrapper).
	 */
	public static function build_sparse_preview_css( $sparse, $args ) {
		if ( ! is_array( $sparse ) || empty( $sparse ) ) {
			return '';
		}
		if ( empty( $args['block_name'] ) || empty( $args['uid'] ) ) {
			return '';
		}

		$part_selector      = isset( $args['part_selector'] ) ? $args['part_selector'] : '';
		$consumer_name      = isset( $args['consumer_name'] ) ? $args['consumer_name'] : null;
		$component_defaults = isset( $args['component_defaults'] ) && is_array( $args['component_defaults'] )
			? $args['component_defaults']
			: array();

		$groups = self::get_groups(
			array(
				'exclude_groups' => isset( $args['exclude_groups'] ) ? (array) $args['exclude_groups'] : array(),
				'exclude_props'  => isset( $args['exclude_props'] ) ? (array) $args['exclude_props'] : array(),
				'consumer_name'  => $consumer_name,
				'part_selector'  => $part_selector,
			)
		);

		// Decision #30 — per-group `partSelector` override. PHP mirrors the
		// JS `resolveSelectorOverrides`: consumer's seed map gets piped
		// through `blocksify.styling-repeater.selector-overrides` filter so
		// Pro callbacks can extend it. Empty string is a meaningful entry
		// (paints on the block root); use `array_key_exists` to keep it
		// distinct from "no override for this group".
		$selector_overrides = self::resolve_selector_overrides(
			isset( $args['selector_overrides'] ) ? $args['selector_overrides'] : array(),
			array(
				'consumer_name' => $consumer_name,
				'part_selector' => $part_selector,
			)
		);

		$css = '';
		foreach ( $groups as $key => $group ) {
			$builder = isset( $group['css_builder'] ) ? $group['css_builder'] : null;
			if ( ! is_callable( $builder ) ) {
				continue;
			}
			$prop_list = isset( $group['props'] ) && is_array( $group['props'] ) ? $group['props'] : array();
			if ( empty( $prop_list ) ) {
				continue;
			}
			$group_selector = array_key_exists( $key, $selector_overrides )
				? $selector_overrides[ $key ]
				: $part_selector;
			$css           .= (string) call_user_func(
				$builder,
				array(
					'sparse'             => $sparse,
					'block_name'         => $args['block_name'],
					'uid'                => $args['uid'],
					'part_selector'      => $group_selector,
					'prop_list'          => $prop_list,
					'component_defaults' => $component_defaults,
				)
			);
		}
		return self::merge_same_selector_rules( $css );
	}

	/**
	 * Coalesce CSS rules that share the same outer wrapper (no wrapper
	 * or the same `@media` query) AND the same selector into one rule
	 * block. PHP twin of JS `mergeSameSelectorRules` in `cssBuilder.js`.
	 *
	 * Each composite's css_builder emits its own full `selector { decls }`
	 * block. When multiple groups land on the same selector with no
	 * responsive / hover layer (typical chip with padding + borderRadius
	 * + fontSize defaults seeded), the concatenated output repeats the
	 * same selector — this pass merges them into one rule per
	 * (media, selector) pair. Hover (`:hover` pseudo) lives in the
	 * selector itself so stays separate from non-hover variants on the
	 * same base selector. Comma-grouped selectors stay verbatim.
	 *
	 * @param string $css Concatenated per-composite emit output.
	 * @return string CSS with same-selector rules merged.
	 */
	public static function merge_same_selector_rules( $css ) {
		if ( ! is_string( $css ) || '' === $css ) {
			return '';
		}
		$blocks = self::split_top_level_rules( $css );
		if ( count( $blocks ) <= 1 ) {
			return $css;
		}
		$groups = array();
		$order  = array();
		foreach ( $blocks as $block ) {
			$parsed = self::decompose_rule( $block );
			if ( null === $parsed ) {
				$key            = '__verbatim__' . count( $order );
				$groups[ $key ] = array( 'verbatim' => $block );
				$order[]        = $key;
				continue;
			}
			$key = $parsed['media'] . '||' . $parsed['selector'];
			if ( ! isset( $groups[ $key ] ) ) {
				$groups[ $key ] = array(
					'media'    => $parsed['media'],
					'selector' => $parsed['selector'],
					'decls'    => array(),
				);
				$order[]        = $key;
			}
			$groups[ $key ]['decls'][] = $parsed['decls'];
		}
		$out = '';
		foreach ( $order as $key ) {
			$entry = $groups[ $key ];
			if ( isset( $entry['verbatim'] ) ) {
				$out .= "\n" . $entry['verbatim'];
				continue;
			}
			$decls = array();
			foreach ( $entry['decls'] as $d ) {
				$d = trim( (string) $d );
				if ( '' !== $d ) {
					$decls[] = $d;
				}
			}
			if ( empty( $decls ) ) {
				continue;
			}
			$merged = implode( ' ', $decls );
			$inner  = $entry['selector'] . ' { ' . $merged . ' }';
			$out   .= "\n" . ( '' !== $entry['media']
				? $entry['media'] . ' { ' . $inner . ' }'
				: $inner );
		}
		return $out;
	}

	/**
	 * Split a CSS string into top-level rule blocks via balanced-brace
	 * scan. Mirror of JS `splitTopLevelRules`.
	 *
	 * @param string $css
	 * @return array Trimmed rule blocks in source order.
	 */
	protected static function split_top_level_rules( $css ) {
		$blocks = array();
		$depth  = 0;
		$start  = 0;
		$len    = strlen( $css );
		for ( $i = 0; $i < $len; $i++ ) {
			$c = $css[ $i ];
			if ( '{' === $c ) {
				$depth++;
			} elseif ( '}' === $c ) {
				$depth--;
				if ( 0 === $depth ) {
					$block = trim( substr( $css, $start, $i + 1 - $start ) );
					if ( '' !== $block ) {
						$blocks[] = $block;
					}
					$start = $i + 1;
				}
			}
		}
		return $blocks;
	}

	/**
	 * Decompose a top-level rule block into `[ media, selector, decls ]`.
	 * Mirror of JS `decomposeRule`. Returns `null` when block doesn't
	 * match either plain or `@media`-wrapped pattern.
	 *
	 * @param string $block Single rule block (trimmed).
	 * @return array|null
	 */
	protected static function decompose_rule( $block ) {
		if ( '' === $block ) {
			return null;
		}
		if ( '@' === $block[0] ) {
			if ( ! preg_match(
				'/^(@[a-z-]+[^{]*?)\s*\{\s*([^{}]+?)\s*\{\s*(.*?)\s*\}\s*\}$/s',
				$block,
				$m
			) ) {
				return null;
			}
			return array(
				'media'    => trim( $m[1] ),
				'selector' => trim( $m[2] ),
				'decls'    => trim( $m[3] ),
			);
		}
		if ( ! preg_match( '/^([^{}]+?)\s*\{\s*(.*?)\s*\}$/s', $block, $m ) ) {
			return null;
		}
		return array(
			'media'    => '',
			'selector' => trim( $m[1] ),
			'decls'    => trim( $m[2] ),
		);
	}

	/**
	 * Resolve the per-group `partSelector` override map for a consumer
	 * context. Mirrors JS `resolveSelectorOverrides`.
	 *
	 * Applies the `blocksify.styling-repeater.selector-overrides` filter
	 * so Pro / themes can inject overrides for specific group + consumer
	 * combinations. Consumer-supplied overrides are the seed; filter
	 * mutates and returns the resolved map.
	 *
	 * @param array $overrides Consumer-supplied seed map (group_key => selector_suffix).
	 * @param array $context   Filter context (consumer_name, part_selector).
	 * @return array Resolved per-group override map (possibly empty).
	 */
	public static function resolve_selector_overrides( $overrides, $context = array() ) {
		$seed     = is_array( $overrides ) ? $overrides : array();
		$resolved = apply_filters(
			'blocksify.styling-repeater.selector-overrides',
			$seed,
			$context
		);
		return is_array( $resolved ) ? $resolved : $seed;
	}

	/**
	 * Build flat-suffix pseudo-attrs from sparse for one prop list. Mirrors
	 * JS `buildPseudoAttrs`.
	 *
	 * @param array $sparse    Sparse `{ [prop]: { base?, hover?, tablet?, mobile? } }`.
	 * @param array $prop_list Group's prop keys.
	 * @return array Flat-suffix attrs slice consumed by trait `emit_decls`.
	 */
	public static function build_pseudo_attrs( $sparse, $prop_list ) {
		$out = array();
		if ( ! is_array( $sparse ) || ! is_array( $prop_list ) ) {
			return $out;
		}
		foreach ( $prop_list as $prop ) {
			$entry = isset( $sparse[ $prop ] ) ? $sparse[ $prop ] : null;
			if ( ! is_array( $entry ) ) {
				continue;
			}
			if ( isset( $entry['base'] ) && null !== $entry['base'] ) {
				$out[ $prop ] = $entry['base'];
			}
			if ( isset( $entry['hover'] ) && null !== $entry['hover'] ) {
				$out[ $prop . 'Hover' ] = $entry['hover'];
			}
			if ( isset( $entry['tablet'] ) && null !== $entry['tablet'] ) {
				$out[ $prop . 'Tablet' ] = $entry['tablet'];
			}
			if ( isset( $entry['mobile'] ) && null !== $entry['mobile'] ) {
				$out[ $prop . 'Mobile' ] = $entry['mobile'];
			}
		}
		return $out;
	}

	/**
	 * Resolve the active group map for a consumer context.
	 *
	 * @param array $args { exclude_groups?, exclude_props?, consumer_name?, part_selector? }.
	 * @return array Group key → group config.
	 */
	public static function get_groups( $args = array() ) {
		$built_in = self::built_in_groups();

		$context = array(
			'consumer_name' => isset( $args['consumer_name'] ) ? $args['consumer_name'] : null,
			'part_selector' => isset( $args['part_selector'] ) ? $args['part_selector'] : '',
		);
		$filtered = apply_filters( 'blocksify.styling-repeater.groups', $built_in, $context );
		if ( ! is_array( $filtered ) ) {
			$filtered = $built_in;
		}

		$exclude_groups = isset( $args['exclude_groups'] ) ? (array) $args['exclude_groups'] : array();
		$exclude_props  = isset( $args['exclude_props'] ) ? (array) $args['exclude_props'] : array();

		$out = array();
		foreach ( $filtered as $key => $group ) {
			if ( in_array( $key, $exclude_groups, true ) ) {
				continue;
			}
			$base_props = isset( $group['props'] ) && is_array( $group['props'] ) ? $group['props'] : array();
			$prop_filtered = apply_filters(
				'blocksify.styling-repeater.props',
				$base_props,
				$key,
				$context
			);
			if ( ! is_array( $prop_filtered ) ) {
				$prop_filtered = $base_props;
			}
			$drop = isset( $exclude_props[ $key ] ) && is_array( $exclude_props[ $key ] )
				? $exclude_props[ $key ]
				: array();
			if ( ! empty( $drop ) ) {
				$prop_filtered = array_values( array_diff( $prop_filtered, $drop ) );
			}
			$group['props'] = $prop_filtered;
			$out[ $key ]    = $group;
		}
		return $out;
	}

	/**
	 * Built-in 9 groups + their PHP `css_builder` callables. Mirrors JS
	 * `getBuiltInGroups()`.
	 */
	protected static function built_in_groups() {
		return array(
			'typography'   => array(
				'props'       => array(
					'fontFamily',
					'fontWeight',
					'fontStyle',
					'fontSize',
					'lineHeight',
					'letterSpacing',
					'textTransform',
					'textDecoration',
					'textAlign',
				),
				'css_builder' => array( __CLASS__, 'typography_css_builder' ),
			),
			'color'        => array(
				'props'       => array( 'textColor' ),
				'css_builder' => array( __CLASS__, 'color_css_builder' ),
			),
			'background'   => array(
				// Phase 3 — single-color (`backgroundColor`). Color trait
				// owns the `background-color:` decl emit, NOT the Background
				// trait (which handles image / gradient / video / shape /
				// overlay — out of element-level sparse scope). JS-side
				// mirrors: same `buildColorPreviewCss` invoked with
				// pseudoAttrs containing only `backgroundColor`.
				'props'       => array( 'backgroundColor' ),
				'css_builder' => array( __CLASS__, 'color_css_builder' ),
			),
			'borderRadius' => array(
				'props'       => array( 'border', 'borderRadius' ),
				'css_builder' => array( __CLASS__, 'border_css_builder' ),
			),
			'layout'       => array(
				'props'       => array(
					'flexDirection',
					'alignItems',
					'justifyContent',
					'flexWrap',
					'columnGap',
					'rowGap',
				),
				'css_builder' => array( __CLASS__, 'layout_css_builder' ),
			),
			'margin'       => array(
				'props'       => array( 'margin' ),
				'css_builder' => array( __CLASS__, 'spacing_css_builder' ),
			),
			'padding'      => array(
				'props'       => array( 'padding' ),
				'css_builder' => array( __CLASS__, 'spacing_css_builder' ),
			),
			'sizing'       => array(
				'props'       => array( 'size' ),
				'css_builder' => array( __CLASS__, 'size_css_builder' ),
			),
			'positioning'  => array(
				'props'       => array( 'position', 'overflow', 'zIndex' ),
				'css_builder' => array( __CLASS__, 'positioning_css_builder' ),
			),
			'boxShadow'    => array(
				'props'       => array( 'shadow' ),
				'css_builder' => array( __CLASS__, 'border_css_builder' ),
			),
		);
	}

	// ------------------------------------------------------------------
	// Per-group css_builder methods.
	//
	// Each builder reads sparse via `build_pseudo_attrs`, then dispatches
	// to the matching trait class's `emit_decls` / `emit_hover_decls`.
	// `emit_per_device` + `emit_hover` handle the device cascade + `:hover`
	// rule wrap so each builder stays small.
	// ------------------------------------------------------------------

	/** Typography is NOT hover-aware (FOUNDATION §6.4 + §7.0). */
	public static function typography_css_builder( $args ) {
		$pseudo = self::build_pseudo_attrs( $args['sparse'], $args['prop_list'] );
		return self::emit_per_device( $pseudo, array( 'Blocksify_Typography_Trait', 'emit_decls' ), $args );
	}

	/** Color is hover-aware. Link color emits as descendant `<sel> a {...}` —
	 *  skipped for sparse storage Phase 2 (Title is a header tag with no link
	 *  unless `link: true` wraps it; the Color group's textColor lands on
	 *  the wrapper, which is what the user wants).
	 */
	public static function color_css_builder( $args ) {
		$pseudo = self::build_pseudo_attrs( $args['sparse'], $args['prop_list'] );
		$css  = self::emit_per_device( $pseudo, array( 'Blocksify_Color_Trait', 'emit_decls' ), $args );
		$css .= self::emit_hover( $pseudo, array( 'Blocksify_Color_Trait', 'emit_hover_decls' ), $args );
		return $css;
	}

	/** Background is partially hover-aware (gradient only, per trait header). */
	public static function background_css_builder( $args ) {
		$pseudo = self::build_pseudo_attrs( $args['sparse'], $args['prop_list'] );
		$css  = self::emit_per_device( $pseudo, array( 'Blocksify_Background_Trait', 'emit_decls' ), $args );
		$css .= self::emit_hover( $pseudo, array( 'Blocksify_Background_Trait', 'emit_hover_decls' ), $args );
		return $css;
	}

	/** Border trait covers border + borderRadius + shadow. Used by both
	 *  `borderRadius` group (props: border, borderRadius) and `boxShadow`
	 *  group (props: shadow) — the pseudo-attrs slice gates which fields
	 *  emit.
	 */
	public static function border_css_builder( $args ) {
		$pseudo = self::build_pseudo_attrs( $args['sparse'], $args['prop_list'] );
		$css  = self::emit_per_device( $pseudo, array( 'Blocksify_Border_Trait', 'emit_decls' ), $args );
		$css .= self::emit_hover( $pseudo, array( 'Blocksify_Border_Trait', 'emit_hover_decls' ), $args );
		return $css;
	}

	/** Spacing covers padding + margin. Hover-aware. */
	public static function spacing_css_builder( $args ) {
		$pseudo = self::build_pseudo_attrs( $args['sparse'], $args['prop_list'] );
		$css  = self::emit_per_device( $pseudo, array( 'Blocksify_Spacing_Trait', 'emit_decls' ), $args );
		$css .= self::emit_hover( $pseudo, array( 'Blocksify_Spacing_Trait', 'emit_hover_decls' ), $args );
		return $css;
	}

	/** Size is structural; not hover-aware. */
	public static function size_css_builder( $args ) {
		$pseudo = self::build_pseudo_attrs( $args['sparse'], $args['prop_list'] );
		return self::emit_per_device( $pseudo, array( 'Blocksify_Size_Trait', 'emit_decls' ), $args );
	}

	/**
	 * Layout group — flex-axis (`display: flex` + `flex-direction` +
	 * `justify-content` + `align-items` + `flex-wrap`) PLUS gap pair
	 * (`row-gap` + `column-gap`). Phase 3.4 + 3.5 fix merged the former
	 * standalone Gap group in. Mirror of JS `LayoutInlineForm`. NOT
	 * hover-aware (FOUNDATION §6.4).
	 *
	 * Decision #69 — threads consumer's seed direction
	 * (`component_defaults.layout.direction`) to `emit_layout_decls` as
	 * the fallback emit value. Without it, any layout prop authored
	 * without an explicit `flexDirection` (e.g. user just picked
	 * `alignItems: center`) emits `flex-direction: column` from the hard
	 * fallback, overriding the consumer's static `flex-direction: row`
	 * baseline. Consumers like Card Style (direction='column' seed) keep
	 * their behaviour because their seed matches the legacy fallback.
	 */
	public static function layout_css_builder( $args ) {
		$pseudo            = self::build_pseudo_attrs( $args['sparse'], $args['prop_list'] );
		$default_direction = isset( $args['component_defaults']['layout']['direction'] )
				&& is_string( $args['component_defaults']['layout']['direction'] )
				&& '' !== $args['component_defaults']['layout']['direction']
			? $args['component_defaults']['layout']['direction']
			: 'column';
		$sel = self::build_selector( $args['block_name'], $args['uid'], $args['part_selector'] );
		$css = '';

		$desktop = self::emit_layout_decls( $pseudo, 'desktop', $default_direction );
		if ( '' !== $desktop ) {
			$css .= $sel . '{' . $desktop . '}';
		}
		$tablet = self::emit_layout_decls( $pseudo, 'tablet', $default_direction );
		if ( '' !== $tablet ) {
			$css .= '@media (max-width:' . self::TABLET_MAX . 'px){' . $sel . '{' . $tablet . '}}';
		}
		$mobile = self::emit_layout_decls( $pseudo, 'mobile', $default_direction );
		if ( '' !== $mobile ) {
			$css .= '@media (max-width:' . self::MOBILE_MAX . 'px){' . $sel . '{' . $mobile . '}}';
		}

		$css .= self::emit_per_device( $pseudo, array( __CLASS__, 'emit_gap_decls' ), $args );
		return $css;
	}

	/**
	 * Resolve `pseudo[$attr . $suffix]` falling through to base + return
	 * trimmed string. Empty when nothing resolves on that device.
	 */
	protected static function resolve_pseudo( $pseudo, $attr, $device ) {
		$suffix = ( 'tablet' === $device ) ? 'Tablet' : ( ( 'mobile' === $device ) ? 'Mobile' : '' );
		if ( '' !== $suffix && isset( $pseudo[ $attr . $suffix ] ) ) {
			return $pseudo[ $attr . $suffix ];
		}
		if ( 'desktop' === $device && isset( $pseudo[ $attr ] ) ) {
			return $pseudo[ $attr ];
		}
		return null;
	}

	/**
	 * Build flex-axis decls for one device. Mirrors JS `buildLayoutDecls`
	 * + the `Blocksify_CSS_Generator::build_flex_decls` flex-axis subset.
	 * Emits `display: flex` + `flex-direction` whenever any of the four
	 * subset props resolves on this device.
	 */
	public static function emit_layout_decls( $pseudo, $device, $default_direction = 'column' ) {
		$dir = self::resolve_pseudo( $pseudo, 'flexDirection', $device );
		$jus = self::resolve_pseudo( $pseudo, 'justifyContent', $device );
		$ali = self::resolve_pseudo( $pseudo, 'alignItems', $device );
		$wrp = self::resolve_pseudo( $pseudo, 'flexWrap', $device );
		$has_any = ( is_string( $dir ) && '' !== $dir )
			|| ( is_string( $jus ) && '' !== $jus )
			|| ( is_string( $ali ) && '' !== $ali )
			|| ( is_string( $wrp ) && '' !== $wrp );
		if ( ! $has_any ) {
			return '';
		}
		$decls     = 'display: flex;';
		$direction = is_string( $dir ) && '' !== $dir ? $dir : $default_direction;
		$decls    .= 'flex-direction: ' . $direction . ';';
		if ( is_string( $jus ) && '' !== $jus ) {
			$decls .= 'justify-content: ' . $jus . ';';
		}
		if ( is_string( $ali ) && '' !== $ali ) {
			$decls .= 'align-items: ' . $ali . ';';
		}
		if ( is_string( $wrp ) && '' !== $wrp ) {
			$decls .= 'flex-wrap: ' . $wrp . ';';
		}
		return $decls;
	}

	/**
	 * Build gap decls for one device. Mirrors JS `buildGapDecls` +
	 * `Blocksify_CSS_Generator::format_gap_value`.
	 */
	public static function emit_gap_decls( $pseudo, $device ) {
		$row = self::resolve_pseudo( $pseudo, 'rowGap', $device );
		$col = self::resolve_pseudo( $pseudo, 'columnGap', $device );
		$decls = '';
		if ( is_array( $row ) ) {
			$row_css = Blocksify_CSS_Generator::format_gap_value( $row );
			if ( '' !== $row_css ) {
				$decls .= 'row-gap: ' . $row_css . ';';
			}
		}
		if ( is_array( $col ) ) {
			$col_css = Blocksify_CSS_Generator::format_gap_value( $col );
			if ( '' !== $col_css ) {
				$decls .= 'column-gap: ' . $col_css . ';';
			}
		}
		return $decls;
	}

	/** Positioning = Position + Overflow + ZIndex emits concatenated. */
	public static function positioning_css_builder( $args ) {
		$pseudo = self::build_pseudo_attrs( $args['sparse'], $args['prop_list'] );
		$css  = self::emit_per_device( $pseudo, array( 'Blocksify_Position_Trait', 'emit_decls' ), $args );
		$css .= self::emit_per_device( $pseudo, array( 'Blocksify_Overflow_Trait', 'emit_decls' ), $args );
		$css .= self::emit_per_device( $pseudo, array( 'Blocksify_ZIndex_Trait', 'emit_decls' ), $args );
		return $css;
	}

	// ------------------------------------------------------------------
	// Internal — per-device + hover emit helpers.
	// ------------------------------------------------------------------

	/**
	 * Walk desktop/tablet/mobile, call trait `emit_decls`, wrap in selector
	 * + @media. Mirrors JS composite preview-css pattern.
	 */
	protected static function emit_per_device( $pseudo, $emit_callable, $args ) {
		if ( ! is_callable( $emit_callable ) ) {
			return '';
		}
		$sel = self::build_selector( $args['block_name'], $args['uid'], $args['part_selector'] );
		$css = '';

		$desktop = (string) call_user_func( $emit_callable, $pseudo, 'desktop', '' );
		if ( '' !== $desktop ) {
			$css .= $sel . '{' . $desktop . '}';
		}
		$tablet = (string) call_user_func( $emit_callable, $pseudo, 'tablet', '' );
		if ( '' !== $tablet ) {
			$css .= '@media (max-width:' . self::TABLET_MAX . 'px){' . $sel . '{' . $tablet . '}}';
		}
		$mobile = (string) call_user_func( $emit_callable, $pseudo, 'mobile', '' );
		if ( '' !== $mobile ) {
			$css .= '@media (max-width:' . self::MOBILE_MAX . 'px){' . $sel . '{' . $mobile . '}}';
		}
		return $css;
	}

	/**
	 * Emit `:hover` rule for hover-aware groups.
	 */
	protected static function emit_hover( $pseudo, $emit_callable, $args ) {
		if ( ! is_callable( $emit_callable ) ) {
			return '';
		}
		$decls = (string) call_user_func( $emit_callable, $pseudo, '' );
		if ( '' === $decls ) {
			return '';
		}
		$sel = self::build_selector( $args['block_name'], $args['uid'], $args['part_selector'] );
		return $sel . ':hover{' . $decls . '}';
	}

	/**
	 * Build the per-instance selector. Mirrors `blockUidClass(blockName, uid)`
	 * from `src/shared/helpers.js` + the trait-parts `partSelector` tail.
	 * Single-class specificity (0,1,0) per FOUNDATION §9.5.
	 */
	protected static function build_selector( $block_name, $uid, $part_selector ) {
		return '.' . self::block_class_prefix( $block_name ) . '-' . $uid . $part_selector;
	}

	/**
	 * Block-class prefix with shorthand registry lookup. Mirror of JS
	 * `blockClassPrefix` (`src/shared/helpers.js`).
	 */
	protected static function block_class_prefix( $block_name ) {
		if ( ! is_string( $block_name ) || '' === $block_name ) {
			return 'bsy-block';
		}
		$segment = strpos( $block_name, '/' ) !== false
			? substr( $block_name, strpos( $block_name, '/' ) + 1 )
			: $block_name;
		$shorthand = isset( self::BLOCK_CLASS_SHORTHAND[ $segment ] )
			? self::BLOCK_CLASS_SHORTHAND[ $segment ]
			: '';
		return 'bsy-' . ( '' !== $shorthand ? $shorthand : ( '' !== $segment ? $segment : 'block' ) );
	}
}

endif;
