<?php
/**
 * Background DOM render — frontend PHP twin of
 * `src/shared/controls/BackgroundControl/render.js`.
 *
 * Section + Container are pure-dynamic blocks (per FOUNDATION §9.5):
 * `save.js` returns bare `<InnerBlocks.Content />`, render.php builds
 * the entire wrapper + background DOM layers + inner-blocks slot.
 * The editor still renders backgrounds via the JS twin so the editor
 * preview matches frontend; this class is the FRONTEND-only path,
 * called from each block's render.php at request time.
 *
 * JS↔PHP parity contract (FOUNDATION §9.1): every method here mirrors
 * a JS function in render.js byte-for-byte for equal input. The two
 * sides are edited together; a divergence breaks editor↔frontend
 * visual parity.
 *
 * Frontend-only simplifications vs the JS source:
 *   - No `inEditor` parameter — always emits live YouTube iframe (the
 *     JS placeholder div exists only to dodge Error 153 in nested
 *     editor iframes; frontend has no nesting).
 *   - No editor-only data-attr skip — frontend always emits the
 *     scroll-effect `data-blocksify-scroll-*` attrs so the Motion.dev
 *     runtime in `frontend.js` picks elements up.
 *
 * @package Blocksify
 */

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

/**
 * Frontend background DOM render helpers. All public methods return
 * HTML strings (or empty string when the layer shouldn't render).
 */
class Blocksify_Background_Render {

	/**
	 * SVG inner content per shape ID. Mirrors `SHAPES` in
	 * `src/shared/controls/BackgroundControl/shapes.js` byte-for-byte.
	 * Each shape uses `fill="currentColor"` so wrapper `color` drives
	 * the rendered fill — caller sets the color via inline style.
	 *
	 * @return array<string,string>
	 */
	protected static function shapes() {
		return array(
			'wave'      => '<path d="M0,50 C300,100 900,0 1200,50 L1200,100 L0,100 Z" fill="currentColor"/>',
			'waves'     => '<path d="M0,50 C150,100 250,0 400,50 C550,100 650,0 800,50 C950,100 1050,0 1200,50 L1200,100 L0,100 Z" fill="currentColor" opacity="0.4"/><path d="M0,70 C150,30 300,100 450,70 C600,40 750,90 900,70 C1050,50 1150,80 1200,70 L1200,100 L0,100 Z" fill="currentColor"/>',
			'curve'     => '<path d="M0,100 C400,0 800,0 1200,100 L1200,100 L0,100 Z" fill="currentColor"/>',
			'tilt'      => '<path d="M0,100 L1200,0 L1200,100 Z" fill="currentColor"/>',
			'triangle'  => '<path d="M600,0 L1200,100 L0,100 Z" fill="currentColor"/>',
			'mountains' => '<path d="M0,100 L200,20 L350,80 L500,10 L650,70 L800,20 L950,90 L1100,30 L1200,60 L1200,100 Z" fill="currentColor"/>',
			'splash'    => '<path d="M0,50 C100,20 200,80 300,40 C400,0 500,90 600,50 C700,10 800,100 900,50 C1000,0 1100,80 1200,40 L1200,100 L0,100 Z" fill="currentColor"/>',
			'arrow'     => '<path d="M0,0 L600,80 L1200,0 L1200,100 L0,100 Z" fill="currentColor"/>',
		);
	}

	/**
	 * Format a {value, unit} dimension into a CSS length string.
	 * Mirrors `formatDimension` in BackgroundControl/helpers.js.
	 *
	 * @param mixed $dim Dimension bag.
	 * @return string
	 */
	protected static function format_dimension( $dim ) {
		if ( ! is_array( $dim ) ) {
			return 'auto';
		}
		if ( ! isset( $dim['value'] ) || ! is_numeric( $dim['value'] ) ) {
			return 'auto';
		}
		$value = (float) $dim['value'];
		$unit  = isset( $dim['unit'] ) && is_string( $dim['unit'] ) && '' !== $dim['unit']
			? $dim['unit']
			: 'px';
		// Match JS `${value}${unit}` — JS Number stringifier drops trailing zeros.
		$formatted = rtrim( rtrim( sprintf( '%F', $value ), '0' ), '.' );
		return $formatted . $unit;
	}

	/**
	 * Compose a CSS rule string from an associative array. Mirrors React
	 * style-object → CSS-text conversion: camelCase keys → kebab-case,
	 * skip null/false/empty values, join with `;`.
	 *
	 * @param array $style Property → value map.
	 * @return string
	 */
	protected static function format_style( $style ) {
		$parts = array();
		foreach ( $style as $prop => $value ) {
			if ( null === $value || false === $value || '' === $value ) {
				continue;
			}
			// camelCase → kebab-case. preg_replace_callback handles
			// `backgroundImage` → `background-image`, `zIndex` → `z-index`.
			$kebab    = preg_replace_callback(
				'/[A-Z]/',
				static function ( $m ) {
					return '-' . strtolower( $m[0] );
				},
				$prop
			);
			$parts[] = $kebab . ':' . $value;
		}
		return implode( ';', $parts );
	}

	/**
	 * Compose `key="value"` HTML attribute string from associative array.
	 * Each value is escaped with esc_attr. Empty input → empty string.
	 *
	 * @param array $attrs Attribute map.
	 * @return string
	 */
	protected static function format_attrs( $attrs ) {
		$parts = array();
		foreach ( $attrs as $key => $value ) {
			if ( null === $value || false === $value ) {
				continue;
			}
			$parts[] = $key . '="' . esc_attr( (string) $value ) . '"';
		}
		return $parts ? ' ' . implode( ' ', $parts ) : '';
	}

	/**
	 * Predicate — is scroll effect active? Gate is the `effect` value
	 * (parallax / zoom), NOT a separate `enabled` flag. Mirrors JS
	 * `isScrollEffectActive()` byte-for-byte.
	 *
	 * @param mixed $scroll_effect Scroll-effect bag.
	 * @return bool
	 */
	protected static function is_scroll_active( $scroll_effect ) {
		return is_array( $scroll_effect )
			&& isset( $scroll_effect['effect'] )
			&& in_array( $scroll_effect['effect'], array( 'parallax', 'zoom' ), true );
	}

	/**
	 * Extract YouTube ID from common URL shapes. Mirrors `parseYoutubeId`.
	 *
	 * @param mixed $raw Public YouTube URL or bare ID.
	 * @return string|null
	 */
	public static function parse_youtube_id( $raw ) {
		if ( ! is_string( $raw ) || '' === trim( $raw ) ) {
			return null;
		}
		$url = trim( $raw );
		if ( preg_match( '#youtu\.be/([a-zA-Z0-9_-]{6,})#', $url, $m ) ) {
			return $m[1];
		}
		if ( preg_match( '#youtube\.com/(?:embed|shorts|v)/([a-zA-Z0-9_-]{6,})#', $url, $m ) ) {
			return $m[1];
		}
		if ( preg_match( '/[?&]v=([a-zA-Z0-9_-]{6,})/', $url, $m ) ) {
			return $m[1];
		}
		if ( preg_match( '/^[a-zA-Z0-9_-]{11}$/', $url ) ) {
			return $url;
		}
		return null;
	}

	/**
	 * Build YouTube iframe URL with minimal canonical bg-video params.
	 * Mirrors `buildYoutubeEmbedUrl`.
	 *
	 * @param string $video_id YouTube ID.
	 * @param array  $video    Video bag (autoplay / loop).
	 * @return string
	 */
	protected static function build_youtube_embed_url( $video_id, $video ) {
		$params = array(
			'autoplay'        => ! empty( $video['autoplay'] ) ? '1' : '0',
			'mute'            => '1',
			'controls'        => '0',
			'modestbranding'  => '1',
		);
		if ( ! empty( $video['loop'] ) ) {
			$params['loop']     = '1';
			$params['playlist'] = $video_id;
		}
		return 'https://www.youtube.com/embed/' . $video_id . '?' . http_build_query( $params );
	}

	/**
	 * Inline style array for a scroll-animated element (img or video).
	 * Mirrors `scrollEffectStyle`.
	 *
	 * @param string $effect    'parallax' | 'zoom'.
	 * @param array  $size_bag  { size, position } for the element.
	 * @return array
	 */
	protected static function scroll_effect_style( $effect, $size_bag ) {
		$object_fit      = isset( $size_bag['size'] ) && 'contain' === $size_bag['size']
			? 'contain'
			: 'cover';
		$object_position = isset( $size_bag['position'] ) && is_string( $size_bag['position'] ) && '' !== $size_bag['position']
			? $size_bag['position']
			: '50% 50%';
		$base = array(
			'position'        => 'absolute',
			'left'            => '0',
			'right'           => '0',
			'border'          => '0',
			'pointerEvents'   => 'none',
			'zIndex'          => '0',
			'objectFit'       => $object_fit,
			'objectPosition'  => $object_position,
			'willChange'      => 'transform',
			'transformOrigin' => 'center',
		);
		if ( 'parallax' === $effect ) {
			return array_merge(
				$base,
				array(
					'top'    => '-50%',
					'bottom' => '-50%',
					'width'  => '100%',
					'height' => '200%',
				)
			);
		}
		return array_merge(
			$base,
			array(
				'top'    => '0',
				'bottom' => '0',
				'width'  => '100%',
				'height' => '100%',
			)
		);
	}

	/**
	 * Build the data-* attribute set for the Motion.dev runtime in
	 * `frontend.js`. Mirrors `scrollEffectDataAttrs` (frontend path
	 * only — JS variant skips when `inEditor`, here we always emit).
	 *
	 * @param array $scroll_effect Scroll-effect bag.
	 * @return array
	 */
	protected static function scroll_effect_data_attrs( $scroll_effect ) {
		$effect    = isset( $scroll_effect['effect'] ) && 'zoom' === $scroll_effect['effect']
			? 'zoom'
			: 'parallax';
		$strength  = isset( $scroll_effect['strength'] ) && is_numeric( $scroll_effect['strength'] )
			? max( 0, min( 1, (float) $scroll_effect['strength'] ) )
			: 0.5;
		$trigger_on = isset( $scroll_effect['triggerOn'] ) && is_array( $scroll_effect['triggerOn'] )
			? $scroll_effect['triggerOn']
			: array( 'desktop', 'tablet', 'mobile' );
		return array(
			'data-blocksify-scroll-effect'   => $effect,
			'data-blocksify-scroll-strength' => (string) $strength,
			'data-blocksify-scroll-trigger'  => wp_json_encode( $trigger_on ),
		);
	}

	/**
	 * Predicate — does this video bag carry meaningful content?
	 * Mirrors `hasVideoContent`.
	 *
	 * @param mixed $video Video bag.
	 * @return bool
	 */
	public static function has_video_content( $video ) {
		if ( ! is_array( $video ) ) {
			return false;
		}
		$source = isset( $video['source'] ) ? $video['source'] : 'local';
		if ( 'local' === $source ) {
			return isset( $video['url'] ) && is_string( $video['url'] ) && '' !== $video['url'];
		}
		if ( 'youtube' === $source ) {
			$embed = isset( $video['embedUrl'] ) ? $video['embedUrl'] : '';
			return null !== self::parse_youtube_id( $embed );
		}
		if ( 'url' === $source ) {
			return isset( $video['embedUrl'] ) && is_string( $video['embedUrl'] ) && '' !== $video['embedUrl'];
		}
		return false;
	}

	/**
	 * Predicate — does this overlay bag carry visible content?
	 * Mirrors `hasOverlayContent`.
	 *
	 * @param mixed $overlay Overlay bag.
	 * @return bool
	 */
	public static function has_overlay_content( $overlay ) {
		// Color-only — a non-empty color is the gate (image dropped, no
		// `enabled` flag).
		if ( ! is_array( $overlay ) ) {
			return false;
		}
		return isset( $overlay['color'] ) && is_string( $overlay['color'] ) && '' !== $overlay['color'];
	}

	/**
	 * Render one video DOM layer (`<video>` for local/url, `<iframe>` for
	 * YouTube). Mirrors `renderVideoLayer` frontend path. `$device` adds
	 * `--{device}` modifier class + `display: none` inline for non-desktop
	 * so per-instance @media CSS controls per-viewport visibility.
	 *
	 * @param mixed $video         Video bag.
	 * @param array $options       { device, scrollEffect }.
	 * @return string
	 */
	public static function video_layer( $video, $options = array() ) {
		if ( ! is_array( $video ) ) {
			return '';
		}
		$device         = isset( $options['device'] ) ? $options['device'] : 'desktop';
		$scroll_effect  = isset( $options['scrollEffect'] ) ? $options['scrollEffect'] : null;
		$source         = isset( $video['source'] ) ? $video['source'] : 'local';
		$class_name     = 'bsy-bg-video bsy-bg-video--' . $device;
		$scroll_active  = self::is_scroll_active( $scroll_effect );
		// Scroll effect on `<video>` elements only (local + url). YouTube
		// iframe oversize-centering trick uses `transform: translate(-50%,
		// -50%)` which would conflict with parallax/zoom transform.
		$scroll_on_video = $scroll_active && ( 'local' === $source || 'url' === $source );

		$fit_mode = isset( $video['fit'] ) && in_array( $video['fit'], array( 'contain', 'none' ), true )
			? $video['fit']
			: 'cover';

		if ( $scroll_on_video ) {
			$size_bag = array(
				'size'     => 'contain' === ( isset( $video['fit'] ) ? $video['fit'] : '' ) ? 'contain' : 'cover',
				'position' => isset( $video['position'] ) ? $video['position'] : null,
			);
			$effect_name = isset( $scroll_effect['effect'] ) && 'zoom' === $scroll_effect['effect']
				? 'zoom'
				: 'parallax';
			$base_style = self::scroll_effect_style( $effect_name, $size_bag );
		} else {
			$base_style = array(
				'position'      => 'absolute',
				'inset'         => '0',
				'width'         => '100%',
				'height'        => '100%',
				'border'        => '0',
				'objectFit'     => $fit_mode,
				'pointerEvents' => 'none',
				'zIndex'        => '0',
			);
		}
		if ( 'desktop' !== $device ) {
			$base_style['display'] = 'none';
		}
		if ( ! $scroll_on_video
			&& isset( $video['position'] )
			&& ( ! isset( $video['fit'] ) || 'none' !== $video['fit'] ) ) {
			$base_style['objectPosition'] = $video['position'];
		}
		if ( isset( $video['opacity'] ) && is_numeric( $video['opacity'] ) && $video['opacity'] < 1 ) {
			$base_style['opacity'] = (string) $video['opacity'];
		}

		if ( 'local' === $source ) {
			if ( empty( $video['url'] ) ) {
				return '';
			}
			$preload    = ! empty( $video['lazyLoad'] ) ? 'metadata' : 'auto';
			$data_attrs = $scroll_on_video ? self::scroll_effect_data_attrs( $scroll_effect ) : array();
			$attrs      = array(
				'class'    => $class_name,
				'preload'  => $preload,
				'style'    => self::format_style( $base_style ),
			);
			if ( ! empty( $video['autoplay'] ) ) {
				$attrs['autoplay'] = 'autoplay';
			}
			if ( ! empty( $video['muted'] ) ) {
				$attrs['muted'] = 'muted';
			}
			if ( ! empty( $video['loop'] ) ) {
				$attrs['loop'] = 'loop';
			}
			if ( ! empty( $video['playsInline'] ) ) {
				$attrs['playsinline'] = 'playsinline';
			}
			if ( ! empty( $video['poster'] ) ) {
				$attrs['poster'] = $video['poster'];
			}
			$attrs   = array_merge( $attrs, $data_attrs );
			$sources = '';
			if ( ! empty( $video['webmUrl'] ) ) {
				$sources .= '<source src="' . esc_url( $video['webmUrl'] ) . '" type="video/webm"/>';
			}
			$sources .= '<source src="' . esc_url( $video['url'] ) . '" type="video/mp4"/>';
			return '<video' . self::format_attrs( $attrs ) . '>' . $sources . '</video>';
		}

		if ( 'url' === $source ) {
			if ( empty( $video['embedUrl'] ) ) {
				return '';
			}
			$preload    = ! empty( $video['lazyLoad'] ) ? 'metadata' : 'auto';
			$data_attrs = $scroll_on_video ? self::scroll_effect_data_attrs( $scroll_effect ) : array();
			$attrs      = array(
				'class'   => $class_name,
				'src'     => esc_url( $video['embedUrl'] ),
				'preload' => $preload,
				'style'   => self::format_style( $base_style ),
			);
			if ( ! empty( $video['autoplay'] ) ) {
				$attrs['autoplay'] = 'autoplay';
			}
			if ( ! empty( $video['muted'] ) ) {
				$attrs['muted'] = 'muted';
			}
			if ( ! empty( $video['loop'] ) ) {
				$attrs['loop'] = 'loop';
			}
			if ( ! empty( $video['playsInline'] ) ) {
				$attrs['playsinline'] = 'playsinline';
			}
			if ( ! empty( $video['poster'] ) ) {
				$attrs['poster'] = $video['poster'];
			}
			$attrs = array_merge( $attrs, $data_attrs );
			return '<video' . self::format_attrs( $attrs ) . '></video>';
		}

		if ( 'youtube' === $source ) {
			$id = self::parse_youtube_id( isset( $video['embedUrl'] ) ? $video['embedUrl'] : '' );
			if ( null === $id ) {
				return '';
			}
			// Frontend always emits live iframe (no nested-iframe Error
			// 153 concern). Oversize iframe centered via transform so
			// YouTube chrome (title bar / "More videos") falls outside
			// the section's `overflow: hidden`.
			$iframe_style = array(
				'position'      => 'absolute',
				'inset'         => 'auto',
				'left'          => '50%',
				'top'           => '50%',
				'width'         => '100vw',
				'height'        => '56.25vw',
				'minWidth'      => '177.78vh',
				'minHeight'     => '100vh',
				'transform'     => 'translate(-50%, -50%)',
				'border'        => '0',
				'pointerEvents' => 'none',
				'zIndex'        => '0',
			);
			if ( 'desktop' !== $device ) {
				$iframe_style['display'] = 'none';
			}
			if ( isset( $video['opacity'] ) && is_numeric( $video['opacity'] ) && $video['opacity'] < 1 ) {
				$iframe_style['opacity'] = (string) $video['opacity'];
			}
			$attrs = array(
				'class'           => $class_name . ' bsy-bg-video--youtube',
				'src'             => esc_url( self::build_youtube_embed_url( $id, $video ) ),
				'title'           => 'YouTube background video',
				'allow'           => 'autoplay; encrypted-media; picture-in-picture',
				'allowfullscreen' => 'false',
				'loading'         => ! empty( $video['lazyLoad'] ) ? 'lazy' : 'eager',
				'style'           => self::format_style( $iframe_style ),
			);
			return '<iframe' . self::format_attrs( $attrs ) . '></iframe>';
		}

		return '';
	}

	/**
	 * Render per-device video layers (desktop / tablet / mobile) for a
	 * block. Each device emits ONLY when its own override is set; cascade
	 * via CSS @media handles which element shows per viewport.
	 *
	 * Mirrors `renderAllVideoLayers` frontend path.
	 *
	 * @param array $attrs   Block attributes (flat-suffix shape).
	 * @param array $options { scrollEffect }.
	 * @return string Concatenated HTML for all per-device layers.
	 */
	public static function videos( $attrs, $options = array() ) {
		$scroll_effect = isset( $options['scrollEffect'] ) ? $options['scrollEffect'] : null;
		$out           = '';
		if ( self::has_video_content( isset( $attrs['backgroundVideo'] ) ? $attrs['backgroundVideo'] : null ) ) {
			$out .= self::video_layer(
				$attrs['backgroundVideo'],
				array( 'device' => 'desktop', 'scrollEffect' => $scroll_effect )
			);
		}
		if ( self::has_video_content( isset( $attrs['backgroundVideoTablet'] ) ? $attrs['backgroundVideoTablet'] : null ) ) {
			$out .= self::video_layer(
				$attrs['backgroundVideoTablet'],
				array( 'device' => 'tablet', 'scrollEffect' => $scroll_effect )
			);
		}
		if ( self::has_video_content( isset( $attrs['backgroundVideoMobile'] ) ? $attrs['backgroundVideoMobile'] : null ) ) {
			$out .= self::video_layer(
				$attrs['backgroundVideoMobile'],
				array( 'device' => 'mobile', 'scrollEffect' => $scroll_effect )
			);
		}
		return $out;
	}

	/**
	 * Render the BG image as an absolutely-positioned `<img>` when scroll
	 * effect is enabled (only path that needs DOM — non-scroll bg images
	 * render via CSS `background-image`). Mirrors `renderImageLayer`.
	 *
	 * @param mixed $image   Image bag.
	 * @param array $options { scrollEffect }.
	 * @return string
	 */
	public static function image( $image, $options = array() ) {
		$scroll_effect = isset( $options['scrollEffect'] ) ? $options['scrollEffect'] : null;
		if ( ! self::is_scroll_active( $scroll_effect ) ) {
			return '';
		}
		if ( ! is_array( $image )
			|| ! isset( $image['url'] )
			|| ! is_string( $image['url'] )
			|| '' === $image['url'] ) {
			return '';
		}
		$effect_name = isset( $scroll_effect['effect'] ) && 'zoom' === $scroll_effect['effect']
			? 'zoom'
			: 'parallax';
		$style       = self::scroll_effect_style( $effect_name, $image );
		$data_attrs  = self::scroll_effect_data_attrs( $scroll_effect );

		$attrs = array(
			'class'    => 'bsy-bg-image',
			'src'      => esc_url( $image['url'] ),
			'alt'      => isset( $image['alt'] ) && is_string( $image['alt'] ) ? $image['alt'] : '',
			'loading'  => 'lazy',
			'decoding' => 'async',
			'style'    => self::format_style( $style ),
		);
		$attrs = array_merge( $attrs, $data_attrs );
		return '<img' . self::format_attrs( $attrs ) . '/>';
	}

	/**
	 * Render the overlay `<div>` layer. Color / image stack is set via
	 * per-instance CSS (`background-image`); this DOM is just the
	 * positioned target. Mirrors `renderOverlayLayer`.
	 *
	 * @param bool $show_overlay Pre-resolved by caller via has_overlay_content
	 *                           across base/tablet/mobile/hover states.
	 * @return string
	 */
	public static function overlay( $show_overlay ) {
		if ( ! $show_overlay ) {
			return '';
		}
		$attrs = array(
			'class'       => 'bsy-bg-overlay',
			'aria-hidden' => 'true',
			'style'       => self::format_style(
				array(
					'position'      => 'absolute',
					'inset'         => '0',
					'zIndex'        => '1',
					'pointerEvents' => 'none',
				)
			),
		);
		return '<div' . self::format_attrs( $attrs ) . '></div>';
	}

	/**
	 * Render one Shape edge (top or bottom). Mirrors `renderShapeEdge`.
	 * Bottom edge auto-flips vertically (SHAPES library designed pointing
	 * DOWN); user `flipY` XORs on top of that natural orientation.
	 *
	 * @param string $edge       'top' | 'bottom'.
	 * @param mixed  $shape_edge Shape edge bag.
	 * @return string
	 */
	public static function shape_edge( $edge, $shape_edge ) {
		if ( ! is_array( $shape_edge )
			|| empty( $shape_edge['type'] )
			|| 'none' === $shape_edge['type'] ) {
			return '';
		}
		$shapes = self::shapes();
		if ( ! isset( $shapes[ $shape_edge['type'] ] ) ) {
			return '';
		}
		$shape_svg     = $shapes[ $shape_edge['type'] ];
		$natural_flip  = ( 'bottom' === $edge );
		$flip_y        = $natural_flip !== ! empty( $shape_edge['flipY'] );
		$transforms    = array();
		if ( ! empty( $shape_edge['flipX'] ) ) {
			$transforms[] = 'scaleX(-1)';
		}
		if ( $flip_y ) {
			$transforms[] = 'scaleY(-1)';
		}
		$width_css  = self::format_dimension( isset( $shape_edge['width'] ) ? $shape_edge['width'] : null );
		$height_css = self::format_dimension( isset( $shape_edge['height'] ) ? $shape_edge['height'] : null );
		$style = array(
			'position'     => 'absolute',
			'left'         => '0',
			'right'        => '0',
			'marginInline' => 'auto',
			$edge          => '0',
			'width'        => 'auto' === $width_css ? '100%' : $width_css,
			'height'       => 'auto' === $height_css ? '80px' : $height_css,
			'color'        => isset( $shape_edge['color'] ) && '' !== $shape_edge['color']
				? $shape_edge['color']
				: '#000',
			'zIndex'       => ! empty( $shape_edge['front'] ) ? '3' : '1',
			'pointerEvents' => 'none',
		);
		if ( $transforms ) {
			$style['transform'] = implode( ' ', $transforms );
		}
		$attrs = array(
			'xmlns'                => 'http://www.w3.org/2000/svg',
			'viewBox'              => '0 0 1200 100',
			'preserveAspectRatio'  => 'none',
			'class'                => 'bsy-bg-shape bsy-bg-shape--' . $edge,
			'style'                => self::format_style( $style ),
			'aria-hidden'          => 'true',
			'focusable'            => 'false',
		);
		return '<svg' . self::format_attrs( $attrs ) . '>' . $shape_svg . '</svg>';
	}

	/**
	 * Convenience: render all background layers for a block in canonical
	 * z-stack order. Used by Section + Container render.php.
	 *
	 * @param array $attrs Block attributes (flat-suffix shape).
	 * @return array { 'before_inner': string, 'shape_bottom': string }
	 *               — caller emits `before_inner` ABOVE the inner content
	 *               wrapper, `shape_bottom` AFTER it (shape bottom edge
	 *               needs to position from the wrapper's bottom).
	 */
	public static function all_layers( $attrs ) {
		$image_value          = isset( $attrs['backgroundImage'] ) ? $attrs['backgroundImage'] : null;
		$scroll_effect_value  = isset( $attrs['backgroundScrollEffect'] ) ? $attrs['backgroundScrollEffect'] : null;
		$shape_value          = isset( $attrs['backgroundShape'] ) ? $attrs['backgroundShape'] : null;
		$shape_top            = is_array( $shape_value ) && isset( $shape_value['top'] ) ? $shape_value['top'] : null;
		$shape_bottom         = is_array( $shape_value ) && isset( $shape_value['bottom'] ) ? $shape_value['bottom'] : null;

		$show_overlay = self::has_overlay_content( isset( $attrs['backgroundOverlay'] ) ? $attrs['backgroundOverlay'] : null )
			|| self::has_overlay_content( isset( $attrs['backgroundOverlayTablet'] ) ? $attrs['backgroundOverlayTablet'] : null )
			|| self::has_overlay_content( isset( $attrs['backgroundOverlayMobile'] ) ? $attrs['backgroundOverlayMobile'] : null );

		$before_inner = '';
		$before_inner .= self::image( $image_value, array( 'scrollEffect' => $scroll_effect_value ) );
		$before_inner .= self::videos( $attrs, array( 'scrollEffect' => $scroll_effect_value ) );
		$before_inner .= self::overlay( $show_overlay );
		$before_inner .= self::shape_edge( 'top', $shape_top );

		return array(
			'before_inner' => $before_inner,
			'shape_bottom' => self::shape_edge( 'bottom', $shape_bottom ),
		);
	}
}
