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

/**
 * Generic element renderer for schema-driven widgets.
 *
 * Unlike every other element class, this one is NOT keyed by a single static
 * type() — one instance serves every schema widget. Jalpi_Renderer registers
 * it once per schema type (see register_elements()), passing the concrete type
 * via set_type(). Rendering is driven by the widget's `render` template in
 * includes/widgets-schema.php.
 *
 * It follows the same CSS/escaping contract as the bespoke elements: one root
 * wrapper tag (required by the interaction engine), a scoped
 * `.jalpi-el.jalpi-{id}` rule built from styles_to_css/bg/border, and every
 * dynamic value escaped.
 */
class Jalpi_Element_Generic extends Jalpi_Element {

	/** The concrete widget type this instance is currently registered as. */
	private string $type = 'generic';

	public static function type(): string { return 'generic'; }

	public function set_type( string $type ): void { $this->type = $type; }

	public function render( array $node, Jalpi_Renderer $renderer ): string {
		$id       = sanitize_html_class( $node['id'] ?? '' );
		$content  = (array) ( $node['content'] ?? [] );
		$styles   = (array) ( $node['styles'] ?? [] );
		$settings = (array) ( $node['settings'] ?? [] );
		$a        = $renderer->get_css_attrs( $node );

		// Scoped CSS — identical construction to the bespoke elements.
		$sel  = '.jalpi-el.jalpi-' . $id;
		$css  = "{$sel}{box-sizing:border-box;";
		$css .= $renderer->bg_data_to_css( (array) ( $settings['bgNormal'] ?? [] ) );
		$css .= $renderer->border_data_to_css( (array) ( $settings['borderNormal'] ?? [] ) );
		$css .= $renderer->styles_to_css( $styles );
		$css .= '}';
		$hover = $renderer->bg_data_to_css( (array) ( $settings['bgHover'] ?? [] ) )
		       . $renderer->border_data_to_css( (array) ( $settings['borderHover'] ?? [] ) );
		if ( $hover ) {
			$css .= "{$sel}:hover{{$hover}}";
		}
		$renderer->add_css( $css );

		$schemas  = function_exists( 'jalpi_widget_schemas' ) ? jalpi_widget_schemas() : [];
		$schema   = $schemas[ $this->type ] ?? [];
		$template = $schema['render'] ?? 'default';

		// Widgets flagged needsJs get the shared frontend script enqueued (each
		// widget's initX() in jalpi-frontend.js self-guards by selector).
		if ( ! empty( $schema['needsJs'] ) ) {
			$renderer->set_needs_js( 'generic' );
		}

		$inner = $this->render_template( $template, $content, $id, $renderer );

		return '<div class="jalpi-el jalpi-' . esc_attr( $id ) . $a['extra_cls'] . '"' . $a['id_attr'] . '>'
			. $inner
			. '</div>';
	}

	/**
	 * Render the widget's inner HTML for a given template key. Each branch
	 * escapes every value it emits. $renderer is available for shared helpers
	 * such as render_icon_value() (icon-library rendering + CDN tracking).
	 */
	private function render_template( string $template, array $content, string $id, Jalpi_Renderer $renderer ): string {
		switch ( $template ) {

			// ── Effects ───────────────────────────────────────────────
			case 'marquee': {
				$text  = sanitize_text_field( $content['text'] ?? '' );
				$speed = max( 1, intval( $content['speed'] ?? 20 ) );
				$dir   = self::enum( $content['direction'] ?? 'left', [ 'left', 'right' ], 'left' );
				$pause = ! empty( $content['pauseOnHover'] ) ? '1' : '0';
				// Item spacing = gap between the repeating copies. Applied as
				// padding-right on the track so offsetWidth (which the runtime
				// uses to compute the seamless loop length) includes it —
				// keeping the loop math correct at any spacing. Absent key = 0
				// (byte-identical output to before this control existed); only
				// NEW marquees get the editor-seeded 60px default.
				$gap      = max( 0, intval( $content['itemSpacing'] ?? 0 ) );
				$gap_css  = $gap > 0 ? 'padding-right:' . esc_attr( (string) $gap ) . 'px;' : '';
				return '<div class="jalpi-marquee" data-speed="' . esc_attr( (string) $speed ) . '" data-dir="' . esc_attr( $dir ) . '" data-pause="' . esc_attr( $pause ) . '" style="overflow:hidden;white-space:nowrap;">'
					. '<span class="jalpi-marquee__track" style="display:inline-block;will-change:transform;' . $gap_css . '">' . esc_html( $text ) . '</span>'
					. '</div>';
			}

			case 'typewriter-text': {
				$tag   = self::heading_tag( $content['tag'] ?? 'h2' );
				$speed = max( 10, intval( $content['typeSpeed'] ?? 100 ) );
				$loop  = ! empty( $content['loop'] ) ? '1' : '0';
				// words is a newline-separated list; sanitize each, JSON-encode
				// for the data-attr so the runtime reads a clean array.
				$raw   = (string) ( $content['words'] ?? '' );
				$words = array_values( array_filter( array_map( 'sanitize_text_field', preg_split( '/\r\n|\r|\n/', $raw ) ) ) );
				$json  = wp_json_encode( $words );

				// Cursor styling — absent keys keep the original glyph caret,
				// so old saved nodes render identically.
				$cur_style = self::enum( $content['cursorStyle'] ?? 'bar', [ 'bar', 'underscore', 'block' ], 'bar' );
				$cur_glyph = [ 'bar' => '|', 'underscore' => '_', 'block' => '▌' ][ $cur_style ];
				$caret_css = '';
				$cc = Jalpi_Sanitizer::sanitize_color( $content['cursorColor'] ?? '' );
				if ( $cc ) {
					$caret_css .= 'color:' . esc_attr( $cc ) . ';';
				}
				$cw = max( 0, intval( $content['cursorWidth'] ?? 0 ) );
				if ( $cw > 0 && 'bar' === $cur_style ) {
					// Width only meaningfully applies to the bar caret — render
					// it as a colored border instead of the glyph for crispness.
					$caret_css .= 'display:inline-block;width:' . esc_attr( (string) $cw ) . 'px;overflow:hidden;color:transparent;'
						. 'background:' . esc_attr( $cc ?: 'currentColor' ) . ';';
				}
				$caret_attr = $caret_css ? ' style="' . $caret_css . '"' : '';
				return "<{$tag} class=\"jalpi-typewriter\" data-words='" . esc_attr( $json ) . "' data-speed=\"" . esc_attr( (string) $speed ) . '" data-loop="' . esc_attr( $loop ) . '"><span class="jalpi-typewriter__text"></span><span class="jalpi-typewriter__caret"' . $caret_attr . '>' . esc_html( $cur_glyph ) . '</span>' . "</{$tag}>";
			}

			case 'shape-divider': {
				$color  = Jalpi_Sanitizer::sanitize_color( $content['color'] ?? '#1a1a1a' ) ?: '#1a1a1a';
				$height = max( 1, intval( $content['height'] ?? 60 ) );
				$shape  = self::enum( $content['shape'] ?? 'wave', [ 'wave', 'tilt', 'curve', 'triangle' ], 'wave' );
				$flip   = ! empty( $content['flip'] ) ? 'transform:scaleX(-1);' : '';
				$paths  = [
					'wave'     => '<path d="M0,40 C300,90 900,-10 1200,40 L1200,120 L0,120 Z"/>',
					'tilt'     => '<path d="M0,120 L1200,0 L1200,120 Z"/>',
					'curve'    => '<path d="M0,120 C400,0 800,0 1200,120 Z"/>',
					'triangle' => '<path d="M0,120 L600,0 L1200,120 Z"/>',
				];
				$path = $paths[ $shape ];
				// Width >100% lets the shape overhang for full-bleed dividers;
				// z-index gives bring-forward/send-backward stacking control.
				// Both absent = identical output to before these controls.
				$wrap_css = 'line-height:0;' . $flip;
				$w_pct    = intval( $content['widthPct'] ?? 0 );
				$svg_w    = '100%';
				if ( $w_pct >= 100 && $w_pct <= 300 && 100 !== $w_pct ) {
					$svg_w     = $w_pct . '%';
					$wrap_css .= 'overflow:hidden;';
				}
				if ( isset( $content['zIndex'] ) && '' !== (string) $content['zIndex'] ) {
					$wrap_css .= 'position:relative;z-index:' . esc_attr( (string) intval( $content['zIndex'] ) ) . ';';
				}
				return '<div class="jalpi-shape-divider" style="' . esc_attr( $wrap_css ) . '">'
					. '<svg viewBox="0 0 1200 120" preserveAspectRatio="none" style="width:' . esc_attr( $svg_w ) . ';height:' . esc_attr( (string) $height ) . 'px;display:block;fill:' . esc_attr( $color ) . ';">'
					. $path // static literal, not user data
					. '</svg></div>';
			}

			// ── Social ────────────────────────────────────────────────
			case 'social-share': {
				// ── NEW model: icon + link + color items ──────────────────
				// When the `items` JSON is present, render a row of linked icons
				// (each icon chosen from the library). Falls through to the
				// legacy share-network rendering below when `items` is absent,
				// so existing Social Share content is byte-identical.
				$items_json = $content['items'] ?? null;
				if ( null !== $items_json && '' !== $items_json ) {
					$items = is_array( $items_json ) ? $items_json : json_decode( (string) $items_json, true );
					if ( is_array( $items ) && ! empty( $items ) ) {
						$style = ( ( $content['style'] ?? 'filled' ) === 'outline' ) ? 'outline' : 'filled';
						$shape = $content['shape'] ?? 'rounded';
						if ( ! in_array( $shape, [ 'circle', 'rounded', 'square' ], true ) ) {
							$shape = 'rounded';
						}

						// ── Icon style controls (Style tab) — all optional; absent
						//    keys reproduce the previous fixed defaults exactly. The
						//    slider controls store bare numbers, so we append units.
						$num = static function ( $v ) { return ( isset( $v ) && '' !== (string) $v && is_numeric( $v ) ) ? (float) $v : null; };
						// dims4: turn a shorthand ("8" or "1 2 3 4") of BARE numbers
						// into a CSS length shorthand ("8px" / "1px 2px 3px 4px").
						// Rejects anything non-numeric (defence in depth).
						$dims4 = static function ( $v ) {
							$v = trim( (string) $v );
							if ( '' === $v ) { return ''; }
							$parts = preg_split( '/\s+/', $v );
							$out   = [];
							foreach ( $parts as $p ) {
								if ( ! is_numeric( $p ) ) { return ''; }
								$out[] = ( 0 + $p ) . 'px';
							}
							return implode( ' ', array_slice( $out, 0, 4 ) );
						};
						$sz          = $num( $content['iconSize'] ?? null );
						$icon_size   = null !== $sz ? $sz . 'px' : '';
						$pd          = $num( $content['iconPadding'] ?? null );
						$padding     = null !== $pd ? $pd . 'em' : '';
						$spacing     = ( $n = $num( $content['iconSpacing'] ?? null ) ) !== null ? max( 0, (int) $n ) : 8;
						$rows_gap    = ( $n = $num( $content['iconRowsGap'] ?? null ) ) !== null ? max( 0, (int) $n ) : 0;
						$color_mode  = ( ( $content['iconColorMode'] ?? 'official' ) === 'custom' ) ? 'custom' : 'official';
						$icon_color  = Jalpi_Sanitizer::sanitize_color( (string) ( $content['iconColor'] ?? '' ) );
						$b_type      = self::enum( $content['iconBorderType'] ?? 'default', [ 'default', 'none', 'solid', 'dashed', 'dotted', 'double' ], 'default' );
						$b_width     = $dims4( $content['iconBorderWidth'] ?? '' ); // e.g. '3px' or '1px 2px 1px 2px'
						$b_color     = Jalpi_Sanitizer::sanitize_color( (string) ( $content['iconBorderColor'] ?? '' ) );
						$radius_len  = $dims4( $content['iconBorderRadius'] ?? '' );
						$hover_anim  = self::enum( $content['hoverAnimation'] ?? 'none', [ 'none', 'grow', 'shrink', 'pulse', 'pulse-grow', 'pulse-shrink', 'push', 'pop', 'bounce-in', 'bounce-out', 'rotate', 'float', 'sink', 'wobble-horizontal', 'wobble-vertical', 'buzz' ], 'none' );

						// Radius: explicit override wins, else the shape default.
						$radius = '' !== $radius_len ? $radius_len : [ 'circle' => '50%', 'rounded' => '8px', 'square' => '0' ][ $shape ];
						// Box sizing: explicit padding/size override the fixed 40px box.
						$box_size = '' !== $icon_size || '' !== $padding
							? ( '' !== $padding ? 'padding:' . esc_attr( $padding ) . ';' : '' ) . ( '' !== $icon_size ? 'font-size:' . esc_attr( $icon_size ) . ';' : '' )
							: 'width:40px;height:40px;';

						$wrap_style = 'display:flex;flex-wrap:wrap;gap:' . esc_attr( (string) $rows_gap ) . 'px ' . esc_attr( (string) $spacing ) . 'px;';
						$anim_cls   = 'none' !== $hover_anim ? ' jalpi-hvr jalpi-hvr--' . $hover_anim : '';

						$html = '<div class="jalpi-share jalpi-share--icons" style="' . esc_attr( $wrap_style ) . '">';
						foreach ( $items as $item ) {
							if ( ! is_array( $item ) ) {
								continue;
							}
							$icon_html = $renderer->render_icon_value( $item['icon'] ?? '' );
							if ( '' === $icon_html ) {
								continue;
							}
							// Per-item color unless a custom global color overrides it.
							$color = 'custom' === $color_mode && '' !== $icon_color
								? $icon_color
								: ( Jalpi_Sanitizer::sanitize_color( (string) ( $item['color'] ?? '' ) ) ?: '#1a1a1a' );
							$href  = esc_url( (string) ( $item['link'] ?? '' ) );

							// Border: 'default' keeps the original 2px solid; 'none'
							// removes it; otherwise use the chosen type/width/color.
							// Width may be a 4-side shorthand, so emit longhands.
							if ( 'none' === $b_type ) {
								$border = 'border:none;';
							} elseif ( 'default' === $b_type ) {
								$border = 'border:2px solid ' . $color . ';';
							} else {
								$bc      = '' !== $b_color ? $b_color : $color;
								$bw      = '' !== $b_width ? $b_width : '1px';
								$border  = 'border-style:' . esc_attr( $b_type ) . ';'
									. 'border-color:' . esc_attr( $bc ) . ';'
									. 'border-width:' . esc_attr( $bw ) . ';';
							}

							// $border already encodes none/default/custom correctly, so
							// it applies the same in both filled and outline styles.
							$box   = 'display:inline-flex;align-items:center;justify-content:center;'
								. $box_size
								. 'border-radius:' . esc_attr( $radius ) . ';text-decoration:none;line-height:1;'
								. ( 'outline' === $style
									? 'background:transparent;' . $border . 'color:' . $color . ';'
									: 'background:' . $color . ';' . $border . 'color:#fff;' );

							if ( '' !== $href ) {
								$html .= '<a class="jalpi-share__btn' . $anim_cls . '" href="' . $href . '" target="_blank" rel="noopener noreferrer" style="' . esc_attr( $box ) . '">' . $icon_html . '</a>';
							} else {
								$html .= '<span class="jalpi-share__btn' . $anim_cls . '" style="' . esc_attr( $box ) . '">' . $icon_html . '</span>';
							}
						}
						$html .= '</div>';
						return $html;
					}
				}

				// ── LEGACY model: share the current page via known networks ──
				$raw_nets = (string) ( $content['networks'] ?? 'facebook,twitter,linkedin,whatsapp' );
				$allowed  = [ 'facebook', 'twitter', 'linkedin', 'whatsapp', 'youtube', 'email', 'pinterest', 'telegram', 'reddit' ];
				// Preserve author order AND duplicates (the repeater allows both);
				// only filter out unknown keys.
				$nets = array_values( array_filter(
					array_map( 'trim', explode( ',', strtolower( $raw_nets ) ) ),
					static function ( $n ) use ( $allowed ) { return in_array( $n, $allowed, true ); }
				) );
				if ( ! $nets ) {
					$nets = [ 'facebook', 'twitter', 'linkedin', 'whatsapp' ];
				}
				// Per-item custom colors: "key:#hex,key:#hex". Absent keys use the
				// network's official color, so pre-repeater content is identical.
				$color_map = [];
				foreach ( explode( ',', (string) ( $content['networkColors'] ?? '' ) ) as $pair ) {
					$kv = array_map( 'trim', explode( ':', $pair, 2 ) );
					if ( count( $kv ) === 2 && '' !== $kv[0] ) {
						$c = Jalpi_Sanitizer::sanitize_color( $kv[1] );
						if ( '' !== $c ) {
							$color_map[ strtolower( $kv[0] ) ] = $c;
						}
					}
				}
				// Empty share URL → the current permalink at render time.
				$url  = self::sanitize_share_url( $content['shareUrl'] ?? '' );
				$text = sanitize_text_field( $content['shareText'] ?? '' );
				$u    = rawurlencode( $url );
				$t    = rawurlencode( $text );
				$style = ( ( $content['style'] ?? 'filled' ) === 'outline' ) ? 'outline' : 'filled';
				$shape = $content['shape'] ?? 'circle';
				if ( ! in_array( $shape, [ 'circle', 'rounded', 'square' ], true ) ) {
					$shape = 'circle';
				}

				$targets = [
					'facebook'  => [ 'https://www.facebook.com/sharer/sharer.php?u=' . $u, '#1877f2', 'Facebook', 'f' ],
					'twitter'   => [ 'https://twitter.com/intent/tweet?url=' . $u . '&text=' . $t, '#1da1f2', 'Twitter/X', '𝕏' ],
					'linkedin'  => [ 'https://www.linkedin.com/sharing/share-offsite/?url=' . $u, '#0a66c2', 'LinkedIn', 'in' ],
					'whatsapp'  => [ 'https://wa.me/?text=' . $t . '%20' . $u, '#25d366', 'WhatsApp', '⌾' ],
					'youtube'   => [ 'https://www.youtube.com/', '#ff0000', 'Youtube', '▶' ],
					'email'     => [ 'mailto:?subject=' . $t . '&body=' . $u, '#6b7280', 'Email', '✉' ],
					'pinterest' => [ 'https://pinterest.com/pin/create/button/?url=' . $u . '&description=' . $t, '#e60023', 'Pinterest', 'P' ],
					'telegram'  => [ 'https://t.me/share/url?url=' . $u . '&text=' . $t, '#0088cc', 'Telegram', '➤' ],
					'reddit'    => [ 'https://www.reddit.com/submit?url=' . $u . '&title=' . $t, '#ff4500', 'Reddit', 'r' ],
				];
				$radius = [ 'circle' => '50%', 'rounded' => '8px', 'square' => '0' ][ $shape ];
				$html = '<div class="jalpi-share" style="display:flex;flex-wrap:wrap;gap:8px;">';
				foreach ( $nets as $net ) {
					if ( empty( $targets[ $net ] ) ) continue;
					list( $href, $color, $label, $glyph ) = $targets[ $net ];
					// Per-item custom color overrides the official one when set.
					if ( isset( $color_map[ $net ] ) ) {
						$color = $color_map[ $net ];
					}
					$btn_style = 'display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;'
						. 'border-radius:' . $radius . ';text-decoration:none;font-weight:700;font-size:15px;'
						. ( 'outline' === $style
							? 'background:transparent;border:2px solid ' . $color . ';color:' . $color . ';'
							: 'background:' . $color . ';border:2px solid ' . $color . ';color:#fff;' );
					$html .= '<a class="jalpi-share__btn" href="' . esc_url( $href ) . '" target="_blank" rel="noopener noreferrer nofollow" '
						. 'aria-label="' . esc_attr( sprintf( /* translators: %s network name */ __( 'Share on %s', 'jalpi-page-builder' ), $label ) ) . '" '
						. 'style="' . esc_attr( $btn_style ) . '">' . esc_html( $glyph ) . '</a>';
				}
				$html .= '</div>';
				return $html;
			}

			case 'whatsapp': {
				// Strip everything but digits from the phone (wa.me wants bare digits).
				$phone = preg_replace( '/\D+/', '', (string) ( $content['phone'] ?? '' ) );
				$msg   = rawurlencode( sanitize_text_field( $content['message'] ?? '' ) );
				$label = sanitize_text_field( $content['label'] ?? '' );
				$bg    = Jalpi_Sanitizer::sanitize_color( $content['bgColor'] ?? '#25d366' ) ?: '#25d366';
				$float = ! empty( $content['floating'] );
				if ( '' === $phone ) {
					return '<div style="color:#94a3b8;font-size:13px;">' . esc_html__( 'Add a WhatsApp number', 'jalpi-page-builder' ) . '</div>';
				}
				$href = 'https://wa.me/' . $phone . ( $msg ? '?text=' . $msg : '' );
				$icon = '<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2a10 10 0 0 0-8.6 15l-1.3 4.7 4.8-1.3A10 10 0 1 0 12 2zm0 18a8 8 0 0 1-4.1-1.1l-.3-.2-2.8.7.8-2.7-.2-.3A8 8 0 1 1 12 20zm4.4-6c-.2-.1-1.4-.7-1.6-.8s-.4-.1-.5.1-.6.8-.8.9-.3.2-.5 0a6.5 6.5 0 0 1-1.9-1.2 7 7 0 0 1-1.3-1.6c-.1-.2 0-.4.1-.5l.4-.4.2-.4a.4.4 0 0 0 0-.4l-.7-1.8c-.2-.5-.4-.4-.5-.4h-.5a.9.9 0 0 0-.6.3 2.7 2.7 0 0 0-.9 2 4.7 4.7 0 0 0 1 2.5 10.7 10.7 0 0 0 4.1 3.6c2 .8 2 .5 2.4.5a2.4 2.4 0 0 0 1.6-1.1 2 2 0 0 0 .1-1.1c0-.1-.2-.2-.4-.3z"/></svg>';
				if ( $float ) {
					$style = 'position:fixed;right:24px;bottom:24px;width:56px;height:56px;border-radius:50%;z-index:9998;'
						. 'display:inline-flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(0,0,0,0.25);'
						. 'background:' . $bg . ';color:#fff;text-decoration:none;';
					return '<a class="jalpi-whatsapp jalpi-whatsapp--float" href="' . esc_url( $href ) . '" target="_blank" rel="noopener noreferrer nofollow" '
						. 'aria-label="' . esc_attr( $label ?: __( 'Chat on WhatsApp', 'jalpi-page-builder' ) ) . '" style="' . esc_attr( $style ) . '">' . $icon . '</a>';
				}
				$style = 'display:inline-flex;align-items:center;gap:8px;padding:10px 18px;border-radius:8px;'
					. 'text-decoration:none;font-weight:600;background:' . $bg . ';color:#fff;';
				return '<a class="jalpi-whatsapp" href="' . esc_url( $href ) . '" target="_blank" rel="noopener noreferrer nofollow" style="' . esc_attr( $style ) . '">'
					. $icon . '<span>' . esc_html( $label ?: __( 'Chat on WhatsApp', 'jalpi-page-builder' ) ) . '</span></a>';
			}

			case 'youtube-gallery': {
				$cols = max( 1, min( 6, intval( $content['columns'] ?? 3 ) ) );
				$raw  = (string) ( $content['videos'] ?? '' );
				$ids  = [];
				foreach ( preg_split( '/\r\n|\r|\n/', $raw ) as $line ) {
					$vid = self::youtube_id( trim( $line ) );
					if ( '' !== $vid ) $ids[] = $vid;
				}
				if ( ! $ids ) {
					return '<div style="color:#94a3b8;font-size:13px;">' . esc_html__( 'Add YouTube video IDs or URLs (one per line)', 'jalpi-page-builder' ) . '</div>';
				}
				$html = '<div class="jalpi-yt-gallery" style="display:grid;grid-template-columns:repeat(' . esc_attr( (string) $cols ) . ',1fr);gap:12px;">';
				foreach ( $ids as $id ) {
					// Facade pattern: lazy thumbnail with a play button; the real
					// iframe is only injected on click (jalpi-frontend.js). No
					// YouTube request happens until the user clicks — fast + private.
					$thumb = 'https://i.ytimg.com/vi/' . $id . '/hqdefault.jpg';
					$html .= '<button type="button" class="jalpi-yt-item" data-id="' . esc_attr( $id ) . '" '
						. 'aria-label="' . esc_attr__( 'Play video', 'jalpi-page-builder' ) . '" '
						. 'style="position:relative;padding:0;border:none;cursor:pointer;background:#000;border-radius:8px;overflow:hidden;aspect-ratio:16/9;">'
						. '<img src="' . esc_url( $thumb ) . '" loading="lazy" alt="" style="width:100%;height:100%;object-fit:cover;display:block;opacity:.85;" />'
						. '<span class="jalpi-yt-play" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;">'
						. '<svg width="48" height="48" viewBox="0 0 68 48" aria-hidden="true"><path d="M66.5 7.7a8.6 8.6 0 0 0-6-6C55 0 34 0 34 0S13 0 7.5 1.6a8.6 8.6 0 0 0-6 6A90 90 0 0 0 0 24a90 90 0 0 0 1.5 16.3 8.6 8.6 0 0 0 6 6C13 48 34 48 34 48s21 0 26.5-1.6a8.6 8.6 0 0 0 6-6A90 90 0 0 0 68 24a90 90 0 0 0-1.5-16.3z" fill="#f00"/><path d="M27 34l18-10-18-10z" fill="#fff"/></svg>'
						. '</span></button>';
				}
				$html .= '</div>';
				return $html;
			}

			case 'facebook-embed': {
				$post = self::sanitize_social_url( $content['postUrl'] ?? '', [ 'facebook.com', 'fb.watch', 'fb.com' ] );
				$w    = max( 200, min( 750, intval( $content['width'] ?? 500 ) ) );
				if ( '' === $post ) {
					return '<div style="color:#94a3b8;font-size:13px;">' . esc_html__( 'Add a public Facebook post URL', 'jalpi-page-builder' ) . '</div>';
				}
				// Official FB embed markup; the SDK (loaded lazily by
				// jalpi-frontend.js) upgrades this to the rendered post. The
				// blockquote fallback links to the post if the SDK is blocked.
				return '<div class="jalpi-fb-embed"><div class="fb-post" data-href="' . esc_url( $post ) . '" data-width="' . esc_attr( (string) $w ) . '" data-lazy="true">'
					. '<blockquote cite="' . esc_url( $post ) . '" class="fb-xfbml-parse-ignore"><a href="' . esc_url( $post ) . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'View Facebook post', 'jalpi-page-builder' ) . '</a></blockquote>'
					. '</div></div>';
			}

			case 'instagram-embed': {
				$post = self::sanitize_social_url( $content['postUrl'] ?? '', [ 'instagram.com', 'instagr.am' ] );
				if ( '' === $post ) {
					return '<div style="color:#94a3b8;font-size:13px;">' . esc_html__( 'Add a public Instagram post URL', 'jalpi-page-builder' ) . '</div>';
				}
				// Official IG embed blockquote; SDK (lazy) upgrades it. Fallback
				// links to the post.
				return '<div class="jalpi-ig-embed"><blockquote class="instagram-media" data-instgrm-permalink="' . esc_url( $post ) . '" data-instgrm-version="14" style="max-width:540px;margin:0 auto;">'
					. '<a href="' . esc_url( $post ) . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'View Instagram post', 'jalpi-page-builder' ) . '</a>'
					. '</blockquote></div>';
			}

			// ── Icon Box (full icon-library picker) ───────────────────
			case 'icon-box': {
				$size  = max( 8, min( 400, intval( $content['size'] ?? 48 ) ) );
				$color = Jalpi_Sanitizer::sanitize_color( $content['color'] ?? '' );
				$align = $content['align'] ?? 'center';
				if ( ! in_array( $align, [ 'left', 'center', 'right' ], true ) ) {
					$align = 'center';
				}
				$title = sanitize_text_field( $content['title'] ?? '' );
				$text  = sanitize_text_field( $content['text'] ?? '' );
				$link  = esc_url( $content['link'] ?? '' );

				// The icon value is an IconValue array (or legacy string) — the
				// shared renderer handles every library + escaping + CDN tracking.
				$icon_html = $renderer->render_icon_value( $content['icon'] ?? '' );
				$icon_style = 'display:inline-flex;font-size:' . esc_attr( (string) $size ) . 'px;line-height:1;'
					. ( $color ? 'color:' . esc_attr( $color ) . ';' : '' );
				// Scope svg/img sizing to the chosen size.
				$icon_wrap = '<span class="jalpi-iconbox__icon" style="' . $icon_style . '">' . $icon_html . '</span>';

				$html  = '<div class="jalpi-iconbox" style="text-align:' . esc_attr( $align ) . ';">';
				$html .= $link
					? '<a href="' . $link . '" style="text-decoration:none;color:inherit;">' . $icon_wrap . '</a>'
					: $icon_wrap;
				if ( '' !== $title ) {
					$html .= '<div class="jalpi-iconbox__title" style="font-weight:700;margin-top:8px;">' . esc_html( $title ) . '</div>';
				}
				if ( '' !== $text ) {
					$html .= '<div class="jalpi-iconbox__text" style="margin-top:4px;opacity:.85;">' . esc_html( $text ) . '</div>';
				}
				$html .= '</div>';
				return $html;
			}

			default:
				// Unknown template — emit escaped text if present, else nothing.
				return isset( $content['text'] ) ? wp_kses_post( $content['text'] ) : '';
		}
	}

	/** Share URL: a valid http(s) URL, else the current permalink. */
	private static function sanitize_share_url( $url ): string {
		$url = trim( (string) $url );
		if ( '' !== $url && preg_match( '#^https?://#i', $url ) ) {
			return esc_url_raw( $url );
		}
		$permalink = get_permalink();
		return $permalink ? $permalink : home_url( '/' );
	}

	/**
	 * Validate a social post URL against an allow-list of hosts. Returns '' if
	 * the URL is malformed or not on an allowed host — prevents the embed
	 * markup from pointing anywhere arbitrary.
	 */
	private static function sanitize_social_url( $url, array $allowed_hosts ): string {
		$url = esc_url_raw( trim( (string) $url ) );
		if ( '' === $url ) return '';
		$host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
		$host = preg_replace( '/^www\./', '', $host );
		foreach ( $allowed_hosts as $ok ) {
			if ( $host === $ok || str_ends_with( $host, '.' . $ok ) ) {
				return $url;
			}
		}
		return '';
	}

	/**
	 * Extract an 11-char YouTube video ID from a URL or bare ID. Returns '' if
	 * no valid ID is found — the ID is later placed into an iframe src, so it
	 * must be strictly [A-Za-z0-9_-]{11} and nothing else.
	 */
	private static function youtube_id( string $input ): string {
		if ( '' === $input ) return '';
		if ( preg_match( '/^[A-Za-z0-9_-]{11}$/', $input ) ) {
			return $input;
		}
		if ( preg_match( '#(?:youtube\.com/(?:watch\?v=|embed/|shorts/)|youtu\.be/)([A-Za-z0-9_-]{11})#', $input, $m ) ) {
			return $m[1];
		}
		return '';
	}

	/** Whitelist a heading tag; fall back to a safe default. */
	private static function heading_tag( $tag ): string {
		$tag = strtolower( (string) $tag );
		return in_array( $tag, [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'div', 'span' ], true ) ? $tag : 'h2';
	}

	/**
	 * Return $value if it's in $allowed, else $default. Guards against the
	 * `in_array($c['x'] ?? 'd', [...]) ? $c['x'] : 'd'` anti-pattern that emits
	 * an "undefined key" warning when the key is absent (the true-branch
	 * re-reads the key without ??).
	 */
	private static function enum( $value, array $allowed, string $default ): string {
		return in_array( $value, $allowed, true ) ? (string) $value : $default;
	}

	/** "#rrggbb" → "r,g,b" for rgba() composition. Falls back to white. */
	private static function hex_to_rgb( string $hex ): 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 '255,255,255';
		}
		return hexdec( substr( $hex, 0, 2 ) ) . ',' . hexdec( substr( $hex, 2, 2 ) ) . ',' . hexdec( substr( $hex, 4, 2 ) );
	}

	/** Permit only a safe length value (digits + unit), else fall back. */
	private static function css_len( $val ): string {
		$val = trim( (string) $val );
		return preg_match( '/^-?\d+(\.\d+)?(px|em|rem|%|vh|vw)?$/', $val ) ? $val : '0px';
	}
}
