<?php
/**
 * Style engine — responsive overrides → CSS classes.
 * Typography/colors target the node AND text leaves so mobile/tablet
 * always win over theme CSS and any residual inline styles.
 *
 * @package AuraBuilder
 */

declare(strict_types=1);

namespace AuraBuilder\Renderer;

use AuraBuilder\Document\Document;
use AuraBuilder\Document\Node;

final class StyleEngine {

	/** @var array<string, string> Base (desktop) rules: selector => decls */
	private array $rules = array();

	/**
	 * Media rules grouped by breakpoint then selector.
	 * Avoids fragile "@media{selector}" composite keys.
	 *
	 * @var array{tablet:array<string,string>,mobile:array<string,string>}
	 */
	private array $media = array(
		'tablet' => array(),
		'mobile' => array(),
	);

	private int $bp_tablet = 1024;
	private int $bp_mobile = 767;

	public function set_breakpoints( int $tablet, int $mobile ): void {
		$this->bp_tablet = max( 480, min( 1600, $tablet ) );
		$this->bp_mobile = max( 320, min( 1200, $mobile ) );
		if ( $this->bp_mobile >= $this->bp_tablet ) {
			$this->bp_mobile = max( 320, $this->bp_tablet - 1 );
		}
	}

	public function class_for( Node $node ): string {
		return 'ab-node-' . preg_replace( '/[^a-zA-Z0-9_-]/', '', $node->id );
	}

	/**
	 * Motion/effects classes from the cascaded style bag (desktop base).
	 *
	 * @return list<string>
	 */
	public function fx_classes( Node $node ): array {
		$bag = $this->cascaded_styles( $node );
		$out = array();

		$entrance = sanitize_key( (string) ( $bag['fx_entrance'] ?? '' ) );
		$hover    = sanitize_key( (string) ( $bag['fx_hover'] ?? '' ) );
		$shadow   = sanitize_key( (string) ( $bag['fx_shadow'] ?? '' ) );

		$entrance_ok = array(
			'fadein', 'fadeinup', 'fadeindown', 'fadeinleft', 'fadeinright',
			'zoomin', 'zoominup', 'slideinup', 'slideindown', 'slideinleft', 'slideinright',
			'bouncein', 'flipinx', 'flipiny', 'rotatein', 'lightspeedin',
		);
		$hover_ok = array(
			'grow', 'shrink', 'lift', 'sink', 'float', 'pulse', 'wobble', 'buzz',
			'glow', 'brightness', 'grayscale', 'blur', 'rotate', 'skew', 'borderglow', 'border-glow',
		);
		$shadow_ok = array( 'sm', 'md', 'lg', 'xl', 'colored' );

		if ( $entrance && $entrance !== 'none' && in_array( $entrance, $entrance_ok, true ) ) {
			$out[] = 'ab-fx';
			$out[] = 'ab-fx-entrance';
			$out[] = 'ab-fx-entrance--' . $entrance;
		}
		if ( $hover && $hover !== 'none' ) {
			$h = str_replace( '_', '-', $hover );
			if ( in_array( $h, $hover_ok, true ) || in_array( $hover, $hover_ok, true ) ) {
				$out[] = 'ab-fx';
				$out[] = 'ab-fx-hover';
				$out[] = 'ab-fx-hover--' . ( $h === 'border-glow' || $hover === 'border_glow' ? 'borderglow' : $h );
			}
		}
		if ( $shadow && $shadow !== 'none' && in_array( $shadow, $shadow_ok, true ) ) {
			$out[] = 'ab-fx-shadow--' . $shadow;
		}

		// Static transform / opacity always get base class if any fx value set.
		if ( $this->has_static_fx( $bag ) ) {
			$out[] = 'ab-fx';
			$out[] = 'ab-fx-static';
		}

		return array_values( array_unique( $out ) );
	}

	/**
	 * Inline style fragment for CSS variables + transform (front + editor).
	 */
	public function fx_inline_style( Node $node ): string {
		$bag   = $this->cascaded_styles( $node );
		$parts = array();

		$dur = (int) ( $bag['fx_entrance_duration'] ?? 0 );
		if ( $dur > 0 ) {
			$parts[] = '--ab-fx-duration:' . max( 100, min( 3000, $dur ) ) . 'ms';
		}
		$delay = (int) ( $bag['fx_entrance_delay'] ?? 0 );
		if ( $delay > 0 ) {
			$parts[] = '--ab-fx-delay:' . max( 0, min( 5000, $delay ) ) . 'ms';
		}
		$hdur = (int) ( $bag['fx_hover_duration'] ?? 0 );
		if ( $hdur > 0 ) {
			$parts[] = '--ab-fx-hover-duration:' . max( 50, min( 1500, $hdur ) ) . 'ms';
		}
		$tr = (int) ( $bag['fx_transition'] ?? 0 );
		if ( $tr > 0 ) {
			$parts[] = '--ab-fx-transition:' . max( 50, min( 2000, $tr ) ) . 'ms';
		}

		$tf = $this->build_transform( $bag );
		if ( $tf !== '' ) {
			$parts[] = 'transform:' . $tf;
		}
		if ( isset( $bag['fx_opacity'] ) && $bag['fx_opacity'] !== '' && $bag['fx_opacity'] !== null ) {
			$op = $this->normalize_opacity( (string) $bag['fx_opacity'] );
			if ( $op !== null ) {
				$parts[] = 'opacity:' . $op;
			}
		}

		return $parts ? implode( ';', $parts ) : '';
	}

	/**
	 * @return array<string, mixed>
	 */
	private function cascaded_styles( Node $node ): array {
		$out = array();
		foreach ( array( 'desktop', 'tablet', 'mobile' ) as $bp ) {
			$bag = $node->styles[ $bp ] ?? null;
			if ( ! is_array( $bag ) ) {
				continue;
			}
			foreach ( $bag as $k => $v ) {
				if ( $v !== '' && $v !== null ) {
					$out[ (string) $k ] = $v;
				}
			}
		}
		// Desktop wins as base for class names / defaults.
		$desk = is_array( $node->styles['desktop'] ?? null ) ? $node->styles['desktop'] : array();
		foreach ( $desk as $k => $v ) {
			if ( $v !== '' && $v !== null ) {
				$out[ (string) $k ] = $v;
			}
		}
		return $out;
	}

	/**
	 * @param array<string, mixed> $bag
	 */
	private function has_static_fx( array $bag ): bool {
		foreach ( array( 'fx_rotate', 'fx_scale', 'fx_translate_x', 'fx_translate_y', 'fx_opacity' ) as $k ) {
			if ( isset( $bag[ $k ] ) && $bag[ $k ] !== '' && $bag[ $k ] !== null ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * @param array<string, mixed> $bag
	 */
	private function build_transform( array $bag ): string {
		$parts = array();
		if ( isset( $bag['fx_translate_x'] ) && $bag['fx_translate_x'] !== '' && $bag['fx_translate_x'] !== null ) {
			$x = $this->normalize_length( (string) $bag['fx_translate_x'] );
			if ( $x !== '' ) {
				$parts[] = 'translateX(' . $x . ')';
			}
		}
		if ( isset( $bag['fx_translate_y'] ) && $bag['fx_translate_y'] !== '' && $bag['fx_translate_y'] !== null ) {
			$y = $this->normalize_length( (string) $bag['fx_translate_y'] );
			if ( $y !== '' ) {
				$parts[] = 'translateY(' . $y . ')';
			}
		}
		if ( isset( $bag['fx_rotate'] ) && $bag['fx_rotate'] !== '' && $bag['fx_rotate'] !== null ) {
			$r = trim( (string) $bag['fx_rotate'] );
			if ( is_numeric( $r ) ) {
				$r .= 'deg';
			}
			if ( preg_match( '/^-?[\d.]+(deg|rad|turn)?$/', $r ) ) {
				$parts[] = 'rotate(' . $r . ')';
			}
		}
		if ( isset( $bag['fx_scale'] ) && $bag['fx_scale'] !== '' && $bag['fx_scale'] !== null ) {
			$s = trim( (string) $bag['fx_scale'] );
			if ( is_numeric( $s ) && (float) $s > 0 ) {
				$parts[] = 'scale(' . $s . ')';
			}
		}
		return implode( ' ', $parts );
	}

	private function normalize_length( string $v ): string {
		$v = trim( $v );
		if ( $v === '' ) {
			return '';
		}
		if ( is_numeric( $v ) ) {
			return $v . 'px';
		}
		if ( preg_match( '/^-?[\d.]+(px|%|em|rem|vh|vw)$/', $v ) ) {
			return $v;
		}
		return '';
	}

	private function normalize_opacity( string $v ): ?string {
		$v = trim( $v );
		if ( $v === '' ) {
			return null;
		}
		if ( str_ends_with( $v, '%' ) ) {
			$n = (float) substr( $v, 0, -1 );
			return (string) max( 0, min( 1, $n / 100 ) );
		}
		if ( is_numeric( $v ) ) {
			$n = (float) $v;
			if ( $n > 1 ) {
				$n = $n / 100;
			}
			return (string) max( 0, min( 1, $n ) );
		}
		return null;
	}

	/**
	 * Selector that includes text leaves (color/font must reach them).
	 * Comments form submit uses its own btn_* colors — never inherit text color.
	 *
	 * @param string $type Node type (optional).
	 */
	private function deep_selector( string $class, string $type = '' ): string {
		$c = '.' . $class;
		// Button widget paints .ab-btn itself; comments_form must NOT recolor the submit.
		$btn = ( $type === 'comments_form' )
			? ''
			: ( $c . ' .ab-btn,' . $c . ' .ab-btn span,' );
		// Layout nodes: never paint typography on the wrapper itself (editor chrome
		// + accidental inheritance). Only push font/color onto content descendants.
		$self = ( $type === 'container' || $type === 'section' || $type === 'root' )
			? ''
			: ( $c . ',' );
		return $self
			. $c . ' .ab-text,'
			. $c . ' .ab-heading,'
			. $c . ' .ab-html,'
			. $c . ' h1,'
			. $c . ' h2,'
			. $c . ' h3,'
			. $c . ' h4,'
			. $c . ' h5,'
			. $c . ' h6,'
			. $c . ' p,'
			. $btn
			. $c . ' [data-field="text"],'
			. $c . ' .ab-loop-card__title,'
			. $c . ' .ab-loop-card__title a,'
			. $c . ' .ab-loop-card__more,'
			. $c . ' .ab-loop-card__excerpt,'
			. $c . ' .ab-loop-card__meta,'
			. $c . ' .ab-loop-card__author,'
			. $c . ' .ab-icon-box__title,'
			. $c . ' .ab-icon-box__desc,'
			. $c . ' .ab-image-box__title,'
			. $c . ' .ab-image-box__desc,'
			. $c . ' .ab-info-box__title,'
			. $c . ' .ab-info-box__text,'
			. $c . ' .ab-comments-form__title,'
			. $c . ' .comment-reply-title,'
			. $c . ' .ab-comment-form label,'
			. $c . ' .ab-comment-form__logged-in,'
			. $c . ' .logged-in-as';
	}

	/**
	 * Box selector for layout/background styles.
	 * Intentionally does NOT include .ab-loop-card / __body — height, padding and
	 * min-height on the loop_grid node were leaking onto every news card
	 * (e.g. min-height:560px → huge empty gap under “Leia mais” on the site).
	 */
	private function box_selector( string $class ): string {
		return '.' . $class;
	}

	public function collect( Document $document ): void {
		foreach ( $document->nodes as $node ) {
			$this->collect_node( $node, $document );
		}
	}

	public function collect_node( Node $node, ?Document $document = null ): void {
		$class = $this->class_for( $node );
		$layout = array();

		if ( $node->type === 'root' ) {
			$layout[] = 'box-sizing:border-box';
			$layout[] = 'width:100% !important';
			// Stack sections; gap controls space between them (default 0).
			$layout[] = 'display:flex';
			$layout[] = 'flex-direction:column';
			$layout[] = 'align-items:stretch';
			$root_gap = isset( $node->settings['gap'] ) && (string) $node->settings['gap'] !== ''
				? (string) $node->settings['gap']
				: '0';
			$layout[] = 'gap:' . $root_gap;
			// Default full-width (Elementor-like); wide/content are opt-in.
			// !important: block themes force .entry-content > * { max-width: content-size }.
			$cw       = (string) ( $node->settings['content_width'] ?? 'full' );
			$max      = (string) ( $node->settings['max_width'] ?? '1200px' );
			if ( $max !== '' && ! preg_match( '/(%|px|rem|em|vw|vh|ch)$/i', $max ) && is_numeric( $max ) ) {
				$max .= 'px';
			}
			if ( $cw === 'content' ) {
				$layout[] = 'max-width:var(--wp--style--global--content-size,650px) !important';
				$layout[] = 'margin-left:auto !important';
				$layout[] = 'margin-right:auto !important';
			} elseif ( $cw === 'wide' ) {
				$layout[] = 'max-width:' . $max . ' !important';
				$layout[] = '--ab-root-max:' . $max;
				$layout[] = 'margin-left:auto !important';
				$layout[] = 'margin-right:auto !important';
			} else {
				$layout[] = 'max-width:none !important';
				$layout[] = 'margin-left:0 !important';
				$layout[] = 'margin-right:0 !important';
			}
		}

		if ( $node->type === 'container' ) {
			$dir   = (string) ( $node->settings['direction'] ?? 'column' );
			$gap   = (string) ( $node->settings['gap'] ?? '16px' );
			$width = (string) ( $node->settings['width'] ?? '100%' );
			// Resolve span from col_span or width % (75% → 9 of 12).
			$span = (int) ( $node->settings['col_span'] ?? 0 );
			if ( $span < 1 ) {
				if ( preg_match( '/([\d.]+)\s*%/', $width, $wm ) ) {
					$span = (int) max( 1, min( 12, (int) round( ( (float) $wm[1] / 100 ) * 12 ) ) );
				} else {
					$span = 12;
				}
			}
			$span = max( 1, min( 12, $span ) );

			$layout[] = 'display:flex';
			$layout[] = 'flex-direction:' . ( $dir === 'row' ? 'row' : 'column' );
			$layout[] = 'gap:' . $gap;
			$layout[] = 'min-width:0';
			$layout[] = 'box-sizing:border-box';
			// Child alignment (container settings).
			$just_c = trim( (string) ( $node->settings['justify_content'] ?? '' ) );
			$align_c = trim( (string) ( $node->settings['align_items'] ?? '' ) );
			$allowed_just  = array( 'flex-start', 'center', 'flex-end', 'space-between', 'space-around', 'space-evenly', 'start', 'end' );
			$allowed_align = array( 'flex-start', 'center', 'flex-end', 'stretch', 'baseline', 'start', 'end' );
			if ( $just_c === '' || ! in_array( $just_c, $allowed_just, true ) ) {
				$just_c = 'flex-start';
			}
			if ( $align_c === '' || ! in_array( $align_c, $allowed_align, true ) ) {
				$align_c = $dir === 'row' ? 'flex-start' : 'stretch';
			}
			$layout[] = 'justify-content:' . $just_c;
			$layout[] = 'align-items:' . $align_c;
			$layout[] = '--ab-row-justify:' . $just_c;
			$layout[] = '--ab-row-align:' . $align_c;
			// Horizontal row: hug content height (no tall empty block).
			if ( $dir === 'row' ) {
				$layout[] = 'flex-wrap:wrap';
				$layout[] = 'height:auto';
				$layout[] = 'min-height:0';
			}

			$parent_grid = false;
			$parent_row  = false;
			$parent_id   = $node->parent_id ?? null;
			if ( $document && $parent_id ) {
				$parent = $document->get_node( (string) $parent_id );
				if ( $parent && $parent->type === 'section' && ( $parent->settings['direction'] ?? '' ) === 'grid' ) {
					$parent_grid = true;
					// 12-col system: span drives width (do not clamp to 1).
					$layout[] = 'grid-column:span ' . $span;
					$layout[] = 'width:100%';
					$layout[] = 'max-width:100%';
					// Stretch so sibling cards match height (buttons pin to bottom via CSS).
					// align-self:start made site cards uneven vs the editor.
					$layout[] = 'align-self:stretch';
					$layout[] = 'height:auto';
					$layout[] = 'min-height:0';
				} elseif (
					$parent
					&& (
						( $parent->type === 'container' && ( $parent->settings['direction'] ?? '' ) === 'row' )
						|| ( $parent->type === 'section' && ( $parent->settings['direction'] ?? '' ) === 'row' )
					)
				) {
					$parent_row = true;
				}
			}
			if ( ! $parent_grid ) {
				if ( $parent_row ) {
					// Main axis = horizontal. Use flex-grow from col_span + basis 0
					// so gap does not force the last column onto a new line
					// (width:25%×4 + gaps was wrapping “Colunas (4)”).
					if ( $width === 'auto' ) {
						$layout[] = 'width:auto';
						$layout[] = 'flex:0 1 auto';
						$layout[] = 'max-width:100%';
					} else {
						$layout[] = 'flex:' . $span . ' 1 0%';
						$layout[] = 'min-width:0';
						$layout[] = 'width:auto';
						$layout[] = 'max-width:100%';
					}
					$layout[] = 'height:auto';
					$layout[] = 'min-height:0';
				} else {
					// Parent is a column stack: NEVER flex:0 0 100% — that uses HEIGHT
					// as the main axis and creates a huge vertical gap under a “Linha”.
					$layout[] = 'width:' . $width;
					$layout[] = 'max-width:' . $width;
					$layout[] = 'flex:0 0 auto';
					$layout[] = 'height:auto';
					$layout[] = 'min-height:0';
					$layout[] = 'align-self:stretch';
				}
			}
		}

		if ( $node->type === 'section' ) {
			$layout[] = 'width:100%';
			$layout[] = 'box-sizing:border-box';
			// Horizontal gutters live on .ab-content-wrap / .ab-page-body so editor
			// and front stay aligned. Default full; boxed centers with max-width.
			if ( ( $node->settings['layout'] ?? 'full' ) === 'boxed' ) {
				$layout[] = 'max-width:var(--ab-content-max,1140px)';
				$layout[] = 'width:var(--ab-content-width,90%)';
				// Do not force horizontal auto if the user set a custom margin (4-side control).
				$has_user_margin = false;
				foreach ( array( 'desktop', 'tablet', 'mobile' ) as $bp_m ) {
					$bag_m = is_array( $node->styles[ $bp_m ] ?? null ) ? $node->styles[ $bp_m ] : array();
					if ( ! empty( $bag_m['margin'] ) ) {
						$has_user_margin = true;
						break;
					}
				}
				if ( ! $has_user_margin ) {
					$layout[] = 'margin-left:auto';
					$layout[] = 'margin-right:auto';
				}
			} else {
				$layout[] = 'max-width:none';
			}

			$dir = (string) ( $node->settings['direction'] ?? 'column' );
			$gap = (string) ( $node->settings['gap'] ?? '16px' );
			// Align from style bag when set (header: center + space-between).
			$desk  = is_array( $node->styles['desktop'] ?? null ) ? $node->styles['desktop'] : array();
			$align = trim( (string) ( $desk['align_items'] ?? '' ) );
			$just  = trim( (string) ( $desk['justify_content'] ?? '' ) );

			if ( $dir === 'grid' ) {
				// Always 12 tracks so column containers use col_span for 75/25 etc.
				$layout[] = 'display:grid';
				$layout[] = 'grid-template-columns:repeat(12,minmax(0,1fr))';
				$layout[] = 'gap:' . $gap;
				$layout[] = 'align-items:' . ( $align !== '' ? $align : 'stretch' );
				if ( $just !== '' ) {
					$layout[] = 'justify-content:' . $just;
				}
				// Mobile default: stack to 1 column so multi-col grids don't squash.
				$this->append_media(
					'mobile',
					'.' . $class,
					'grid-template-columns:1fr !important'
				);
				$this->append_media(
					'mobile',
					'.' . $class . ' > .ab-type-container',
					'grid-column:1 / -1 !important;width:100% !important;max-width:100% !important;flex:none !important'
				);
				// Leaves directly in grid also full-width on mobile.
				$this->append_media(
					'mobile',
					'.' . $class . ' > .ab-node:not(.ab-type-container)',
					'grid-column:1 / -1 !important;width:100% !important;max-width:100% !important'
				);
			} elseif ( $dir === 'row' ) {
				$layout[] = 'display:flex';
				$layout[] = 'flex-direction:row';
				$layout[] = 'flex-wrap:wrap';
				$layout[] = 'align-items:' . ( $align !== '' ? $align : 'center' );
				$layout[] = 'justify-content:' . ( $just !== '' ? $just : 'space-between' );
				$layout[] = 'gap:' . $gap;
				// Mobile: stack row sections vertically.
				$this->append_media(
					'mobile',
					'.' . $class,
					'flex-direction:column !important'
				);
				$this->append_media(
					'mobile',
					'.' . $class . ' > .ab-type-container',
					'width:100% !important;max-width:100% !important;flex:0 0 auto !important'
				);
				$this->append_media(
					'mobile',
					'.' . $class . ' > .ab-node:not(.ab-type-container)',
					'width:100% !important;max-width:100% !important;flex:0 0 auto !important'
				);
			} else {
				$layout[] = 'display:flex';
				$layout[] = 'flex-direction:column';
				$layout[] = 'align-items:' . ( $align !== '' ? $align : 'stretch' );
				$layout[] = 'gap:' . $gap;
			}
		}

		// Leaf widgets as direct children of a section (header: logo + menu).
		if (
			$document
			&& $node->parent_id
			&& ! in_array( $node->type, array( 'section', 'container', 'root' ), true )
		) {
			$parent = $document->get_node( $node->parent_id );
			if ( $parent && $parent->type === 'section' ) {
				$pdir = (string) ( $parent->settings['direction'] ?? 'column' );
				if ( $pdir === 'grid' ) {
					// Full row — avoid 1/12 track squash.
					$layout[] = 'grid-column:1 / -1';
					$layout[] = 'max-width:100%';
				} elseif ( $pdir === 'row' ) {
					if ( $node->type === 'nav_menu' ) {
						$layout[] = 'flex:1 1 auto';
						$layout[] = 'min-width:0';
						$layout[] = 'width:auto';
						$layout[] = 'max-width:100%';
					} else {
						$layout[] = 'flex:0 1 auto';
						$layout[] = 'width:auto';
						$layout[] = 'max-width:100%';
					}
				}
			}
		}

		// Layout on the node itself only.
		if ( $layout ) {
			$this->rules[ '.' . $class ] = implode( ';', $layout );
		}

		// Per-breakpoint visual styles (color, bg, font, spacing…).
		foreach ( array( 'desktop', 'tablet', 'mobile' ) as $bp ) {
			$bag = $node->styles[ $bp ] ?? null;
			if ( is_object( $bag ) ) {
				$bag = (array) $bag;
			}
			if ( empty( $bag ) || ! is_array( $bag ) ) {
				continue;
			}

			// image_box: contorno vem do switch settings.border — não do estilo genérico.
			if ( $node->type === 'image_box' && (string) ( $node->settings['border'] ?? '0' ) !== '1' ) {
				unset( $bag['border'] );
			}
			// Root: largura só por settings.content_width — ignora styles width/max_width
			// (evita “voltar” para 1200px no footer/header quando o modo é 100%).
			if ( $node->type === 'root' ) {
				unset( $bag['max_width'], $bag['width'] );
			}

			$box_decls  = $this->map_styles( $bag, false );
			$text_decls = $this->map_styles( $bag, true );

			// max_width + width 100% with margin L/R = 0 leaves a dead strip on the right
			// (block stays left-aligned). Auto-center unless user set a real horizontal margin.
			$max_w = isset( $bag['max_width'] ) ? trim( (string) $bag['max_width'] ) : '';
			if ( $max_w !== '' && $max_w !== 'none' && $max_w !== '100%' && $node->type !== 'root' ) {
				$m_raw = isset( $bag['margin'] ) ? trim( (string) $bag['margin'] ) : '';
				if ( $this->margin_horizontal_is_zero( $m_raw ) ) {
					$box_decls[] = 'margin-left:auto !important';
					$box_decls[] = 'margin-right:auto !important';
				}
			}
			// Explicit full width without max_width → force no cap (beats stale CSS).
			$w_bag = isset( $bag['width'] ) ? trim( (string) $bag['width'] ) : '';
			if (
				( $w_bag === '100%' || $w_bag === '100' )
				&& ( $max_w === '' || $max_w === 'none' || $max_w === '100%' )
				&& $node->type !== 'root'
			) {
				$box_decls[] = 'width:100% !important';
				$box_decls[] = 'max-width:none !important';
			}

			if ( $node->type === 'image_box' && (string) ( $node->settings['border'] ?? '0' ) !== '1' ) {
				$box_decls[] = 'border:none !important';
			}

			if ( ! empty( $bag['hide'] ) ) {
				$box_decls[]  = 'display:none !important';
				$text_decls[] = 'display:none !important';
			}

			// Mirror z-index into --ab-z for editor selection elevation.
			if ( isset( $bag['z_index'] ) && is_scalar( $bag['z_index'] ) && (string) $bag['z_index'] !== '' ) {
				$box_decls[] = '--ab-z:' . preg_replace( '/[^\d\-]/', '', (string) $bag['z_index'] );
			}
			// Radius must clip children (images/galleries paint square over rounded boxes).
			if ( isset( $bag['radius'] ) && is_scalar( $bag['radius'] ) ) {
				$rad = trim( (string) $bag['radius'] );
				if ( $rad !== '' && $rad !== '0' && $rad !== '0px' ) {
					$box_decls[] = 'overflow:hidden';
					$box_decls[] = '--ab-radius:' . $rad;
				}
			}

			// Media with fixed height: fill the node box (parity with editor live resize).
			$h_val = '';
			if ( isset( $bag['height'] ) && is_scalar( $bag['height'] ) ) {
				$h_val = trim( (string) $bag['height'] );
			}
			if ( $h_val === '' && isset( $bag['min_height'] ) && is_scalar( $bag['min_height'] ) ) {
				$h_val = trim( (string) $bag['min_height'] );
			}
			$media_fill = array( 'image', 'gallery', 'carousel', 'image_carousel_pro', 'image_float', 'image_overlay', 'video', 'preview_card' );
			$fill_sel   = '';
			$fill_body  = '';
			if ( $h_val !== '' && $h_val !== 'auto' && in_array( $node->type, $media_fill, true ) ) {
				$box_decls[] = 'overflow:hidden';
				if ( $node->type === 'image' ) {
					// Fixed-height image boxes fill the frame. Explicit contain/fill/none honored.
					$fit_raw = isset( $node->settings['object_fit'] ) ? sanitize_key( (string) $node->settings['object_fit'] ) : '';
					$fit     = in_array( $fit_raw, array( 'contain', 'cover', 'fill', 'none' ), true ) ? $fit_raw : 'cover';
					if ( $fit_raw === '' ) {
						$fit = 'cover';
					}
					// Focus of the crop — "X% Y%" from focal picker or keywords.
					$pos = strtolower( trim( (string) ( $node->settings['object_position'] ?? '50% 50%' ) ) );
					$pos = preg_replace( '/\s+/', ' ', $pos ) ?? '50% 50%';
					if ( preg_match( '/^(-?\d+(?:\.\d+)?)\s*%?\s+(-?\d+(?:\.\d+)?)\s*%?$/', $pos, $pm ) ) {
						$pos = round( max( 0, min( 100, (float) $pm[1] ) ) ) . '% ' . round( max( 0, min( 100, (float) $pm[2] ) ) ) . '%';
					} else {
						$pos_ok = array(
							'center center',
							'center top',
							'center bottom',
							'left center',
							'right center',
							'left top',
							'right top',
							'left bottom',
							'right bottom',
							'top',
							'bottom',
							'left',
							'right',
							'center',
						);
						if ( ! in_array( $pos, $pos_ok, true ) ) {
							$pos = '50% 50%';
						}
						if ( $pos === 'top' || $pos === 'bottom' ) {
							$pos = 'center ' . $pos;
						} elseif ( $pos === 'left' || $pos === 'right' ) {
							$pos = $pos . ' center';
						} elseif ( $pos === 'center' ) {
							$pos = 'center center';
						}
					}
					$box_decls[] = '--ab-img-pos:' . $pos;
					$fill_sel    = '.' . $class . ' .ab-image,.' . $class . ' .ab-image img,.' . $class . ' img';
					$fill_body   = 'width:100%!important;max-width:100%!important;height:100%!important;object-fit:' . $fit . '!important;object-position:' . $pos . '!important;display:block!important;box-sizing:border-box!important;vertical-align:top!important';
				} elseif ( $node->type === 'gallery' ) {
					$fill_sel  = '.' . $class . ' .ab-gallery';
					$fill_body = 'height:100%;width:100%;max-width:100%;min-height:0;box-sizing:border-box';
					// Item + img fill rules applied via second append below.
				} elseif ( $node->type === 'carousel' || $node->type === 'image_carousel_pro' ) {
					$fill_sel  = '.' . $class . ' .ab-carousel,.' . $class . ' .ab-img-carousel';
					$fill_body = 'height:100%!important;width:100%;max-width:100%;box-sizing:border-box;--ab-carousel-h:' . $h_val . ( preg_match( '/^\d+$/', $h_val ) ? 'px' : '' );
				} else {
					$fill_sel  = '.' . $class . ' img';
					$fill_body = 'width:100%!important;height:100%!important;object-fit:cover;display:block';
				}
			}

			if ( ! $box_decls && ! $text_decls && $fill_body === '' ) {
				continue;
			}

			// Buttons: paint the .ab-btn face, not the 100%-wide wrapper.
			// High specificity to beat ThemeStyles `.ab-document a.ab-btn{color:white!important}`.
			if ( $node->type === 'button' ) {
				$self = '.' . $class . ' a.ab-btn,'
					. '.' . $class . ' a.ab-btn.wp-element-button,'
					. '.' . $class . ' .ab-btn,'
					. '.' . $class . ' .ab-btn-wrap .ab-btn';
			} else {
				$self = $this->box_selector( $class );
			}
			$deep = $this->deep_selector( $class, (string) $node->type );

			// Expose custom button colors as CSS vars (ThemeStyles/front.css read them).
			$btn_var_sel = '.' . $class . ',.' . $class . ' .ab-btn,.' . $class . ' a.ab-btn,.' . $class . ' a.ab-btn.wp-element-button';
			$btn_vars    = '';
			if ( $node->type === 'button' ) {
				$vars = array();
				if ( isset( $bag['color'] ) && is_scalar( $bag['color'] ) && trim( (string) $bag['color'] ) !== '' ) {
					$vars[] = '--ab-btn-color:' . trim( (string) $bag['color'] );
				}
				if ( isset( $bag['background'] ) && is_scalar( $bag['background'] ) && trim( (string) $bag['background'] ) !== '' ) {
					$vars[] = '--ab-btn-bg:' . trim( (string) $bag['background'] );
				}
				if ( $vars ) {
					$btn_vars = implode( ';', $vars );
				}
			}

			if ( $bp === 'desktop' ) {
				if ( $btn_vars !== '' ) {
					$this->append_rule( $btn_var_sel, $btn_vars );
				}
				if ( $box_decls ) {
					$this->append_rule( $self, implode( ';', $box_decls ) );
				}
				if ( $text_decls ) {
					$this->append_rule( $deep, implode( ';', $text_decls ) );
				}
				if ( $fill_sel && $fill_body ) {
					$this->append_rule( $fill_sel, $fill_body );
					if ( $node->type === 'gallery' ) {
						$this->append_rule(
							'.' . $class . ' .ab-gallery--grid',
							'height:100%;grid-auto-rows:minmax(0,1fr);align-content:stretch'
						);
						$this->append_rule(
							'.' . $class . ' .ab-gallery__item',
							'aspect-ratio:auto;height:100%;min-height:0;overflow:hidden'
						);
						$this->append_rule(
							'.' . $class . ' .ab-gallery__item img',
							'width:100%!important;height:100%!important;object-fit:cover;display:block'
						);
					}
				}
			} elseif ( $bp === 'tablet' ) {
				// Tablet and below; mobile rules override later.
				if ( $btn_vars !== '' ) {
					$this->append_media( 'tablet', $btn_var_sel, $btn_vars );
				}
				if ( $box_decls ) {
					$this->append_media( 'tablet', $self, implode( ';', $box_decls ) );
				}
				if ( $text_decls ) {
					$this->append_media( 'tablet', $deep, implode( ';', $text_decls ) );
				}
				if ( $fill_sel && $fill_body ) {
					$this->append_media( 'tablet', $fill_sel, $fill_body );
				}
			} else {
				if ( $btn_vars !== '' ) {
					$this->append_media( 'mobile', $btn_var_sel, $btn_vars );
				}
				if ( $box_decls ) {
					$this->append_media( 'mobile', $self, implode( ';', $box_decls ) );
				}
				if ( $text_decls ) {
					$this->append_media( 'mobile', $deep, implode( ';', $text_decls ) );
				}
				if ( $fill_sel && $fill_body ) {
					$this->append_media( 'mobile', $fill_sel, $fill_body );
				}
			}
		}
	}

	private function append_rule( string $selector, string $body ): void {
		if ( isset( $this->rules[ $selector ] ) ) {
			$this->rules[ $selector ] .= ';' . $body;
		} else {
			$this->rules[ $selector ] = $body;
		}
	}

	private function append_media( string $bp, string $selector, string $body ): void {
		if ( $bp !== 'tablet' && $bp !== 'mobile' ) {
			return;
		}
		if ( isset( $this->media[ $bp ][ $selector ] ) ) {
			$this->media[ $bp ][ $selector ] .= ';' . $body;
		} else {
			$this->media[ $bp ][ $selector ] = $body;
		}
	}

	public function css(): string {
		$out = array();
		foreach ( $this->rules as $selector => $body ) {
			$out[] = $selector . '{' . $body . '}';
		}

		// Tablet first, then mobile so mobile wins when both match.
		if ( ! empty( $this->media['tablet'] ) ) {
			$inner = array();
			foreach ( $this->media['tablet'] as $sel => $body ) {
				$inner[] = $sel . '{' . $body . '}';
			}
			$out[] = '@media (max-width:' . $this->bp_tablet . 'px){' . implode( '', $inner ) . '}';
		}
		if ( ! empty( $this->media['mobile'] ) ) {
			$inner = array();
			foreach ( $this->media['mobile'] as $sel => $body ) {
				$inner[] = $sel . '{' . $body . '}';
			}
			$out[] = '@media (max-width:' . $this->bp_mobile . 'px){' . implode( '', $inner ) . '}';
		}

		return implode( '', $out );
	}

	/**
	 * @param array<string, mixed> $styles
	 * @param bool                 $text_only If true, only color/font properties (for deep selector).
	 * @return list<string>
	 */
	private function map_styles( array $styles, bool $text_only = false ): array {
		$text_keys = array(
			'color',
			'font_size',
			'font_weight',
			'font_family',
			'font_style',
			'line_height',
			'letter_spacing',
			'text_align',
			'text_transform',
			'text_decoration',
		);

		$map = array(
			'margin'          => 'margin',
			'padding'         => 'padding',
			'width'           => 'width',
			'height'          => 'height',
			'max_width'       => 'max-width',
			'min_height'      => 'min-height',
			'z_index'         => 'z-index',
			'position'        => 'position',
			'background'      => 'background-color',
			'color'           => 'color',
			'font_size'       => 'font-size',
			'font_weight'     => 'font-weight',
			'font_family'     => 'font-family',
			'font_style'      => 'font-style',
			'line_height'     => 'line-height',
			'letter_spacing'  => 'letter-spacing',
			'text_align'      => 'text-align',
			'text_transform'  => 'text-transform',
			'text_decoration' => 'text-decoration',
			'border'          => 'border',
			'border_width'    => 'border-width',
			'border_style'    => 'border-style',
			'border_color'    => 'border-color',
			'radius'          => 'border-radius',
			'opacity'         => 'opacity',
			'display'         => 'display',
			'gap'             => 'gap',
			'flex'            => 'flex',
			'bg_image'        => 'background-image',
			'bg_size'         => 'background-size',
			'bg_position'     => 'background-position',
			'bg_repeat'       => 'background-repeat',
			'bg_attachment'   => 'background-attachment',
			// Motion effects → CSS variables (classes applied separately).
			'fx_entrance_duration' => '--ab-fx-duration',
			'fx_entrance_delay'    => '--ab-fx-delay',
			'fx_hover_duration'    => '--ab-fx-hover-duration',
			'fx_transition'        => '--ab-fx-transition',
		);

		$decls = array();
		// Static transform + opacity from fx_* (box only).
		if ( ! $text_only ) {
			$tf = $this->build_transform( $styles );
			if ( $tf !== '' ) {
				$decls[] = 'transform:' . $tf;
			}
			if ( isset( $styles['fx_opacity'] ) && $styles['fx_opacity'] !== '' && $styles['fx_opacity'] !== null ) {
				$op = $this->normalize_opacity( (string) $styles['fx_opacity'] );
				if ( $op !== null ) {
					$decls[] = 'opacity:' . $op;
				}
			}
		}

		foreach ( $styles as $k => $v ) {
			$key = sanitize_key( (string) $k );
			// Skip fx keys handled as classes / transform.
			if ( in_array( $key, array( 'fx_entrance', 'fx_hover', 'fx_shadow', 'fx_rotate', 'fx_scale', 'fx_translate_x', 'fx_translate_y', 'fx_opacity' ), true ) ) {
				continue;
			}
			// Section layout (display/flex) is owned by settings.direction — do not
			// re-apply bag "display" or it fights grid/row rules.
			if ( $key === 'display' || $key === 'align_items' || $key === 'justify_content' ) {
				continue;
			}
			if ( $key === 'hide' || ! isset( $map[ $key ] ) || ! is_scalar( $v ) || is_bool( $v ) ) {
				continue;
			}
			// Deep path = color/font only. NEVER push background/bg_* here:
			// section background was leaking onto every .ab-heading / .ab-text / .ab-btn
			// (navy bars behind hero titles on the public page).
			if ( $text_only && ! in_array( $key, $text_keys, true ) ) {
				continue;
			}
			// Also never put bg_image/size/position on deep text selectors.
			if ( $text_only && str_starts_with( $key, 'bg_' ) ) {
				continue;
			}
			// box rules: include text keys too (color inherits on wrapper).

			$val = trim( (string) $v );
			if ( $val === '' ) {
				continue;
			}

			if ( $key === 'bg_image' ) {
				// Handled below with gradient layering — skip bare pass.
				continue;
			}
			// Gradient / solid background handled after loop for correct layering.
			if ( in_array( $key, array( 'bg_type', 'bg_grad_kind', 'bg_grad_angle', 'bg_grad_stops', 'bg_gradient' ), true ) ) {
				continue;
			}
			if ( $key === 'background' ) {
				// Applied after we know if gradient is active.
				continue;
			}

			// fx duration vars need unit (ms).
			if ( in_array( $key, array( 'fx_entrance_duration', 'fx_entrance_delay', 'fx_hover_duration', 'fx_transition' ), true ) ) {
				if ( ! is_numeric( $val ) ) {
					continue;
				}
				$n = (int) $val;
				if ( $key === 'fx_entrance_duration' ) {
					$n = max( 100, min( 3000, $n ) );
				} elseif ( $key === 'fx_entrance_delay' ) {
					$n = max( 0, min( 5000, $n ) );
				} else {
					$n = max( 50, min( 2000, $n ) );
				}
				$decls[] = $map[ $key ] . ':' . $n . 'ms';
				continue;
			}

			if ( ! preg_match( '/^(#([0-9a-fA-F]{3,8})|rgba?\([^)]+\)|hsla?\([^)]+\)|var\([^)]+\)|[\w\s\-.,%()\/"\':+]+)$/', $val ) ) {
				$val = preg_replace( '/[^\w\s\-#.,%()\/"\':+]/', '', $val ) ?? '';
			}
			if ( $val === '' ) {
				continue;
			}

			// Button/link text: also set -webkit-text-fill-color so theme
			// visited/link styles and WebKit cannot keep red fill.
			if ( $key === 'color' ) {
				$decls[] = 'color:' . $val . ' !important';
				$decls[] = '-webkit-text-fill-color:' . $val . ' !important';
				// CSS var for ThemeStyles / front.css defaults (white on solid buttons).
				$decls[] = '--ab-btn-color:' . $val;
				continue;
			}

			// Quote multi-word font names: Roboto → "Roboto", sans-serif
			if ( $key === 'font_family' ) {
				$val = $this->normalize_font_family( $val );
				if ( $val === '' ) {
					continue;
				}
			}

			// !important so tablet/mobile beat theme + leftover desktop rules.
			// margin/padding must beat section defaults and theme margin-inline:auto.
			$imp = in_array(
				$key,
				array(
					'color',
					'background',
					'font_size',
					'font_family',
					'font_weight',
					'text_align',
					'text_transform',
					'bg_image',
					'bg_size',
					'bg_position',
					'bg_repeat',
					'bg_attachment',
					'width',
					'max_width',
					'display',
					'margin',
					'padding',
					'height',
					'min_height',
					'radius',
					'border',
					'border_width',
					'border_style',
					'border_color',
				),
				true
			)
				? ' !important'
				: '';

			// background-color only — NEVER the "background" shorthand (resets image).
			$decls[] = $map[ $key ] . ':' . $val . $imp;
			if ( $key === 'color' ) {
				$decls[] = '-webkit-text-fill-color:' . $val . $imp;
			}
		}

		if ( ! $text_only ) {
			$bg_decls = $this->build_background_decls( $styles );
			foreach ( $bg_decls as $d ) {
				$decls[] = $d;
			}
		}

		return $decls;
	}

	/**
	 * Solid / gradient / image layers for background (box only).
	 *
	 * @param array<string, mixed> $styles
	 * @return list<string>
	 */
	private function build_background_decls( array $styles ): array {
		$decls = array();
		$grad  = $this->resolve_gradient( $styles );
		$img   = '';
		if ( ! empty( $styles['bg_image'] ) && is_scalar( $styles['bg_image'] ) ) {
			$url = esc_url_raw( (string) $styles['bg_image'] );
			if ( $url !== '' ) {
				$safe = str_replace( array( '\\', "'" ), array( '\\\\', "\\'" ), $url );
				$img  = "url('" . $safe . "')";
			}
		}
		$layers = array();
		if ( $img !== '' ) {
			$layers[] = $img;
		}
		if ( $grad !== '' ) {
			$layers[] = $grad;
		}
		if ( $layers ) {
			$decls[] = 'background-image:' . implode( ', ', $layers ) . ' !important';
			if ( $img !== '' ) {
				if ( empty( $styles['bg_size'] ) ) {
					$decls[] = 'background-size:cover !important';
				}
				if ( empty( $styles['bg_position'] ) ) {
					$decls[] = 'background-position:center center !important';
				}
				if ( empty( $styles['bg_repeat'] ) ) {
					$decls[] = 'background-repeat:no-repeat !important';
				}
			}
		}
		$solid = isset( $styles['background'] ) && is_scalar( $styles['background'] )
			? trim( (string) $styles['background'] )
			: '';
		if ( $grad !== '' ) {
			// Fallback under gradient: first stop color if available.
			$first = $this->first_grad_color( $styles );
			if ( $first !== '' ) {
				$decls[] = 'background-color:' . $first . ' !important';
				$decls[] = '--ab-btn-bg:' . $first;
			} elseif ( $solid !== '' ) {
				$decls[] = 'background-color:' . $solid . ' !important';
				$decls[] = '--ab-btn-bg:' . $solid;
			}
		} elseif ( $solid !== '' ) {
			$decls[] = 'background-color:' . $solid . ' !important';
			$decls[] = '--ab-btn-bg:' . $solid;
		}
		return $decls;
	}

	/**
	 * @param array<string, mixed> $styles
	 */
	private function resolve_gradient( array $styles ): string {
		$type = sanitize_key( (string) ( $styles['bg_type'] ?? '' ) );
		$raw  = isset( $styles['bg_gradient'] ) ? trim( (string) $styles['bg_gradient'] ) : '';
		if ( $type === 'solid' ) {
			return '';
		}
		if ( $raw !== '' && preg_match( '/^(linear|radial)-gradient\s*\(/i', $raw ) ) {
			// Allow safe gradient CSS.
			$clean = preg_replace( '/[^\w\s\-#.,%()\/"\':+]/', '', $raw ) ?? '';
			return $clean;
		}
		// Build from stops.
		$stops_raw = isset( $styles['bg_grad_stops'] ) ? (string) $styles['bg_grad_stops'] : '';
		if ( $stops_raw === '' || $type !== 'gradient' ) {
			return '';
		}
		$stops = json_decode( $stops_raw, true );
		if ( ! is_array( $stops ) || count( $stops ) < 2 ) {
			return '';
		}
		$parts = array();
		foreach ( $stops as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$c = trim( (string) ( $row['c'] ?? $row['color'] ?? '' ) );
			$p = isset( $row['p'] ) ? (int) $row['p'] : ( isset( $row['pos'] ) ? (int) $row['pos'] : 0 );
			if ( $c === '' ) {
				continue;
			}
			// Allow hex / rgba / hsla only.
			if ( ! preg_match( '/^(#([0-9a-fA-F]{3,8})|rgba?\([^)]+\)|hsla?\([^)]+\))$/', $c ) ) {
				continue;
			}
			$p       = max( 0, min( 100, $p ) );
			$parts[] = $c . ' ' . $p . '%';
		}
		if ( count( $parts ) < 2 ) {
			return '';
		}
		$kind  = sanitize_key( (string) ( $styles['bg_grad_kind'] ?? 'linear' ) );
		$angle = (int) ( $styles['bg_grad_angle'] ?? 135 );
		$angle = ( ( $angle % 360 ) + 360 ) % 360;
		if ( $kind === 'radial' ) {
			return 'radial-gradient(circle at center, ' . implode( ', ', $parts ) . ')';
		}
		return 'linear-gradient(' . $angle . 'deg, ' . implode( ', ', $parts ) . ')';
	}

	/**
	 * True when margin shorthand leaves left/right as 0/empty (not auto).
	 * Used to auto-center blocks that have max_width but only a top margin.
	 */
	private function margin_horizontal_is_zero( string $margin ): bool {
		$margin = strtolower( trim( (string) ( preg_replace( '/\s+/', ' ', $margin ) ?? '' ) ) );
		if ( $margin === '' ) {
			return true;
		}
		$parts = preg_split( '/\s+/', $margin ) ?: array();
		$n     = count( $parts );
		// 1 value → all sides; non-zero means user set every side.
		if ( $n === 1 ) {
			return $parts[0] === '0' || $parts[0] === '0px';
		}
		$left  = '0';
		$right = '0';
		if ( $n === 2 || $n === 3 ) {
			// TB LR  or  T LR B
			$left = $right = $parts[1];
		} elseif ( $n >= 4 ) {
			// T R B L
			$right = $parts[1];
			$left  = $parts[3];
		}
		if ( $left === 'auto' || $right === 'auto' ) {
			return false;
		}
		$zero = static function ( string $v ): bool {
			return $v === '0' || $v === '0px' || $v === '';
		};
		return $zero( $left ) && $zero( $right );
	}

	/**
	 * @param array<string, mixed> $styles
	 */
	private function first_grad_color( array $styles ): string {
		$raw = isset( $styles['bg_grad_stops'] ) ? (string) $styles['bg_grad_stops'] : '';
		if ( $raw === '' ) {
			return '';
		}
		$stops = json_decode( $raw, true );
		if ( ! is_array( $stops ) || empty( $stops[0] ) || ! is_array( $stops[0] ) ) {
			return '';
		}
		$c = trim( (string) ( $stops[0]['c'] ?? $stops[0]['color'] ?? '' ) );
		if ( $c !== '' && preg_match( '/^(#([0-9a-fA-F]{3,8})|rgba?\([^)]+\)|hsla?\([^)]+\))$/', $c ) ) {
			return $c;
		}
		return '';
	}

	/**
	 * Normalize font-family CSS value; quote multi-word names.
	 */
	private function normalize_font_family( string $val ): string {
		$val = trim( $val );
		if ( $val === '' || $val === 'custom' ) {
			return '';
		}
		// Already a CSS var stack or full stack with quotes.
		if ( str_starts_with( $val, 'var(' ) || str_contains( $val, ',' ) ) {
			return $val;
		}
		// Single family name like Roboto or Open Sans.
		$name = trim( $val, " \t\n\r\0\x0B\"'" );
		if ( $name === '' ) {
			return '';
		}
		if ( preg_match( '/\s/', $name ) ) {
			return '"' . $name . '", sans-serif';
		}
		// Generic keywords stay bare.
		$generic = array( 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'inherit', 'initial' );
		if ( in_array( strtolower( $name ), $generic, true ) ) {
			return strtolower( $name );
		}
		return '"' . $name . '", sans-serif';
	}

	/**
	 * Collect Google Font family names used in the document styles.
	 *
	 * @return list<string>
	 */
	public function google_font_families( Document $document ): array {
		$skip = array(
			'system-ui',
			'georgia',
			'arial',
			'helvetica',
			'helvetica neue',
			'times',
			'times new roman',
			'verdana',
			'tahoma',
			'trebuchet ms',
			'courier',
			'courier new',
			'inherit',
			'serif',
			'sans-serif',
			'monospace',
			'cursive',
			'fantasy',
			'segoe ui',
			'ui-sans-serif',
			'ui-serif',
			'ui-monospace',
			'impact',
			'comic sans ms',
		);
		$found = array();
		foreach ( $document->nodes as $node ) {
			if ( ! $node instanceof Node ) {
				continue;
			}
			foreach ( array( 'desktop', 'tablet', 'mobile' ) as $bp ) {
				$bag = $node->styles[ $bp ] ?? null;
				if ( ! is_array( $bag ) ) {
					continue;
				}
				$ff = isset( $bag['font_family'] ) ? trim( (string) $bag['font_family'] ) : '';
				if ( $ff === '' || str_starts_with( $ff, 'var(' ) ) {
					continue;
				}
				$first = trim( explode( ',', $ff )[0], " \t\"'" );
				if ( $first === '' ) {
					continue;
				}
				$low = strtolower( $first );
				if ( in_array( $low, $skip, true ) ) {
					continue;
				}
				$found[ $first ] = true;
			}
		}
		return array_keys( $found );
	}

	/**
	 * @param list<string> $families
	 */
	public static function google_fonts_link( array $families ): string {
		if ( ! $families ) {
			return '';
		}
		$parts = array();
		foreach ( $families as $f ) {
			$f = trim( $f );
			if ( $f === '' ) {
				continue;
			}
			$parts[] = 'family=' . rawurlencode( $f ) . ':wght@300;400;500;600;700;800';
		}
		if ( ! $parts ) {
			return '';
		}
		return 'https://fonts.googleapis.com/css2?' . implode( '&', $parts ) . '&display=swap';
	}
}
