<?php
defined( 'ABSPATH' ) || exit;

/**
 * Converts generated Markdown into Gutenberg-compatible block markup.
 */
final class Costudio_Publisher_Markdown_Renderer {

	private function __construct() {
	}

	public static function to_gutenberg( string $markdown, string $accent = '' ): string {
		// TLDR auto-wrap (self-healing). Some LLMs strip the wp:html comments
		// around the TLDR <div>, so orphan TLDR-shaped divs are wrapped before
		// Parsedown and Gutenberg block conversion run.
		$tldr_stash = array();
		$markdown   = preg_replace_callback(
			'/<!--\s*wp:html\s*-->[\s\S]*?<!--\s*\/wp:html\s*-->/i',
			static function ( array $matches ) use ( &$tldr_stash ): string {
				$key                = '<!--FOS_TLDR_STASH_' . count( $tldr_stash ) . '-->';
				$tldr_stash[ $key ] = $matches[0];
				return $key;
			},
			$markdown
		);
		$markdown   = preg_replace_callback(
			'/<div\b[^>]*\bstyle\s*=\s*"[^"]*\bborder-left\b[^"]*"[^>]*>[\s\S]*?<\/div>/i',
			static function ( array $m ): string {
				$block       = $m[0];
				$has_heading = (bool) preg_match( '/<(h[1-6]|strong)\b/i', $block );
				$has_content = (bool) preg_match( '/<(ul|ol|p)\b/i', $block );
				if ( ! $has_heading || ! $has_content ) {
					return $block;
				}
				return "\n\n<!-- wp:html -->\n" . trim( $block ) . "\n<!-- /wp:html -->\n\n";
			},
			$markdown
		);
		// Self-healing for structural HTML blocks (cos-block: cards, checklists,
		// step lists) when an LLM strips the wp:html comments. Properly commented
		// blocks were already stashed above, so only orphans reach here.
		$markdown = self::wrap_orphan_cos_blocks( $markdown );
		if ( ! empty( $tldr_stash ) ) {
			$markdown = strtr( $markdown, $tldr_stash );
		}

		$pre_blocks = array();
		$pre_marker = '<!--FOS_HTML_BLOCK_%d-->';
		$markdown   = preg_replace_callback(
			'/<!--\s*wp:html\s*-->.*?<!--\s*\/wp:html\s*-->/is',
			static function ( array $matches ) use ( &$pre_blocks, $pre_marker, $accent ): string {
				$idx                = count( $pre_blocks );
				$block              = self::normalize_inline_markdown_in_html( $matches[0] );
				$block              = self::style_checklist_inline( $block, $accent );
				$pre_blocks[ $idx ] = $block;
				return "\n\n" . sprintf( $pre_marker, $idx ) . "\n\n";
			},
			$markdown
		);

		$ref_blocks = array();
		$ref_marker = '<!--FOS_REF_BLOCK_%d-->';
		$markdown   = preg_replace_callback(
			'/<sup\s+class="costudio-ref"[^>]*>.*?<\/sup>/is',
			static function ( array $matches ) use ( &$ref_blocks, $ref_marker ): string {
				$idx                = count( $ref_blocks );
				$ref_blocks[ $idx ] = $matches[0];
				return sprintf( $ref_marker, $idx );
			},
			$markdown
		);

		$parsedown = new \Costudio_Vendor\Markdown();
		$parsedown->setSafeMode( false );

		$html = $parsedown->text( $markdown );

		$html = preg_replace_callback(
			'/<li>(.*?)<\/li>/is',
			static function ( array $m ): string {
				$inner = $m[1];
				$inner = preg_replace( '/\s*<p>\s*/i', '', $inner );
				$inner = preg_replace( '/\s*<\/p>\s*/i', '<br><br>', $inner );
				$inner = preg_replace( '/(?:<br><br>)+$/i', '', $inner );
				return '<li>' . trim( $inner ) . '</li>';
			},
			$html
		);

		$html_blocks = array();
		$html        = preg_replace_callback(
			'/<!-- wp:html -->.*?<!-- \/wp:html -->/is',
			static function ( array $matches ) use ( &$html_blocks ): string {
				$key                 = '___WP_HTML_BLOCK_' . count( $html_blocks ) . '___';
				$html_blocks[ $key ] = $matches[0];
				return $key;
			},
			$html
		);

		$wrapped = preg_replace_callback(
			'/<(h[1-6]|p|ul|ol|blockquote|pre|table|figure)(\b[^>]*)>(.*?)<\/\1>/si',
			static function ( array $m ) use ( $accent ): string {
				return self::wrap_element_in_block( $m, $accent );
			},
			$html
		);

		$result = $wrapped ?: $html;

		foreach ( $html_blocks as $key => $block ) {
			$result = str_replace( $key, "\n" . $block . "\n", $result );
		}

		if ( ! empty( $pre_blocks ) ) {
			$result = preg_replace(
				'/<p>\s*(<!--FOS_HTML_BLOCK_\d+-->)\s*<\/p>/',
				'$1',
				$result
			);
			$result = preg_replace(
				'/<!--\s*wp:paragraph\s*-->\s*(<!--FOS_HTML_BLOCK_\d+-->)\s*<!--\s*\/wp:paragraph\s*-->/',
				'$1',
				$result
			);
			foreach ( $pre_blocks as $idx => $block ) {
				$marker = sprintf( $pre_marker, $idx );
				$result = str_replace( $marker, "\n\n" . $block . "\n\n", $result );
			}
		}

		if ( ! empty( $ref_blocks ) ) {
			foreach ( $ref_blocks as $idx => $block ) {
				$marker = sprintf( $ref_marker, $idx );
				$result = str_replace( $marker, $block, $result );
			}
		}

		return $result;
	}

	/**
	 * Convert stray Markdown emphasis (`**bold**`, `*italic*`) that an agent may
	 * have left inside a raw `<!-- wp:html -->` block into real HTML tags.
	 *
	 * Content inside a wp:html block is passed through verbatim — Parsedown never
	 * sees it — so any Markdown the LLM slips in (e.g. a TLDR `<li>**Label:** …`)
	 * would otherwise render literally as asterisks on the page. This is a
	 * deterministic safety net for the TLDR / callout boxes that complements the
	 * agent-side rule forbidding Markdown inside HTML.
	 *
	 * Blocks containing a `<script>` (e.g. JSON-LD schema) are left untouched so
	 * legitimate asterisks in data are never mangled.
	 */
	private static function normalize_inline_markdown_in_html( string $block ): string {
		if ( false !== stripos( $block, '<script' ) ) {
			return $block;
		}
		// Bold first so the single-asterisk pass cannot cannibalise `**` pairs.
		$block = preg_replace( '/\*\*(?!\s)(.+?)(?<!\s)\*\*/s', '<strong>$1</strong>', $block ) ?? $block;
		// Italic: remaining single `*` pairs on one run, no adjacent space, not
		// glued to a word/another asterisk (avoids 2*3, mid-word, or ** leftovers).
		$block = preg_replace( '/(?<![\*\w])\*(?!\s)([^*\n]+?)(?<!\s)\*(?![\*\w])/s', '<em>$1</em>', $block ) ?? $block;
		return $block;
	}

	private static function wrap_element_in_block( array $m, string $accent = '' ): string {
		$tag  = strtolower( $m[1] );
		$full = $m[0];

		switch ( $tag ) {
			case 'h1':
			case 'h2':
			case 'h3':
			case 'h4':
			case 'h5':
			case 'h6':
				$level = (int) $tag[1];
				return "<!-- wp:heading {\"level\":{$level}} -->\n{$full}\n<!-- /wp:heading -->";
			case 'p':
				return "<!-- wp:paragraph -->\n{$full}\n<!-- /wp:paragraph -->";
			case 'ul':
				return "<!-- wp:list -->\n{$full}\n<!-- /wp:list -->";
			case 'ol':
				return "<!-- wp:list {\"ordered\":true} -->\n{$full}\n<!-- /wp:list -->";
			case 'blockquote':
				$inner = preg_replace( '/<p\b[^>]*>(.*?)<\/p>/si', "<!-- wp:paragraph -->\n<p>$1</p>\n<!-- /wp:paragraph -->", trim( $m[3] ) );
				if ( false === strpos( $inner, '<!-- wp:paragraph -->' ) ) {
					$inner = "<!-- wp:paragraph -->\n<p>{$inner}</p>\n<!-- /wp:paragraph -->";
				}
				return "<!-- wp:quote -->\n<blockquote class=\"wp-block-quote\">\n{$inner}\n</blockquote>\n<!-- /wp:quote -->";
			case 'pre':
				return "<!-- wp:code -->\n{$full}\n<!-- /wp:code -->";
			case 'table':
				$styled_table = self::style_table_inline( $full, $accent );
				$fig_style    = 'overflow-x:auto;border:1px solid #e5e7eb;border-radius:10px;box-shadow:0 1px 3px rgba(0,0,0,.06)';
				return "<!-- wp:html -->\n<figure class=\"wp-block-table cos-table\" style=\"{$fig_style}\">{$styled_table}</figure>\n<!-- /wp:html -->";
			case 'figure':
				return "<!-- wp:image -->\n{$full}\n<!-- /wp:image -->";
			default:
				return $full;
		}
	}

	/**
	 * Inline-style a generated `<table>` so it renders consistently on any
	 * destination site (local or remote via REST) without depending on an
	 * enqueued stylesheet — remote publishing would strip that. Every cell gets
	 * inline styles derived from the site's brand accent (header background).
	 *
	 * The rounded corners / outer border / horizontal scroll live on the wrapping
	 * `<figure>` (see the `table` case); this method only styles the table and its
	 * cells. `$accent` is validated here — an invalid/empty value falls back to a
	 * neutral slate header, so the table still looks intentional.
	 *
	 * Idempotent: a table already carrying `data-cos-styled` is returned untouched.
	 * Graceful: if the DOM extension is unavailable or parsing fails, the original
	 * table markup is returned unchanged.
	 */
	private static function style_table_inline( string $table_html, string $accent ): string {
		if ( '' === trim( $table_html ) ) {
			return $table_html;
		}
		if ( false !== stripos( $table_html, 'data-cos-styled' ) ) {
			return $table_html;
		}
		if ( ! class_exists( '\DOMDocument' ) ) {
			return $table_html;
		}

		$header_bg = self::sanitize_hex( $accent );
		if ( '' === $header_bg ) {
			$header_bg = '#334155'; // neutral slate when no valid brand color.
		}

		$dom       = new \DOMDocument();
		$prev_state = libxml_use_internal_errors( true );
		$loaded    = $dom->loadHTML(
			'<?xml encoding="UTF-8">' . $table_html,
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
		);
		libxml_clear_errors();
		libxml_use_internal_errors( $prev_state );
		if ( ! $loaded ) {
			return $table_html;
		}

		$table = $dom->getElementsByTagName( 'table' )->item( 0 );
		if ( ! $table instanceof \DOMElement ) {
			return $table_html;
		}

		$table->setAttribute( 'data-cos-styled', '1' );
		self::merge_style( $table, 'border-collapse:collapse;width:100%;font-size:.95rem;margin:0' );

		foreach ( iterator_to_array( $dom->getElementsByTagName( 'th' ) ) as $th ) {
			$th_style = 'background:' . $header_bg . ';color:#fff;padding:.7rem 1rem;font-weight:600;border:none';
			// Preserve an author-set column alignment — Parsedown emits text-align
			// for `:---:` / `---:` columns; only default to left when none exists.
			if ( stripos( (string) $th->getAttribute( 'style' ), 'text-align' ) === false ) {
				$th_style .= ';text-align:left';
			}
			self::merge_style( $th, $th_style );
		}

		$body_row_index = 0;
		foreach ( iterator_to_array( $dom->getElementsByTagName( 'tr' ) ) as $tr ) {
			$cells = array();
			foreach ( $tr->childNodes as $child ) {
				if ( $child instanceof \DOMElement && 'td' === strtolower( $child->nodeName ) ) {
					$cells[] = $child;
				}
			}
			if ( empty( $cells ) ) {
				continue; // header-only row.
			}
			$zebra = ( 0 === $body_row_index % 2 ) ? '#ffffff' : '#fafafa';
			foreach ( $cells as $i => $td ) {
				$style = 'padding:.65rem 1rem;border-top:1px solid #ececec;background:' . $zebra;
				if ( 0 === $i ) {
					$style .= ';font-weight:600';
				}
				self::merge_style( $td, $style );
			}
			$body_row_index++;
		}

		$styled = $dom->saveHTML( $table );
		return ( is_string( $styled ) && '' !== trim( $styled ) ) ? $styled : $table_html;
	}

	/**
	 * Wrap orphaned `cos-block` divs (an LLM stripped the `wp:html` comments)
	 * in `<!-- wp:html -->` markers so they survive Gutenberg conversion and
	 * reach `style_checklist_inline()`.
	 *
	 * Finds the true outer `</div>` with a nesting-depth scan instead of a
	 * blank-line heuristic, so it doesn't depend on the agent's exact
	 * whitespace after the block (a real published block was previously left
	 * unwrapped — and so unstyled — because nothing followed its closing tag
	 * with a blank line). Tolerates `cos-block` appearing alongside other
	 * classes. Unbalanced markup is left untouched rather than risking
	 * dropped content.
	 */
	private static function wrap_orphan_cos_blocks( string $markdown ): string {
		if ( false === stripos( $markdown, 'cos-block' ) ) {
			return $markdown;
		}

		$out    = '';
		$cursor = 0;
		$length = strlen( $markdown );

		while ( $cursor < $length ) {
			if ( ! preg_match(
				'/<div\b[^>]*\bclass\s*=\s*"[^"]*\bcos-block\b[^"]*"[^>]*>/i',
				$markdown,
				$m,
				PREG_OFFSET_CAPTURE,
				$cursor
			) ) {
				break;
			}
			$start   = $m[0][1];
			$tag_end = $start + strlen( $m[0][0] );
			$out    .= substr( $markdown, $cursor, $start - $cursor );

			// Nesting-depth scan for the matching outer </div>.
			$depth = 1;
			$pos   = $tag_end;
			$close = null;
			while ( $depth > 0 ) {
				$next_open  = stripos( $markdown, '<div', $pos );
				$next_close = stripos( $markdown, '</div>', $pos );
				if ( false === $next_close ) {
					break; // Unbalanced markup — bail, leave this block untouched.
				}
				if ( false !== $next_open && $next_open < $next_close ) {
					$depth++;
					$pos = $next_open + 4;
				} else {
					--$depth;
					$pos = $next_close + 6;
					if ( 0 === $depth ) {
						$close = $pos;
					}
				}
			}

			if ( null === $close ) {
				// No balanced close found — emit the opening tag as-is and keep
				// scanning. Never drop content; the block just won't be wrapped.
				$out   .= substr( $markdown, $start, $tag_end - $start );
				$cursor = $tag_end;
				continue;
			}

			$block  = substr( $markdown, $start, $close - $start );
			$out   .= "\n\n<!-- wp:html -->\n" . trim( $block ) . "\n<!-- /wp:html -->\n\n";
			$cursor = $close;
		}
		$out .= substr( $markdown, $cursor );

		return $out;
	}

	/**
	 * Auto-style a `cos-block` "savable checklist" (the agent-contract template
	 * with `<span style="color:[NICHE_COLOR]">☑</span>` items) into the polished
	 * card treatment: brand-accent left border, circular check badge in the
	 * title, and per-item square check badges rendered as inline SVG instead of
	 * the raw ☑ glyph — which depends on system/emoji font support and can render
	 * as an empty box on some devices.
	 *
	 * Scope is deliberately narrow: only `cos-block` divs whose first `<li>`
	 * contains the ☑ glyph are touched. Other cos-block variants (TLDR/Key
	 * Takeaways callouts, pros/cons cards, numbered step lists) do not match this
	 * signature and pass through unchanged.
	 *
	 * Idempotent: a block already carrying `data-cos-checklist-styled` is
	 * returned untouched. Graceful: if the DOM extension is unavailable or
	 * parsing fails, the original markup is returned unchanged — this must never
	 * be the reason a publish fails.
	 */
	private static function style_checklist_inline( string $html, string $accent ): string {
		if ( false === stripos( $html, 'cos-block' ) || false === strpos( $html, '☑' ) ) {
			return $html;
		}
		if ( false !== stripos( $html, 'data-cos-checklist-styled' ) ) {
			return $html;
		}
		if ( ! class_exists( '\DOMDocument' ) ) {
			return $html;
		}

		$accent_hex = self::sanitize_hex( $accent );
		if ( '' === $accent_hex ) {
			$accent_hex = '#334155'; // neutral slate when no valid brand color.
		}

		$dom        = new \DOMDocument();
		$prev_state = libxml_use_internal_errors( true );
		$loaded     = $dom->loadHTML(
			'<?xml encoding="UTF-8">' . $html,
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
		);
		libxml_clear_errors();
		libxml_use_internal_errors( $prev_state );
		if ( ! $loaded ) {
			return $html;
		}

		$xpath  = new \DOMXPath( $dom );
		$blocks = $xpath->query( "//div[contains(concat(' ', normalize-space(@class), ' '), ' cos-block ')]" );
		if ( ! $blocks instanceof \DOMNodeList || 0 === $blocks->length ) {
			return $html;
		}

		$changed = false;

		foreach ( iterator_to_array( $blocks ) as $block ) {
			if ( ! $block instanceof \DOMElement ) {
				continue;
			}
			$lists = $block->getElementsByTagName( 'ul' );
			if ( 0 === $lists->length ) {
				continue;
			}
			$ul    = $lists->item( 0 );
			$items = $ul->getElementsByTagName( 'li' );
			if ( 0 === $items->length ) {
				continue;
			}
			$first_li_html = (string) $dom->saveHTML( $items->item( 0 ) );
			if ( false === strpos( $first_li_html, '☑' ) ) {
				continue; // Not a checklist-shaped cos-block (e.g. TLDR, step list).
			}

			// 1. Outer card: brand-accent left border + idempotency marker.
			self::merge_style( $block, 'border-left:4px solid ' . $accent_hex );
			$block->setAttribute( 'data-cos-checklist-styled', '1' );

			// 2. Title: turn "<strong>✓ Title</strong>" into a circular badge + text.
			$strongs = $block->getElementsByTagName( 'strong' );
			if ( $strongs->length > 0 ) {
				$title_el   = $strongs->item( 0 );
				$title_text = trim( preg_replace( '/^[✓\x{2611}\s]+/u', '', (string) $title_el->textContent ) ?? '' );

				while ( $title_el->firstChild ) {
					$title_el->removeChild( $title_el->firstChild );
				}
				self::merge_style( $title_el, 'display:flex;align-items:center;gap:0.6rem;font-size:1.05rem' );
				$title_el->appendChild( self::checklist_badge_fragment( $dom, $accent_hex, 24, 14, false ) );
				$title_el->appendChild( $dom->createTextNode( $title_text ) );
			}

			// 3. List: strip bullets, then rebuild each item as an SVG-badge row
			//    with a thin divider between items (none on the first).
			self::merge_style( $ul, 'list-style:none;margin:0.6rem 0 0 0;padding:0' );

			$divider = 'border-top:1px solid ' . self::hex_to_rgba( $accent_hex, 0.15 );
			$index   = 0;
			foreach ( iterator_to_array( $items ) as $li ) {
				if ( ! $li instanceof \DOMElement ) {
					continue;
				}

				// Remove only the checkbox glyph <span> (identified by its ☑
				// text), then trim a leftover leading whitespace-only text
				// node. Every other child stays attached to $li and is moved
				// (not recreated) into the new label wrapper below — so an
				// item like `☑ <strong>Situation:</strong> "script text"`
				// keeps its <strong> instead of being flattened to plain text.
				$checkbox_span = null;
				foreach ( iterator_to_array( $li->childNodes ) as $child ) {
					if ( $child instanceof \DOMElement
						&& 'span' === $child->nodeName
						&& false !== strpos( (string) $child->textContent, '☑' )
					) {
						$checkbox_span = $child;
						break;
					}
				}
				if ( $checkbox_span instanceof \DOMElement ) {
					$li->removeChild( $checkbox_span );
				}
				while ( $li->firstChild instanceof \DOMText && '' === trim( (string) $li->firstChild->textContent ) ) {
					$li->removeChild( $li->firstChild );
				}
				// The checkbox span is normally followed by a single space
				// before the label text (e.g. `☑</span> Item text`); strip
				// only that leading whitespace, not the whole text node.
				if ( $li->firstChild instanceof \DOMText ) {
					$li->firstChild->data = ltrim( $li->firstChild->data );
				}
				$remaining_children = iterator_to_array( $li->childNodes );

				$row_style = 'display:flex;align-items:flex-start;gap:0.65rem;padding:0.5rem 0';
				if ( $index > 0 ) {
					$row_style .= ';' . $divider;
				}
				self::merge_style( $li, $row_style );

				$li->appendChild( self::checklist_badge_fragment( $dom, $accent_hex, 18, 11, true ) );

				$label = $dom->createElement( 'span' );
				$label->setAttribute( 'style', 'color:#2a2a3d;font-size:0.97rem;line-height:1.45' );
				foreach ( $remaining_children as $node ) {
					// appendChild() on a node from the same document moves it
					// (detaches it from $li first) rather than cloning it.
					$label->appendChild( $node );
				}
				$li->appendChild( $label );

				++$index;
			}

			$changed = true;
		}

		if ( ! $changed ) {
			return $html;
		}

		// LIBXML_HTML_NOIMPLIED means no implied <html>/<body> wrapper was
		// created, so the parsed nodes (the leading `<?xml ...>` declaration,
		// the wp:html comments, and the cos-block div) all live directly under
		// the document — there is no <body> to fetch. Reserialize every
		// document child except that leading declaration, in original order,
		// so both Gutenberg comments and the styled div are preserved.
		$out = '';
		foreach ( iterator_to_array( $dom->childNodes ) as $child ) {
			if ( XML_PI_NODE === $child->nodeType ) {
				continue;
			}
			$out .= (string) $dom->saveHTML( $child );
		}

		return '' !== trim( $out ) ? $out : $html;
	}

	/**
	 * Build a single check-badge (`<span><svg>…</svg></span>`) as a document
	 * fragment ready to append to a title or list-item node. `$square` picks the
	 * rounded-square list-item variant vs. the circular title-badge variant.
	 */
	private static function checklist_badge_fragment( \DOMDocument $dom, string $accent_hex, int $size, int $icon_size, bool $square ): \DOMDocumentFragment {
		$radius = $square ? '4px' : '50%';
		$extra  = $square ? 'flex-shrink:0;margin-top:0.15rem;' : 'flex-shrink:0;';
		$stroke = $square ? '2.2' : '2';

		$style = sprintf(
			'%sdisplay:inline-flex;align-items:center;justify-content:center;width:%dpx;height:%dpx;background:%s;border-radius:%s',
			$extra,
			$size,
			$size,
			$accent_hex,
			$radius
		);

		$xml = sprintf(
			'<span style="%s"><svg width="%d" height="%d" viewBox="0 0 16 16" fill="none"><path d="M3 8.5L6.5 12L13 4" stroke="white" stroke-width="%s" stroke-linecap="round" stroke-linejoin="round"/></svg></span>',
			$style,
			$icon_size,
			$icon_size,
			$stroke
		);

		$frag = $dom->createDocumentFragment();
		try {
			$frag->appendXML( $xml );
		} catch ( \Throwable $e ) {
			// If the fragment fails to parse for any reason, fail closed with an
			// empty fragment rather than breaking the whole publish.
		}
		return $frag;
	}

	/**
	 * Convert a validated `#rrggbb`/`#rgb` hex color to an `rgba()` string at the
	 * given alpha, for the subtle divider lines between checklist rows.
	 */
	private static function hex_to_rgba( string $hex, float $alpha ): string {
		$hex = ltrim( $hex, '#' );
		if ( 3 === strlen( $hex ) ) {
			$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
		}
		if ( 6 !== strlen( $hex ) || ! ctype_xdigit( $hex ) ) {
			return 'rgba(0,0,0,' . $alpha . ')';
		}
		$r = hexdec( substr( $hex, 0, 2 ) );
		$g = hexdec( substr( $hex, 2, 2 ) );
		$b = hexdec( substr( $hex, 4, 2 ) );
		return "rgba({$r},{$g},{$b},{$alpha})";
	}

	/**
	 * Append inline CSS to an element's existing `style` attribute (never clobbers).
	 */
	private static function merge_style( \DOMElement $el, string $style ): void {
		$existing = trim( $el->getAttribute( 'style' ) );
		if ( '' !== $existing ) {
			$existing = rtrim( $existing, ';' ) . ';';
		}
		$el->setAttribute( 'style', $existing . $style );
	}

	/**
	 * Return a normalized `#rrggbb`/`#rgb` hex or '' when the input is not a valid
	 * hex color — so a brand color is never interpolated into a style attribute
	 * unvalidated.
	 */
	private static function sanitize_hex( string $hex ): string {
		$hex = trim( $hex );
		return preg_match( '/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $hex ) ? $hex : '';
	}
}
