<?php
/**
 * Validation and merge helpers for the Global Styles ability.
 *
 * @package WPAgentCompanion
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/* ---------------------------------------------------------------------------
 * Validierung (strikt, defense-in-depth)
 * ------------------------------------------------------------------------- */

/**
 * Validiert einen Preset-Slug: nur [a-z0-9-], max. 40 Zeichen, nicht leer und kein
 * fuehrender oder abschliessender Bindestrich. Rein numerische Slugs (z. B. "40" fuer
 * die Abstands-Skala von Twenty Twenty-Five) sind bewusst erlaubt, weil WordPress
 * selbst solche Preset-Slugs vergibt und ein Design-System-Writer sie ersetzen koennen
 * muss. Der Slug landet ausschliesslich in CSS-Klassen (has-<slug>-...) und CSS-
 * Variablen (--wp--preset--...--<slug>); der enge Zeichensatz schliesst Injection aus.
 *
 * @param mixed $slug
 * @return string|WP_Error
 */
function wpagent_gs_validate_slug( $slug ) {
	$slug = is_string( $slug ) ? trim( $slug ) : '';
	if ( '' === $slug || strlen( $slug ) > 40 || ! preg_match( '/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/', $slug ) ) {
		return new WP_Error(
			'wpagent_gs_bad_slug',
			/* translators: %s: submitted preset slug. */
			sprintf( __( 'Ungültiger Slug "%s". Erlaubt: Kleinbuchstaben, Ziffern und Bindestriche (kein führender/abschließender Bindestrich).', 'wp-agent-companion' ), is_string( $slug ) ? $slug : gettype( $slug ) ),
			array( 'status' => 400 )
		);
	}
	return $slug;
}

/**
 * Validiert einen Farbwert. Erlaubt ausschliesslich Hex (#rgb/#rgba/#rrggbb/
 * #rrggbbaa) oder eine eng gefasste rgb()/rgba()/hsl()/hsla()-Notation, deren
 * Innenleben nur Ziffern, Punkte, Kommas, Prozent, Schraegstrich und Leerzeichen
 * enthaelt. Alles andere (url(), var(), CSS-Trenner, spitze Klammern) wird
 * abgelehnt, damit keine CSS-Injection in die generierte Stylesheet-Ausgabe
 * gelangt.
 *
 * @param mixed $value
 * @return string|WP_Error
 */
function wpagent_gs_validate_color( $value ) {
	$value = is_string( $value ) ? trim( $value ) : '';
	if ( '' === $value || strlen( $value ) > 64 ) {
		return new WP_Error( 'wpagent_gs_bad_color', __( 'Leerer oder zu langer Farbwert.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	if ( preg_match( '/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $value ) ) {
		return $value;
	}
	if (
		preg_match( '/^(rgb|rgba|hsl|hsla)\(\s*([0-9.,%\/\s]+)\)$/', $value, $matches )
		&& wpagent_gs_color_function_is_valid( $matches[1], $matches[2] )
	) {
		return $value;
	}
	return new WP_Error(
		'wpagent_gs_bad_color',
		/* translators: %s: submitted CSS color value. */
		sprintf( __( 'Ungültiger Farbwert "%s". Erlaubt sind Hex oder rgb()/rgba()/hsl()/hsla().', 'wp-agent-companion' ), $value ),
		array( 'status' => 400 )
	);
}

/**
 * Prüft die Komponenten einer rgb()/rgba()/hsl()/hsla()-Funktion. Unterstützt
 * die klassische Kommaform sowie die moderne Leerzeichenform mit optionalem
 * Slash-Alpha. Wertebereiche werden wie im CSS-Standard vom Browser geklemmt,
 * leere Komponenten und gemischte Trennsyntax werden jedoch abgelehnt.
 */
function wpagent_gs_color_function_is_valid( string $function, string $arguments ): bool {
	$arguments = trim( $arguments );
	if ( '' === $arguments ) {
		return false;
	}

	$number      = '(?:\d+(?:\.\d+)?|\.\d+)';
	$numeric     = '/^' . $number . '%?$/';
	$percentage  = '/^' . $number . '%$/';
	$is_hsl      = 0 === strpos( $function, 'hsl' );
	$has_commas  = false !== strpos( $arguments, ',' );
	$has_slash   = false !== strpos( $arguments, '/' );
	$components  = array();
	$alpha       = null;

	if ( $has_commas ) {
		// Legacy-Kommasyntax erlaubt keinen Slash. rgba()/hsla() tragen genau
		// eine vierte Alpha-Komponente, rgb()/hsl() genau drei Komponenten.
		if ( $has_slash ) {
			return false;
		}
		$components = array_map( 'trim', explode( ',', $arguments ) );
		$expected   = in_array( $function, array( 'rgba', 'hsla' ), true ) ? 4 : 3;
		if ( $expected !== count( $components ) || in_array( '', $components, true ) ) {
			return false;
		}
		if ( 4 === $expected ) {
			$alpha = array_pop( $components );
		}
	} else {
		$parts = preg_split( '/\s*\/\s*/', $arguments );
		if ( ! is_array( $parts ) || count( $parts ) > 2 || in_array( '', $parts, true ) ) {
			return false;
		}
		$components = preg_split( '/\s+/', trim( $parts[0] ) );
		$alpha      = $parts[1] ?? null;
		if ( ! is_array( $components ) || 3 !== count( $components ) ) {
			return false;
		}
	}

	if ( $is_hsl ) {
		if (
			! preg_match( '/^' . $number . '$/', $components[0] )
			|| ! preg_match( $percentage, $components[1] )
			|| ! preg_match( $percentage, $components[2] )
		) {
			return false;
		}
	} else {
		foreach ( $components as $component ) {
			if ( ! preg_match( $numeric, $component ) ) {
				return false;
			}
		}
	}

	return null === $alpha || (bool) preg_match( $numeric, trim( $alpha ) );
}

/**
 * Prüft eine clamp()/min()/max()/calc()-Funktion als begrenzte Tokenfolge.
 *
 * Erlaubt Zahlen, bekannte CSS-Längeneinheiten, Rechenoperatoren, verschachtelte
 * Gruppen und die vier freigegebenen Funktionen. So werden nicht nur Klammern,
 * sondern auch fehlende Operanden, unbekannte Bezeichner und falsche clamp()-
 * Argumentzahlen erkannt.
 */
function wpagent_gs_css_math_is_valid( string $value ): bool {
	$compact = preg_replace( '/\s+/', '', $value );
	if ( null === $compact || '' === $compact ) {
		return false;
	}

	$number = '(?:\d+(?:\.\d+)?|\.\d+)';
	$unit   = '(?:vmin|vmax|rem|px|em|vw|vh|pt|ch|ex|%)';
	$match  = preg_match_all(
		'/(?:clamp|min|max|calc)(?=\()|' . $number . $unit . '?|[(),+\-*\/]/',
		$compact,
		$matches
	);
	if ( false === $match || implode( '', $matches[0] ) !== $compact ) {
		return false;
	}

	$tokens         = $matches[0];
	$stack          = array();
	$expect_operand = true;
	$functions      = array( 'clamp', 'min', 'max', 'calc' );

	for ( $index = 0, $count = count( $tokens ); $index < $count; ++$index ) {
		$token = $tokens[ $index ];

		if ( in_array( $token, $functions, true ) ) {
			if ( ! $expect_operand || '(' !== ( $tokens[ $index + 1 ] ?? null ) ) {
				return false;
			}
			$stack[] = array( 'function' => $token, 'commas' => 0 );
			++$index; // Die zum Funktionsnamen gehörende öffnende Klammer.
			$expect_operand = true;
			continue;
		}

		if ( '(' === $token ) {
			if ( ! $expect_operand ) {
				return false;
			}
			$stack[]        = array( 'function' => '', 'commas' => 0 );
			$expect_operand = true;
			continue;
		}

		if ( preg_match( '/^' . $number . $unit . '?$/', $token ) ) {
			if ( ! $expect_operand ) {
				return false;
			}
			$expect_operand = false;
			continue;
		}

		if ( ',' === $token ) {
			$top = count( $stack ) - 1;
			if ( $expect_operand || $top < 0 || '' === $stack[ $top ]['function'] ) {
				return false;
			}
			++$stack[ $top ]['commas'];
			$expect_operand = true;
			continue;
		}

		if ( in_array( $token, array( '+', '-', '*', '/' ), true ) ) {
			if ( $expect_operand && ! in_array( $token, array( '+', '-' ), true ) ) {
				return false;
			}
			$expect_operand = true;
			continue;
		}

		if ( ')' === $token ) {
			if ( $expect_operand || array() === $stack ) {
				return false;
			}
			$frame = array_pop( $stack );
			if (
				( 'calc' === $frame['function'] && 0 !== $frame['commas'] )
				|| ( 'clamp' === $frame['function'] && 2 !== $frame['commas'] )
			) {
				return false;
			}
			$expect_operand = false;
			continue;
		}

		return false;
	}

	return ! $expect_operand && array() === $stack;
}

/**
 * Validiert eine CSS-Groesse (Schriftgroesse oder Abstand). Erlaubt entweder eine
 * einfache Laenge mit bekannter Einheit oder eine der Funktionen clamp/min/max/calc.
 * Zusaetzlich wird der komplette String auf einen sicheren Zeichensatz beschraenkt
 * (Kleinbuchstaben, Ziffern, %, . , math-Operatoren, Klammern, Leerzeichen) und auf
 * gefaehrliche Teilstrings geprueft. Ohne Doppelpunkt/Semikolon/Anfuehrungszeichen
 * und ohne url()/expression() ist ein Ausbruch aus dem CSS-Wert nicht moeglich.
 *
 * @param mixed $value
 * @return string|WP_Error
 */
function wpagent_gs_validate_size( $value ) {
	$value = is_string( $value ) ? trim( $value ) : '';
	if ( '' === $value || strlen( $value ) > 64 ) {
		return new WP_Error( 'wpagent_gs_bad_size', __( 'Leere oder zu lange Größe.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	// Harter Zeichensatz-Filter: keine Grossbuchstaben, keine CSS-Trenner, keine
	// Anfuehrungszeichen, keine spitzen Klammern, kein Backslash, kein Doppelpunkt.
	if ( ! preg_match( '#^[0-9a-z%.,()+\-*/\s]+$#', $value ) ) {
		/* translators: %s: submitted CSS size value. */
		return new WP_Error( 'wpagent_gs_bad_size', sprintf( __( 'Ungültige Zeichen im Größenwert "%s".', 'wp-agent-companion' ), $value ), array( 'status' => 400 ) );
	}
	foreach ( array( 'url', 'expression', 'import', 'javascript', '//', '/*' ) as $bad ) {
		if ( false !== strpos( $value, $bad ) ) {
			return new WP_Error( 'wpagent_gs_bad_size', __( 'Unerlaubter Ausdruck im Größenwert.', 'wp-agent-companion' ), array( 'status' => 400 ) );
		}
	}
	// Erlaubte Formen: reine Laenge mit Einheit ODER clamp/min/max/calc(...).
	$is_length = (bool) preg_match( '/^\d+(?:\.\d+)?(?:px|rem|em|%|vw|vh|vmin|vmax|pt|ch|ex)$/', $value );
	$is_func   = (bool) preg_match( '/^(?:clamp|min|max|calc)\(.+\)$/', $value ) && wpagent_gs_css_math_is_valid( $value );
	if ( ! $is_length && ! $is_func ) {
		/* translators: %s: submitted CSS size value. */
		return new WP_Error( 'wpagent_gs_bad_size', sprintf( __( 'Größe "%s" ist keine erlaubte CSS-Länge oder clamp/min/max/calc().', 'wp-agent-companion' ), $value ), array( 'status' => 400 ) );
	}
	return $value;
}

/**
 * Validiert einen Font-Stack. Erlaubt Buchstaben, Ziffern, Leerzeichen, Kommas,
 * Anfuehrungszeichen, Bindestriche, Unterstriche und Punkte. Verbietet CSS-Trenner,
 * spitze Klammern, Backslash, Schraegstrich und url()/@font-face-artige Konstrukte,
 * damit keine Remote-Ladung oder Injection moeglich ist.
 *
 * @param mixed $value
 * @return string|WP_Error
 */
function wpagent_gs_validate_font_family( $value ) {
	$value = is_string( $value ) ? trim( $value ) : '';
	if ( '' === $value || strlen( $value ) > 200 ) {
		return new WP_Error( 'wpagent_gs_bad_family', __( 'Leerer oder zu langer Font-Stack.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	if ( ! preg_match( '/^[A-Za-z0-9 ,\'"._\-]+$/', $value ) ) {
		/* translators: %s: submitted CSS font stack. */
		return new WP_Error( 'wpagent_gs_bad_family', sprintf( __( 'Ungültige Zeichen im Font-Stack "%s".', 'wp-agent-companion' ), $value ), array( 'status' => 400 ) );
	}
	if ( false !== stripos( $value, 'url' ) ) {
		return new WP_Error( 'wpagent_gs_bad_family', __( 'url() ist im Font-Stack nicht erlaubt.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	return $value;
}

/**
 * Validiert eine Liste von {slug, <value_key>, name?}-Eintraegen ueber einen
 * uebergebenen Wert-Validator. Slugs muessen innerhalb der Liste eindeutig sein.
 *
 * @param mixed    $list
 * @param string   $value_key
 * @param callable $value_validator
 * @param string   $label
 * @return array|WP_Error   Normalisierte Preset-Eintraege (slug/name/<value_key>).
 */
function wpagent_gs_validate_preset_list( $list, string $value_key, callable $value_validator, string $label ) {
	if ( ! is_array( $list ) ) {
		/* translators: %s: design-token category label. */
		return new WP_Error( 'wpagent_gs_bad_list', sprintf( __( '%s muss eine Liste sein.', 'wp-agent-companion' ), $label ), array( 'status' => 400 ) );
	}
	if ( count( $list ) > 60 ) {
		/* translators: %s: design-token category label. */
		return new WP_Error( 'wpagent_gs_too_many', sprintf( __( '%s: zu viele Einträge (max. 60).', 'wp-agent-companion' ), $label ), array( 'status' => 400 ) );
	}

	$out   = array();
	$seen  = array();
	foreach ( $list as $entry ) {
		if ( ! is_array( $entry ) ) {
			/* translators: %s: design-token category label. */
			return new WP_Error( 'wpagent_gs_bad_entry', sprintf( __( '%s: jeder Eintrag muss ein Objekt sein.', 'wp-agent-companion' ), $label ), array( 'status' => 400 ) );
		}
		$slug = wpagent_gs_validate_slug( $entry['slug'] ?? null );
		if ( is_wp_error( $slug ) ) {
			return $slug;
		}
		if ( isset( $seen[ $slug ] ) ) {
			/* translators: 1: design-token category label, 2: duplicate preset slug. */
			return new WP_Error( 'wpagent_gs_dup_slug', sprintf( __( '%1$s: doppelter Slug "%2$s".', 'wp-agent-companion' ), $label, $slug ), array( 'status' => 400 ) );
		}
		$seen[ $slug ] = true;

		$value = call_user_func( $value_validator, $entry[ $value_key ] ?? null );
		if ( is_wp_error( $value ) ) {
			return $value;
		}

		$preset = array( 'slug' => $slug, $value_key => $value );
		if ( isset( $entry['name'] ) && is_string( $entry['name'] ) && '' !== trim( $entry['name'] ) ) {
			$name = trim( wp_strip_all_tags( $entry['name'] ) );
			if ( strlen( $name ) > 100 ) {
				$name = substr( $name, 0, 100 );
			}
			$preset['name'] = $name;
		} else {
			$preset['name'] = $slug;
		}
		$out[] = $preset;
	}

	return $out;
}

/**
 * Validiert einen einzelnen styles-Farbwert. Erlaubt entweder eine Preset-Referenz
 * (var:preset|color|<slug>) auf einen gueltigen Slug oder einen direkten,
 * validierten Farbwert.
 *
 * @param mixed $value
 * @return string|WP_Error
 */
function wpagent_gs_validate_style_color( $value ) {
	$value = is_string( $value ) ? trim( $value ) : '';
	if ( preg_match( '/^var:preset\|color\|(.+)$/', $value, $m ) ) {
		$slug = wpagent_gs_validate_slug( $m[1] );
		if ( is_wp_error( $slug ) ) {
			return $slug;
		}
		return 'var:preset|color|' . $slug;
	}
	return wpagent_gs_validate_color( $value );
}

/**
 * Validiert die (optionale) styles-Struktur gegen einen engen Allowlist-Pfadbaum.
 * Unbekannte Pfade werden abgelehnt, damit ausser den dokumentierten Standard-Farben
 * und dem blockGap nichts in die Global-Styles gelangt (kein freies CSS).
 *
 * @param mixed $styles
 * @return array|WP_Error   Validierter styles-Teilbaum.
 */
function wpagent_gs_validate_styles( $styles ) {
	if ( ! is_array( $styles ) ) {
		return new WP_Error( 'wpagent_gs_bad_styles', __( 'styles muss ein Objekt sein.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}

	$out     = array();
	$allowed = array( 'color', 'spacing', 'elements' );
	foreach ( array_keys( $styles ) as $key ) {
		if ( ! in_array( $key, $allowed, true ) ) {
			/* translators: %s: unsupported top-level styles key. */
			return new WP_Error( 'wpagent_gs_bad_styles', sprintf( __( 'styles.%s ist nicht erlaubt.', 'wp-agent-companion' ), $key ), array( 'status' => 400 ) );
		}
	}

	// styles.color.background / styles.color.text
	if ( isset( $styles['color'] ) ) {
		if ( ! is_array( $styles['color'] ) ) {
			return new WP_Error( 'wpagent_gs_bad_styles', __( 'styles.color muss ein Objekt sein.', 'wp-agent-companion' ), array( 'status' => 400 ) );
		}
		foreach ( $styles['color'] as $k => $v ) {
			if ( ! in_array( $k, array( 'background', 'text' ), true ) ) {
				/* translators: %s: unsupported styles.color key. */
				return new WP_Error( 'wpagent_gs_bad_styles', sprintf( __( 'styles.color.%s ist nicht erlaubt.', 'wp-agent-companion' ), $k ), array( 'status' => 400 ) );
			}
			$val = wpagent_gs_validate_style_color( $v );
			if ( is_wp_error( $val ) ) {
				return $val;
			}
			$out['color'][ $k ] = $val;
		}
	}

	// styles.spacing.blockGap
	if ( isset( $styles['spacing'] ) ) {
		if ( ! is_array( $styles['spacing'] ) ) {
			return new WP_Error( 'wpagent_gs_bad_styles', __( 'styles.spacing muss ein Objekt sein.', 'wp-agent-companion' ), array( 'status' => 400 ) );
		}
		foreach ( array_keys( $styles['spacing'] ) as $k ) {
			if ( 'blockGap' !== $k ) {
				/* translators: %s: unsupported styles.spacing key. */
				return new WP_Error( 'wpagent_gs_bad_styles', sprintf( __( 'styles.spacing.%s ist nicht erlaubt.', 'wp-agent-companion' ), $k ), array( 'status' => 400 ) );
			}
		}
		if ( array_key_exists( 'blockGap', $styles['spacing'] ) ) {
			$gap = (string) $styles['spacing']['blockGap'];
			if ( preg_match( '/^var:preset\|spacing\|(.+)$/', trim( $gap ), $m ) ) {
				$slug = wpagent_gs_validate_slug( $m[1] );
				if ( is_wp_error( $slug ) ) {
					return $slug;
				}
				$out['spacing']['blockGap'] = 'var:preset|spacing|' . $slug;
			} else {
				$val = wpagent_gs_validate_size( $gap );
				if ( is_wp_error( $val ) ) {
					return $val;
				}
				$out['spacing']['blockGap'] = $val;
			}
		}
	}

	// styles.elements.button.color.{background,text}, styles.elements.link.color.text
	if ( isset( $styles['elements'] ) ) {
		if ( ! is_array( $styles['elements'] ) ) {
			return new WP_Error( 'wpagent_gs_bad_styles', __( 'styles.elements muss ein Objekt sein.', 'wp-agent-companion' ), array( 'status' => 400 ) );
		}
		$allowed_elements = array(
			'button' => array( 'background', 'text' ),
			'link'   => array( 'text' ),
		);
		foreach ( $styles['elements'] as $el => $conf ) {
			if ( ! isset( $allowed_elements[ $el ] ) ) {
				/* translators: %s: unsupported styles element key. */
				return new WP_Error( 'wpagent_gs_bad_styles', sprintf( __( 'styles.elements.%s ist nicht erlaubt.', 'wp-agent-companion' ), $el ), array( 'status' => 400 ) );
			}
			if ( ! is_array( $conf ) || ! isset( $conf['color'] ) || ! is_array( $conf['color'] ) ) {
				/* translators: %s: styles element key. */
				return new WP_Error( 'wpagent_gs_bad_styles', sprintf( __( 'styles.elements.%s.color muss ein Objekt sein.', 'wp-agent-companion' ), $el ), array( 'status' => 400 ) );
			}
			foreach ( $conf['color'] as $ck => $cv ) {
				if ( ! in_array( $ck, $allowed_elements[ $el ], true ) ) {
					/* translators: 1: styles element key, 2: unsupported color key. */
					return new WP_Error( 'wpagent_gs_bad_styles', sprintf( __( 'styles.elements.%1$s.color.%2$s ist nicht erlaubt.', 'wp-agent-companion' ), $el, $ck ), array( 'status' => 400 ) );
				}
				$val = wpagent_gs_validate_style_color( $cv );
				if ( is_wp_error( $val ) ) {
					return $val;
				}
				$out['elements'][ $el ]['color'][ $ck ] = $val;
			}
		}
	}

	return $out;
}

/* ---------------------------------------------------------------------------
 * Merge-Helfer
 * ------------------------------------------------------------------------- */

/**
 * Fuehrt validierte Preset-Eintraege mit einer bestehenden Custom-Liste zusammen.
 * replace=true ersetzt die Liste vollstaendig; sonst Upsert per Slug (bestehende
 * Slugs werden ueberschrieben, neue angehaengt, Reihenfolge stabil).
 *
 * @param array $existing
 * @param array $incoming
 * @param bool  $replace
 * @return array
 */
function wpagent_gs_merge_presets( array $existing, array $incoming, bool $replace ): array {
	if ( $replace ) {
		return array_values( $incoming );
	}

	$by_slug = array();
	$order   = array();
	foreach ( $existing as $entry ) {
		if ( is_array( $entry ) && isset( $entry['slug'] ) && is_scalar( $entry['slug'] ) ) {
			$slug = trim( (string) $entry['slug'] );
			if ( '' === $slug ) {
				continue;
			}
			if ( ! array_key_exists( $slug, $by_slug ) ) {
				$order[] = $slug;
			}
			$by_slug[ $slug ] = $entry;
		}
	}
	foreach ( $incoming as $entry ) {
		$slug = $entry['slug'];
		if ( ! array_key_exists( $slug, $by_slug ) ) {
			$order[] = $slug;
		}
		$by_slug[ $slug ] = $entry;
	}

	$out = array();
	foreach ( $order as $slug ) {
		$out[] = $by_slug[ $slug ];
	}
	return $out;
}

/**
 * Prüft, ob eine Preset-Struktur nach WordPress-Herkunft gruppiert ist
 * ({default|blocks|theme|custom: [...]}) statt als flache Liste vorzuliegen.
 *
 * @param array $presets
 * @return bool
 */
function wpagent_gs_presets_are_origin_keyed( array $presets ): bool {
	foreach ( array( 'default', 'blocks', 'theme', 'custom' ) as $origin ) {
		if ( array_key_exists( $origin, $presets ) && is_array( $presets[ $origin ] ) ) {
			return true;
		}
	}
	return false;
}

/**
 * Mergt Incoming-Presets in eine bestehende Preset-Struktur und erhält dabei die
 * WordPress-Origin-Form. Bei origin-keyed Daten wird ausschliesslich die custom-
 * Liste ersetzt/upgesertet; theme/default/blocks bleiben unverändert.
 *
 * @param mixed $existing
 * @param array $incoming
 * @param bool  $replace
 * @return array
 */
function wpagent_gs_merge_preset_node( $existing, array $incoming, bool $replace ): array {
	$existing = is_array( $existing ) ? $existing : array();
	if ( wpagent_gs_presets_are_origin_keyed( $existing ) ) {
		$custom             = isset( $existing['custom'] ) && is_array( $existing['custom'] ) ? $existing['custom'] : array();
		$existing['custom'] = wpagent_gs_merge_presets( $custom, $incoming, $replace );
		return $existing;
	}
	return wpagent_gs_merge_presets( $existing, $incoming, $replace );
}

/**
 * Sammelt slug-Werte rekursiv aus flachen und origin-keyed Preset-Strukturen.
 *
 * @param mixed $node
 * @return string[]
 */
function wpagent_gs_collect_preset_slugs( $node ): array {
	$slugs = array();
	if ( ! is_array( $node ) ) {
		return $slugs;
	}

	if ( isset( $node['slug'] ) && is_scalar( $node['slug'] ) ) {
		$slug = trim( (string) $node['slug'] );
		if ( '' !== $slug ) {
			$slugs[] = $slug;
		}
	}

	foreach ( $node as $value ) {
		if ( is_array( $value ) ) {
			$slugs = array_merge( $slugs, wpagent_gs_collect_preset_slugs( $value ) );
		}
	}

	return array_values( array_unique( $slugs ) );
}

/**
 * Flacht eine Preset-Struktur zu slug => Eintrag ab. Bei doppelten Slugs gewinnt
 * der später gelesene Eintrag, passend zur WordPress-Preset-Auflösung.
 *
 * @param mixed $node
 * @return array<string,array>
 */
function wpagent_gs_preset_entries_by_slug( $node ): array {
	$entries = array();
	if ( ! is_array( $node ) ) {
		return $entries;
	}

	if ( isset( $node['slug'] ) && is_scalar( $node['slug'] ) ) {
		$slug = trim( (string) $node['slug'] );
		if ( '' !== $slug ) {
			$entries[ $slug ] = $node;
		}
	}

	foreach ( $node as $value ) {
		if ( is_array( $value ) ) {
			$entries = array_replace( $entries, wpagent_gs_preset_entries_by_slug( $value ) );
		}
	}

	return $entries;
}

/**
 * Liest die Base-Preset-Slugs (Core + Theme, ohne aktuelle User-Customs).
 *
 * @param string[] $path
 * @return string[]
 */
function wpagent_gs_base_preset_slugs( array $path ): array {
	if ( ! function_exists( 'wp_get_global_settings' ) ) {
		return array();
	}
	$settings = wp_get_global_settings( $path, array( 'origin' => 'base' ) );
	return wpagent_gs_collect_preset_slugs( $settings );
}

/**
 * Prüft alle var:preset-Referenzen im validierten styles-Baum gegen die Presets,
 * die nach dem Merge tatsächlich verfügbar sind.
 *
 * @param array    $styles
 * @param string[] $color_slugs
 * @param string[] $spacing_slugs
 * @return true|WP_Error
 */
function wpagent_gs_validate_style_references( array $styles, array $color_slugs, array $spacing_slugs ) {
	foreach ( $styles as $value ) {
		if ( is_array( $value ) ) {
			$res = wpagent_gs_validate_style_references( $value, $color_slugs, $spacing_slugs );
			if ( is_wp_error( $res ) ) {
				return $res;
			}
			continue;
		}
		if ( ! is_string( $value ) || 0 !== strpos( $value, 'var:preset|' ) ) {
			continue;
		}
		if ( preg_match( '/^var:preset\|color\|([a-z0-9][a-z0-9-]*)$/', $value, $m ) ) {
			if ( ! in_array( $m[1], $color_slugs, true ) ) {
				return new WP_Error(
					'wpagent_gs_unknown_color_ref',
					/* translators: %s: unknown color preset slug. */
					sprintf( __( 'Unbekannte Farb-Preset-Referenz "%s" in styles.', 'wp-agent-companion' ), $m[1] ),
					array( 'status' => 400 )
				);
			}
		} elseif ( preg_match( '/^var:preset\|spacing\|([a-z0-9][a-z0-9-]*)$/', $value, $m ) ) {
			if ( ! in_array( $m[1], $spacing_slugs, true ) ) {
				return new WP_Error(
					'wpagent_gs_unknown_spacing_ref',
					/* translators: %s: unknown spacing preset slug. */
					sprintf( __( 'Unbekannte Abstands-Preset-Referenz "%s" in styles.', 'wp-agent-companion' ), $m[1] ),
					array( 'status' => 400 )
				);
			}
		}
	}

	return true;
}

/**
 * Rekursives, wertueberschreibendes Deep-Merge fuer den validierten styles-Baum.
 *
 * @param array $base
 * @param array $overlay
 * @return array
 */
function wpagent_gs_deep_merge( array $base, array $overlay ): array {
	foreach ( $overlay as $key => $value ) {
		if ( is_array( $value ) && isset( $base[ $key ] ) && is_array( $base[ $key ] ) ) {
			$base[ $key ] = wpagent_gs_deep_merge( $base[ $key ], $value );
		} else {
			$base[ $key ] = $value;
		}
	}
	return $base;
}
