<?php
/**
 * Gutenberg block emitters and theme-token resolution for landing patterns.
 *
 * @package WPAgentCompanion
 */

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

/* ---------------------------------------------------------------------------
 * Block-Bausteine
 *
 * Jede Funktion erzeugt kanonisches Gutenberg-Markup: die JSON-Attribute im
 * Kommentar und die HTML-Klassen/Inline-Styles im Tag müssen zusammenpassen,
 * sonst wertet der Editor den Block als ungültig (Block-Recovery). blockGap und
 * Farb-Slugs erscheinen bewusst NICHT im Inline-Style, weil Gutenberg sie über
 * generierte Layout-/Preset-Klassen löst und der gespeicherte Style sie nicht
 * enthält.
 * ------------------------------------------------------------------------- */

/**
 * JSON-Attribute für einen Block-Kommentar, ohne escapte Slashes/Unicode (so
 * serialisiert auch der Editor).
 */
function wpagent_pattern_attrs_json( array $attrs ): string {
	return (string) wp_json_encode( $attrs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
}

/**
 * core/heading mit optionaler Schriftgrösse (theme.json-Slug) und Ausrichtung.
 */
function wpagent_pattern_block_heading( string $text, int $level = 2, string $font_size = '', string $align = '' ): string {
	$level     = max( 1, min( 6, $level ) );
	$font_size = '' !== $font_size ? wpagent_pattern_pick_font( $font_size ) : '';

	$attrs = array();
	if ( '' !== $align ) {
		$attrs['textAlign'] = $align;
	}
	if ( 2 !== $level ) {
		$attrs['level'] = $level;
	}
	if ( '' !== $font_size ) {
		$attrs['fontSize'] = $font_size;
	}

	$classes = array( 'wp-block-heading' );
	if ( '' !== $align ) {
		$classes[] = 'has-text-align-' . $align;
	}
	if ( '' !== $font_size ) {
		$classes[] = 'has-' . $font_size . '-font-size';
	}

	$attr_json = array() === $attrs ? '' : ' ' . wpagent_pattern_attrs_json( $attrs );
	$class     = ' class="' . esc_attr( implode( ' ', $classes ) ) . '"';

	return "<!-- wp:heading{$attr_json} -->\n<h{$level}{$class}>" . esc_html( $text ) . "</h{$level}>\n<!-- /wp:heading -->";
}

/**
 * core/paragraph mit optionaler Schriftgrösse, Ausrichtung und Textfarbe.
 */
function wpagent_pattern_block_paragraph( string $text, string $font_size = '', string $align = '', string $text_color = '' ): string {
	$font_size = '' !== $font_size ? wpagent_pattern_pick_font( $font_size ) : '';

	$attrs = array();
	if ( '' !== $align ) {
		$attrs['align'] = $align;
	}
	if ( '' !== $text_color ) {
		$attrs['textColor'] = $text_color;
	}
	if ( '' !== $font_size ) {
		$attrs['fontSize'] = $font_size;
	}

	$classes = array();
	if ( '' !== $align ) {
		$classes[] = 'has-text-align-' . $align;
	}
	if ( '' !== $text_color ) {
		$classes[] = 'has-' . $text_color . '-color';
		$classes[] = 'has-text-color';
	}
	if ( '' !== $font_size ) {
		$classes[] = 'has-' . $font_size . '-font-size';
	}

	$attr_json = array() === $attrs ? '' : ' ' . wpagent_pattern_attrs_json( $attrs );
	$class     = array() === $classes ? '' : ' class="' . esc_attr( implode( ' ', $classes ) ) . '"';

	return "<!-- wp:paragraph{$attr_json} -->\n<p{$class}>" . esc_html( $text ) . "</p>\n<!-- /wp:paragraph -->";
}

/**
 * core/buttons (mittig) mit genau einem core/button. Der gefüllte Standard-Stil
 * nutzt die Button-Farben des Themes; $outline erzeugt den Umriss-Stil (dezenter
 * Sekundär-Button, z. B. pro Karte).
 */
function wpagent_pattern_block_button( string $label, string $url, bool $outline = false ): string {
	$label = esc_html( $label );
	$href  = esc_url( '' === trim( $url ) ? '#' : $url );

	$btn_attrs = $outline ? ' ' . wpagent_pattern_attrs_json( array( 'className' => 'is-style-outline' ) ) : '';
	$btn_class = $outline ? 'wp-block-button is-style-outline' : 'wp-block-button';

	$button = "<!-- wp:button{$btn_attrs} -->\n<div class=\"{$btn_class}\"><a class=\"wp-block-button__link wp-element-button\" href=\"{$href}\">{$label}</a></div>\n<!-- /wp:button -->";

	$wrap_attrs = wpagent_pattern_attrs_json( array( 'layout' => array( 'type' => 'flex', 'justifyContent' => 'center' ) ) );

	return "<!-- wp:buttons {$wrap_attrs} -->\n<div class=\"wp-block-buttons\">{$button}</div>\n<!-- /wp:buttons -->";
}

/**
 * core/image (natürliche Grösse), für Bild-Logos und optionale Karten-Bilder.
 */
function wpagent_pattern_block_image( string $url, string $alt ): string {
	$src   = esc_url( $url );
	$alt_a = esc_attr( $alt );
	$attrs = wpagent_pattern_attrs_json( array( 'sizeSlug' => 'large', 'linkDestination' => 'none' ) );

	return "<!-- wp:image {$attrs} -->\n<figure class=\"wp-block-image size-large\"><img src=\"{$src}\" alt=\"{$alt_a}\"/></figure>\n<!-- /wp:image -->";
}

/**
 * Namensliste der im Plugin mitgelieferten Phosphor-Icons (assets/icons/<name>.svg,
 * Weight „regular“, MIT-Lizenz, siehe assets/icons/LICENSE.md). Bewusst hartkodiert
 * statt per glob: der Icon-Satz ist Teil des ausgelieferten Codes, so bleibt die
 * Allowlist ohne Dateisystem-I/O pro Aufruf deterministisch. Eine neue SVG-Datei wird
 * erst nutzbar, wenn ihr Name hier ergänzt wird (und umgekehrt kann kein Name eine
 * fehlende Datei referenzieren).
 *
 * @return string[]
 */
function wpagent_pattern_icon_allowlist(): array {
	return array(
		'check',
		'check-circle',
		'star',
		'rocket',
		'shield-check',
		'lightning',
		'heart',
		'gear-six',
		'chart-line-up',
		'users-three',
		'lock-key',
		'magic-wand',
		'clock',
		'globe',
		'sparkle',
		'trophy',
		'thumbs-up',
		'wrench',
		'plugs-connected',
		'gauge',
	);
}

/**
 * Icon-Block: ein kleines, fest dimensioniertes core/image, dessen Quelle eine im
 * Plugin mitgelieferte Phosphor-SVG-Datei ist. Bewusst nativ (core/image statt rohem
 * SVG/core/html), damit der Freeform-Report 0 bleibt und der Block im Editor gültig ist.
 *
 * Warum genau diese Serialisierung (sonst Block-Recovery):
 *   - url/alt liest core/image als source-Attribute direkt aus dem <img>, sie stehen
 *     NICHT im Block-Kommentar. Das Bild ist dekorativ, daher alt="" (leer).
 *   - width/height sind in WordPress' aktuellem core/image String-Attribute (CSS-Werte);
 *     save() schreibt sie als Inline-Style width/height auf das <img>, nicht als HTML-
 *     Attribute. Sind width ODER height gesetzt, trägt die <figure> zusätzlich is-resized.
 *   - sizeSlug erzeugt die Klasse size-large (wie wpagent_pattern_block_image), linkDestination
 *     "none" hält das Bild ohne umschliessendes <a>. lightbox.enabled=false verhindert,
 *     dass dekorative Icons bei global aktivierter Bild-Lightbox klickbar werden. Ohne
 *     Attachment-ID entsteht keine wp-image-Klasse (imageClasses leer, className entfällt).
 *
 * Unbekannter oder leerer Name liefert '' (kein Icon), damit aufrufende Sektionen sauber
 * ohne Icon degradieren, statt eine kaputte Bildquelle zu rendern.
 *
 * @param string $name Icon-Name aus wpagent_pattern_icon_allowlist().
 * @param int    $size Kantenlänge in Pixeln (quadratisch, clamp 1..512, Standard 48).
 */
function wpagent_pattern_block_icon( string $name, int $size = 48 ): string {
	$name = trim( $name );
	if ( '' === $name || ! in_array( $name, wpagent_pattern_icon_allowlist(), true ) ) {
		return '';
	}

	$size = max( 1, min( 512, $size ) );
	$dim  = $size . 'px';

	// Plugin-URL aus der bestehenden Pfad-Konstante ableiten (kein neuer Konstanten-
	// Eintrag): plugins_url() nimmt dirname() der Referenzdatei als Plugin-Wurzel.
	$url = plugins_url( 'assets/icons/' . $name . '.svg', WPAGENT_COMPANION_DIR . 'wp-agent-companion.php' );
	$src = esc_url( $url );

	$attrs = wpagent_pattern_attrs_json(
		array(
			'width'           => $dim,
			'height'          => $dim,
			'sizeSlug'        => 'large',
			'linkDestination' => 'none',
			'lightbox'        => array( 'enabled' => false ),
		)
	);
	$style = esc_attr( 'width:' . $dim . ';height:' . $dim );

	return "<!-- wp:image {$attrs} -->\n<figure class=\"wp-block-image size-large is-resized\"><img src=\"{$src}\" alt=\"\" style=\"{$style}\"/></figure>\n<!-- /wp:image -->";
}

/**
 * core/gallery: kanonische Serialisierung einer Bildergalerie aus verschachtelten
 * core/image-Blöcken. Warum genau so (sonst Block-Recovery):
 *   - Die äussere <figure> trägt has-nested-images (der Gallery-v2-Marker), columns-N
 *     (muss zum columns-Attribut passen) und is-cropped (Standard-Zuschnitt von save()).
 *   - Das columns-Attribut steht im Block-Kommentar; linkTo "none" spiegelt, dass die
 *     inneren Bilder KEIN linkDestination tragen (sonst würde save() ein <a> um das
 *     <img> legen und die Validierung schlägt fehl).
 *   - Jedes verschachtelte core/image trägt NUR {"sizeSlug":"large"} (kein
 *     linkDestination); die Bild-URL/Alt liest Gutenberg als source-Attribute direkt
 *     aus dem <img>. Deshalb wird hier eine eigene Serialisierung ohne linkDestination
 *     genutzt statt wpagent_pattern_block_image (das linkDestination:none setzt).
 *   - Zwischen zwei core/image-Blöcken steht exakt "\n\n"; die erste image-Klammer folgt
 *     direkt auf die öffnende <figure>, die letzte </figure> folgt direkt auf den
 *     schliessenden image-Kommentar (native Serialisierung, kein Whitespace dazwischen).
 *
 * @param array{url:string,alt:string}[] $images  Liste aus URL + Alt-Text. Leere URLs
 *                                                werden übersprungen; ohne gültiges Bild
 *                                                liefert die Funktion '' (Sektion degradiert).
 * @param int                            $columns Spaltenzahl (clamp 1..6, muss zu columns-N passen).
 */
function wpagent_pattern_block_gallery( array $images, int $columns ): string {
	$columns = max( 1, min( 6, $columns ) );

	$image_blocks = array();
	foreach ( $images as $image ) {
		if ( ! is_array( $image ) ) {
			continue;
		}
		$url = trim( (string) ( $image['url'] ?? '' ) );
		if ( '' === $url ) {
			continue;
		}
		$src   = esc_url( $url );
		$alt_a = esc_attr( (string) ( $image['alt'] ?? '' ) );
		$attrs = wpagent_pattern_attrs_json( array( 'sizeSlug' => 'large' ) );

		$image_blocks[] = "<!-- wp:image {$attrs} -->\n<figure class=\"wp-block-image size-large\"><img src=\"{$src}\" alt=\"{$alt_a}\"/></figure>\n<!-- /wp:image -->";
	}

	if ( array() === $image_blocks ) {
		return '';
	}

	$gallery_attrs = wpagent_pattern_attrs_json( array( 'columns' => $columns, 'linkTo' => 'none' ) );
	$figure_class  = esc_attr( 'wp-block-gallery has-nested-images columns-' . $columns . ' is-cropped' );
	$inner         = implode( "\n\n", $image_blocks );

	return "<!-- wp:gallery {$gallery_attrs} -->\n<figure class=\"{$figure_class}\">{$inner}</figure>\n<!-- /wp:gallery -->";
}

/**
 * core/table: kanonische Serialisierung einer statischen Vergleichstabelle. Warum genau so
 * (sonst Block-Recovery):
 *   - useBlockProps.save() gibt der äußeren <figure> exakt class="wp-block-table"; die
 *     <table> liegt direkt darin (kein Whitespace dazwischen).
 *   - Der Zellinhalt (th/td) ist in block.json eine rich-text-Quelle (source=rich-text): er
 *     steht ROH im jeweiligen Tag und gehört NICHT in die Block-Attribute. Er wird mit
 *     esc_html escaped.
 *   - Die Kopfzeile ist <thead><tr> aus <th>-Zellen (die Spalten), die Datenzeilen sind ein
 *     <tbody> mit je einer <tr> aus <td>-Zellen (Label + Werte). Leere Sektionen
 *     (kein <thead>/<tbody>) werden weggelassen; ist die Tabelle komplett leer, liefert
 *     save() null, daher gibt die Funktion '' zurück (die Sektion degradiert dann zum
 *     reinen Textband).
 *   - hasFixedLayout wird gesetzt und erzeugt die Klasse has-fixed-layout auf der <table>.
 *     Klasse und Attribut müssen zusammenpassen: das Attribut steht auch dann explizit im
 *     Kommentar, wenn der aktive core/table-Default bereits true ist. Grund: die
 *     Editor-Validierung vergleicht ausschließlich das gespeicherte HTML gegen die Ausgabe
 *     von save( geparste Attribute ). Ein Kommentar-Attribut, das dem Default entspricht,
 *     ist für diesen Vergleich unsichtbar (kein Recovery), schützt aber Installationen,
 *     deren Default noch false ist (dort erzwingt true erst die Klasse).
 *   - Es entstehen keine weiteren Kommentar-Attribute (kein align, kein style): die Tabelle
 *     bleibt content-breit im umschliessenden constrained group.
 * Zwischen Zellen und Zeilen steht KEIN Whitespace (native Serialisierung, JSX verwirft ihn),
 * damit der Editor den Block als gültig akzeptiert.
 *
 * @param string[] $columns Spalten-Überschriften (roh, werden escaped). Leer => kein <thead>.
 * @param array    $rows    Datenzeilen ({ label, values: string[] }). Bei nicht-leeren $columns
 *                          wird jede Zeile auf genau count($columns) Zellen normalisiert (Label +
 *                          count-1 Werte; fehlende leer, überzählige abgeschnitten), sonst
 *                          Label + alle Werte.
 */
function wpagent_pattern_block_table( array $columns, array $rows ): string {
	// Kopfzeile aus den Spalten.
	$head_cells = '';
	foreach ( $columns as $col ) {
		$head_cells .= '<th>' . esc_html( (string) $col ) . '</th>';
	}
	$thead = '' !== $head_cells ? '<thead><tr>' . $head_cells . '</tr></thead>' : '';

	$col_count = count( $columns );

	// Datenzeilen aus Label + Werten.
	$body_rows = '';
	foreach ( $rows as $row ) {
		if ( ! is_array( $row ) ) {
			continue;
		}
		$label  = (string) ( $row['label'] ?? '' );
		$values = isset( $row['values'] ) && is_array( $row['values'] ) ? array_values( $row['values'] ) : array();

		$cells = '<td>' . esc_html( $label ) . '</td>';
		if ( $col_count > 0 ) {
			// Rechteckige Tabelle: genau count-1 Wertspalten (Label ist die erste Spalte).
			for ( $i = 0; $i < $col_count - 1; $i++ ) {
				$val    = isset( $values[ $i ] ) ? (string) $values[ $i ] : '';
				$cells .= '<td>' . esc_html( $val ) . '</td>';
			}
		} else {
			foreach ( $values as $val ) {
				$cells .= '<td>' . esc_html( (string) $val ) . '</td>';
			}
		}
		$body_rows .= '<tr>' . $cells . '</tr>';
	}
	$tbody = '' !== $body_rows ? '<tbody>' . $body_rows . '</tbody>' : '';

	if ( '' === $thead && '' === $tbody ) {
		return ''; // Leere Tabelle: core/table save() liefert null.
	}

	$attrs = wpagent_pattern_attrs_json( array( 'hasFixedLayout' => true ) );

	return "<!-- wp:table {$attrs} -->\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\">{$thead}{$tbody}</table></figure>\n<!-- /wp:table -->";
}

/**
 * core/details: kanonische Serialisierung eines Akkordeon-Eintrags (eine Frage mit
 * aufklappbarer Antwort). Warum genau so (sonst Block-Recovery):
 *   - Die Frage (summary) ist in block.json eine rich-text-Quelle (source=rich-text,
 *     selector=summary). Gutenberg liest sie direkt roh aus dem <summary>, sie gehört
 *     also NICHT in die Block-Attribute im Kommentar.
 *   - useBlockProps.save() vergibt dem <details> exakt class="wp-block-details".
 *   - showContent ist standardmässig false, daher KEIN open-Attribut: das Akkordeon
 *     startet geschlossen und der Wrapper bleibt attributfrei.
 *   - Der innere Inhalt ist bereits serialisiertes Core-Block-Markup (typisch ein
 *     core/paragraph als Antwort) und steht als InnerBlocks.Content direkt nach dem
 *     </summary>.
 * Die Newlines folgen der nativen Serialisierung: \n nach dem Öffnungs- und vor dem
 * Schluss-Kommentar, aber KEIN Whitespace zwischen </summary> und Inhalt bzw. Inhalt
 * und </details> (JSX verwirft ihn), damit der Editor den Block als gültig akzeptiert.
 *
 * @param string $summary Frage; steht roh im <summary> (wird escaped).
 * @param string $inner   Serialisiertes inneres Block-Markup (Antwort); darf leer sein.
 */
function wpagent_pattern_block_details( string $summary, string $inner ): string {
	$sum = esc_html( $summary );
	return "<!-- wp:details -->\n<details class=\"wp-block-details\"><summary>{$sum}</summary>{$inner}</details>\n<!-- /wp:details -->";
}

/**
 * core/media-text: kanonische Serialisierung eines Split-Layouts aus Medienbild
 * und Inhaltsspalte (InnerBlocks). Warum genau so (sonst Block-Recovery):
 *   - mediaPosition/mediaType/mediaWidth/align stehen im Block-Kommentar; mediaUrl
 *     und mediaAlt liest Gutenberg als source-Attribute direkt aus dem <img>, sie
 *     gehören NICHT in den Kommentar (siehe block.json: source=attribute).
 *   - mediaType MUSS "image" sein, sonst rendert save() keine figure (der Renderer
 *     schlägt sonst auf noop zurück) und die Validierung scheitert.
 *   - mediaWidth bleibt beim Standard 50, weil save() nur bei mediaWidth != 50 ein
 *     grid-template-columns-Inline-Style schreibt; so bleibt der Wrapper stilfrei.
 *   - Ohne mediaId bekommt das Bild keine wp-image-/size-Klasse (imageClasses leer).
 *   - isStackedOnMobile ist per block.json true, daher IMMER is-stacked-on-mobile.
 *   - Bei mediaPosition "right" steht die Inhaltsspalte VOR der figure und der
 *     Wrapper trägt has-media-on-the-right; sonst figure zuerst.
 * Das Bild ist ein rohes <img> in der figure (kein verschachtelter core/image),
 * exakt wie die native core/media-text-Serialisierung.
 *
 * @param string $image_url Bild-URL (Pflicht).
 * @param string $image_alt Alt-Text (darf leer sein).
 * @param string $inner     Serialisiertes inneres Block-Markup der Inhaltsspalte.
 * @param string $position  'left' oder 'right' (Seite des Bildes).
 * @param string $align     '', 'wide' oder 'full'.
 */
function wpagent_pattern_block_media_text( string $image_url, string $image_alt, string $inner, string $position = 'left', string $align = '' ): string {
	$position = 'right' === $position ? 'right' : 'left';
	$align    = in_array( $align, array( 'wide', 'full' ), true ) ? $align : '';
	$src      = esc_url( $image_url );
	$alt_a    = esc_attr( $image_alt );

	$attrs = array();
	if ( '' !== $align ) {
		$attrs['align'] = $align;
	}
	if ( 'right' === $position ) {
		$attrs['mediaPosition'] = 'right';
	}
	$attrs['mediaType']  = 'image';
	$attrs['mediaWidth'] = 50;

	$classes = array( 'wp-block-media-text' );
	if ( 'wide' === $align ) {
		$classes[] = 'alignwide';
	} elseif ( 'full' === $align ) {
		$classes[] = 'alignfull';
	}
	if ( 'right' === $position ) {
		$classes[] = 'has-media-on-the-right';
	}
	$classes[] = 'is-stacked-on-mobile';

	$figure  = '<figure class="wp-block-media-text__media"><img src="' . $src . '" alt="' . $alt_a . '"/></figure>';
	$content = '<div class="wp-block-media-text__content">' . $inner . '</div>';
	$body    = 'right' === $position ? $content . $figure : $figure . $content;

	$class_attr = ' class="' . esc_attr( implode( ' ', $classes ) ) . '"';
	$attr_json  = ' ' . wpagent_pattern_attrs_json( $attrs );

	return "<!-- wp:media-text{$attr_json} -->\n<div{$class_attr}>{$body}</div>\n<!-- /wp:media-text -->";
}

/**
 * core/cover: kanonische Serialisierung eines Hintergrundbild-Blocks mit Farb-Overlay
 * (der Hero mit Bild). Warum genau so (sonst Block-Recovery):
 *   - url/dimRatio/align/layout stehen im Block-Kommentar; overlayColor und
 *     isUserOverlayColor nur, wenn ein vorhandener Theme-Slug aufgelöst wurde. Die
 *     Bild-URL wird zusätzlich als roher <img>-src gerendert (save() liest sie nicht
 *     als source-Attribut zurück, deshalb steht sie in beiden). isUserOverlayColor
 *     markiert, dass die Overlay-Farbe bewusst gewählt ist.
 *   - Kind-Reihenfolge im Wrapper ist fix: ZUERST das <img class="wp-block-cover__image-background">,
 *     DANN der <span class="wp-block-cover__background ..."> (das Farb-Overlay), DANN der
 *     <div class="wp-block-cover__inner-container"> mit dem Inhalt. Eine andere Reihenfolge
 *     macht den Block ungültig.
 *   - Die dim-Klasse ist IMMER "has-background-dim"; nur wenn dimRatio != 50 kommt
 *     direkt davor zusätzlich der auf den nächsten 10er-Bucket gerundete Modifier
 *     "has-background-dim-{N}" (save() lässt den Modifier beim Standard 50 weg).
 *   - Der Wrapper trägt alignfull/alignwide passend zu align; das <img> hat data-object-fit="cover".
 *     alt darf leer sein (alt="").
 *
 * @param string $image_url    Hintergrundbild-URL (Pflicht).
 * @param string $image_alt    Alt-Text (darf leer sein).
 * @param string $overlay_slug theme.json-Farb-Slug des Overlays (darf leer sein; dann nutzt core/cover sein kanonisches Default-Overlay).
 * @param int    $dim_ratio    Deckkraft des Overlays 0..100 (wird geklemmt).
 * @param string $inner        Serialisiertes inneres Block-Markup der Inhaltsspalte.
 * @param string $align        '', 'wide' oder 'full' (Standard 'full').
 */
function wpagent_pattern_block_cover( string $image_url, string $image_alt, string $overlay_slug, int $dim_ratio, string $inner, string $align = 'full' ): string {
	$align = in_array( $align, array( 'wide', 'full' ), true ) ? $align : 'full';
	$dim   = max( 0, min( 100, $dim_ratio ) );
	$src   = esc_url( $image_url );
	// src wird bewusst mit esc_url gesäubert (Protokoll-/XSS-Schutz); die url steht roh im
	// Kommentar-Attribut (wp_json_encode). Bei Query-String-URLs unterscheiden sich die beiden
	// Escapings (esc_url schreibt &#038;, core/cover save() schreibt &amp;), aber Gutenbergs
	// Block-Validierung normalisiert HTML-Entities vor dem Vergleich, sodass der Block gültig
	// bleibt (per wp.data isValid mit einer &-URL belegt). esc_url NICHT gegen esc_attr tauschen:
	// das wäre ein Security-Downgrade ohne Validitäts-Gewinn.
	// core/cover save() gibt dem image-background IMMER alt="" (dekoratives Hintergrund-
	// bild; die alt-Eigenschaft wird nicht auf dieses img angewandt). Ein anderer Alt-Wert
	// wuerde die Editor-Validierung brechen (Block-Recovery). $image_alt bleibt daher hier
	// bewusst ungenutzt; ein dekoratives Bild soll leeren Alt-Text tragen.
	unset( $image_alt );

	$attrs = array(
		'url'      => $image_url,
		'dimRatio' => $dim,
	);
	if ( '' !== $overlay_slug ) {
		$attrs['overlayColor']       = $overlay_slug;
		$attrs['isUserOverlayColor'] = true;
	}
	$attrs['align']  = $align;
	$attrs['layout'] = array( 'type' => 'constrained' );

	$div_classes = array( 'wp-block-cover' );
	if ( 'full' === $align ) {
		$div_classes[] = 'alignfull';
	} elseif ( 'wide' === $align ) {
		$div_classes[] = 'alignwide';
	}

	// dim-Klasse: immer has-background-dim; der -{N}-Modifier nur abweichend von 50 (Standard).
	$dim_class    = max( 0, min( 100, (int) ( 10 * round( $dim / 10 ) ) ) );
	$span_classes = array( 'wp-block-cover__background' );
	if ( '' !== $overlay_slug ) {
		$span_classes[] = 'has-' . $overlay_slug . '-background-color';
	}
	if ( 50 !== $dim ) {
		$span_classes[] = 'has-background-dim-' . $dim_class;
	}
	$span_classes[] = 'has-background-dim';

	$div_class  = esc_attr( implode( ' ', $div_classes ) );
	$span_class = esc_attr( implode( ' ', $span_classes ) );
	$attr_json  = wpagent_pattern_attrs_json( $attrs );

	$img  = '<img class="wp-block-cover__image-background" alt="" src="' . $src . '" data-object-fit="cover"/>';
	$span = '<span aria-hidden="true" class="' . $span_class . '"></span>';
	$body = '<div class="wp-block-cover__inner-container">' . $inner . '</div>';

	return "<!-- wp:cover {$attr_json} -->\n<div class=\"{$div_class}\">{$img}{$span}{$body}</div>\n<!-- /wp:cover -->";
}

/**
 * Umschliesst inneres Markup mit einem core/group. Baut Kommentar-Attribute,
 * HTML-Klassen und Inline-Style aus derselben Quelle, damit sie deckungsgleich
 * zur Gutenberg-Serialisierung sind.
 *
 * @param string $inner Serialisiertes inneres Block-Markup.
 * @param array  $opts  align, backgroundColor, textColor, borderColor, borderWidth,
 *                      radius, padding (top/right/bottom/left als Spacing-Slug),
 *                      blockGap (Spacing-Slug), layout.
 */
function wpagent_pattern_group( string $inner, array $opts = array() ): string {
	$align     = (string) ( $opts['align'] ?? '' );
	$bg        = (string) ( $opts['backgroundColor'] ?? '' );
	$text      = (string) ( $opts['textColor'] ?? '' );
	$border    = (string) ( $opts['borderColor'] ?? '' );
	$border_w  = (string) ( $opts['borderWidth'] ?? '' );
	$radius    = (string) ( $opts['radius'] ?? '' );
	$padding   = isset( $opts['padding'] ) && is_array( $opts['padding'] ) ? $opts['padding'] : array();
	$block_gap = (string) ( $opts['blockGap'] ?? '' );
	$layout    = isset( $opts['layout'] ) && is_array( $opts['layout'] ) ? $opts['layout'] : array( 'type' => 'constrained' );

	// 1. Kommentar-Attribute.
	$attrs = array();
	if ( '' !== $align ) {
		$attrs['align'] = $align;
	}
	if ( '' !== $bg ) {
		$attrs['backgroundColor'] = $bg;
	}
	if ( '' !== $text ) {
		$attrs['textColor'] = $text;
	}
	if ( '' !== $border ) {
		$attrs['borderColor'] = $border;
	}

	$style        = array();
	$border_style = array();
	if ( '' !== $radius ) {
		$border_style['radius'] = $radius;
	}
	if ( '' !== $border_w ) {
		$border_style['width'] = $border_w;
	}
	if ( array() !== $border_style ) {
		$style['border'] = $border_style;
	}

	$spacing  = array();
	$pad_slug = array(); // Seite => aufgelöster Abstands-Slug (für Attribut UND Inline-Style).
	foreach ( array( 'top', 'right', 'bottom', 'left' ) as $side ) {
		$raw = isset( $padding[ $side ] ) ? (string) $padding[ $side ] : '';
		if ( '' === $raw ) {
			continue;
		}
		$resolved = wpagent_pattern_pick_spacing( $raw );
		if ( '' !== $resolved ) {
			$pad_slug[ $side ] = $resolved;
		}
	}
	if ( array() !== $pad_slug ) {
		$pad_attr = array();
		foreach ( $pad_slug as $side => $slug ) {
			$pad_attr[ $side ] = 'var:preset|spacing|' . $slug;
		}
		$spacing['padding'] = $pad_attr;
	}
	$gap_slug = '' !== $block_gap ? wpagent_pattern_pick_spacing( $block_gap ) : '';
	if ( '' !== $gap_slug ) {
		$spacing['blockGap'] = 'var:preset|spacing|' . $gap_slug;
	}
	if ( array() !== $spacing ) {
		$style['spacing'] = $spacing;
	}
	if ( array() !== $style ) {
		$attrs['style'] = $style;
	}
	$attrs['layout'] = $layout;

	// 2. HTML-Klassen (Reihenfolge unkritisch, Validierung vergleicht als Menge).
	$classes = array( 'wp-block-group' );
	if ( 'full' === $align ) {
		$classes[] = 'alignfull';
	} elseif ( 'wide' === $align ) {
		$classes[] = 'alignwide';
	}
	if ( '' !== $border ) {
		$classes[] = 'has-border-color';
		$classes[] = 'has-' . $border . '-border-color';
	}
	if ( '' !== $text ) {
		$classes[] = 'has-' . $text . '-color';
		$classes[] = 'has-text-color';
	}
	if ( '' !== $bg ) {
		$classes[] = 'has-' . $bg . '-background-color';
		$classes[] = 'has-background';
	}

	// 3. Inline-Style: nur Rahmen und Padding (blockGap/Farbe erscheinen NICHT).
	$decls = array();
	if ( '' !== $border_w ) {
		$decls[] = 'border-width:' . $border_w;
	}
	if ( '' !== $radius ) {
		$decls[] = 'border-radius:' . $radius;
	}
	foreach ( array( 'top', 'right', 'bottom', 'left' ) as $side ) {
		if ( isset( $pad_slug[ $side ] ) ) {
			$decls[] = 'padding-' . $side . ':var(--wp--preset--spacing--' . $pad_slug[ $side ] . ')';
		}
	}

	$class_attr = ' class="' . esc_attr( implode( ' ', $classes ) ) . '"';
	$style_attr = array() === $decls ? '' : ' style="' . esc_attr( implode( ';', $decls ) ) . '"';
	$attr_json  = wpagent_pattern_attrs_json( $attrs );

	return "<!-- wp:group {$attr_json} -->\n<div{$class_attr}{$style_attr}>{$inner}</div>\n<!-- /wp:group -->";
}
