<?php
/**
 * Block attribute shape helpers for D4→D5 conversion (innerContent / breakpoints).
 *
 * @package DiviSupreme
 */

declare(strict_types=1);

namespace DiviSupreme\Includes\Divi5Conversion;

use ET\Builder\Packages\Conversion\ValueExpansion;
use ET\Builder\Packages\StyleLibrary\Declarations\Background\Background;

/**
 * Mutates converted attrs to match D5 innerContent layout.
 */
final class ConversionAttrs
{

	/**
	 * Image Text Reveal: `reveal_text_animation` select values (D4/D5 parity, Animate.css).
	 *
	 * @var list<string>
	 */
	private const IMAGE_TEXT_REVEAL_ANIMATION_ALLOWLIST = array(
		'fadeIn',
		'bounce',
		'flash',
		'pulse',
		'rubberBand',
		'shake',
		'swing',
		'tada',
		'wobble',
		'jello',
		'bounceIn',
		'bounceInDown',
		'bounceInLeft',
		'bounceInRight',
		'bounceInUp',
		'fadeInDown',
		'fadeInDownBig',
		'fadeInLeft',
		'fadeInLeftBig',
		'fadeInRight',
		'fadeInRightBig',
		'fadeInUp',
		'fadeInUpBig',
		'slideInUp',
		'slideInDown',
		'slideInLeft',
		'slideInRight',
		'flip',
		'flipInX',
		'flipInY',
		'rotateIn',
		'rotateInDownLeft',
		'rotateInDownRight',
		'rotateInUpLeft',
		'rotateInUpRight',
		'zoomIn',
		'zoomInDown',
		'zoomInLeft',
		'zoomInRight',
		'zoomInUp',
		'lightSpeedIn',
		'rollIn',
		'hinge',
	);

	/**
	 * True when a scalar innerContent leaf should be replaced with a default (missing, null, or whitespace-only string).
	 *
	 * Does not treat numeric/string zero as empty so migrated `0` / `0px` / `0ms` are preserved.
	 *
	 * @param mixed $val Raw value.
	 * @return bool
	 */
	private static function is_scalar_inner_value_absent( $val ): bool {
		if ( null === $val ) {
			return true;
		}
		if ( is_string( $val ) ) {
			return '' === trim( $val );
		}

		return false;
	}

	/**
	 * Replace range values only when missing, null, or whitespace-only (preserves explicit zero).
	 *
	 * @param array  $converted_attrs Reference.
	 * @param string $attr_key        Attribute key.
	 * @param string $default         Default with unit.
	 * @return void
	 */
	public static function normalize_range_attr(array &$converted_attrs, string $attr_key, string $default): void
	{
		if (!isset($converted_attrs[$attr_key]) || !is_array($converted_attrs[$attr_key])) {
			$converted_attrs[$attr_key] = array();
		}
		if (!isset($converted_attrs[$attr_key]['innerContent']) || !is_array($converted_attrs[$attr_key]['innerContent'])) {
			$converted_attrs[$attr_key]['innerContent'] = array();
		}

		if (!isset($converted_attrs[$attr_key]['innerContent']['desktop']) || !is_array($converted_attrs[$attr_key]['innerContent']['desktop'])) {
			$converted_attrs[$attr_key]['innerContent']['desktop'] = array();
		}
		$desktop = &$converted_attrs[$attr_key]['innerContent']['desktop'];
		if ( ! array_key_exists( 'value', $desktop ) || self::is_scalar_inner_value_absent( $desktop['value'] ) ) {
			$desktop['value'] = $default;
		}

		foreach (array('tablet', 'phone') as $breakpoint) {
			if (!isset($converted_attrs[$attr_key]['innerContent'][$breakpoint]) || !is_array($converted_attrs[$attr_key]['innerContent'][$breakpoint])) {
				continue;
			}
			$bp = &$converted_attrs[$attr_key]['innerContent'][$breakpoint];
			if ( ! array_key_exists( 'value', $bp ) ) {
				continue;
			}
			if ( ! self::is_scalar_inner_value_absent( $bp['value'] ) ) {
				continue;
			}
			$bp['value'] = $default;
		}
	}

	/**
	 * Circle Info child: Advanced → Background is edited on the Content tab (`module.decoration.background`).
	 * Outline maps D4 background → `module.decoration.background`; `module.styleProps.background` targets
	 * `.dsm_circle_info {{selector}}.dsm-circle-info-content-wrapper` for D4 CSS parity (not the module root).
	 * Migrator output that only has background on `dsm_circle_content_wrapper_style` (older promote step)
	 * is merged onto `module` and removed from the wrapper to avoid conflicting duplicate controls/CSS.
	 *
	 * @param array<string, mixed> $converted_attrs Reference.
	 * @return void
	 */
	public static function ensure_circle_info_child_background_consolidated_on_module(array &$converted_attrs): void
	{
		$wrapper_bg = $converted_attrs['dsm_circle_content_wrapper_style']['decoration']['background'] ?? null;
		$module_bg  = $converted_attrs['module']['decoration']['background'] ?? null;

		$wrapper_has = is_array($wrapper_bg) && ! empty($wrapper_bg);
		$module_has  = is_array($module_bg) && ! empty($module_bg);

		if ($wrapper_has && ! $module_has) {
			if (! isset($converted_attrs['module']['decoration']) || ! is_array($converted_attrs['module']['decoration'])) {
				$converted_attrs['module']['decoration'] = array();
			}
			$converted_attrs['module']['decoration']['background'] = $wrapper_bg;

			unset($converted_attrs['dsm_circle_content_wrapper_style']['decoration']['background']);
			self::unset_empty_decoration_nested_key_if_decorations_empty($converted_attrs, 'dsm_circle_content_wrapper_style');

			return;
		}

		if ($module_has && $wrapper_has) {
			unset($converted_attrs['dsm_circle_content_wrapper_style']['decoration']['background']);
			self::unset_empty_decoration_nested_key_if_decorations_empty($converted_attrs, 'dsm_circle_content_wrapper_style');
		}
	}

	/**
	 * Remove decoration key from an attribute when decoration becomes empty-ish (avoids orphaned empty objects).
	 *
	 * @param array<string, mixed> $converted_attrs Reference.
	 * @param string               $element_key      Attribute root key (e.g. `dsm_circle_content_wrapper_style`).
	 * @return void
	 */
	private static function unset_empty_decoration_nested_key_if_decorations_empty(array &$converted_attrs, string $element_key): void
	{
		$dec = $converted_attrs[ $element_key ]['decoration'] ?? null;
		if (! is_array($dec) || ! empty($dec)) {
			return;
		}
		unset($converted_attrs[ $element_key ]['decoration']);
	}

	/**
	 * Ensures background image sub-keys (size cover, repeat, etc.) are present when a URL is set so Divi's
	 * background declaration output emits CSS (defaults are omitted from saved JSON unless explicit).
	 *
	 * @param array<string, mixed> $attrs Reference — full block/module attrs (`module.decoration.background.*`).
	 * @return void
	 */
	public static function hydrate_circle_info_child_module_background_image_props_for_declaration_output(array &$attrs): void
	{
		if (! isset($attrs['module']['decoration']['background']) || ! is_array($attrs['module']['decoration']['background'])) {
			return;
		}

		$defaults_image = Background::$background_default_attr['image'];
		$bg = &$attrs['module']['decoration']['background'];

		foreach ($bg as &$bp_val) {
			if (! is_array($bp_val)) {
				continue;
			}
			foreach ($bp_val as &$state_val) {
				if (! is_array($state_val)) {
					continue;
				}

				if (! isset($state_val['image']) || ! is_array($state_val['image'])) {
					continue;
				}

				$url = $state_val['image']['url'] ?? null;
				if ('' === $url || null === $url) {
					continue;
				}
				if (is_string($url) && '' === trim($url)) {
					continue;
				}

				$state_val['image'] = array_merge($defaults_image, $state_val['image'] ?? array());
				if (isset($state_val['image']['parallax']) && is_array($state_val['image']['parallax'])) {
					$state_val['image']['parallax'] = array_merge(
						$defaults_image['parallax'] ?? array(),
						$state_val['image']['parallax']
					);
				}
			}
		}
		unset($bp_val, $state_val);
	}

	/**
	 * Contact Form 7: D4 color-alpha background options → D5 element `decoration.background` solid color (desktop).
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs (by reference).
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attrs.
	 * @return void
	 */
	public static function migrate_contact_form7_d4_legacy_background_colors(
		array &$converted_attrs,
		array $d4_attrs
	): void {
		$map = array(
			'input_background_color' => 'input_textarea_select',
			'file_background_color' => 'file',
			'error_msg_background_color' => 'error_msg',
			'validation_error_background_color' => 'error_validation',
			'validation_success_background_color' => 'validation_success',
		);

		foreach ($map as $d4_key => $element_key) {
			if (isset($converted_attrs[ $d4_key ])) {
				unset($converted_attrs[ $d4_key ]);
			}
		}

		foreach ($map as $d4_key => $element_key) {
			if (! isset($d4_attrs[ $d4_key ])) {
				continue;
			}
			self::merge_contact_form7_d4_color_into_element_background(
				$converted_attrs,
				$element_key,
				$d4_attrs[ $d4_key ]
			);
		}
	}

	/**
	 * Set `*.decoration.background.desktop.value.color` from a D4 color-alpha value when empty.
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs (by reference).
	 * @param string               $element_key     D5 attribute key (e.g. `file`).
	 * @param mixed                $raw             D4 shortcode value.
	 * @return void
	 */
	private static function merge_contact_form7_d4_color_into_element_background(
		array &$converted_attrs,
		string $element_key,
		$raw
	): void {
		if (is_int($raw) || is_float($raw)) {
			$raw = (string) $raw;
		}
		if (! is_string($raw)) {
			return;
		}

		$color = trim($raw);
		if ('' === $color) {
			return;
		}

		if (! isset($converted_attrs[ $element_key ]) || ! is_array($converted_attrs[ $element_key ])) {
			$converted_attrs[ $element_key ] = array();
		}
		if (! isset($converted_attrs[ $element_key ]['decoration']) || ! is_array($converted_attrs[ $element_key ]['decoration'])) {
			$converted_attrs[ $element_key ]['decoration'] = array();
		}
		if (! isset($converted_attrs[ $element_key ]['decoration']['background']) || ! is_array($converted_attrs[ $element_key ]['decoration']['background'])) {
			$converted_attrs[ $element_key ]['decoration']['background'] = array();
		}

		$bg = &$converted_attrs[ $element_key ]['decoration']['background'];
		if (! isset($bg['desktop']) || ! is_array($bg['desktop'])) {
			$bg['desktop'] = array();
		}
		if (! isset($bg['desktop']['value']) || ! is_array($bg['desktop']['value'])) {
			$bg['desktop']['value'] = array();
		}

		$existing = $bg['desktop']['value']['color'] ?? null;
		if (is_string($existing) && '' !== trim($existing)) {
			return;
		}

		$bg['desktop']['value']['color'] = $color;
	}

	/**
	 * FAQ toggle row backgrounds: migrate legacy plain color `innerContent` (D5 color picker) into native `decoration.background`.
	 *
	 * Applies to `dsm_open_bg_color` and `dsm_close_bg_color`. Removes `innerContent` after merge so stale keys do not collide with Background saves.
	 *
	 * @param array<string, mixed> $attrs Module attrs reference.
	 * @return void
	 */
	public static function migrate_faq_toggle_legacy_inner_content_color_into_decoration_background(array &$attrs): void {
		foreach (array('dsm_open_bg_color', 'dsm_close_bg_color') as $key) {
			if (! isset($attrs[ $key ]) || ! \is_array($attrs[ $key ])) {
				continue;
			}
			self::migrate_faq_single_toggle_legacy_inner_content_into_decoration_bg($attrs[ $key ]);
		}
	}

	/**
	 * Pull a CSS color string from legacy FAQ color-picker `innerContent` leaf values.
	 *
	 * @param mixed $raw Value at `innerContent.{bp}.value`.
	 * @return string Non-empty color or empty string when nothing to migrate.
	 */
	private static function faq_toggle_extract_legacy_inner_color_string($raw): string {
		if (\is_string($raw)) {
			return trim($raw);
		}

		if (\is_array($raw)) {
			foreach (array('hex', 'rgba', 'color', 'value') as $color_key) {
				$piece = $raw[ $color_key ] ?? null;
				if (\is_string($piece) && '' !== trim($piece)) {
					return trim($piece);
				}
			}
		}

		if (\is_scalar($raw)) {
			return trim((string) $raw);
		}

		return '';
	}

	/**
	 * Whether `decoration.background.*.value` already uses non-solid layers (do not overwrite with legacy flat color).
	 *
	 * @param array<string, mixed> $val `decoration.background.*.value` node.
	 * @return bool
	 */
	private static function faq_toggle_decoration_background_has_non_color_layers(array $val): bool {
		if (isset($val['gradient']) && \is_array($val['gradient']) && array() !== $val['gradient']) {
			return true;
		}

		if (isset($val['video']) && \is_array($val['video']) && array() !== $val['video']) {
			return true;
		}

		if (isset($val['pattern']) && \is_array($val['pattern']) && array() !== $val['pattern']) {
			return true;
		}

		if (isset($val['mask']) && \is_array($val['mask']) && array() !== $val['mask']) {
			return true;
		}

		$image = $val['image'] ?? null;
		if (\is_array($image)) {
			foreach (array('url', 'src') as $image_key) {
				$url = $image[ $image_key ] ?? '';
				if (\is_string($url) && '' !== trim($url)) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Migrate one FAQ toggle bg element's legacy `innerContent` color strings.
	 *
	 * @param array<string, mixed> $element Attr node reference (`dsm_*_bg_color`).
	 * @return void
	 */
	private static function migrate_faq_single_toggle_legacy_inner_content_into_decoration_bg(array &$element): void {
		$legacy = $element['innerContent'] ?? null;

		unset($element['innerContent']);

		if (! \is_array($legacy)) {
			return;
		}

		foreach (array('desktop', 'tablet', 'phone') as $bp) {
			$bp_legacy = $legacy[ $bp ] ?? null;

			if (! \is_array($bp_legacy)) {
				continue;
			}

			$raw = $bp_legacy['value'] ?? null;

			$color = self::faq_toggle_extract_legacy_inner_color_string($raw);

			if ('' === $color) {
				continue;
			}

			if (! isset($element['decoration']) || ! \is_array($element['decoration'])) {
				$element['decoration'] = array();
			}

			if (! isset($element['decoration']['background']) || ! \is_array($element['decoration']['background'])) {
				$element['decoration']['background'] = array();
			}

			$existing_bp = $element['decoration']['background'][ $bp ] ?? array();

			$existing_val = array();

			if (\is_array($existing_bp) && isset($existing_bp['value']) && \is_array($existing_bp['value'])) {
				$existing_val = $existing_bp['value'];
			}

			// Do not replace native gradient/image/video saves with a legacy flat color.
			if (self::faq_toggle_decoration_background_has_non_color_layers($existing_val)) {
				continue;
			}

			// Prefer legacy innerContent whenever it carries an explicit color — merged defaults
			// often pre-fill `decoration.background.*.color` (`#ffffff` / `#f4f4f4`), which must not block migration.
			$merged_value = array_merge($existing_val, array('color' => $color));

			$element['decoration']['background'][ $bp ] = array(
				'value' => $merged_value,
			);
		}
	}

	/**
	 * Copy parent FAQ (`dsm/faq`) open/closed toggle backgrounds onto a child (`dsm/faq-child`) when the child never set its own row colors.
	 *
	 * Historically designers set these on the parent only; child items still show the same paint on the frontend via
	 * parent-scoped selectors, but the child's own `dsm_*_bg_color` attrs stay empty/default so Visual Builder reads an
	 * empty native Background field. This merges the parent's effective values for use in child settings and child element styles.
	 *
	 * @param array<string, mixed>       $child_attrs   Child module attrs reference.
	 * @param array<string, mixed>|null $parent_attrs Parent `dsm/faq` attrs.
	 * @return void
	 */
	public static function hydrate_faq_child_toggle_backgrounds_from_parent(array &$child_attrs, ?array $parent_attrs): void {
		if (! \is_array($parent_attrs) || array() === $parent_attrs) {
			return;
		}

		self::migrate_faq_toggle_legacy_inner_content_color_into_decoration_background($parent_attrs);

		$toggles = array(
			'dsm_open_bg_color'  => '#ffffff',
			'dsm_close_bg_color' => '#f4f4f4',
		);

		foreach ($toggles as $key => $placeholder_hex) {
			if (! isset($parent_attrs[ $key ]) || ! \is_array($parent_attrs[ $key ])) {
				continue;
			}

			$encoded = wp_json_encode($parent_attrs[ $key ]);
			if (false === $encoded || ! \is_string($encoded)) {
				continue;
			}

			$cloned = json_decode($encoded, true);
			if (! \is_array($cloned)) {
				continue;
			}

			if (! isset($child_attrs[ $key ]) || ! \is_array($child_attrs[ $key ])) {
				$child_attrs[ $key ] = $cloned;
				continue;
			}

			if (self::faq_toggle_child_row_bg_is_placeholder_or_unused($child_attrs[ $key ], $placeholder_hex)) {
				$child_attrs[ $key ] = $cloned;
			}
		}
	}

	/**
	 * Whether the child FAQ row background attr is safe to replace with the parent's (no child-specific flat color / rich background).
	 *
	 * @param array<string, mixed> $el              One `dsm_*_bg_color` element.
	 * @param string               $placeholder_hex Default solid for that state (open vs closed) from module JSON.
	 * @return bool
	 */
	private static function faq_toggle_child_row_bg_is_placeholder_or_unused(array $el, string $placeholder_hex): bool {
		$inner = $el['innerContent'] ?? null;
		if (\is_array($inner)) {
			foreach (array('desktop', 'tablet', 'phone') as $bp) {
				$chunk = $inner[ $bp ] ?? null;
				if (! \is_array($chunk)) {
					continue;
				}

				if ('' !== self::faq_toggle_extract_legacy_inner_color_string($chunk['value'] ?? null)) {
					return false;
				}
			}
		}

		$bg = $el['decoration']['background'] ?? null;

		if (! \is_array($bg) || array() === $bg) {
			return true;
		}

		foreach (array('desktop', 'tablet', 'phone') as $bp) {
			$val = $bg[ $bp ]['value'] ?? null;
			if (! \is_array($val)) {
				continue;
			}

			if (self::faq_toggle_decoration_background_has_non_color_layers($val)) {
				return false;
			}

			$c = isset($val['color']) && \is_string($val['color']) ? trim($val['color']) : '';

			if ('' !== $c && 0 !== strcasecmp($c, $placeholder_hex)) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Set innerContent.desktop.value if missing or empty (do not override explicit migrated values).
	 *
	 * @param array  $converted_attrs Reference.
	 * @param string $attr_key        Attribute key.
	 * @param string $value           Value to store.
	 * @param bool   $force           When true, always set desktop value.
	 * @return void
	 */
	public static function set_inner_desktop_value(array &$converted_attrs, string $attr_key, string $value, bool $force = false): void
	{
		if ( ! $force ) {
			$desktop_branch = $converted_attrs[ $attr_key ]['innerContent']['desktop'] ?? null;
			if ( is_array( $desktop_branch ) && array_key_exists( 'value', $desktop_branch ) ) {
				$existing = $desktop_branch['value'];
				if ( null !== $existing && ! ( is_string( $existing ) && '' === trim( $existing ) ) ) {
					return;
				}
			}
		}

		if (!isset($converted_attrs[$attr_key]) || !is_array($converted_attrs[$attr_key])) {
			$converted_attrs[$attr_key] = array();
		}
		if (!isset($converted_attrs[$attr_key]['innerContent']) || !is_array($converted_attrs[$attr_key]['innerContent'])) {
			$converted_attrs[$attr_key]['innerContent'] = array();
		}
		if (!isset($converted_attrs[$attr_key]['innerContent']['desktop']) || !is_array($converted_attrs[$attr_key]['innerContent']['desktop'])) {
			$converted_attrs[$attr_key]['innerContent']['desktop'] = array();
		}
		$converted_attrs[$attr_key]['innerContent']['desktop']['value'] = $value;
	}

	/**
	 * Set innerContent.desktop.value to headingLink shape `{ text: string }`.
	 *
	 * @param array  $converted_attrs Reference.
	 * @param string $attr_key        Attribute key.
	 * @param string $text            Plain text to store.
	 * @param bool   $force           When true, always set desktop value.
	 */
	public static function set_inner_desktop_heading_link_text(array &$converted_attrs, string $attr_key, string $text, bool $force = false): void
	{
		if (!$force) {
			$existing = $converted_attrs[ $attr_key ]['innerContent']['desktop']['value'] ?? null;
			if (is_string($existing) && '' !== $existing) {
				return;
			}
			if (is_array($existing) && isset($existing['text']) && is_string($existing['text']) && '' !== $existing['text']) {
				return;
			}
		}

		if (!isset($converted_attrs[ $attr_key ]) || !is_array($converted_attrs[ $attr_key ])) {
			$converted_attrs[ $attr_key ] = array();
		}
		if (!isset($converted_attrs[ $attr_key ]['innerContent']) || !is_array($converted_attrs[ $attr_key ]['innerContent'])) {
			$converted_attrs[ $attr_key ]['innerContent'] = array();
		}
		if (!isset($converted_attrs[ $attr_key ]['innerContent']['desktop']) || !is_array($converted_attrs[ $attr_key ]['innerContent']['desktop'])) {
			$converted_attrs[ $attr_key ]['innerContent']['desktop'] = array();
		}
		$converted_attrs[ $attr_key ]['innerContent']['desktop']['value'] = array(
			'text' => $text,
		);
	}

	/**
	 * When D5 innerContent is still empty after outline migration, copy plain text from the D4 shortcode attr.
	 *
	 * @param array<string, mixed> $converted_attrs Reference.
	 * @param array<string, mixed> $d4_attrs        Original D4 attribute map.
	 * @param string               $attr_key        Same key in D4 and D5 (e.g. `dsm_circle_info_title`).
	 */
	public static function hydrate_inner_heading_link_text_from_d4_when_empty(array &$converted_attrs, array $d4_attrs, string $attr_key): void
	{
		if (! array_key_exists($attr_key, $d4_attrs)) {
			return;
		}
		$raw = $d4_attrs[ $attr_key ];
		if (is_int($raw) || is_float($raw)) {
			$raw = (string) $raw;
		}
		if (! is_string($raw)) {
			return;
		}
		$s = trim($raw);
		if ('' === $s) {
			return;
		}

		$desktop = $converted_attrs[ $attr_key ]['innerContent']['desktop'] ?? null;
		if (is_array($desktop)) {
			$v = $desktop['value'] ?? null;
			if (is_string($v) && '' !== trim($v)) {
				return;
			}
			if (is_array($v) && isset($v['text']) && is_string($v['text']) && '' !== trim($v['text'])) {
				return;
			}
			if (isset($desktop['text']) && is_string($desktop['text']) && '' !== trim($desktop['text'])) {
				return;
			}
		}

		self::set_inner_desktop_heading_link_text($converted_attrs, $attr_key, $s, true);
	}

	/**
	 * Circle Info child: D4 omitted or blank title/body fields must stay empty on D5 — migrator can merge placeholderContent demo copy.
	 *
	 * @param array<string, mixed> $converted_attrs Reference.
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attrs.
	 * @return void
	 */
	public static function clear_circle_info_child_placeholder_text_when_d4_empty( array &$converted_attrs, array $d4_attrs ): void {
		$heading_attrs = array( 'dsm_circle_info_title', 'dsm_content_title' );

		foreach ( $heading_attrs as $key ) {
			if ( ! self::d4_plain_string_semantically_empty( $d4_attrs, $key ) ) {
				continue;
			}
			self::set_inner_desktop_heading_link_text( $converted_attrs, $key, '', true );
		}

		foreach ( array( 'dsm_circle_info_content' ) as $rich_key ) {
			if ( ! self::d4_html_or_plain_semantically_empty( $d4_attrs, $rich_key ) ) {
				continue;
			}
			self::force_inner_content_desktop_rich_text_empty( $converted_attrs, $rich_key );
		}
	}

	/**
	 * Plain shortcode attributes (titles): empty when key missing or trim is empty after coercing number to string.
	 *
	 * @param array<string, mixed> $d4_attrs D4 attrs.
	 * @param string               $key      Shortcode attribute name.
	 * @return bool True when absent, non-string after coercion, or only whitespace.
	 */
	private static function d4_plain_string_semantically_empty( array $d4_attrs, string $key ): bool {
		if ( ! array_key_exists( $key, $d4_attrs ) ) {
			return true;
		}
		$raw = $d4_attrs[ $key ];
		if ( is_int( $raw ) || is_float( $raw ) ) {
			$raw = (string) $raw;
		}
		if ( ! is_string( $raw ) ) {
			return false;
		}

		return '' === trim( $raw );
	}

	/**
	 * HTML body fields (`dsm_circle_info_content`): treat missing, whitespace-only, or empty-ish HTML as empty.
	 *
	 * @param array<string, mixed> $d4_attrs D4 attrs.
	 * @param string               $key      Shortcode attribute name.
	 * @return bool
	 */
	private static function d4_html_or_plain_semantically_empty( array $d4_attrs, string $key ): bool {
		if ( ! array_key_exists( $key, $d4_attrs ) ) {
			return true;
		}
		$raw = $d4_attrs[ $key ];
		if ( is_int( $raw ) || is_float( $raw ) ) {
			$raw = (string) $raw;
		}
		if ( ! is_string( $raw ) ) {
			return false;
		}
		$t = trim( $raw );
		if ( '' === $t ) {
			return true;
		}
		$plain = trim(
			wp_strip_all_tags(
				html_entity_decode( $t, ENT_QUOTES, 'UTF-8' )
			)
		);

		return '' === $plain;
	}

	/**
	 * Force `innerContent.desktop.value` to `{ text: '' }` for rich-text module attributes.
	 *
	 * @param array<string, mixed> $converted_attrs Reference.
	 * @param string               $attr_key        D5 attribute key.
	 * @return void
	 */
	private static function force_inner_content_desktop_rich_text_empty( array &$converted_attrs, string $attr_key ): void {
		if ( ! isset( $converted_attrs[ $attr_key ] ) || ! is_array( $converted_attrs[ $attr_key ] ) ) {
			$converted_attrs[ $attr_key ] = array();
		}
		if ( ! isset( $converted_attrs[ $attr_key ]['innerContent'] ) || ! is_array( $converted_attrs[ $attr_key ]['innerContent'] ) ) {
			$converted_attrs[ $attr_key ]['innerContent'] = array();
		}
		if ( ! isset( $converted_attrs[ $attr_key ]['innerContent']['desktop'] ) || ! is_array( $converted_attrs[ $attr_key ]['innerContent']['desktop'] ) ) {
			$converted_attrs[ $attr_key ]['innerContent']['desktop'] = array();
		}
		$converted_attrs[ $attr_key ]['innerContent']['desktop']['value'] = array(
			'text' => '',
		);
	}

	/**
	 * Map D4 font `header_level` (or sibling key) to D5 `headingLevel` on a heading element’s decoration.font.font.
	 *
	 * When D4 omits the attr, Divi uses the module’s fallback (e.g. `et_pb_process_header_level( $level, 'h1' )` on Glitch Text).
	 *
	 * @param array  $converted_attrs     Reference.
	 * @param array  $d4_attrs            Original shortcode attrs.
	 * @param string $heading_attr_key    D5 attribute name (e.g. header).
	 * @param string $d4_level_key        D4 shortcode key (e.g. header_level).
	 * @param string $default_when_empty  Used when D4 value is empty/invalid and migrator left no valid `headingLevel`.
	 */
	public static function normalize_heading_level_from_d4(
		array &$converted_attrs,
		array $d4_attrs,
		string $heading_attr_key,
		string $d4_level_key = 'header_level',
		string $default_when_empty = 'h3'
	): void {
		$from_d4 = '';
		if (isset($d4_attrs[ $d4_level_key ]) && is_string($d4_attrs[ $d4_level_key ])) {
			$from_d4 = $d4_attrs[ $d4_level_key ];
		}

		$level = $default_when_empty;
		if ('' !== $from_d4) {
			$t = strtolower($from_d4);
			if (1 === preg_match('/^h[1-6]$/', $t)) {
				$level = $t;
			}
		}

		if (!isset($converted_attrs[ $heading_attr_key ]) || !is_array($converted_attrs[ $heading_attr_key ])) {
			$converted_attrs[ $heading_attr_key ] = array();
		}

		$h = &$converted_attrs[ $heading_attr_key ];
		if (!isset($h['decoration']) || !is_array($h['decoration'])) {
			$h['decoration'] = array();
		}
		if (!isset($h['decoration']['font']) || !is_array($h['decoration']['font'])) {
			$h['decoration']['font'] = array();
		}
		if (!isset($h['decoration']['font']['font']) || !is_array($h['decoration']['font']['font'])) {
			$h['decoration']['font']['font'] = array();
		}

		$font_tree = &$h['decoration']['font']['font'];

		foreach (array('desktop', 'tablet', 'phone') as $bp) {
			if (!isset($font_tree[ $bp ]) || !is_array($font_tree[ $bp ])) {
				$font_tree[ $bp ] = array();
			}
			if (!isset($font_tree[ $bp ]['value']) || !is_array($font_tree[ $bp ]['value'])) {
				$font_tree[ $bp ]['value'] = array();
			}

			$cur = $font_tree[ $bp ]['value']['headingLevel'] ?? null;
			$cur_ok = is_string($cur) && 1 === preg_match('/^h[1-6]$/i', $cur);

			if ('' !== $from_d4 || !$cur_ok) {
				$font_tree[ $bp ]['value']['headingLevel'] = $level;
			}
		}
	}

	/**
	 * Ensure single-value innerContent uses an allowed token (e.g. divider flex align).
	 *
	 * @param array  $converted_attrs Reference.
	 * @param string $attr_key        Attribute key.
	 * @param array  $allowed         Allowed string values.
	 * @param string $default         Fallback when missing or invalid.
	 */
	public static function normalize_inner_desktop_select_value(
		array &$converted_attrs,
		string $attr_key,
		array $allowed,
		string $default
	): void {
		$raw = null;
		if (isset($converted_attrs[ $attr_key ]['innerContent']['desktop']['value'])) {
			$raw = $converted_attrs[ $attr_key ]['innerContent']['desktop']['value'];
		}
		if (is_string($raw) && '' !== $raw && in_array($raw, $allowed, true)) {
			return;
		}
		self::set_inner_desktop_value($converted_attrs, $attr_key, $default, true);
	}

	/**
	 * Allowlist for Image Text Reveal character animation (matches D5 select + D4 options).
	 *
	 * @return list<string>
	 */
	public static function get_image_text_reveal_animation_allowlist(): array {
		return self::IMAGE_TEXT_REVEAL_ANIMATION_ALLOWLIST;
	}

	/**
	 * D4 often omits `reveal_text_animation` when it matches default; migration can leave empty/invalid D5 innerContent.
	 * Hydrate from raw D4 shortcode attrs, then normalize to a valid token (default `fadeIn`).
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs (by ref).
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attrs.
	 * @return void
	 */
	public static function hydrate_image_text_reveal_animation_from_d4( array &$converted_attrs, array $d4_attrs ): void {
		$allowed = self::IMAGE_TEXT_REVEAL_ANIMATION_ALLOWLIST;
		$cur     = $converted_attrs['reveal_text_animation']['innerContent']['desktop']['value'] ?? null;
		if ( is_string( $cur ) && '' !== trim( $cur ) && in_array( $cur, $allowed, true ) ) {
			return;
		}
		$from_d4 = $d4_attrs['reveal_text_animation'] ?? null;
		if ( is_string( $from_d4 ) && '' !== trim( $from_d4 ) && in_array( $from_d4, $allowed, true ) ) {
			self::set_inner_desktop_value( $converted_attrs, 'reveal_text_animation', $from_d4, true );
			return;
		}
		self::normalize_inner_desktop_select_value( $converted_attrs, 'reveal_text_animation', $allowed, 'fadeIn' );
	}

	/**
	 * Normalize dual-heading Display selects (D4 `inline-block` vs D5 `inline_block`; fill empty/invalid).
	 *
	 * @param array $attrs Module attrs (by reference).
	 * @return void
	 */
	public static function normalize_dual_heading_display_types(array &$attrs): void
	{
		foreach (array('before_display_type', 'middle_display_type', 'after_display_type') as $key) {
			self::normalize_inner_desktop_select_value(
				$attrs,
				$key,
				array('inline_block', 'block'),
				'inline_block'
			);
		}
	}

	/**
	 * Dual heading: heading tag select expects lowercase tokens; D4 / migration may store wrong case or empties.
	 *
	 * @param array $attrs Module attrs (by reference).
	 * @return void
	 */
	public static function normalize_dual_heading_heading_html_tag(array &$attrs): void
	{
		$allowed = array('h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'span', 'p');
		$raw     = $attrs['heading_html_tag']['innerContent']['desktop']['value'] ?? null;
		$pick    = 'h2';
		if (is_string($raw)) {
			$t = strtolower(trim($raw));
			if ('' !== $t && in_array($t, $allowed, true)) {
				$pick = $t;
			}
		}
		self::set_inner_desktop_value($attrs, 'heading_html_tag', $pick, true);
	}

	/**
	 * Copy legacy single `parts_gap` into column/row gap when those desktop values were never set (D4→D5 / old saves).
	 *
	 * @param array $attrs Module attrs (by reference).
	 * @return void
	 */
	public static function migrate_dual_heading_legacy_parts_gap(array &$attrs): void
	{
		$legacy = $attrs['parts_gap']['innerContent']['desktop']['value'] ?? null;
		if (! is_string($legacy) || '' === trim($legacy)) {
			return;
		}
		$legacy_trim = trim($legacy);
		foreach (array('parts_column_gap', 'parts_row_gap') as $key) {
			$desktop_branch = $attrs[ $key ]['innerContent']['desktop'] ?? null;
			$has_explicit     = is_array($desktop_branch) && array_key_exists('value', $desktop_branch);
			if ($has_explicit) {
				continue;
			}
			self::set_inner_desktop_value($attrs, $key, $legacy_trim, true);
		}
	}

	/**
	 * Map D4 `text_orientation` / D5 `module.advanced.text.text.*.orientation` into
	 * `main_text.decoration.font.font.*.textAlign` so flex `justify-content` matches (header font had `hide_text_align` in D4).
	 *
	 * @param string $orientation Raw orientation from module text or D4 shortcode.
	 * @return string|null       left|center|right|justify or null if unknown.
	 */
	private static function dual_heading_orientation_to_main_text_align(string $orientation): ?string
	{
		$o = strtolower(trim($orientation));
		if ('' === $o) {
			return null;
		}
		switch ($o) {
			case 'force_left':
			case 'forceleft':
				return 'left';
			case 'force_right':
			case 'forceright':
				return 'right';
			case 'left':
			case 'center':
			case 'right':
			case 'justify':
				return $o;
			case 'justified':
				return 'justify';
			default:
				return null;
		}
	}

	/**
	 * Set main_text font textAlign when empty (one breakpoint).
	 *
	 * @param array<string, mixed> $attrs        Module attrs (by reference).
	 * @param string               $breakpoint   desktop|tablet|phone.
	 * @param string               $text_align   left|center|right|justify.
	 */
	private static function set_dual_heading_main_font_text_align_if_empty(array &$attrs, string $breakpoint, string $text_align): void
	{
		$key = 'main_text';
		if (! isset($attrs[ $key ]) || ! is_array($attrs[ $key ])) {
			$attrs[ $key ] = array();
		}
		$h = &$attrs[ $key ];
		if (! isset($h['decoration']) || ! is_array($h['decoration'])) {
			$h['decoration'] = array();
		}
		if (! isset($h['decoration']['font']) || ! is_array($h['decoration']['font'])) {
			$h['decoration']['font'] = array();
		}
		if (! isset($h['decoration']['font']['font']) || ! is_array($h['decoration']['font']['font'])) {
			$h['decoration']['font']['font'] = array();
		}
		$font_tree = &$h['decoration']['font']['font'];
		if (! isset($font_tree[ $breakpoint ]) || ! is_array($font_tree[ $breakpoint ])) {
			$font_tree[ $breakpoint ] = array();
		}
		if (! isset($font_tree[ $breakpoint ]['value']) || ! is_array($font_tree[ $breakpoint ]['value'])) {
			$font_tree[ $breakpoint ]['value'] = array();
		}
		$existing = $font_tree[ $breakpoint ]['value']['textAlign'] ?? null;
		if (is_string($existing) && '' !== trim($existing)) {
			return;
		}
		$font_tree[ $breakpoint ]['value']['textAlign'] = $text_align;
	}

	/**
	 * Hydrate main font textAlign from module text orientation and/or D4 `text_orientation*` shortcode attrs.
	 *
	 * @param array<string, mixed>      $attrs                 Module attrs (by reference).
	 * @param array<string, mixed>|null $d4_shortcode_attrs    Optional D4 attrs when `module.advanced.text` is incomplete.
	 */
	public static function migrate_dual_heading_main_font_text_align_from_orientation(array &$attrs, ?array $d4_shortcode_attrs = null): void
	{
		$d4_keys = array(
			'desktop' => 'text_orientation',
			'tablet'  => 'text_orientation_tablet',
			'phone'   => 'text_orientation_phone',
		);
		foreach ($d4_keys as $bp => $d4_key) {
			$orientation = null;
			$text_text     = $attrs['module']['advanced']['text']['text'] ?? null;
			if (is_array($text_text) && isset($text_text[ $bp ]['value']) && is_array($text_text[ $bp ]['value'])) {
				$o = $text_text[ $bp ]['value']['orientation'] ?? null;
				if (is_string($o) && '' !== trim($o)) {
					$orientation = $o;
				}
			}
			if (null === $orientation && is_array($d4_shortcode_attrs) && isset($d4_shortcode_attrs[ $d4_key ]) && is_string($d4_shortcode_attrs[ $d4_key ]) && '' !== trim($d4_shortcode_attrs[ $d4_key ])) {
				$orientation = $d4_shortcode_attrs[ $d4_key ];
			}
			if (null === $orientation) {
				continue;
			}
			$mapped = self::dual_heading_orientation_to_main_text_align($orientation);
			if (null === $mapped) {
				continue;
			}
			self::set_dual_heading_main_font_text_align_if_empty($attrs, $bp, $mapped);
		}
	}

	/**
	 * All dual-heading attr fixes after D4 migration or stale saves.
	 *
	 * @param array<string, mixed>      $attrs                 Module attrs (by reference).
	 * @param array<string, mixed>|null $d4_shortcode_attrs  Pass original D4 attrs during migrator post-convert for orientation fallback.
	 */
	public static function normalize_dual_heading_attrs(array &$attrs, ?array $d4_shortcode_attrs = null): void
	{
		self::migrate_dual_heading_legacy_parts_gap($attrs);
		self::normalize_dual_heading_display_types($attrs);
		self::normalize_dual_heading_heading_html_tag($attrs);
		self::normalize_range_attr($attrs, 'parts_column_gap', '0px');
		self::normalize_range_attr($attrs, 'parts_row_gap', '0px');
		self::normalize_inner_desktop_select_value($attrs, 'parts_flex_direction', array('row', 'column'), 'row');
		self::normalize_inner_desktop_select_value($attrs, 'parts_column_align', array('stretch', 'start', 'center', 'end'), 'stretch');
		self::migrate_dual_heading_main_font_text_align_from_orientation($attrs, $d4_shortcode_attrs);
	}

	/**
	 * Ensure D5 Toggle / yes-no innerContent is exactly `on` or `off` (D4 often omits default_on_front attrs).
	 *
	 * @param array  $converted_attrs Reference.
	 * @param string $attr_key        Attribute key.
	 * @param string $default         `on` or `off`.
	 */
	public static function normalize_inner_desktop_yes_no(
		array &$converted_attrs,
		string $attr_key,
		string $default
	): void {
		if ('on' !== $default && 'off' !== $default) {
			$default = 'off';
		}
		$raw = null;
		if (isset($converted_attrs[ $attr_key ]['innerContent']['desktop']['value'])) {
			$raw = $converted_attrs[ $attr_key ]['innerContent']['desktop']['value'];
		}
		if ('on' === $raw || 'off' === $raw) {
			return;
		}
		self::set_inner_desktop_value($converted_attrs, $attr_key, $default, true);
	}

	/**
	 * Coerce Toggle / yes-no desktop values to `on`|`off` (handles bool, 0/1, legacy strings).
	 *
	 * @param array  $attrs    Module attrs by reference.
	 * @param string $attr_key Attribute key.
	 * @param string $fallback `on` or `off` when value is missing or not coercible.
	 */
	public static function coerce_inner_desktop_toggle_on_off(array &$attrs, string $attr_key, string $fallback): void
	{
		if ('on' !== $fallback && 'off' !== $fallback) {
			$fallback = 'off';
		}
		$raw = null;
		if (isset($attrs[ $attr_key ]['innerContent']['desktop']['value'])) {
			$raw = $attrs[ $attr_key ]['innerContent']['desktop']['value'];
		}
		if ('on' === $raw || 'off' === $raw) {
			return;
		}
		if (true === $raw) {
			self::set_inner_desktop_value($attrs, $attr_key, 'on', true);
			return;
		}
		if (false === $raw) {
			self::set_inner_desktop_value($attrs, $attr_key, 'off', true);
			return;
		}
		if (1 === $raw) {
			self::set_inner_desktop_value($attrs, $attr_key, 'on', true);
			return;
		}
		if (0 === $raw) {
			self::set_inner_desktop_value($attrs, $attr_key, 'off', true);
			return;
		}
		if (is_string($raw)) {
			$v = strtolower(trim($raw));
			if ('on' === $v || 'off' === $v) {
				self::set_inner_desktop_value($attrs, $attr_key, $v, true);
				return;
			}
			if (in_array($v, array('1', 'true', 'yes'), true)) {
				self::set_inner_desktop_value($attrs, $attr_key, 'on', true);
				return;
			}
			if (in_array($v, array('0', 'false', 'no'), true)) {
				self::set_inner_desktop_value($attrs, $attr_key, 'off', true);
				return;
			}
		}
		self::set_inner_desktop_value($attrs, $attr_key, $fallback, true);
	}

	/**
	 * Map a D4 yes/no shortcode attr to D5 innerContent (D4 omits attrs when equal to default_on_front).
	 *
	 * @param array  $converted_attrs Reference.
	 * @param array  $d4_attrs        Original D4 shortcode attrs (same keys as module fields).
	 * @param string $attr_key        Attribute key.
	 * @param string $when_omitted    `on` or `off` when the attr is missing or empty in D4.
	 */
	public static function sync_scroll_image_yes_no_from_d4(
		array &$converted_attrs,
		array $d4_attrs,
		string $attr_key,
		string $when_omitted
	): void {
		$raw_d4 = $d4_attrs[ $attr_key ] ?? null;
		if (null === $raw_d4 || (is_string($raw_d4) && '' === trim($raw_d4))) {
			self::set_inner_desktop_value($converted_attrs, $attr_key, $when_omitted, true);
			return;
		}
		if (true === $raw_d4) {
			self::set_inner_desktop_value($converted_attrs, $attr_key, 'on', true);
			return;
		}
		if (false === $raw_d4) {
			self::set_inner_desktop_value($converted_attrs, $attr_key, 'off', true);
			return;
		}
		$v = is_string($raw_d4) ? strtolower(trim($raw_d4)) : '';
		if ('on' === $v || 'off' === $v) {
			self::set_inner_desktop_value($converted_attrs, $attr_key, $v, true);
			return;
		}
		if (in_array($v, array('1', 'true', 'yes'), true)) {
			self::set_inner_desktop_value($converted_attrs, $attr_key, 'on', true);
			return;
		}
		if (in_array($v, array('0', 'false', 'no'), true)) {
			self::set_inner_desktop_value($converted_attrs, $attr_key, 'off', true);
			return;
		}
		self::set_inner_desktop_value($converted_attrs, $attr_key, $when_omitted, true);
	}

	/**
	 * Normalize Scroll Image attrs after D5 merge (VB refresh, stored JSON with omitted defaults).
	 *
	 * @param array $attrs Module attrs by reference.
	 */
	public static function normalize_scroll_image_merged_attrs(array &$attrs): void
	{
		self::upgrade_scroll_image_main_upload_attr_key_to_image($attrs);
		self::migrate_scroll_image_decoration_target_element_to_image($attrs);
		self::consolidate_scroll_image_legacy_title_into_decoration($attrs);
		self::coerce_inner_desktop_toggle_on_off($attrs, 'show_in_lightbox', 'off');
		self::coerce_inner_desktop_toggle_on_off($attrs, 'show_lightbox_other_img', 'off');
		self::coerce_inner_desktop_toggle_on_off($attrs, 'use_video_popup', 'off');
		self::coerce_inner_desktop_toggle_on_off($attrs, 'scroll_image_reverse_direction', 'off');
		self::coerce_inner_desktop_toggle_on_off($attrs, 'use_overlay', 'off');
		self::coerce_inner_desktop_toggle_on_off($attrs, 'use_icon', 'on');
		self::coerce_inner_desktop_toggle_on_off($attrs, 'overlay_on_hover', 'on');
		self::normalize_inner_desktop_select_value($attrs, 'url_new_window', array('off', 'on'), 'off');
		self::normalize_inner_desktop_select_value($attrs, 'scroll_image_direction', array('vertical', 'horizontal'), 'vertical');
		self::normalize_inner_desktop_select_value($attrs, 'scroll_image_mobile_behavior', array('auto', 'hover', 'static'), 'auto');
		self::normalize_range_attr($attrs, 'scroll_image_speed', '10s');
		self::ensure_inner_desktop_color_hex($attrs, 'lightbox_close_color', 'rgba(255,255,255,0.2)');
		self::ensure_inner_desktop_non_empty_string($attrs, 'lightbox_max_width', '860px');
	}

	/**
	 * Normalize Perspective Image attrs after D5 merge (VB refresh, stored JSON + defaults).
	 *
	 * @param array<string, mixed> $attrs Module attrs by reference.
	 */
	public static function normalize_perspective_image_merged_attrs(array &$attrs): void
	{
		self::normalize_upload_inner_content_to_object_value($attrs, 'src');
		self::normalize_upload_inner_content_to_object_value($attrs, 'show_lightbox_other_img_src');
		self::merge_perspective_image_alt_title_into_src($attrs);
		self::consolidate_perspective_image_alt_title_into_decoration($attrs);
	}

	/**
	 * Set desktop innerContent when value is missing or empty string (e.g. image size slug).
	 *
	 * @param array  $converted_attrs Reference.
	 * @param string $attr_key        Attribute key.
	 * @param string $fallback        Non-empty default.
	 */
	public static function ensure_inner_desktop_non_empty_string(
		array &$converted_attrs,
		string $attr_key,
		string $fallback
	): void {
		$raw = null;
		if (isset($converted_attrs[ $attr_key ]['innerContent']['desktop']['value'])) {
			$raw = $converted_attrs[ $attr_key ]['innerContent']['desktop']['value'];
		}
		if (is_string($raw) && '' !== $raw) {
			return;
		}
		self::set_inner_desktop_value($converted_attrs, $attr_key, $fallback, true);
	}

	/**
	 * Fill color-picker innerContent when migration left it empty (use D4 default or accent).
	 *
	 * @param array  $converted_attrs Reference.
	 * @param string $attr_key        Attribute key.
	 * @param string $fallback_hex    CSS color string.
	 */
	public static function ensure_inner_desktop_color_hex(
		array &$converted_attrs,
		string $attr_key,
		string $fallback_hex
	): void {
		$raw = null;
		if (isset($converted_attrs[ $attr_key ]['innerContent']['desktop']['value'])) {
			$raw = $converted_attrs[ $attr_key ]['innerContent']['desktop']['value'];
		}
		if (is_string($raw) && '' !== $raw) {
			return;
		}
		self::set_inner_desktop_value($converted_attrs, $attr_key, $fallback_hex, true);
	}

	/**
	 * D4 Advanced → Text → Background (`background_layout`) → D5 `module.advanced.text.text.desktop.value.color`.
	 *
	 * @param array $converted_attrs Reference.
	 * @param array $d4_attrs        Original shortcode attrs.
	 */
	public static function ensure_module_text_color_from_d4_background_layout(
		array &$converted_attrs,
		array $d4_attrs
	): void {
		if (! isset($converted_attrs['module']) || ! is_array($converted_attrs['module'])) {
			$converted_attrs['module'] = array();
		}
		if (! isset($converted_attrs['module']['advanced']) || ! is_array($converted_attrs['module']['advanced'])) {
			$converted_attrs['module']['advanced'] = array();
		}
		if (! isset($converted_attrs['module']['advanced']['text']) || ! is_array($converted_attrs['module']['advanced']['text'])) {
			$converted_attrs['module']['advanced']['text'] = array();
		}
		if (! isset($converted_attrs['module']['advanced']['text']['text']) || ! is_array($converted_attrs['module']['advanced']['text']['text'])) {
			$converted_attrs['module']['advanced']['text']['text'] = array();
		}
		if (! isset($converted_attrs['module']['advanced']['text']['text']['desktop']) || ! is_array($converted_attrs['module']['advanced']['text']['text']['desktop'])) {
			$converted_attrs['module']['advanced']['text']['text']['desktop'] = array();
		}
		if (! isset($converted_attrs['module']['advanced']['text']['text']['desktop']['value']) || ! is_array($converted_attrs['module']['advanced']['text']['text']['desktop']['value'])) {
			$converted_attrs['module']['advanced']['text']['text']['desktop']['value'] = array();
		}

		$val = &$converted_attrs['module']['advanced']['text']['text']['desktop']['value'];
		$color = $val['color'] ?? null;
		if (is_string($color) && '' !== $color) {
			return;
		}

		$bg = '';
		if (isset($d4_attrs['background_layout']) && is_string($d4_attrs['background_layout'])) {
			$bg = strtolower(trim($d4_attrs['background_layout']));
		}
		$val['color'] = ('dark' === $bg) ? 'dark' : 'light';
	}

	/**
	 * Ensure `module.advanced.text.text.*.value.orientation` from D4 `text_orientation*` when migrator left it empty.
	 *
	 * @param array<string, mixed> $converted_attrs Reference.
	 * @param array<string, mixed> $d4_attrs        Original shortcode attrs.
	 */
	private static function ensure_module_text_orientation_from_d4_shortcode(array &$converted_attrs, array $d4_attrs): void
	{
		if (! isset($converted_attrs['module']) || ! is_array($converted_attrs['module'])) {
			$converted_attrs['module'] = array();
		}
		if (! isset($converted_attrs['module']['advanced']) || ! is_array($converted_attrs['module']['advanced'])) {
			$converted_attrs['module']['advanced'] = array();
		}
		if (! isset($converted_attrs['module']['advanced']['text']) || ! is_array($converted_attrs['module']['advanced']['text'])) {
			$converted_attrs['module']['advanced']['text'] = array();
		}
		if (! isset($converted_attrs['module']['advanced']['text']['text']) || ! is_array($converted_attrs['module']['advanced']['text']['text'])) {
			$converted_attrs['module']['advanced']['text']['text'] = array();
		}

		$map = array(
			'desktop' => 'text_orientation',
			'tablet'  => 'text_orientation_tablet',
			'phone'   => 'text_orientation_phone',
		);

		foreach ($map as $bp => $d4_key) {
			if (! isset($converted_attrs['module']['advanced']['text']['text'][ $bp ]) || ! is_array($converted_attrs['module']['advanced']['text']['text'][ $bp ])) {
				$converted_attrs['module']['advanced']['text']['text'][ $bp ] = array();
			}
			if (! isset($converted_attrs['module']['advanced']['text']['text'][ $bp ]['value']) || ! is_array($converted_attrs['module']['advanced']['text']['text'][ $bp ]['value'])) {
				$converted_attrs['module']['advanced']['text']['text'][ $bp ]['value'] = array();
			}
			$existing = $converted_attrs['module']['advanced']['text']['text'][ $bp ]['value']['orientation'] ?? null;
			if (is_string($existing) && '' !== trim($existing)) {
				continue;
			}
			if (! isset($d4_attrs[ $d4_key ]) || ! is_string($d4_attrs[ $d4_key ])) {
				continue;
			}
			$raw = strtolower(trim($d4_attrs[ $d4_key ]));
			if ('' === $raw) {
				continue;
			}
			$orientation = null;
			switch ($raw) {
				case 'left':
				case 'center':
				case 'right':
				case 'justify':
					$orientation = $raw;
					break;
				case 'justified':
					$orientation = 'justify';
					break;
				default:
					break;
			}
			if (null === $orientation) {
				continue;
			}
			$converted_attrs['module']['advanced']['text']['text'][ $bp ]['value']['orientation'] = $orientation;
		}
	}

	/**
	 * Card Carousel parent: D4 Text group (`background_layout`, `text_orientation`) → D5 `module.advanced.text` (classes on child `.dsm_card_wrapper`).
	 *
	 * @param array<string, mixed> $converted_attrs Reference.
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attrs.
	 */
	public static function hydrate_card_carousel_parent_text_group_from_d4(array &$converted_attrs, array $d4_attrs): void
	{
		self::ensure_module_text_color_from_d4_background_layout($converted_attrs, $d4_attrs);
		self::ensure_module_text_orientation_from_d4_shortcode($converted_attrs, $d4_attrs);
	}

	/**
	 * Resolve D4 divider line style: shortcode → global preset → solid.
	 *
	 * @param array $d4_attrs Original shortcode attrs.
	 * @param array $allowed  Allowed border-style keywords.
	 */
	private static function text_divider_resolve_divider_style(array $d4_attrs, array $allowed): string
	{
		if (isset($d4_attrs['divider_style']) && is_string($d4_attrs['divider_style'])) {
			$c = trim($d4_attrs['divider_style']);
			if ('' !== $c && in_array($c, $allowed, true)) {
				return $c;
			}
		}
		if (class_exists('ET_Global_Settings')) {
			$g = \ET_Global_Settings::get_value('dsm_text_divider-divider_style');
			if (is_string($g)) {
				$g = trim($g);
				if ('' !== $g && in_array($g, $allowed, true)) {
					return $g;
				}
			}
		}

		return 'solid';
	}

	/**
	 * After migrator: apply D4 `default` / `default_on_front` equivalents so VB fields are not blank.
	 *
	 * @param array $converted_attrs Reference.
	 * @param array $d4_attrs        Original shortcode attrs.
	 */
	public static function apply_text_divider_d4_defaults(array &$converted_attrs, array $d4_attrs): void
	{
		self::normalize_heading_level_from_d4($converted_attrs, $d4_attrs, 'header', 'header_level');

		$divider_styles = array('solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'inset', 'outset', 'none');
		$style_default = self::text_divider_resolve_divider_style($d4_attrs, $divider_styles);
		self::normalize_inner_desktop_select_value($converted_attrs, 'divider_style', $divider_styles, $style_default);

		$align_fallback = 'center';
		if (isset($d4_attrs['text_alignment']) && is_string($d4_attrs['text_alignment'])) {
			$t = strtolower(trim($d4_attrs['text_alignment']));
			if (in_array($t, array('left', 'center', 'right'), true)) {
				$align_fallback = $t;
			}
		}
		self::normalize_inner_desktop_select_value(
			$converted_attrs,
			'divider_alignment',
			array('left', 'center', 'right'),
			$align_fallback
		);

		$pos_fallback = 'center';
		if (isset($d4_attrs['divider_position']) && is_string($d4_attrs['divider_position'])) {
			$t = trim($d4_attrs['divider_position']);
			if (in_array($t, array('flex-start', 'center', 'flex-end'), true)) {
				$pos_fallback = $t;
			}
		}
		self::normalize_inner_desktop_select_value(
			$converted_attrs,
			'divider_position',
			array('flex-start', 'center', 'flex-end'),
			$pos_fallback
		);

		self::normalize_range_attr($converted_attrs, 'text_gap', '10px');
		self::normalize_range_attr($converted_attrs, 'divider_weight', '1px');
		self::normalize_range_attr($converted_attrs, 'height', '23px');

		$line_color = '';
		if (isset($d4_attrs['color']) && is_string($d4_attrs['color'])) {
			$line_color = trim($d4_attrs['color']);
		}
		if ('' === $line_color && function_exists('et_builder_accent_color')) {
			$line_color = trim((string) et_builder_accent_color());
		}
		if ('' === $line_color) {
			$line_color = '#2ea3f2';
		}
		self::ensure_inner_desktop_color_hex($converted_attrs, 'color', $line_color);

		self::ensure_module_text_color_from_d4_background_layout($converted_attrs, $d4_attrs);
	}

	/**
	 * Random Image: fill omitted default_on_front toggles and image size slugs.
	 *
	 * @param array $converted_attrs Reference.
	 * @param array $d4_attrs        Original shortcode attrs.
	 */
	public static function apply_random_image_d4_defaults(array &$converted_attrs, array $d4_attrs): void
	{
		$sizes_fallback = (isset($d4_attrs['sizes']) && is_string($d4_attrs['sizes']) && '' !== trim($d4_attrs['sizes']))
			? trim($d4_attrs['sizes'])
			: 'full';
		self::ensure_inner_desktop_non_empty_string($converted_attrs, 'sizes', $sizes_fallback);

		$lb_fallback = (isset($d4_attrs['lightbox_img_sizes']) && is_string($d4_attrs['lightbox_img_sizes']) && '' !== trim($d4_attrs['lightbox_img_sizes']))
			? trim($d4_attrs['lightbox_img_sizes'])
			: 'full';
		self::ensure_inner_desktop_non_empty_string($converted_attrs, 'lightbox_img_sizes', $lb_fallback);

		self::normalize_inner_desktop_yes_no($converted_attrs, 'use_image_link_url', 'off');
		self::normalize_inner_desktop_select_value($converted_attrs, 'url_new_window', array('off', 'on'), 'off');
		self::normalize_inner_desktop_yes_no($converted_attrs, 'show_in_lightbox', 'off');
		self::normalize_inner_desktop_yes_no($converted_attrs, 'use_overlay', 'off');
		self::normalize_inner_desktop_yes_no($converted_attrs, 'overlay_on_hover', 'on');
		self::normalize_inner_desktop_yes_no($converted_attrs, 'force_fullwidth', 'off');
	}

	/**
	 * Scroll Image: fill omitted default_on_front options (matches DSM_Scroll_Image).
	 *
	 * @param array $converted_attrs Reference.
	 * @param array $d4_attrs        Original shortcode attrs.
	 */
	public static function apply_scroll_image_d4_defaults(array &$converted_attrs, array $d4_attrs): void
	{
		self::sync_scroll_image_yes_no_from_d4($converted_attrs, $d4_attrs, 'show_in_lightbox', 'off');
		self::sync_scroll_image_yes_no_from_d4($converted_attrs, $d4_attrs, 'show_lightbox_other_img', 'off');
		self::sync_scroll_image_yes_no_from_d4($converted_attrs, $d4_attrs, 'use_video_popup', 'off');
		self::sync_scroll_image_yes_no_from_d4($converted_attrs, $d4_attrs, 'scroll_image_reverse_direction', 'off');
		self::sync_scroll_image_yes_no_from_d4($converted_attrs, $d4_attrs, 'use_overlay', 'off');
		self::sync_scroll_image_yes_no_from_d4($converted_attrs, $d4_attrs, 'use_icon', 'on');
		self::sync_scroll_image_yes_no_from_d4($converted_attrs, $d4_attrs, 'overlay_on_hover', 'on');
		self::sync_scroll_image_yes_no_from_d4($converted_attrs, $d4_attrs, 'url_new_window', 'off');
		self::normalize_range_attr($converted_attrs, 'scroll_image_speed', '10s');

		$dir_fb = 'vertical';
		if (isset($d4_attrs['scroll_image_direction']) && is_string($d4_attrs['scroll_image_direction'])) {
			$d = strtolower(trim($d4_attrs['scroll_image_direction']));
			if (in_array($d, array('vertical', 'horizontal'), true)) {
				$dir_fb = $d;
			}
		}
		self::normalize_inner_desktop_select_value($converted_attrs, 'scroll_image_direction', array('vertical', 'horizontal'), $dir_fb);

		$mob_fb = 'auto';
		if (isset($d4_attrs['scroll_image_mobile_behavior']) && is_string($d4_attrs['scroll_image_mobile_behavior'])) {
			$m = strtolower(trim($d4_attrs['scroll_image_mobile_behavior']));
			if (in_array($m, array('auto', 'hover', 'static'), true)) {
				$mob_fb = $m;
			}
		}
		self::normalize_inner_desktop_select_value($converted_attrs, 'scroll_image_mobile_behavior', array('auto', 'hover', 'static'), $mob_fb);
		self::ensure_inner_desktop_color_hex($converted_attrs, 'lightbox_close_color', 'rgba(255,255,255,0.2)');

		$mw = (isset($d4_attrs['lightbox_max_width']) && is_string($d4_attrs['lightbox_max_width']) && '' !== trim($d4_attrs['lightbox_max_width']))
			? trim($d4_attrs['lightbox_max_width'])
			: '860px';
		self::ensure_inner_desktop_non_empty_string($converted_attrs, 'lightbox_max_width', $mw);
	}

	/**
	 * Text Path: fill D4 defaults / migrator gaps (path type, alignment, ranges, direction, stroke).
	 *
	 * @param array $converted_attrs Reference.
	 * @param array $d4_attrs        Original shortcode attrs.
	 */
	public static function apply_text_path_d4_defaults(array &$converted_attrs, array $d4_attrs): void
	{
		$path_types_allowed = dsm_get_text_path_allowed_types();
		if (isset($converted_attrs['dsm_path_type']['innerContent']['desktop']['value']) && is_string($converted_attrs['dsm_path_type']['innerContent']['desktop']['value'])) {
			$pv = strtolower(trim($converted_attrs['dsm_path_type']['innerContent']['desktop']['value']));
			if (in_array($pv, $path_types_allowed, true)) {
				self::set_inner_desktop_value($converted_attrs, 'dsm_path_type', $pv, true);
			}
		}
		$path_type_fallback = 'wave';
		if (isset($d4_attrs['dsm_path_type']) && is_string($d4_attrs['dsm_path_type'])) {
			$pt = strtolower(trim($d4_attrs['dsm_path_type']));
			if (in_array($pt, $path_types_allowed, true)) {
				$path_type_fallback = $pt;
			}
		}
		self::normalize_inner_desktop_select_value($converted_attrs, 'dsm_path_type', $path_types_allowed, $path_type_fallback);

		$align_allowed = array('left', 'center', 'right');
		if (isset($converted_attrs['dsm_path_alignment']['innerContent']['desktop']['value']) && is_string($converted_attrs['dsm_path_alignment']['innerContent']['desktop']['value'])) {
			$av = strtolower(trim($converted_attrs['dsm_path_alignment']['innerContent']['desktop']['value']));
			if (in_array($av, $align_allowed, true)) {
				self::set_inner_desktop_value($converted_attrs, 'dsm_path_alignment', $av, true);
			}
		}
		$align_fallback = 'center';
		if (isset($d4_attrs['dsm_path_alignment']) && is_string($d4_attrs['dsm_path_alignment'])) {
			$a = strtolower(trim($d4_attrs['dsm_path_alignment']));
			if (in_array($a, $align_allowed, true)) {
				$align_fallback = $a;
			}
		}
		self::normalize_inner_desktop_select_value($converted_attrs, 'dsm_path_alignment', $align_allowed, $align_fallback);

		$allowed = array('default', 'rtl', 'ltr');

		if (isset($converted_attrs['dsm_text_dir']['innerContent']['desktop']['value']) && is_string($converted_attrs['dsm_text_dir']['innerContent']['desktop']['value'])) {
			$norm_dir = strtolower(trim($converted_attrs['dsm_text_dir']['innerContent']['desktop']['value']));
			if (in_array($norm_dir, $allowed, true)) {
				self::set_inner_desktop_value($converted_attrs, 'dsm_text_dir', $norm_dir, true);
			}
		}

		$dir_fallback = 'default';
		if (isset($d4_attrs['dsm_text_dir']) && is_string($d4_attrs['dsm_text_dir'])) {
			$t = strtolower(trim($d4_attrs['dsm_text_dir']));
			if (in_array($t, $allowed, true)) {
				$dir_fallback = $t;
			}
		}
		self::normalize_inner_desktop_select_value($converted_attrs, 'dsm_text_dir', $allowed, $dir_fallback);

		self::normalize_inner_desktop_yes_no($converted_attrs, 'dsm_show_path', 'off');

		$stroke_fallback = '#e8178a';
		if (isset($d4_attrs['dsm_stroke_color']) && is_string($d4_attrs['dsm_stroke_color']) && '' !== trim($d4_attrs['dsm_stroke_color'])) {
			$stroke_fallback = trim($d4_attrs['dsm_stroke_color']);
		}
		self::ensure_inner_desktop_color_hex($converted_attrs, 'dsm_stroke_color', $stroke_fallback);
		self::normalize_range_attr($converted_attrs, 'dsm_stroke_width', '1px');
		self::normalize_range_attr($converted_attrs, 'dsm_text_path_size', '500px');
		self::normalize_range_attr($converted_attrs, 'dsm_path_rotate', '0deg');
		self::normalize_range_attr($converted_attrs, 'dsm_path_word_spacing', '0px');
		self::normalize_range_attr($converted_attrs, 'dsm_path_starting_point', '0%');
	}

	/**
	 * Normalize legacy string `innerContent.*.value` to headingLink `{ text: string }` for each breakpoint/state.
	 *
	 * @param array  $converted_attrs Reference.
	 * @param string $attr_key        Attribute key.
	 */
	public static function normalize_inner_value_to_heading_link_text_shape(array &$converted_attrs, string $attr_key): void
	{
		if (!isset($converted_attrs[ $attr_key ]['innerContent']) || !is_array($converted_attrs[ $attr_key ]['innerContent'])) {
			return;
		}

		$inner = &$converted_attrs[ $attr_key ]['innerContent'];

		foreach (array_keys($inner) as $breakpoint) {
			if (!isset($inner[ $breakpoint ]) || !is_array($inner[ $breakpoint ])) {
				continue;
			}

			$bucket = &$inner[ $breakpoint ];

			// ET conversion outline paths like `*.innerContent.*.text` store the string on `text`, not `value`.
			// VB `FieldContainer` + `subName="text"` expects `innerContent.{bp}.value` as string or `{ text: string }`.
			if (! array_key_exists('value', $bucket) && isset($bucket['text']) && is_scalar($bucket['text']) && ! is_bool($bucket['text'])) {
				$bucket['value'] = (string) $bucket['text'];
				unset($bucket['text']);
			}

			if (!isset($bucket['value'])) {
				continue;
			}

			$value = &$bucket['value'];

			if (is_string($value)) {
				$value = array(
					'text' => $value,
				);
			}
		}
	}

	/**
	 * Move `module.advanced.link` from migrator output into a `headingLink` element's innerContent (url/target).
	 *
	 * Used when the module has no Advanced → Link UI but exposes LinkGroup on a named element (e.g. glitch_text).
	 *
	 * @param array  $converted_attrs  Converted block attrs (by reference).
	 * @param string $heading_attr_key Attribute key with `elementType` headingLink.
	 * @return void
	 */
	public static function merge_module_advanced_link_into_heading_link_element(array &$converted_attrs, string $heading_attr_key): void
	{
		if (
			!isset($converted_attrs['module']['advanced']['link'])
			|| !is_array($converted_attrs['module']['advanced']['link'])
		) {
			return;
		}

		$link = $converted_attrs['module']['advanced']['link'];

		self::normalize_inner_value_to_heading_link_text_shape($converted_attrs, $heading_attr_key);

		if (
			!isset($converted_attrs[$heading_attr_key]['innerContent'])
			|| !is_array($converted_attrs[$heading_attr_key]['innerContent'])
		) {
			unset($converted_attrs['module']['advanced']['link']);

			return;
		}

		$inner = &$converted_attrs[$heading_attr_key]['innerContent'];

		foreach ($link as $breakpoint => $bp_data) {
			if (!is_string($breakpoint) || !is_array($bp_data)) {
				continue;
			}
			if (!isset($bp_data['value']) || !is_array($bp_data['value'])) {
				continue;
			}
			if (!isset($inner[$breakpoint]) || !is_array($inner[$breakpoint])) {
				continue;
			}

			$url_raw = $bp_data['value']['url'] ?? '';
			$url = is_string($url_raw) ? trim($url_raw) : '';
			$target_raw = $bp_data['value']['target'] ?? 'off';
			$target = is_string($target_raw) && ('on' === $target_raw || 'off' === $target_raw)
				? $target_raw
				: 'off';

			if ('' === $url) {
				continue;
			}

			$val = &$inner[$breakpoint]['value'];
			if (!is_array($val)) {
				$val = array(
					'text' => is_string($val) ? $val : '',
				);
			}

			$val['url'] = is_string($url_raw) ? trim($url_raw) : $url;
			$val['target'] = $target;
		}

		unset($converted_attrs['module']['advanced']['link']);
	}

	/**
	 * Icon List Item (`dsm_icon_list_child`): D4 stores the row link as `url` + `url_new_window`, not standard
	 * Divi `link_options` shortcode attrs — the conversion outline path alone does not reliably populate
	 * {@see LinkGroup} storage at `module.advanced.link`.
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs (by reference).
	 * @param array<string, mixed> $d4_attrs       Original D4 shortcode attributes.
	 * @return void
	 */
	public static function hydrate_icon_list_child_link_from_d4(array &$converted_attrs, array $d4_attrs): void
	{
		if (! isset($d4_attrs['url'])) {
			return;
		}

		$url = trim((string) $d4_attrs['url']);
		if ('' === $url) {
			return;
		}

		$target = (isset($d4_attrs['url_new_window']) && 'on' === $d4_attrs['url_new_window']) ? 'on' : 'off';

		if (! isset($converted_attrs['module']) || ! is_array($converted_attrs['module'])) {
			$converted_attrs['module'] = array();
		}
		if (! isset($converted_attrs['module']['advanced']) || ! is_array($converted_attrs['module']['advanced'])) {
			$converted_attrs['module']['advanced'] = array();
		}
		if (! isset($converted_attrs['module']['advanced']['link']) || ! is_array($converted_attrs['module']['advanced']['link'])) {
			$converted_attrs['module']['advanced']['link'] = array();
		}

		$link = &$converted_attrs['module']['advanced']['link'];

		foreach (array('desktop', 'tablet', 'phone') as $bp) {
			if (! isset($link[ $bp ]) || ! is_array($link[ $bp ])) {
				$link[ $bp ] = array();
			}
			if (! isset($link[ $bp ]['value']) || ! is_array($link[ $bp ]['value'])) {
				$link[ $bp ]['value'] = array();
			}

			$val = &$link[ $bp ]['value'];
			$existing = isset($val['url']) ? trim((string) $val['url']) : '';
			if ('' !== $existing) {
				continue;
			}

			$val['url']    = $url;
			$val['target'] = $target;
		}
	}

	/**
	 * Post-convert finalize: merge D4 / `htmlAttributes` into `module.decoration.attributes` when needed,
	 * then remove `htmlAttributes` (primary path: {@see SupremeSharedConversion} + outline per ET docs).
	 *
	 * @param array $converted_attrs Converted block attrs (by reference).
	 * @param array $d4_attrs        Original D4 shortcode attributes.
	 * @return void
	 */
	public static function merge_d4_module_id_class_into_decoration_attributes(array &$converted_attrs, array $d4_attrs): void
	{
		$id = '';
		$class = '';

		if (isset($d4_attrs['module_id']) && is_string($d4_attrs['module_id']) && '' !== $d4_attrs['module_id']) {
			$id = $d4_attrs['module_id'];
		}
		if (isset($d4_attrs['module_class']) && is_string($d4_attrs['module_class']) && '' !== $d4_attrs['module_class']) {
			$class = $d4_attrs['module_class'];
		}

		$html_val = null;
		if (
			isset($converted_attrs['module']['advanced']['htmlAttributes']['desktop']['value'])
			&& is_array($converted_attrs['module']['advanced']['htmlAttributes']['desktop']['value'])
		) {
			$html_val = $converted_attrs['module']['advanced']['htmlAttributes']['desktop']['value'];
		}
		if (is_array($html_val)) {
			if ('' === $id && isset($html_val['id']) && is_string($html_val['id']) && '' !== $html_val['id']) {
				$id = $html_val['id'];
			}
			if ('' === $class && isset($html_val['class']) && is_string($html_val['class']) && '' !== $html_val['class']) {
				$class = $html_val['class'];
			}
		}

		if ('' !== $id || '' !== $class) {
			self::ensure_module_decoration_attributes_container($converted_attrs);
			$list = &$converted_attrs['module']['decoration']['attributes']['desktop']['value']['attributes'];
			if ('' !== $id) {
				self::set_or_merge_decoration_attribute_row($list, 'id', $id, 'CSS ID');
			}
			if ('' !== $class) {
				self::set_or_merge_decoration_attribute_row($list, 'class', $class, 'CSS Class');
			}
		}

		if (isset($converted_attrs['module']['advanced']['htmlAttributes'])) {
			unset($converted_attrs['module']['advanced']['htmlAttributes']);
		}
	}

	/**
	 * Top-level legacy keys leaked from unmigrated Supreme D4 Custom Attributes extension.
	 *
	 * @var list<string>
	 */
	private const DSM_EXTENSION_CUSTOM_ATTRIBUTE_D4_KEYS = array(
		'dsm_modules_custom_attribute_on_off',
		'dsm_modules_custom_attribute_add',
		'dsm_modules_custom_attribute_css_selector',
		'dsm_custom_attributes_options',
	);

	/**
	 * Remove migrated D4 Supreme Custom Attributes shortcode props from converted block attrs.
	 *
	 * @param array<string, mixed> $converted_attrs Reference.
	 * @return void
	 */
	public static function purge_dsm_extension_custom_attrs_from_converted(array &$converted_attrs): void
	{
		foreach (self::DSM_EXTENSION_CUSTOM_ATTRIBUTE_D4_KEYS as $key) {
			if (isset($converted_attrs[ $key ])) {
				unset($converted_attrs[ $key ]);
			}
		}
	}

	/**
	 * Parse D4 `dsm_custom_attributes_options` (sortable_list JSON — often URL-encoded wrappers).
	 *
	 * @param array<string, mixed> $d4_attrs Original D4 shortcode attrs.
	 * @return list<array<string, mixed>>
	 */
	private static function decode_dsm_d4_custom_attributes_options_rows(array $d4_attrs): array
	{
		if (! isset($d4_attrs['dsm_custom_attributes_options'])) {
			return array();
		}

		$raw = $d4_attrs['dsm_custom_attributes_options'];
		if (! is_string($raw) || '' === trim($raw)) {
			return array();
		}

		$s = wp_unslash($raw);
		$s = rawurldecode($s);
		$s = wp_specialchars_decode($s, ENT_QUOTES);
		$s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
		$s = str_replace(array('&#91;', '&#93;'), array('[', ']'), $s);

		$decoded = json_decode($s, true);

		if (
			JSON_ERROR_NONE !== json_last_error()
			|| null === $decoded
			|| (! is_array($decoded))
		) {
			$decoded_fix = wp_unslash($raw);
			$decoded_fix = str_replace(array('&#91;', '&#93;'), array('[', ']'), $decoded_fix);
			$decoded     = json_decode($decoded_fix, true);
		}

		if (
			(JSON_ERROR_NONE !== json_last_error() || null === $decoded || ! is_array($decoded))
			&& preg_match('/\{.*}/s', $s, $object_match)
		) {
			$row_obj = json_decode($object_match[0], true);
			if (JSON_ERROR_NONE === json_last_error() && is_array($row_obj)) {
				return array_values(array( $row_obj ));
			}
		}

		if (! is_array($decoded) || array() === $decoded) {
			return array();
		}

		// Single associative row object → wrap as one row.
		$looks_like_row = isset($decoded['link_url'])
			|| isset($decoded['link_text']);

		if ($looks_like_row && self::looks_like_associative_single_row_shape($decoded)) {
			return array( $decoded );
		}

		return array_values($decoded);
	}

	/**
	 * @param array<string, mixed> $maybe_row Decoded payload.
	 * @return bool
	 */
	private static function looks_like_associative_single_row_shape(array $maybe_row): bool
	{
		$numeric_keys_exist = array_filter(array_keys($maybe_row), static function ($k) {

			return is_int($k) || ctype_digit((string) $k);
		});

		return array() === $numeric_keys_exist;
	}

	/**
	 * Migrate D4 Supreme Pro Custom Attributes (extension fields + sortable JSON) into D5 Advanced → Attributes.
	 *
	 * Preserves UX by mapping D4 placement (`wrapper`|`button`|`css`) to decoration `targetElement` for Divi separation.
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs (reference).
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attrs (reference unchanged).
	 * @param string|null          $d5_module_name  D5 block module name (`dsm/card`, `dsm/button`, …).
	 * @return void
	 */
	public static function migrate_dsm_d4_extension_custom_attributes_into_decoration_attributes(
		array &$converted_attrs,
		array $d4_attrs,
		?string $d5_module_name = null
	): void {
		self::purge_dsm_extension_custom_attrs_from_converted($converted_attrs);

		if (
			! isset($d4_attrs['dsm_modules_custom_attribute_on_off'])
			|| ! self::is_d4_attr_on($d4_attrs['dsm_modules_custom_attribute_on_off'])
		) {
			return;
		}

		$rows = self::decode_dsm_d4_custom_attributes_options_rows($d4_attrs);
		if (array() === $rows) {
			return;
		}

		$add_raw        = isset($d4_attrs['dsm_modules_custom_attribute_add']) ? (string) $d4_attrs['dsm_modules_custom_attribute_add'] : 'wrapper';
		$add            = strtolower(trim($add_raw));
		$valid_places   = array( 'wrapper', 'button', 'css' );

		if (! in_array($add, $valid_places, true)) {
			$add = 'wrapper';
		}

		$target_element = '';

		switch ($add) {
			case 'button':
				// Element keys differ: `dsm/button` exposes `button_one` / `button_two`; Divi core `divi/button` uses `button`; other DSM modules often use `button`.
				if ('dsm/button' === $d5_module_name) {
					$target_element = 'button_one';
				} else {
					// Includes `divi/button` (core `button` element) and DSM modules with a `button` attribute key.
					$target_element = 'button';
				}

				break;
			case 'css':
				$sel_raw = isset($d4_attrs['dsm_modules_custom_attribute_css_selector']) ? trim((string) $d4_attrs['dsm_modules_custom_attribute_css_selector']) : '';
				if ('' !== $sel_raw) {
					// D4 inserts before `class="…"` using the substring after stripping a leading dot.
					$target_element = ltrim($sel_raw, '. ');
				}

				break;
			default:
				// Module wrapper (root selector).
				$target_element = '';

				break;
		}

		foreach ($rows as $row) {
			if (! is_array($row)) {
				continue;
			}

			$name = isset($row['link_url']) ? trim((string) $row['link_url']) : '';

			if ('' === $name) {
				continue;
			}

			if (preg_match('/\s/', $name) || strpos($name, '<') !== false || strpos($name, '"') !== false || strpos($name, '\'') !== false) {
				continue;
			}

			if (! preg_match('/^[a-zA-Z_:][a-zA-Z0-9:._\-]*$/', $name)) {
				continue;
			}

			$value_raw = isset($row['link_text']) ? trim((string) $row['link_text']) : '';
			if (strpos($value_raw, '<') !== false) {
				continue;
			}

			$value = sanitize_text_field($value_raw);

			self::append_targeted_decoration_attribute(
				$converted_attrs,
				strtolower($name),
				$value,
				$name,
				(string) $target_element
			);
		}
	}

	/**
	 * FAQ / FAQ child: map D4 `module_id` / `module_class` into `module.decoration.attributes`.
	 * Delegates to {@see self::merge_d4_module_id_class_into_decoration_attributes()}; shared post-convert already runs that for all `dsm/*` modules.
	 *
	 * @param array $converted_attrs Converted block attrs (by reference).
	 * @param array $d4_attrs        Original D4 shortcode attributes.
	 * @return void
	 */
	public static function merge_d4_faq_module_id_class_into_decoration_attributes(array &$converted_attrs, array $d4_attrs): void
	{
		self::merge_d4_module_id_class_into_decoration_attributes($converted_attrs, $d4_attrs);
	}

	/**
	 * `dsm/image-hotspots` (and legacy `dsm/image-hotspot`): map D4 `hotspot_event` select values to D5 `hover` | `click`.
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs (by reference).
	 * @return void
	 */
	public static function normalize_image_hotspot_hotspot_event_from_d4(array &$converted_attrs): void
	{
		if (!isset($converted_attrs['hotspot_event']['innerContent']['desktop']['value'])) {
			return;
		}

		$v = $converted_attrs['hotspot_event']['innerContent']['desktop']['value'];
		if (!is_string($v) || '' === $v) {
			return;
		}

		$map = array(
			'mouseenter' => 'hover',
			'mouseenter_focus' => 'hover',
			'mouseenter_click' => 'click',
			'click' => 'click',
			'click_focus' => 'click',
		);

		if (isset($map[$v])) {
			$converted_attrs['hotspot_event']['innerContent']['desktop']['value'] = $map[$v];
		}
	}

	/**
	 * `dsm/post-carousel`: merge D4 `dsm_include_{post_type}_{taxonomy}` term lists into `term_ids`.
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs (by reference).
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attributes.
	 * @return void
	 */
	public static function hydrate_post_carousel_term_ids_from_d4(array &$converted_attrs, array $d4_attrs): void
	{
		$post_type = 'page';
		if (isset($d4_attrs['post_type']) && is_string($d4_attrs['post_type']) && '' !== $d4_attrs['post_type']) {
			$post_type = $d4_attrs['post_type'];
		}

		$prefix = 'dsm_include_' . $post_type . '_';
		$merged  = array();

		foreach ($d4_attrs as $key => $val) {
			if (!is_string($key) || strpos($key, $prefix) !== 0) {
				continue;
			}
			$taxonomy = substr($key, strlen($prefix));
			if ('' === $taxonomy || !is_string($val) || '' === trim($val)) {
				continue;
			}
			$parts = array_filter(array_map('trim', explode(',', $val)));
			foreach ($parts as $id_str) {
				$tid = (int) $id_str;
				if ($tid > 0) {
					$merged[] = $taxonomy . ':' . $tid;
				}
			}
		}

		if (array() === $merged) {
			return;
		}

		if (!isset($converted_attrs['term_ids']) || !is_array($converted_attrs['term_ids'])) {
			$converted_attrs['term_ids'] = array();
		}
		if (!isset($converted_attrs['term_ids']['innerContent']) || !is_array($converted_attrs['term_ids']['innerContent'])) {
			$converted_attrs['term_ids']['innerContent'] = array();
		}
		if (!isset($converted_attrs['term_ids']['innerContent']['desktop']) || !is_array($converted_attrs['term_ids']['innerContent']['desktop'])) {
			$converted_attrs['term_ids']['innerContent']['desktop'] = array();
		}
		$converted_attrs['term_ids']['innerContent']['desktop']['value'] = array_values(array_unique($merged));
	}

	/**
	 * `dsm/blog-carousel`: D4 `include_categories` (comma list / `all` / `current` / numeric ids) → `include_categories` checkboxes.
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs (by reference).
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attributes.
	 * @return void
	 */
	public static function hydrate_blog_carousel_include_categories_from_d4(array &$converted_attrs, array $d4_attrs): void
	{
		if (!isset($d4_attrs['include_categories'])) {
			return;
		}
		$raw = $d4_attrs['include_categories'];
		if (is_array($raw)) {
			$tokens = $raw;
		} elseif (is_string($raw)) {
			$raw = trim($raw);
			if ('' === $raw) {
				return;
			}
			$tokens = array_filter(array_map('trim', explode(',', $raw)));
		} else {
			return;
		}
		if (array() === $tokens) {
			return;
		}
		$out = array();
		if (in_array('all', $tokens, true)) {
			$out[] = 'all';
		} else {
			foreach ($tokens as $t) {
				if (!is_string($t) && !is_numeric($t)) {
					continue;
				}
				$t = is_numeric($t) ? (string) (int) $t : $t;
				if ('current' === $t) {
					$out[] = 'current';
				} elseif (ctype_digit($t)) {
					$tid = (int) $t;
					if ($tid > 0) {
						$out[] = 'category:' . $tid;
					}
				}
			}
		}
		if (array() === $out) {
			return;
		}
		if (!isset($converted_attrs['include_categories']) || !is_array($converted_attrs['include_categories'])) {
			$converted_attrs['include_categories'] = array();
		}
		if (!isset($converted_attrs['include_categories']['innerContent']) || !is_array($converted_attrs['include_categories']['innerContent'])) {
			$converted_attrs['include_categories']['innerContent'] = array();
		}
		if (!isset($converted_attrs['include_categories']['innerContent']['desktop']) || !is_array($converted_attrs['include_categories']['innerContent']['desktop'])) {
			$converted_attrs['include_categories']['innerContent']['desktop'] = array();
		}
		$converted_attrs['include_categories']['innerContent']['desktop']['value'] = array_values(array_unique($out, SORT_REGULAR));
	}

	/**
	 * Whether a D4 yes/no (or migrator) attribute is "on" (matches {@see self::sync_scroll_image_yes_no_from_d4()}).
	 *
	 * @param mixed $raw D4 shortcode / migrator value.
	 * @return bool
	 */
	private static function is_d4_attr_on( $raw ): bool {
		if ( true === $raw || 1 === $raw ) {
			return true;
		}
		if ( false === $raw || 0 === $raw ) {
			return false;
		}
		if ( null === $raw ) {
			return false;
		}
		if ( is_string( $raw ) ) {
			$v = strtolower( trim( $raw ) );
			if ( '' === $v ) {
				return false;
			}
			if ( in_array( $v, array( 'on', '1', 'true', 'yes' ), true ) ) {
				return true;
			}
			if ( in_array( $v, array( 'off', '0', 'false', 'no' ), true ) ) {
				return false;
			}
		}

		return false;
	}

	/**
	 * Parse D4 font icon string (readmore_icon, etc.) to D5 button `icon.settings` shape.
	 *
	 * @param string $raw D4-encoded icon string.
	 * @return array<string, string>|null
	 */
	private static function convert_d4_font_icon_string_to_button_icon_settings(string $raw): ?array
	{
		$raw = trim($raw);
		if ('' === $raw) {
			return null;
		}

		if (! class_exists(ValueExpansion::class)) {
			return null;
		}

		$icon_flat = ValueExpansion::convertIcon($raw);
		if (false === $icon_flat || ! is_array($icon_flat) || array() === $icon_flat) {
			return null;
		}

		if (! isset($icon_flat['type'])) {
			$icon_flat['type'] = 'divi';
		}
		if (! isset($icon_flat['weight']) || '' === trim((string) ($icon_flat['weight'] ?? ''))) {
			$icon_flat['weight'] = '400';
		}

		return $icon_flat;
	}

	/**
	 * D4 `custom_readmore` from migrator attrs (snake_case, camelCase, or case-insensitive key).
	 *
	 * @param array<string, mixed> $d4_attrs Original D4 shortcode attributes.
	 * @return mixed|null
	 */
	private static function post_carousel_get_d4_custom_readmore_raw(array $d4_attrs)
	{
		if (isset($d4_attrs['custom_readmore'])) {
			return $d4_attrs['custom_readmore'];
		}
		if (isset($d4_attrs['customReadmore'])) {
			return $d4_attrs['customReadmore'];
		}
		foreach ($d4_attrs as $key => $value) {
			if (is_string($key) && strtolower($key) === 'custom_readmore') {
				return $value;
			}
		}

		return null;
	}

	/**
	 * Whether a decoration subtree has a non-empty scalar leaf (recursive).
	 *
	 * @param mixed $node Decoration fragment.
	 * @return bool
	 */
	private static function post_carousel_decoration_subtree_has_non_empty_leaf($node): bool
	{
		if (is_string($node)) {
			return '' !== trim($node);
		}
		if (is_int($node) || is_float($node)) {
			return true;
		}
		if (! is_array($node) || array() === $node) {
			return false;
		}
		foreach ($node as $child) {
			if (self::post_carousel_decoration_subtree_has_non_empty_leaf($child)) {
				return true;
			}
		}

		return false;
	}

	/**
	 * True when migrated `readmore.decoration` has real button/font/bg/border data (not spacing-only defaults).
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs.
	 * @return bool
	 */
	private static function post_carousel_readmore_decoration_implies_custom_styles(array $converted_attrs): bool
	{
		$readmore = $converted_attrs['readmore'] ?? null;
		if (! is_array($readmore)) {
			return false;
		}
		$dec = $readmore['decoration'] ?? null;
		if (! is_array($dec)) {
			return false;
		}

		foreach (array('font', 'background', 'border', 'boxShadow', 'filters', 'position', 'sizing') as $key) {
			if (! isset($dec[$key]) || ! is_array($dec[$key])) {
				continue;
			}
			if (self::post_carousel_decoration_subtree_has_non_empty_leaf($dec[$key])) {
				return true;
			}
		}

		$btn = $dec['button'] ?? null;
		if (! is_array($btn)) {
			return false;
		}
		foreach (array('desktop', 'tablet', 'phone') as $bp) {
			$val = $btn[$bp]['value'] ?? null;
			if (! is_array($val)) {
				continue;
			}
			foreach ($val as $vk => $vv) {
				if ('enable' === $vk || 'icon' === $vk) {
					continue;
				}
				if (is_array($vv)) {
					if (self::post_carousel_decoration_subtree_has_non_empty_leaf($vv)) {
						return true;
					}
				} elseif (null !== $vv && '' !== $vv) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Set `readmore.decoration.button.*.value.enable` for all breakpoints.
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs (by reference).
	 * @param string               $enable          `on` or `off`.
	 * @return void
	 */
	private static function post_carousel_set_readmore_button_style_toggle(array &$converted_attrs, string $enable): void
	{
		if ('on' !== $enable && 'off' !== $enable) {
			return;
		}
		if (! isset($converted_attrs['readmore']) || ! is_array($converted_attrs['readmore'])) {
			$converted_attrs['readmore'] = array();
		}
		if (! isset($converted_attrs['readmore']['decoration']) || ! is_array($converted_attrs['readmore']['decoration'])) {
			$converted_attrs['readmore']['decoration'] = array();
		}
		if (! isset($converted_attrs['readmore']['decoration']['button']) || ! is_array($converted_attrs['readmore']['decoration']['button'])) {
			$converted_attrs['readmore']['decoration']['button'] = array();
		}
		foreach (array('desktop', 'tablet', 'phone') as $bp) {
			if (! isset($converted_attrs['readmore']['decoration']['button'][$bp]) || ! is_array($converted_attrs['readmore']['decoration']['button'][$bp])) {
				$converted_attrs['readmore']['decoration']['button'][$bp] = array();
			}
			if (! isset($converted_attrs['readmore']['decoration']['button'][$bp]['value']) || ! is_array($converted_attrs['readmore']['decoration']['button'][$bp]['value'])) {
				$converted_attrs['readmore']['decoration']['button'][$bp]['value'] = array();
			}
			$converted_attrs['readmore']['decoration']['button'][$bp]['value']['enable'] = $enable;
		}
	}

	/**
	 * Turn on D5 "Use Custom Styles for Readmore" when D4 had `custom_readmore` on or decoration implies it.
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs (by reference).
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attributes.
	 * @return void
	 */
	public static function finalize_post_carousel_readmore_button_enable(array &$converted_attrs, array $d4_attrs): void
	{
		$raw = self::post_carousel_get_d4_custom_readmore_raw($d4_attrs);
		$explicit = null !== $raw && self::is_d4_attr_on($raw);
		$implies = self::post_carousel_readmore_decoration_implies_custom_styles($converted_attrs);
		if ($explicit || $implies) {
			self::post_carousel_set_readmore_button_style_toggle($converted_attrs, 'on');
		}
	}

	/**
	 * `dsm/post-carousel`: D4 `custom_readmore` / `readmore_use_icon` → `readmore.decoration.button`.
	 *
	 * When `readmore_use_icon` is missing/empty and `custom_readmore` is on, treats icon as on (ET button default; shortcode often omits it).
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs (by reference).
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attributes.
	 * @return void
	 */
	public static function hydrate_post_carousel_readmore_from_d4(array &$converted_attrs, array $d4_attrs): void
	{
		$raw = self::post_carousel_get_d4_custom_readmore_raw($d4_attrs);
		$custom_readmore_explicit = null !== $raw && self::is_d4_attr_on($raw);
		$custom_readmore_implies = self::post_carousel_readmore_decoration_implies_custom_styles($converted_attrs);
		$custom_readmore_effective = $custom_readmore_explicit || $custom_readmore_implies;

		// D4 `readmore_use_icon`: ET button field defaults to `on` but shortcode often omits it; module defaults use `''`.
		// Only infer `on` when custom readmore styles are enabled (`custom_readmore`), matching when the toggle exists in D4.
		$readmore_use_icon_raw = '';
		if (isset($d4_attrs['readmore_use_icon'])) {
			$ru = $d4_attrs['readmore_use_icon'];
			if (true === $ru || false === $ru || is_int($ru)) {
				$readmore_use_icon_raw = self::is_d4_attr_on($ru) ? 'on' : 'off';
			} else {
				$readmore_use_icon_raw = strtolower(trim((string) $ru));
			}
		}
		if ('' === $readmore_use_icon_raw) {
			$readmore_use_icon_raw = $custom_readmore_effective ? 'on' : 'off';
		}
		$show_readmore_icon = ('off' !== $readmore_use_icon_raw);

		if (!isset($converted_attrs['readmore']) || !is_array($converted_attrs['readmore'])) {
			$converted_attrs['readmore'] = array();
		}
		if (!isset($converted_attrs['readmore']['decoration']) || !is_array($converted_attrs['readmore']['decoration'])) {
			$converted_attrs['readmore']['decoration'] = array();
		}
		if (!isset($converted_attrs['readmore']['decoration']['button']) || !is_array($converted_attrs['readmore']['decoration']['button'])) {
			$converted_attrs['readmore']['decoration']['button'] = array();
		}

		if ($show_readmore_icon) {
			$icon_raw = isset($d4_attrs['readmore_icon']) ? trim((string) $d4_attrs['readmore_icon']) : '';
			$icon_settings = self::convert_d4_font_icon_string_to_button_icon_settings($icon_raw);
			if (null === $icon_settings) {
				// D4: empty readmore_icon uses et_pb_process_font_icon default glyph "5" (Divi icon font).
				$icon_settings = array(
					'type'    => 'divi',
					'unicode' => '&#x35;',
					'weight'  => '400',
				);
			}

			foreach (array('desktop', 'tablet', 'phone') as $bp) {
				if (!isset($converted_attrs['readmore']['decoration']['button'][$bp]) || !is_array($converted_attrs['readmore']['decoration']['button'][$bp])) {
					$converted_attrs['readmore']['decoration']['button'][$bp] = array();
				}
				if (!isset($converted_attrs['readmore']['decoration']['button'][$bp]['value']) || !is_array($converted_attrs['readmore']['decoration']['button'][$bp]['value'])) {
					$converted_attrs['readmore']['decoration']['button'][$bp]['value'] = array();
				}
				if (!isset($converted_attrs['readmore']['decoration']['button'][$bp]['value']['icon']) || !is_array($converted_attrs['readmore']['decoration']['button'][$bp]['value']['icon'])) {
					$converted_attrs['readmore']['decoration']['button'][$bp]['value']['icon'] = array();
				}
				$converted_attrs['readmore']['decoration']['button'][$bp]['value']['icon']['enable'] = 'on';
				$converted_attrs['readmore']['decoration']['button'][$bp]['value']['icon']['settings'] = $icon_settings;
			}
		} else {
			foreach (array('desktop', 'tablet', 'phone') as $bp) {
				if (!isset($converted_attrs['readmore']['decoration']['button'][$bp]) || !is_array($converted_attrs['readmore']['decoration']['button'][$bp])) {
					$converted_attrs['readmore']['decoration']['button'][$bp] = array();
				}
				if (!isset($converted_attrs['readmore']['decoration']['button'][$bp]['value']) || !is_array($converted_attrs['readmore']['decoration']['button'][$bp]['value'])) {
					$converted_attrs['readmore']['decoration']['button'][$bp]['value'] = array();
				}
				if (!isset($converted_attrs['readmore']['decoration']['button'][$bp]['value']['icon']) || !is_array($converted_attrs['readmore']['decoration']['button'][$bp]['value']['icon'])) {
					$converted_attrs['readmore']['decoration']['button'][$bp]['value']['icon'] = array();
				}
				$converted_attrs['readmore']['decoration']['button'][$bp]['value']['icon']['enable'] = 'off';
			}
		}

		self::finalize_post_carousel_readmore_button_enable($converted_attrs, $d4_attrs);
	}

	/**
	 * Ensure `module.decoration.attributes.desktop.value.attributes` exists.
	 *
	 * @param array $converted_attrs Reference.
	 * @return void
	 */
	private static function ensure_module_decoration_attributes_container(array &$converted_attrs): void
	{
		if (!isset($converted_attrs['module']) || !is_array($converted_attrs['module'])) {
			$converted_attrs['module'] = array();
		}
		if (!isset($converted_attrs['module']['decoration']) || !is_array($converted_attrs['module']['decoration'])) {
			$converted_attrs['module']['decoration'] = array();
		}
		if (!isset($converted_attrs['module']['decoration']['attributes']) || !is_array($converted_attrs['module']['decoration']['attributes'])) {
			$converted_attrs['module']['decoration']['attributes'] = array();
		}
		if (!isset($converted_attrs['module']['decoration']['attributes']['desktop']) || !is_array($converted_attrs['module']['decoration']['attributes']['desktop'])) {
			$converted_attrs['module']['decoration']['attributes']['desktop'] = array();
		}
		if (!isset($converted_attrs['module']['decoration']['attributes']['desktop']['value']) || !is_array($converted_attrs['module']['decoration']['attributes']['desktop']['value'])) {
			$converted_attrs['module']['decoration']['attributes']['desktop']['value'] = array();
		}
		if (!isset($converted_attrs['module']['decoration']['attributes']['desktop']['value']['attributes']) || !is_array($converted_attrs['module']['decoration']['attributes']['desktop']['value']['attributes'])) {
			$converted_attrs['module']['decoration']['attributes']['desktop']['value']['attributes'] = array();
		}
	}

	/**
	 * Set or update one row in the decoration attributes list (matches Divi Text module migration shape).
	 *
	 * @param array  $attributes_list Reference to list of attribute rows.
	 * @param string $name            HTML attribute name (`id` or `class`).
	 * @param string $value           Attribute value.
	 * @param string $admin_label     Builder row label.
	 * @return void
	 */
	public static function set_or_merge_decoration_attribute_row(array &$attributes_list, string $name, string $value, string $admin_label): void
	{
		foreach ($attributes_list as $idx => $row) {
			if (!is_array($row)) {
				continue;
			}
			if (($row['name'] ?? '') === $name) {
				$attributes_list[ $idx ]['value'] = $value;
				if (!isset($attributes_list[ $idx ]['adminLabel'])) {
					$attributes_list[ $idx ]['adminLabel'] = $admin_label;
				}
				return;
			}
		}

		$attributes_list[] = array(
			'id' => uniqid('', true),
			'name' => $name,
			'value' => $value,
			'adminLabel' => $admin_label,
			'targetElement' => '',
		);
	}

	/**
	 * Ensure `{$element}.decoration.attributes.desktop.value.attributes` exists.
	 *
	 * @param array  $converted_attrs Reference.
	 * @param string $element_key     Attribute key (e.g. button_one).
	 * @return void
	 */
	private static function ensure_element_decoration_attributes_container(array &$converted_attrs, string $element_key): void
	{
		if (!isset($converted_attrs[ $element_key ]) || !is_array($converted_attrs[ $element_key ])) {
			$converted_attrs[ $element_key ] = array();
		}
		if (!isset($converted_attrs[ $element_key ]['decoration']) || !is_array($converted_attrs[ $element_key ]['decoration'])) {
			$converted_attrs[ $element_key ]['decoration'] = array();
		}
		if (!isset($converted_attrs[ $element_key ]['decoration']['attributes']) || !is_array($converted_attrs[ $element_key ]['decoration']['attributes'])) {
			$converted_attrs[ $element_key ]['decoration']['attributes'] = array();
		}
		if (!isset($converted_attrs[ $element_key ]['decoration']['attributes']['desktop']) || !is_array($converted_attrs[ $element_key ]['decoration']['attributes']['desktop'])) {
			$converted_attrs[ $element_key ]['decoration']['attributes']['desktop'] = array();
		}
		if (!isset($converted_attrs[ $element_key ]['decoration']['attributes']['desktop']['value']) || !is_array($converted_attrs[ $element_key ]['decoration']['attributes']['desktop']['value'])) {
			$converted_attrs[ $element_key ]['decoration']['attributes']['desktop']['value'] = array();
		}
		if (!isset($converted_attrs[ $element_key ]['decoration']['attributes']['desktop']['value']['attributes']) || !is_array($converted_attrs[ $element_key ]['decoration']['attributes']['desktop']['value']['attributes'])) {
			$converted_attrs[ $element_key ]['decoration']['attributes']['desktop']['value']['attributes'] = array();
		}
	}

	/**
	 * Merge D4 `button_*_id` / `button_*_css` into decoration attributes for each button element and for Advanced → Attributes (targetElement).
	 *
	 * @param array<string, mixed> $converted_attrs Reference.
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attributes.
	 * @return void
	 */
	public static function merge_d4_button_elements_id_class_into_decoration_attributes(array &$converted_attrs, array $d4_attrs): void
	{
		$pairs = array(
			array( 'button_one_id', 'button_one_css', 'button_one' ),
			array( 'button_two_id', 'button_two_css', 'button_two' ),
		);

		foreach ($pairs as $pair) {
			list($id_key, $class_key, $el) = $pair;
			$id_raw    = $d4_attrs[ $id_key ] ?? '';
			$class_raw = $d4_attrs[ $class_key ] ?? '';
			$id        = is_string($id_raw) ? sanitize_text_field($id_raw) : '';
			$class     = is_string($class_raw) ? sanitize_text_field($class_raw) : '';

			if ('' === $id && '' === $class) {
				continue;
			}

			self::ensure_element_decoration_attributes_container($converted_attrs, $el);
			$list = &$converted_attrs[ $el ]['decoration']['attributes']['desktop']['value']['attributes'];
			if ('' !== $id) {
				self::set_or_merge_decoration_attribute_row($list, 'id', $id, 'CSS ID');
			}
			if ('' !== $class) {
				self::set_or_merge_decoration_attribute_row($list, 'class', $class, 'CSS Class');
			}

			// Advanced → Attributes (module decoration + targetElement) uses a separate store from
			// Design → AttributesGroup attrName="button_one"|"button_two". Duplicate D4 id/class so both UIs show migrated values.
			if ('' !== $id) {
				self::append_targeted_decoration_attribute($converted_attrs, 'id', $id, 'CSS ID', $el);
			}
			if ('' !== $class) {
				self::append_targeted_decoration_attribute($converted_attrs, 'class', $class, 'CSS Class', $el);
			}
		}
	}

	/**
	 * Read `id` and `class` from a button element's decoration attributes (frontend render).
	 *
	 * @param array<string, mixed> $element_attrs Attribute subtree for `button_one` or `button_two`.
	 * @return array{id: string, class: string}
	 */
	public static function extract_element_decoration_id_and_class(array $element_attrs): array
	{
		$out = array(
			'id' => '',
			'class' => '',
		);

		$attr_root = $element_attrs['decoration']['attributes'] ?? array();
		if (! is_array($attr_root)) {
			return $out;
		}

		$rows = null;
		foreach (array('desktop', 'tablet', 'phone') as $bp) {
			$candidate = $attr_root[ $bp ]['value']['attributes'] ?? null;
			if (is_array($candidate) && array() !== $candidate) {
				$rows = $candidate;
				break;
			}
		}
		if (! is_array($rows)) {
			$rows = $attr_root['attributes'] ?? null;
		}
		if (! is_array($rows)) {
			return $out;
		}

		foreach ($rows as $row) {
			if (!is_array($row)) {
				continue;
			}
			$name = isset($row['name']) ? (string) $row['name'] : '';
			$val  = isset($row['value']) ? (string) $row['value'] : '';
			if ('id' === $name && '' !== $val) {
				$out['id'] = $val;
			}
			if ('class' === $name && '' !== $val) {
				$out['class'] = $val;
			}
		}

		return $out;
	}

	/**
	 * Append or update one Advanced → Attributes row for a specific target element (e.g. image).
	 *
	 * @param array  $converted_attrs Reference.
	 * @param string $name            HTML attribute name (e.g. alt, title).
	 * @param string $value           Attribute value.
	 * @param string $admin_label     Row label in the builder.
	 * @param string $target_element  Target key matching a module.json attribute name (e.g. blob_image).
	 * @return void
	 */
	public static function append_targeted_decoration_attribute(array &$converted_attrs, string $name, string $value, string $admin_label, string $target_element): void
	{
		if ('' === $value) {
			return;
		}

		self::ensure_module_decoration_attributes_container($converted_attrs);
		$list = &$converted_attrs['module']['decoration']['attributes']['desktop']['value']['attributes'];

		foreach ($list as $idx => $row) {
			if (!is_array($row)) {
				continue;
			}
			if (($row['name'] ?? '') === $name && ($row['targetElement'] ?? '') === $target_element) {
				$list[ $idx ]['value'] = $value;
				if (!isset($list[ $idx ]['adminLabel'])) {
					$list[ $idx ]['adminLabel'] = $admin_label;
				}
				return;
			}
		}

		$list[] = array(
			'id' => uniqid('', true),
			'name' => $name,
			'value' => $value,
			'adminLabel' => $admin_label,
			'targetElement' => $target_element,
		);
	}

	/**
	 * Whether Advanced → Attributes already defines a non-empty value for this name and target element.
	 *
	 * @param array  $attrs           Block attrs.
	 * @param string $name            HTML attribute name.
	 * @param string $target_element  module.json field key (e.g. image).
	 */
	public static function decoration_attributes_has_targeted_value(
		array $attrs,
		string $name,
		string $target_element
	): bool {
		$list = $attrs['module']['decoration']['attributes']['desktop']['value']['attributes'] ?? null;
		if (! is_array($list)) {
			return false;
		}
		foreach ($list as $row) {
			if (! is_array($row)) {
				continue;
			}
			if (($row['name'] ?? '') !== $name || ($row['targetElement'] ?? '') !== $target_element) {
				continue;
			}
			$val = isset($row['value']) && is_string($row['value']) ? trim($row['value']) : '';

			return '' !== $val;
		}

		return false;
	}

	/**
	 * Read desktop innerContent as a string or headingLink `{ text }`.
	 *
	 * @param array  $attrs      Module attrs.
	 * @param string $attr_key   Field key.
	 */
	public static function read_inner_desktop_plain_or_heading_text(array $attrs, string $attr_key): string
	{
		if (! isset($attrs[ $attr_key ]['innerContent']['desktop']['value'])) {
			return '';
		}
		$raw = $attrs[ $attr_key ]['innerContent']['desktop']['value'];
		if (is_string($raw)) {
			return trim($raw);
		}
		if (is_array($raw) && isset($raw['text']) && is_string($raw['text'])) {
			return trim($raw['text']);
		}

		return '';
	}

	/**
	 * D4 `alt` / `title_text` → Advanced → Attributes on main upload (`src` target).
	 *
	 * @param array<string, mixed> $converted_attrs Reference.
	 * @param array<string, mixed> $d4_attrs        D4 shortcode attrs.
	 */
	public static function migrate_perspective_image_alt_title_from_d4_to_decoration(
		array &$converted_attrs,
		array $d4_attrs
	): void {
		$targets = array( 'src', 'image' );
		if (
			! self::decoration_attributes_has_targeted_value_any($converted_attrs, 'alt', $targets)
			&& isset($d4_attrs['alt'])
			&& is_string($d4_attrs['alt'])
		) {
			$a = trim($d4_attrs['alt']);
			if ('' !== $a) {
				$safe = sanitize_text_field($a);
				if ('' !== $safe) {
					self::append_targeted_decoration_attribute(
						$converted_attrs,
						'alt',
						$safe,
						'Image Alt',
						'src'
					);
				}
			}
		}
		if (
			! self::decoration_attributes_has_targeted_value_any($converted_attrs, 'title', $targets)
			&& isset($d4_attrs['title_text'])
			&& is_string($d4_attrs['title_text'])
		) {
			$t = trim($d4_attrs['title_text']);
			if ('' !== $t) {
				$safe = sanitize_text_field($t);
				if ('' !== $safe) {
					self::append_targeted_decoration_attribute(
						$converted_attrs,
						'title',
						$safe,
						'Image Title',
						'src'
					);
				}
			}
		}
	}

	/**
	 * Whether Advanced → Attributes defines a non-empty value for this name on any of the target keys.
	 *
	 * @param array<string, mixed> $attrs           Block attrs.
	 * @param string               $name            HTML attribute name.
	 * @param list<string>         $target_elements module.json field keys (e.g. src, image).
	 */
	private static function decoration_attributes_has_targeted_value_any(
		array $attrs,
		string $name,
		array $target_elements
	): bool {
		foreach ($target_elements as $el) {
			if (self::decoration_attributes_has_targeted_value($attrs, $name, $el)) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Move `src.innerContent` alt/title into Advanced → Attributes when missing there (then drop from upload).
	 *
	 * @param array<string, mixed> $attrs Module attrs by reference.
	 */
	public static function consolidate_perspective_image_alt_title_into_decoration(array &$attrs): void
	{
		if (! isset($attrs['src']['innerContent']['desktop']['value']) || ! is_array($attrs['src']['innerContent']['desktop']['value'])) {
			return;
		}
		$value   = &$attrs['src']['innerContent']['desktop']['value'];
		$pairs   = array(
			'alt' => array( 'Image Alt', 'alt' ),
			'title' => array( 'Image Title', 'title' ),
		);
		$targets = array( 'src', 'image' );
		foreach ($pairs as $src_key => $meta) {
			$admin_label = $meta[0];
			$dec_name    = $meta[1];
			if (! isset($value[ $src_key ]) || ! is_string($value[ $src_key ])) {
				continue;
			}
			$raw = trim($value[ $src_key ]);
			if ('' === $raw) {
				unset($value[ $src_key ]);
				continue;
			}
			if (self::decoration_attributes_has_targeted_value_any($attrs, $dec_name, $targets)) {
				unset($value[ $src_key ]);
				continue;
			}
			$safe = sanitize_text_field($raw);
			if ('' === $safe) {
				unset($value[ $src_key ]);
				continue;
			}
			self::append_targeted_decoration_attribute($attrs, $dec_name, $safe, $admin_label, 'src');
			unset($value[ $src_key ]);
		}
		if (
			isset($attrs['module']['decoration']['attributes'])
			&& class_exists('\ET\Builder\Security\AttributeSecurity\AttributeSecurity')
		) {
			$attrs['module']['decoration']['attributes'] = \ET\Builder\Security\AttributeSecurity\AttributeSecurity::sanitize_module_attributes(
				$attrs['module']['decoration']['attributes']
			);
		}
	}

	/**
	 * D4 shortcode `title_text` → Advanced → Attributes on the main image (`image` target).
	 *
	 * @param array $converted_attrs Reference.
	 * @param array $d4_attrs        D4 shortcode attrs.
	 */
	public static function migrate_scroll_image_title_from_d4_to_decoration(
		array &$converted_attrs,
		array $d4_attrs
	): void {
		if (
			self::decoration_attributes_has_targeted_value($converted_attrs, 'title', 'image')
			|| self::decoration_attributes_has_targeted_value($converted_attrs, 'title', 'src')
		) {
			return;
		}
		$title = '';
		if (isset($d4_attrs['title_text']) && is_string($d4_attrs['title_text'])) {
			$title = trim($d4_attrs['title_text']);
		}
		if ('' === $title) {
			return;
		}
		$safe = sanitize_text_field($title);
		if ('' === $safe) {
			return;
		}
		self::append_targeted_decoration_attribute(
			$converted_attrs,
			'title',
			$safe,
			'Image Title',
			'image'
		);
	}

	/**
	 * Move legacy `title_text` innerContent into decoration attributes and remove the field.
	 *
	 * @param array $attrs Reference.
	 */
	public static function consolidate_scroll_image_legacy_title_into_decoration(array &$attrs): void
	{
		if (! isset($attrs['title_text'])) {
			return;
		}
		$title = self::read_inner_desktop_plain_or_heading_text($attrs, 'title_text');
		unset($attrs['title_text']);
		if ('' === $title) {
			return;
		}
		if (
			self::decoration_attributes_has_targeted_value($attrs, 'title', 'image')
			|| self::decoration_attributes_has_targeted_value($attrs, 'title', 'src')
		) {
			return;
		}
		$safe = sanitize_text_field($title);
		if ('' === $safe) {
			return;
		}
		self::append_targeted_decoration_attribute(
			$attrs,
			'title',
			$safe,
			'Image Title',
			'image'
		);
		if (
			isset($attrs['module']['decoration']['attributes'])
			&& class_exists('\ET\Builder\Security\AttributeSecurity\AttributeSecurity')
		) {
			$attrs['module']['decoration']['attributes'] = \ET\Builder\Security\AttributeSecurity\AttributeSecurity::sanitize_module_attributes(
				$attrs['module']['decoration']['attributes']
			);
		}
	}

	/**
	 * Rename legacy `src` upload attr to `image` (module.json element key for Attributes target).
	 *
	 * @param array $attrs Reference.
	 */
	public static function upgrade_scroll_image_main_upload_attr_key_to_image(array &$attrs): void
	{
		if (isset($attrs['image']) && is_array($attrs['image'])) {
			if (isset($attrs['src'])) {
				unset($attrs['src']);
			}
			return;
		}
		if (isset($attrs['src']) && is_array($attrs['src'])) {
			$attrs['image'] = $attrs['src'];
			unset($attrs['src']);
		}
	}

	/**
	 * Update Advanced → Attributes rows that targeted the old `src` key to `image`.
	 *
	 * @param array $attrs Reference.
	 */
	public static function migrate_scroll_image_decoration_target_element_to_image(array &$attrs): void
	{
		if (! isset($attrs['module']['decoration']['attributes']['desktop']['value']['attributes'])) {
			return;
		}
		$list = &$attrs['module']['decoration']['attributes']['desktop']['value']['attributes'];
		if (! is_array($list)) {
			return;
		}
		foreach ($list as $idx => $row) {
			if (! is_array($row)) {
				continue;
			}
			if (($row['targetElement'] ?? '') === 'src') {
				$list[ $idx ]['targetElement'] = 'image';
			}
		}
	}

	/**
	 * Coerce string or legacy upload values to Divi 5 `{ src, id? }` for one innerContent leaf.
	 *
	 * @param mixed $v Value by reference.
	 */
	private static function normalize_upload_inner_leaf(&$v): void
	{
		if (is_string($v) || is_numeric($v)) {
			$s = trim((string) $v);
			if ('' === $s) {
				$v = array( 'src' => '' );
				return;
			}
			if (str_starts_with($s, '$variable(') || (str_starts_with($s, '{') && str_contains($s, '"type"'))) {
				return;
			}
			$v = array( 'src' => $s );
			return;
		}
		if (! is_array($v)) {
			return;
		}
		if (isset($v['src']) && is_string($v['src'])) {
			return;
		}
		if (! isset($v['src']) && isset($v['url']) && is_string($v['url'])) {
			$out = array( 'src' => $v['url'] );
			if (! empty($v['id']) && is_scalar($v['id'])) {
				$out['id'] = (string) $v['id'];
			}
			$v = $out;
		}
	}

	/**
	 * Coerce string or legacy upload values to Divi 5 `{ src, id? }` under innerContent breakpoints.
	 *
	 * Normalizes `value` and nested `hover` under each of `desktop` / `tablet` / `phone`, and `value`
	 * on flat keys such as `desktop-hover` (Divi may store hover as `…desktop.hover.src` or legacy string on `hover`).
	 *
	 * @param array<string, mixed> $attrs    Module attrs (by reference).
	 * @param string               $attr_key Attribute key (e.g. image).
	 */
	public static function normalize_upload_inner_content_to_object_value(array &$attrs, string $attr_key): void
	{
		if (! isset($attrs[ $attr_key ]) || ! is_array($attrs[ $attr_key ])) {
			return;
		}
		if (! isset($attrs[ $attr_key ]['innerContent']) || ! is_array($attrs[ $attr_key ]['innerContent'])) {
			return;
		}
		$inner = &$attrs[ $attr_key ]['innerContent'];

		foreach (array_keys($inner) as $bp) {
			if (! isset($inner[ $bp ]) || ! is_array($inner[ $bp ])) {
				continue;
			}
			$bucket = &$inner[ $bp ];
			foreach (array( 'value', 'hover' ) as $state_key) {
				if (! array_key_exists($state_key, $bucket)) {
					continue;
				}
				self::normalize_upload_inner_leaf($bucket[ $state_key ]);
			}
		}
	}

	/**
	 * Image Accordion child: ensure upload fields use `{ src }` from D4 shortcode when innerContent is missing or a bare URL string.
	 *
	 * @param array<string, mixed> $converted_attrs Converted attrs (by reference).
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attrs.
	 */
	public static function hydrate_image_accordion_child_uploads_from_d4(array &$converted_attrs, array $d4_attrs): void
	{
		foreach (array( 'image_accordion_src', 'image_accordion_icon_image' ) as $attr_key) {
			if (! isset($d4_attrs[ $attr_key ])) {
				continue;
			}
			$raw_in = $d4_attrs[ $attr_key ];
			$raw    = '';
			if (is_string($raw_in)) {
				$raw = trim($raw_in);
			} elseif (is_numeric($raw_in)) {
				$raw = trim((string) $raw_in);
			} else {
				continue;
			}
			if ('' === $raw) {
				continue;
			}
			if (ctype_digit($raw)) {
				$url = wp_get_attachment_url((int) $raw);
				if (is_string($url) && '' !== $url) {
					$raw = $url;
				}
			}
			if (! isset($converted_attrs[ $attr_key ]) || ! is_array($converted_attrs[ $attr_key ])) {
				$converted_attrs[ $attr_key ] = array();
			}
			if (! isset($converted_attrs[ $attr_key ]['innerContent']) || ! is_array($converted_attrs[ $attr_key ]['innerContent'])) {
				$converted_attrs[ $attr_key ]['innerContent'] = array();
			}
			if (! isset($converted_attrs[ $attr_key ]['innerContent']['desktop']) || ! is_array($converted_attrs[ $attr_key ]['innerContent']['desktop'])) {
				$converted_attrs[ $attr_key ]['innerContent']['desktop'] = array();
			}
			$desk = &$converted_attrs[ $attr_key ]['innerContent']['desktop'];
			if (! array_key_exists('value', $desk)) {
				$desk['value'] = array( 'src' => $raw );
				continue;
			}
			$val = &$desk['value'];
			if (is_string($val) || is_numeric($val)) {
				self::normalize_upload_inner_leaf($val);
			}
			if (! is_array($val)) {
				$desk['value'] = array( 'src' => $raw );
				continue;
			}
			if (! isset($val['src']) || ! is_string($val['src']) || '' === trim($val['src'])) {
				$val['src'] = $raw;
			}
		}
	}

	/**
	 * Ensure `lottie_url` exists from raw D4 shortcode attrs when the migrator omits or clears innerContent.
	 *
	 * @param array<string, mixed> $converted_attrs Converted attrs (by reference).
	 * @param array<string, mixed> $d4_attrs        Original D4 attribute array (`lottie_url` string).
	 */
	public static function hydrate_lottie_url_from_d4_shortcode_attrs(array &$converted_attrs, array $d4_attrs): void
	{
		if (! isset($d4_attrs['lottie_url']) || ! is_string($d4_attrs['lottie_url'])) {
			return;
		}
		$raw = trim($d4_attrs['lottie_url']);
		if ('' === $raw) {
			return;
		}
		if (! isset($converted_attrs['lottie_url']) || ! is_array($converted_attrs['lottie_url'])) {
			$converted_attrs['lottie_url'] = array();
		}
		if (! isset($converted_attrs['lottie_url']['innerContent']) || ! is_array($converted_attrs['lottie_url']['innerContent'])) {
			$converted_attrs['lottie_url']['innerContent'] = array();
		}
		if (! isset($converted_attrs['lottie_url']['innerContent']['desktop']) || ! is_array($converted_attrs['lottie_url']['innerContent']['desktop'])) {
			$converted_attrs['lottie_url']['innerContent']['desktop'] = array();
		}
		$desk = &$converted_attrs['lottie_url']['innerContent']['desktop'];
		if (! array_key_exists('value', $desk)) {
			$desk['value'] = array( 'src' => $raw );

			return;
		}
		$val  = &$desk['value'];
		$leaf = &$val;
		if (is_string($leaf) || is_numeric($leaf)) {
			self::normalize_upload_inner_leaf($leaf);
		}
		if (! is_array($leaf)) {
			$desk['value'] = array( 'src' => $raw );

			return;
		}
		if (! isset($leaf['src']) || ! is_string($leaf['src']) || '' === trim($leaf['src'])) {
			$leaf['src'] = $raw;
		}
	}

	/**
	 * During D4→D5 migration, copy remote Lottie JSON into the Media Library so the Visual Builder
	 * loads same-origin URLs (avoids CORS when the original host blocks cross-origin requests).
	 *
	 * Runs when `current_user_can( 'upload_files' )` or when the filtered
	 * `dsm_lottie_sideload_without_upload_files_cap` is true (e.g. trusted batch migration).
	 *
	 * @param array<string, mixed> $converted_attrs Converted module attrs (by reference).
	 */
	public static function sideload_external_lottie_json_urls(array &$converted_attrs): void
	{
		$may_upload = current_user_can('upload_files')
			|| (bool) apply_filters('dsm_lottie_sideload_without_upload_files_cap', false);

		if (! $may_upload) {
			return;
		}
		if (! isset($converted_attrs['lottie_url']) || ! is_array($converted_attrs['lottie_url'])) {
			return;
		}
		if (! isset($converted_attrs['lottie_url']['innerContent']) || ! is_array($converted_attrs['lottie_url']['innerContent'])) {
			return;
		}
		$inner = &$converted_attrs['lottie_url']['innerContent'];

		foreach (array_keys($inner) as $bp) {
			if (! isset($inner[ $bp ]) || ! is_array($inner[ $bp ])) {
				continue;
			}
			$bucket = &$inner[ $bp ];
			foreach (array( 'value', 'hover' ) as $state_key) {
				if (! array_key_exists($state_key, $bucket)) {
					continue;
				}
				$slot = &$bucket[ $state_key ];
				if (is_string($slot) || is_numeric($slot)) {
					self::normalize_upload_inner_leaf($slot);
				}
				if (! is_array($slot)) {
					continue;
				}
				$leaf = &$slot;
				if (! isset($leaf['src']) || ! is_string($leaf['src'])) {
					continue;
				}
				$new = self::sideload_lottie_json_url_to_media_if_external($leaf['src']);
				if (null !== $new) {
					$leaf['src'] = $new['src'];
					$leaf['id']  = $new['id'];
				}
			}
		}
	}

	/**
	 * Normalized hostnames that count as “this site” (skip import; JSON is already served here).
	 *
	 * @return list<string>
	 */
	private static function get_lottie_site_hosts_allowlist(): array
	{
		$hosts = array();
		foreach (array( home_url(), site_url() ) as $u) {
			$h = wp_parse_url((string) $u, PHP_URL_HOST);
			if (is_string($h) && '' !== $h) {
				$hosts[] = strtolower($h);
			}
		}
		if (function_exists('is_multisite') && is_multisite()) {
			$h = wp_parse_url((string) network_home_url(), PHP_URL_HOST);
			if (is_string($h) && '' !== $h) {
				$hosts[] = strtolower($h);
			}
		}

		$filtered = apply_filters('dsm_lottie_site_hosts_allowlist', $hosts);
		if (! is_array($filtered)) {
			$filtered = $hosts;
		}

		$out = array();
		foreach ($filtered as $h) {
			if (is_string($h) && '' !== trim($h)) {
				$out[] = strtolower(trim($h));
			}
		}

		return array_values(array_unique($out));
	}

	/**
	 * Whether DSM Miscellaneous allows JSON (and .lottie) in the Media Library.
	 *
	 * Must match {@see \DiviSupreme\Core\Settings::allow_json_mime_types()} / field `dsm_allow_mime_json_upload`.
	 */
	private static function dsm_misc_json_lottie_upload_allowed(): bool
	{
		$opts = get_option('dsm_settings_misc', array());
		if (! is_array($opts)) {
			return false;
		}

		if (! array_key_exists('dsm_allow_mime_json_upload', $opts)) {
			return true;
		}

		return $opts['dsm_allow_mime_json_upload'] === 'on';
	}

	/**
	 * Import remote Lottie JSON when the URL is http(s) on a host not allowlisted for this site.
	 *
	 * Uses {@see wp_remote_get()} + {@see wp_upload_bits()} + {@see wp_insert_attachment()} when **Miscellaneous →
	 * Allow JSON & .lottie uploads** is enabled; otherwise skips (parity with Media Library gating).
	 *
	 * @param string $url Raw `src` URL.
	 * @return array{src: string, id: string}|null New `src` + attachment id, or null on skip/failure.
	 */
	private static function sideload_lottie_json_url_to_media_if_external(string $url): ?array
	{
		$url = trim($url);
		if ('' === $url || ! self::should_import_remote_lottie_json_url($url)) {
			return null;
		}

		if (! self::dsm_misc_json_lottie_upload_allowed()) {
			return null;
		}

		$path = wp_parse_url($url, PHP_URL_PATH);
		$base = 'lottie-animation.json';
		if (is_string($path) && '' !== $path) {
			$b = basename($path);
			if (is_string($b) && '' !== $b) {
				$base = $b;
			}
		}
		$base      = sanitize_file_name($base);
		$stem      = preg_replace('/\.[^.]+$/', '', $base);
		if (! is_string($stem) || '' === $stem) {
			$stem = 'lottie-animation';
		}
		$file_name = 'lottie-migrated-' . substr(md5($url), 0, 12) . '-' . $stem . '.json';
		$file_name = sanitize_file_name($file_name);
		if ('' === $file_name || ! str_ends_with(strtolower($file_name), '.json')) {
			$file_name = 'lottie-migrated-' . substr(md5($url), 0, 12) . '.json';
		}

		$http_args = apply_filters(
			'dsm_lottie_import_remote_args',
			array(
				'timeout'     => 60,
				'redirection' => 5,
				'sslverify'   => true,
				'headers'     => array(
					'Accept' => 'application/json, text/plain, */*;q=0.1',
				),
				'user-agent'  => (string) apply_filters(
					'dsm_lottie_import_user_agent',
					'Mozilla/5.0 (compatible; WordPress/' . get_bloginfo('version') . '; +' . home_url() . ')'
				),
			)
		);

		if (! is_array($http_args)) {
			$http_args = array(
				'timeout'     => 60,
				'redirection' => 5,
				'sslverify'   => true,
			);
		}

		$response = wp_remote_get($url, $http_args);

		if (is_wp_error($response)) {
			return null;
		}

		$code = wp_remote_retrieve_response_code($response);
		if ($code < 200 || $code >= 300) {
			return null;
		}

		$body = wp_remote_retrieve_body($response);
		if (! is_string($body) || '' === $body) {
			return null;
		}

		$max_bytes = (int) apply_filters('dsm_lottie_import_max_bytes', 15728640);
		if ($max_bytes > 0 && strlen($body) > $max_bytes) {
			return null;
		}

		$body = preg_replace('/^\xEF\xBB\xBF/', '', $body);
		if (! is_string($body)) {
			return null;
		}

		if (! apply_filters('dsm_lottie_skip_json_validate', false)) {
			json_decode($body);
			if (JSON_ERROR_NONE !== json_last_error()) {
				return null;
			}
		}

		$upload = wp_upload_bits($file_name, null, $body);

		if (! is_array($upload) || ! empty($upload['error'])) {
			return null;
		}

		$file_path = isset($upload['file']) && is_string($upload['file']) ? $upload['file'] : '';
		if ('' === $file_path || ! is_file($file_path)) {
			return null;
		}

		$attachment_id = wp_insert_attachment(
			array(
				'post_mime_type' => 'application/json',
				'post_title'     => sanitize_file_name($stem),
				'post_content'   => '',
				'post_status'    => 'inherit',
				'post_author'    => get_current_user_id() > 0 ? get_current_user_id() : 1,
			),
			$file_path,
			0,
			true
		);

		if (is_wp_error($attachment_id) || ! is_numeric($attachment_id) || (int) $attachment_id < 1) {
			wp_delete_file($file_path);

			return null;
		}

		wp_update_attachment_metadata((int) $attachment_id, array());

		$new_url = wp_get_attachment_url((int) $attachment_id);
		if (! is_string($new_url) || '' === $new_url) {
			return null;
		}

		return array(
			'src' => $new_url,
			'id'  => (string) (int) $attachment_id,
		);
	}

	/**
	 * Whether the Lottie JSON URL should be fetched and stored in this site’s uploads (other hostname or CDN).
	 *
	 * @param string $src Full URL.
	 */
	private static function should_import_remote_lottie_json_url(string $src): bool
	{
		$s = trim($src);
		if ('' === $s || str_starts_with($s, 'data:')) {
			return false;
		}
		if (str_starts_with($s, '$variable(') || (str_starts_with($s, '{') && str_contains($s, '"type"'))) {
			return false;
		}
		if (! preg_match('#^https?://#i', $s)) {
			return false;
		}
		$parsed = wp_parse_url($s);
		if (! is_array($parsed) || empty($parsed['host']) || ! is_string($parsed['host'])) {
			return false;
		}
		$host = strtolower($parsed['host']);

		if (in_array($host, self::get_lottie_site_hosts_allowlist(), true)) {
			return false;
		}

		$uploads = wp_upload_dir();
		if (! empty($uploads['baseurl']) && is_string($uploads['baseurl']) && str_starts_with($s, $uploads['baseurl'])) {
			return false;
		}

		return true;
	}

	/**
	 * Move legacy top-level `alt` / `title_text` into `src` upload value (Divi 5 `elementType: image`).
	 *
	 * @param array<string, mixed> $attrs Module attrs by reference.
	 */
	public static function merge_perspective_image_alt_title_into_src(array &$attrs): void
	{
		if (! isset($attrs['src']['innerContent']) || ! is_array($attrs['src']['innerContent'])) {
			return;
		}
		$inner = &$attrs['src']['innerContent'];
		foreach (array_keys($inner) as $bp) {
			if (! isset($inner[ $bp ]['value']) || ! is_array($inner[ $bp ]['value'])) {
				continue;
			}
			$value = &$inner[ $bp ]['value'];
			if (isset($attrs['alt']['innerContent'][ $bp ]['value'])) {
				$alt_v = $attrs['alt']['innerContent'][ $bp ]['value'];
				if (is_string($alt_v) && '' !== $alt_v && ( ! isset($value['alt']) || '' === (string) $value['alt'] )) {
					$value['alt'] = $alt_v;
				}
			}
			if (isset($attrs['title_text']['innerContent'][ $bp ]['value'])) {
				$t_v = $attrs['title_text']['innerContent'][ $bp ]['value'];
				if (is_string($t_v) && '' !== $t_v && ( ! isset($value['title']) || '' === (string) $value['title'] )) {
					$value['title'] = $t_v;
				}
			}
		}
		unset($attrs['alt'], $attrs['title_text']);
	}

	/**
	 * Copy migrated `module.advanced.align` (Divi Image pattern) and/or raw D4 `align*` into top-level `align.innerContent`.
	 *
	 * D4 → D5 migrator maps `align` to `module.advanced.align.*` for image-like modules; Supreme FE/VB still reads `align.innerContent`.
	 *
	 * @param array<string, mixed> $converted_attrs Converted attrs (by reference).
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attrs.
	 * @return void
	 */
	public static function migrate_block_reveal_image_align(array &$converted_attrs, array $d4_attrs): void {
		if (isset($converted_attrs['module']['advanced']['align']) && is_array($converted_attrs['module']['advanced']['align'])) {
			$src_align = $converted_attrs['module']['advanced']['align'];
			foreach (array('desktop', 'tablet', 'phone') as $bp) {
				if (! isset($src_align[ $bp ]) || ! is_array($src_align[ $bp ])) {
					continue;
				}
				$branch = $src_align[ $bp ];
				$raw    = null;
				if (isset($branch['value']) && is_string($branch['value'])) {
					$raw = trim($branch['value']);
				} elseif (isset($branch['value']) && is_numeric($branch['value'])) {
					$raw = (string) $branch['value'];
				}
				if (null !== $raw && '' !== $raw) {
					self::set_align_inner_value_if_empty($converted_attrs, $bp, $raw);
				}
			}
		}

		$d4_map = array(
			'desktop' => 'align',
			'tablet'  => 'align_tablet',
			'phone'   => 'align_phone',
		);
		foreach ($d4_map as $bp => $d4_key) {
			if (! isset($d4_attrs[ $d4_key ]) || ! is_string($d4_attrs[ $d4_key ])) {
				continue;
			}
			$v = trim($d4_attrs[ $d4_key ]);
			if ('' === $v) {
				continue;
			}
			self::set_align_inner_value_if_empty($converted_attrs, $bp, $v);
		}
	}

	/**
	 * Map migrated `heading_html_tag.innerContent` into `block_reveal_text.decoration.font.font.*.headingLevel` when empty.
	 *
	 * @param array<string, mixed> $converted_attrs Converted attrs (by reference).
	 * @return void
	 */
	public static function migrate_block_reveal_text_heading_level_from_heading_html_tag( array &$converted_attrs ): void {
		if ( ! isset( $converted_attrs['heading_html_tag']['innerContent'] ) || ! is_array( $converted_attrs['heading_html_tag']['innerContent'] ) ) {
			return;
		}

		$inner    = $converted_attrs['heading_html_tag']['innerContent'];
		$fallback = 'h2';
		if ( isset( $inner['desktop']['value'] ) && is_string( $inner['desktop']['value'] ) ) {
			$fallback = strtolower( trim( $inner['desktop']['value'] ) );
		}
		$allowed = array( 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'span', 'p' );
		if ( ! in_array( $fallback, $allowed, true ) ) {
			$fallback = 'h2';
		}

		if ( ! isset( $converted_attrs['block_reveal_text'] ) || ! is_array( $converted_attrs['block_reveal_text'] ) ) {
			$converted_attrs['block_reveal_text'] = array();
		}

		$br = &$converted_attrs['block_reveal_text'];
		foreach ( array( 'desktop', 'tablet', 'phone' ) as $bp ) {
			$existing = null;
			if ( isset( $br['decoration']['font']['font'][ $bp ]['value']['headingLevel'] ) ) {
				$existing = $br['decoration']['font']['font'][ $bp ]['value']['headingLevel'];
			}
			if ( is_string( $existing ) && '' !== trim( $existing ) ) {
				continue;
			}

			$tag = $fallback;
			if ( isset( $inner[ $bp ]['value'] ) && is_string( $inner[ $bp ]['value'] ) && '' !== trim( $inner[ $bp ]['value'] ) ) {
				$tag = strtolower( trim( $inner[ $bp ]['value'] ) );
			}
			if ( ! in_array( $tag, $allowed, true ) ) {
				$tag = $fallback;
			}

			if ( ! isset( $br['decoration'] ) || ! is_array( $br['decoration'] ) ) {
				$br['decoration'] = array();
			}
			if ( ! isset( $br['decoration']['font'] ) || ! is_array( $br['decoration']['font'] ) ) {
				$br['decoration']['font'] = array();
			}
			if ( ! isset( $br['decoration']['font']['font'] ) || ! is_array( $br['decoration']['font']['font'] ) ) {
				$br['decoration']['font']['font'] = array();
			}
			if ( ! isset( $br['decoration']['font']['font'][ $bp ] ) || ! is_array( $br['decoration']['font']['font'][ $bp ] ) ) {
				$br['decoration']['font']['font'][ $bp ] = array();
			}
			if ( ! isset( $br['decoration']['font']['font'][ $bp ]['value'] ) || ! is_array( $br['decoration']['font']['font'][ $bp ]['value'] ) ) {
				$br['decoration']['font']['font'][ $bp ]['value'] = array();
			}
			$br['decoration']['font']['font'][ $bp ]['value']['headingLevel'] = $tag;
		}
	}

	/**
	 * Map D4 `header_text_align*` into `block_reveal_text.decoration.font.font.*.value.textAlign`.
	 *
	 * @param array<string, mixed> $converted_attrs Converted attrs (by reference).
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attrs.
	 * @return void
	 */
	public static function migrate_block_reveal_text_header_text_align(array &$converted_attrs, array $d4_attrs): void {
		$d4_map = array(
			'desktop' => 'header_text_align',
			'tablet'  => 'header_text_align_tablet',
			'phone'   => 'header_text_align_phone',
		);
		foreach ($d4_map as $bp => $d4_key) {
			if (! isset($d4_attrs[ $d4_key ]) || ! is_string($d4_attrs[ $d4_key ])) {
				continue;
			}
			$v = strtolower(trim($d4_attrs[ $d4_key ]));
			if ('' === $v) {
				continue;
			}
			if ('justified' === $v) {
				$v = 'justify';
			}
			$allowed = array('left', 'center', 'right', 'justify');
			if (! in_array($v, $allowed, true)) {
				continue;
			}
			self::set_block_reveal_text_font_text_align_if_empty($converted_attrs, $bp, $v);
		}
	}

	/**
	 * @param array<string, mixed> $converted_attrs Converted attrs (by reference).
	 * @param string               $breakpoint      desktop|tablet|phone.
	 * @param string               $value           Alignment token.
	 * @return void
	 */
	private static function set_align_inner_value_if_empty(array &$converted_attrs, string $breakpoint, string $value): void {
		$existing = $converted_attrs['align']['innerContent'][ $breakpoint ]['value'] ?? null;
		if (is_string($existing) && '' !== trim($existing)) {
			return;
		}
		if (! isset($converted_attrs['align']) || ! is_array($converted_attrs['align'])) {
			$converted_attrs['align'] = array();
		}
		if (! isset($converted_attrs['align']['innerContent']) || ! is_array($converted_attrs['align']['innerContent'])) {
			$converted_attrs['align']['innerContent'] = array();
		}
		if (! isset($converted_attrs['align']['innerContent'][ $breakpoint ]) || ! is_array($converted_attrs['align']['innerContent'][ $breakpoint ])) {
			$converted_attrs['align']['innerContent'][ $breakpoint ] = array();
		}
		$converted_attrs['align']['innerContent'][ $breakpoint ]['value'] = $value;
	}

	/**
	 * @param array<string, mixed> $converted_attrs Converted attrs (by reference).
	 * @param string               $breakpoint      desktop|tablet|phone.
	 * @param string               $text_align      left|center|right|justify.
	 * @return void
	 */
	private static function set_block_reveal_text_font_text_align_if_empty(array &$converted_attrs, string $breakpoint, string $text_align): void {
		$key = 'block_reveal_text';
		if (! isset($converted_attrs[ $key ]) || ! is_array($converted_attrs[ $key ])) {
			$converted_attrs[ $key ] = array();
		}
		$h = &$converted_attrs[ $key ];
		if (! isset($h['decoration']) || ! is_array($h['decoration'])) {
			$h['decoration'] = array();
		}
		if (! isset($h['decoration']['font']) || ! is_array($h['decoration']['font'])) {
			$h['decoration']['font'] = array();
		}
		if (! isset($h['decoration']['font']['font']) || ! is_array($h['decoration']['font']['font'])) {
			$h['decoration']['font']['font'] = array();
		}
		$font_tree = &$h['decoration']['font']['font'];
		if (! isset($font_tree[ $breakpoint ]) || ! is_array($font_tree[ $breakpoint ])) {
			$font_tree[ $breakpoint ] = array();
		}
		if (! isset($font_tree[ $breakpoint ]['value']) || ! is_array($font_tree[ $breakpoint ]['value'])) {
			$font_tree[ $breakpoint ]['value'] = array();
		}
		$existing = $font_tree[ $breakpoint ]['value']['textAlign'] ?? null;
		if (is_string($existing) && '' !== trim($existing)) {
			return;
		}
		$font_tree[ $breakpoint ]['value']['textAlign'] = $text_align;
	}

	/**
	 * Map D4 `{prefix}_text_align*` into `{element}.decoration.font.font.*.value.textAlign` when empty.
	 *
	 * @param array<string, mixed> $converted_attrs Converted attrs (by reference).
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attrs.
	 * @param string               $element_key     D5 font element key (e.g. header, dsm_caption).
	 * @param string               $d4_prefix       D4 shortcode prefix (e.g. header, dsm_caption).
	 * @return void
	 */
	public static function migrate_d4_font_text_align_if_empty(
		array &$converted_attrs,
		array $d4_attrs,
		string $element_key,
		string $d4_prefix
	): void {
		$d4_map = array(
			'desktop' => $d4_prefix . '_text_align',
			'tablet'  => $d4_prefix . '_text_align_tablet',
			'phone'   => $d4_prefix . '_text_align_phone',
		);

		foreach ( $d4_map as $bp => $d4_key ) {
			if ( ! isset( $d4_attrs[ $d4_key ] ) || ! is_string( $d4_attrs[ $d4_key ] ) ) {
				continue;
			}
			$v = strtolower( trim( $d4_attrs[ $d4_key ] ) );
			if ( '' === $v ) {
				continue;
			}
			if ( 'justified' === $v ) {
				$v = 'justify';
			}
			if ( ! in_array( $v, array( 'left', 'center', 'right', 'justify' ), true ) ) {
				continue;
			}
			self::set_element_font_decoration_value_if_empty( $converted_attrs, $element_key, $bp, 'textAlign', $v );
		}
	}

	/**
	 * Map D4 `{prefix}_text_color*` into `{element}.decoration.font.font.*.value.color` when empty.
	 *
	 * @param array<string, mixed> $converted_attrs Converted attrs (by reference).
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attrs.
	 * @param string               $element_key     D5 font element key.
	 * @param string               $d4_prefix       D4 shortcode prefix.
	 * @return void
	 */
	public static function migrate_d4_font_text_color_if_empty(
		array &$converted_attrs,
		array $d4_attrs,
		string $element_key,
		string $d4_prefix
	): void {
		$d4_map = array(
			'desktop' => $d4_prefix . '_text_color',
			'tablet'  => $d4_prefix . '_text_color_tablet',
			'phone'   => $d4_prefix . '_text_color_phone',
		);

		foreach ( $d4_map as $bp => $d4_key ) {
			if ( ! isset( $d4_attrs[ $d4_key ] ) || ! is_string( $d4_attrs[ $d4_key ] ) ) {
				continue;
			}
			$v = trim( $d4_attrs[ $d4_key ] );
			if ( '' === $v ) {
				continue;
			}
			self::set_element_font_decoration_value_if_empty( $converted_attrs, $element_key, $bp, 'color', $v );
		}
	}

	/**
	 * @param array<string, mixed> $converted_attrs Converted attrs (by reference).
	 * @param string               $element_key     D5 font element key.
	 * @param string               $breakpoint      desktop|tablet|phone.
	 * @param string               $value_key       Font value key (textAlign, color, …).
	 * @param string               $value           Value to set when empty.
	 * @return void
	 */
	private static function set_element_font_decoration_value_if_empty(
		array &$converted_attrs,
		string $element_key,
		string $breakpoint,
		string $value_key,
		string $value
	): void {
		if ( ! isset( $converted_attrs[ $element_key ] ) || ! is_array( $converted_attrs[ $element_key ] ) ) {
			$converted_attrs[ $element_key ] = array();
		}
		$h = &$converted_attrs[ $element_key ];
		if ( ! isset( $h['decoration'] ) || ! is_array( $h['decoration'] ) ) {
			$h['decoration'] = array();
		}
		if ( ! isset( $h['decoration']['font'] ) || ! is_array( $h['decoration']['font'] ) ) {
			$h['decoration']['font'] = array();
		}
		if ( ! isset( $h['decoration']['font']['font'] ) || ! is_array( $h['decoration']['font']['font'] ) ) {
			$h['decoration']['font']['font'] = array();
		}
		$font_tree = &$h['decoration']['font']['font'];
		if ( ! isset( $font_tree[ $breakpoint ] ) || ! is_array( $font_tree[ $breakpoint ] ) ) {
			$font_tree[ $breakpoint ] = array();
		}
		if ( ! isset( $font_tree[ $breakpoint ]['value'] ) || ! is_array( $font_tree[ $breakpoint ]['value'] ) ) {
			$font_tree[ $breakpoint ]['value'] = array();
		}
		$existing = $font_tree[ $breakpoint ]['value'][ $value_key ] ?? null;
		if ( is_string( $existing ) && '' !== trim( $existing ) ) {
			return;
		}
		$font_tree[ $breakpoint ]['value'][ $value_key ] = $value;
	}

	/**
	 * Step Flow: copy D4 direction offset range attrs into D5 innerContent (desktop/tablet/phone).
	 *
	 * D4 uses separate shortcode keys (`dsm_dir_offset_top_tablet`, etc.). The outline's
	 * `*.innerContent.*` path does not always populate breakpoints or `default_unit` (px) for legacy saves.
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs (by reference).
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attrs.
	 * @return void
	 */
	public static function migrate_step_flow_direction_offset_ranges_from_d4(array &$converted_attrs, array $d4_attrs): void {
		foreach ( array( 'dsm_dir_offset_top', 'dsm_dir_offset_left' ) as $attr_key ) {
			self::apply_d4_responsive_range_keys_to_inner_content(
				$converted_attrs,
				$d4_attrs,
				$attr_key,
				'px'
			);
		}
	}

	/**
	 * Merge one D4 responsive range field (desktop + `_tablet` + `_phone`) into a single D5 innerContent attr.
	 *
	 * @param array<string, mixed> $converted_attrs Converted attrs (by reference).
	 * @param array<string, mixed> $d4_attrs        D4 shortcode attrs.
	 * @param string               $attr_key        D5 / D4 base key (no suffix).
	 * @param string               $default_unit    Appended when the value is unitless (e.g. px).
	 * @return void
	 */
	private static function apply_d4_responsive_range_keys_to_inner_content(
		array &$converted_attrs,
		array $d4_attrs,
		string $attr_key,
		string $default_unit
	): void {
		$d4_keys = array(
			'desktop' => $attr_key,
			'tablet'  => $attr_key . '_tablet',
			'phone'   => $attr_key . '_phone',
		);

		foreach ( $d4_keys as $breakpoint => $d4_key ) {
			if ( ! array_key_exists( $d4_key, $d4_attrs ) ) {
				continue;
			}
			$normalized = self::normalize_d4_range_scalar_to_css_string( $d4_attrs[ $d4_key ], $default_unit );
			if ( null === $normalized ) {
				continue;
			}

			if ( ! isset( $converted_attrs[ $attr_key ] ) || ! is_array( $converted_attrs[ $attr_key ] ) ) {
				$converted_attrs[ $attr_key ] = array();
			}
			if ( ! isset( $converted_attrs[ $attr_key ]['innerContent'] ) || ! is_array( $converted_attrs[ $attr_key ]['innerContent'] ) ) {
				$converted_attrs[ $attr_key ]['innerContent'] = array();
			}
			if ( ! isset( $converted_attrs[ $attr_key ]['innerContent'][ $breakpoint ] ) || ! is_array( $converted_attrs[ $attr_key ]['innerContent'][ $breakpoint ] ) ) {
				$converted_attrs[ $attr_key ]['innerContent'][ $breakpoint ] = array();
			}
			$converted_attrs[ $attr_key ]['innerContent'][ $breakpoint ]['value'] = $normalized;
		}
	}

	/**
	 * Merge D4 `color-alpha` responsive keys (`attr`, `attr_tablet`, `attr_phone`) into D5 `innerContent` breakpoints.
	 *
	 * D4 stores tablet/phone as separate shortcode attrs (`mobile_options`). The conversion outline
	 * `*.innerContent.*` path does not always hydrate tablet/phone for migrated layouts, so tooltip
	 * background (and similar) can be missing on some breakpoints while the frontend still had them.
	 *
	 * @param array<string, mixed> $converted_attrs Reference.
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attrs.
	 * @param string               $attr_key        Base attribute key (e.g. `tooltip_background_color`).
	 * @return void
	 */
	public static function hydrate_d4_responsive_color_alpha_into_inner_content(
		array &$converted_attrs,
		array $d4_attrs,
		string $attr_key
	): void {
		$d4_keys = array(
			'desktop' => $attr_key,
			'tablet'  => $attr_key . '_tablet',
			'phone'   => $attr_key . '_phone',
		);

		foreach ( $d4_keys as $breakpoint => $d4_key ) {
			if ( ! array_key_exists( $d4_key, $d4_attrs ) ) {
				continue;
			}

			$raw = $d4_attrs[ $d4_key ];
			if ( is_int( $raw ) || is_float( $raw ) ) {
				$raw = (string) $raw;
			}
			if ( ! is_string( $raw ) ) {
				continue;
			}

			$normalized = trim( $raw );
			if ( '' === $normalized ) {
				continue;
			}

			if ( ! isset( $converted_attrs[ $attr_key ] ) || ! is_array( $converted_attrs[ $attr_key ] ) ) {
				$converted_attrs[ $attr_key ] = array();
			}
			if ( ! isset( $converted_attrs[ $attr_key ]['innerContent'] ) || ! is_array( $converted_attrs[ $attr_key ]['innerContent'] ) ) {
				$converted_attrs[ $attr_key ]['innerContent'] = array();
			}
			if ( ! isset( $converted_attrs[ $attr_key ]['innerContent'][ $breakpoint ] ) || ! is_array( $converted_attrs[ $attr_key ]['innerContent'][ $breakpoint ] ) ) {
				$converted_attrs[ $attr_key ]['innerContent'][ $breakpoint ] = array();
			}

			$converted_attrs[ $attr_key ]['innerContent'][ $breakpoint ]['value'] = $normalized;
		}
	}

	/**
	 * Normalize a D4 range option value to a CSS length string (add default unit if digits-only).
	 *
	 * @param mixed  $raw          Raw shortcode value.
	 * @param string $default_unit Unit to append for plain numbers (D4 Step Flow offsets use px).
	 * @return string|null Normalized string or null when absent / not usable.
	 */
	private static function normalize_d4_range_scalar_to_css_string( $raw, string $default_unit ): ?string {
		if ( null === $raw ) {
			return null;
		}

		if ( is_int( $raw ) || is_float( $raw ) ) {
			return (string) $raw . $default_unit;
		}

		if ( ! is_string( $raw ) ) {
			return null;
		}

		$s = trim( $raw );
		if ( '' === $s ) {
			return null;
		}

		if ( preg_match( '/^-?[0-9]+(\\.[0-9]+)?$/', $s ) ) {
			return $s . $default_unit;
		}

		return $s;
	}

	/**
	 * Typing Effect: D4 stored `typing_effect` as a pipe-separated string; D5 uses SortableList rows `{ value, dragID }`.
	 *
	 * The migrator can merge {@see module-default-render-attributes.json} demo rows (e.g. "Divi", "Supreme") before this
	 * runs. Prefer the original D4 shortcode string so migrated layouts do not keep placeholder rows.
	 *
	 * @param array<string, mixed> $converted_attrs Reference.
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attrs.
	 * @return void
	 */
	public static function migrate_typing_effect_pipe_string_to_sortable_list( array &$converted_attrs, array $d4_attrs ): void {
		if ( ! isset( $converted_attrs['typing_effect'] ) || ! is_array( $converted_attrs['typing_effect'] ) ) {
			$converted_attrs['typing_effect'] = array();
		}
		if ( ! isset( $converted_attrs['typing_effect']['innerContent'] ) || ! is_array( $converted_attrs['typing_effect']['innerContent'] ) ) {
			$converted_attrs['typing_effect']['innerContent'] = array();
		}
		if ( ! isset( $converted_attrs['typing_effect']['innerContent']['desktop'] ) || ! is_array( $converted_attrs['typing_effect']['innerContent']['desktop'] ) ) {
			$converted_attrs['typing_effect']['innerContent']['desktop'] = array();
		}

		$desk = &$converted_attrs['typing_effect']['innerContent']['desktop'];

		// Authority: D4 shortcode attribute (always apply when present, overwriting merged SortableList defaults).
		if ( isset( $d4_attrs['typing_effect'] ) && is_string( $d4_attrs['typing_effect'] ) ) {
			$pipe_string = trim( $d4_attrs['typing_effect'] );
			if ( '' === $pipe_string ) {
				$desk['value'] = array();
				self::normalize_typing_effect_block_attrs( $converted_attrs );
				return;
			}

			$parts = array_filter(
				array_map( 'trim', explode( '|', $pipe_string ) ),
				static function ( $p ) {
					return '' !== $p;
				}
			);

			$out = array();
			foreach ( $parts as $p ) {
				$out[] = array(
					'value'  => $p,
					'dragID' => wp_generate_uuid4(),
				);
			}
			$desk['value'] = $out;

			self::normalize_typing_effect_block_attrs( $converted_attrs );
			return;
		}

		self::normalize_typing_effect_block_attrs( $converted_attrs );
	}

	/**
	 * Coerce a single raw `typing_effect` leaf into SortableList rows `{ value, dragID }`.
	 *
	 * @param mixed $raw Pipe string, JSON string, string[], or row array.
	 * @return array<int, array{value: string, dragID: string}>
	 */
	public static function normalize_typing_effect_sortable_rows_from_raw( $raw ): array {
		if ( is_array( $raw ) ) {
			if ( array() === $raw ) {
				return array();
			}

			$first = $raw[0];
			if ( is_array( $first ) && isset( $first['value'] ) ) {
				$out = array();
				foreach ( $raw as $row ) {
					if ( ! is_array( $row ) || ! isset( $row['value'] ) || ! is_string( $row['value'] ) ) {
						continue;
					}
					$value = trim( $row['value'] );
					if ( '' === $value ) {
						continue;
					}
					$drag_id = isset( $row['dragID'] ) && is_string( $row['dragID'] ) && '' !== trim( $row['dragID'] )
						? trim( $row['dragID'] )
						: wp_generate_uuid4();
					$out[] = array(
						'value'  => $value,
						'dragID' => $drag_id,
					);
				}
				return $out;
			}

			$out = array();
			foreach ( $raw as $part ) {
				if ( ! is_string( $part ) ) {
					continue;
				}
				$value = trim( $part );
				if ( '' === $value ) {
					continue;
				}
				$out[] = array(
					'value'  => $value,
					'dragID' => wp_generate_uuid4(),
				);
			}
			return $out;
		}

		if ( ! is_string( $raw ) ) {
			return array();
		}

		$trimmed = trim( $raw );
		if ( '' === $trimmed ) {
			return array();
		}

		if ( '[' === $trimmed[0] || '{' === $trimmed[0] ) {
			$decoded = json_decode( $trimmed, true );
			if ( is_array( $decoded ) ) {
				$from_json = self::normalize_typing_effect_sortable_rows_from_raw( $decoded );
				if ( array() !== $from_json ) {
					return $from_json;
				}
			}

			$decoded = json_decode( html_entity_decode( $trimmed, ENT_QUOTES | ENT_HTML5, 'UTF-8' ), true );
			if ( is_array( $decoded ) ) {
				$from_json = self::normalize_typing_effect_sortable_rows_from_raw( $decoded );
				if ( array() !== $from_json ) {
					return $from_json;
				}
			}
		}

		if ( false !== strpos( $trimmed, '|' ) ) {
			$parts = array_filter(
				array_map( 'trim', explode( '|', $trimmed ) ),
				static function ( $p ) {
					return '' !== $p;
				}
			);
			$out     = array();
			foreach ( $parts as $p ) {
				$out[] = array(
					'value'  => $p,
					'dragID' => wp_generate_uuid4(),
				);
			}
			return $out;
		}

		return array(
			array(
				'value'  => $trimmed,
				'dragID' => wp_generate_uuid4(),
			),
		);
	}

	/**
	 * Normalize saved `dsm/typing-effect` block attrs (D5 import / raw load / register attrs).
	 *
	 * @param array<string, mixed> $attrs Block attrs by reference.
	 * @return void
	 */
	public static function normalize_typing_effect_block_attrs( array &$attrs ): void {
		if ( ! isset( $attrs['typing_effect'] ) || ! is_array( $attrs['typing_effect'] ) ) {
			$attrs['typing_effect'] = array();
		}
		if ( ! isset( $attrs['typing_effect']['innerContent'] ) || ! is_array( $attrs['typing_effect']['innerContent'] ) ) {
			$attrs['typing_effect']['innerContent'] = array();
		}

		$inner = &$attrs['typing_effect']['innerContent'];
		$modes = array( 'desktop', 'tablet', 'phone' );
		$rows  = array();

		foreach ( $modes as $mode ) {
			if ( ! isset( $inner[ $mode ] ) || ! is_array( $inner[ $mode ] ) ) {
				continue;
			}
			$raw = $inner[ $mode ]['value'] ?? null;
			if ( null === $raw || ( is_string( $raw ) && '' === trim( $raw ) ) ) {
				continue;
			}
			$normalized = self::normalize_typing_effect_sortable_rows_from_raw( $raw );
			if ( array() !== $normalized ) {
				$rows = $normalized;
				break;
			}
		}

		if ( array() === $rows ) {
			return;
		}

		foreach ( $modes as $mode ) {
			if ( ! isset( $inner[ $mode ] ) || ! is_array( $inner[ $mode ] ) ) {
				$inner[ $mode ] = array();
			}
			$inner[ $mode ]['value'] = $rows;
		}
	}

	/**
	 * Rich text (`elementType` content): VB binds `FieldContainer` `subName="text"` → `innerContent.*.value.text`.
	 * D4→D5 conversion often leaves `innerContent.desktop.value` as a raw HTML string; SSR coerces it, but the builder shows an empty field until the shape is fixed.
	 *
	 * @param array<string, mixed> $attrs    Block attrs (by reference).
	 * @param string|null          $attr_key Optional. `content` (flipbox-child) or omit to normalize `front_content` and `back_content` (flipbox parent).
	 * @return void
	 */
	public static function normalize_flipbox_rich_content_inner_content_for_vb( array &$attrs, ?string $attr_key = null ): void {
		$keys = null !== $attr_key && '' !== $attr_key
			? array( $attr_key )
			: array( 'front_content', 'back_content' );

		foreach ( $keys as $key ) {
			if ( ! isset( $attrs[ $key ] ) || ! is_array( $attrs[ $key ] ) ) {
				continue;
			}
			if ( isset( $attrs[ $key ]['innerContent'] ) && is_string( $attrs[ $key ]['innerContent'] ) ) {
				$attrs[ $key ]['innerContent'] = array(
					'desktop' => array(
						'value' => array(
							'text' => $attrs[ $key ]['innerContent'],
						),
					),
				);
				continue;
			}
			if ( ! isset( $attrs[ $key ]['innerContent'] ) || ! is_array( $attrs[ $key ]['innerContent'] ) ) {
				continue;
			}
			$attrs[ $key ]['innerContent'] = self::normalize_rich_text_inner_content_strings_to_text_objects( $attrs[ $key ]['innerContent'] );
		}
	}

	/**
	 * Coerce `desktop|tablet|phone|phoneWide` × `value|hover|sticky` leaves from HTML strings to `{ text: string }`.
	 *
	 * @param array<string, mixed> $ic innerContent branch.
	 * @return array<string, mixed>
	 */
	private static function normalize_rich_text_inner_content_strings_to_text_objects( array $ic ): array {
		$states = array( 'value', 'hover', 'sticky' );
		$bps    = array( 'desktop', 'tablet', 'phone', 'phoneWide' );
		foreach ( $bps as $bp ) {
			if ( ! isset( $ic[ $bp ] ) || ! is_array( $ic[ $bp ] ) ) {
				continue;
			}
			foreach ( $states as $state ) {
				if ( ! isset( $ic[ $bp ][ $state ] ) ) {
					continue;
				}
				if ( is_string( $ic[ $bp ][ $state ] ) ) {
					$ic[ $bp ][ $state ] = array(
						'text' => $ic[ $bp ][ $state ],
					);
				}
			}
		}
		if ( isset( $ic['value'] ) && is_string( $ic['value'] ) ) {
			$ic['value'] = array(
				'text' => $ic['value'],
			);
		}

		return $ic;
	}

	/**
	 * Write flipbox height strings into `module.decoration.sizing.*.value.height` (desktop / tablet / phone).
	 *
	 * @param array<string, mixed>       $converted_attrs Converted block attrs (by reference).
	 * @param array<string, string|null> $heights         Keys `desktop`, `tablet`, `phone`; null or empty skips.
	 * @return void
	 */
	private static function write_flipbox_d4_height_breakpoints_to_module_sizing( array &$converted_attrs, array $heights ): void {
		if ( ! isset( $converted_attrs['module'] ) || ! is_array( $converted_attrs['module'] ) ) {
			$converted_attrs['module'] = array();
		}
		if ( ! isset( $converted_attrs['module']['decoration'] ) || ! is_array( $converted_attrs['module']['decoration'] ) ) {
			$converted_attrs['module']['decoration'] = array();
		}
		if ( ! isset( $converted_attrs['module']['decoration']['sizing'] ) || ! is_array( $converted_attrs['module']['decoration']['sizing'] ) ) {
			$converted_attrs['module']['decoration']['sizing'] = array();
		}

		$sizing = &$converted_attrs['module']['decoration']['sizing'];

		foreach ( array( 'desktop', 'tablet', 'phone' ) as $bp ) {
			$height = $heights[ $bp ] ?? null;
			if ( null === $height || '' === trim( (string) $height ) ) {
				continue;
			}
			$h = trim( (string) $height );
			if ( ! isset( $sizing[ $bp ] ) || ! is_array( $sizing[ $bp ] ) ) {
				$sizing[ $bp ] = array();
			}
			if ( ! isset( $sizing[ $bp ]['value'] ) || ! is_array( $sizing[ $bp ]['value'] ) ) {
				$sizing[ $bp ]['value'] = array();
			}
			$sizing[ $bp ]['value']['height'] = $h;
		}
	}

	/**
	 * D5 may use `phoneWide`; mirror `phone` height when `phoneWide` is unset or `auto`.
	 *
	 * @param array<string, mixed> $attrs Module attrs (by reference).
	 * @return void
	 */
	public static function mirror_flipbox_phone_module_sizing_height_to_phone_wide_when_empty( array &$attrs ): void {
		if ( ! isset( $attrs['module'] ) || ! is_array( $attrs['module'] ) ) {
			return;
		}
		if ( ! isset( $attrs['module']['decoration'] ) || ! is_array( $attrs['module']['decoration'] ) ) {
			return;
		}
		if ( ! isset( $attrs['module']['decoration']['sizing'] ) || ! is_array( $attrs['module']['decoration']['sizing'] ) ) {
			return;
		}
		$sizing = &$attrs['module']['decoration']['sizing'];
		$phone_h = $sizing['phone']['value']['height'] ?? null;
		if ( ! is_string( $phone_h ) ) {
			return;
		}
		$phone_h = trim( $phone_h );
		if ( '' === $phone_h || 'auto' === strtolower( $phone_h ) ) {
			return;
		}
		$pw_h = $sizing['phoneWide']['value']['height'] ?? null;
		$pw_s = is_string( $pw_h ) ? trim( $pw_h ) : '';
		if ( '' !== $pw_s && 'auto' !== strtolower( $pw_s ) ) {
			return;
		}
		if ( ! isset( $sizing['phoneWide'] ) || ! is_array( $sizing['phoneWide'] ) ) {
			$sizing['phoneWide'] = array();
		}
		if ( ! isset( $sizing['phoneWide']['value'] ) || ! is_array( $sizing['phoneWide']['value'] ) ) {
			$sizing['phoneWide']['value'] = array();
		}
		$sizing['phoneWide']['value']['height'] = $phone_h;
	}

	/**
	 * Copy D4 shortcode `flipbox_height` (+ tablet/phone when responsive is active) into `module.decoration.sizing`.
	 *
	 * When the custom range is unset on the shortcode, falls back to Divi Advanced → **Sizing → Height** (`height`,
	 * `height_tablet`, `height_phone`) so layouts that never touched “Flipbox height” still migrate.
	 *
	 * When those are also absent, D4 still rendered **200px** (`flipbox_height` field `default` in D4 FlipBoxPerk with
	 * `default_on_front` empty so the attr is often omitted from the shortcode). D5 defaults use 320px for new modules;
	 * this pass writes **200px** on desktop so migrated layouts match the D4 front end.
	 *
	 * **Always overwrites** each breakpoint that has a usable D4 value so JSON defaults (e.g. `320px`) do not block migration.
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs (by reference).
	 * @param array<string, mixed> $d4_attrs        Original D4 shortcode attrs.
	 * @return void
	 */
	public static function migrate_flipbox_d4_height_shortcode_into_module_sizing( array &$converted_attrs, array $d4_attrs ): void {
		$desktop = self::normalize_d4_range_scalar_to_css_string( $d4_attrs['flipbox_height'] ?? null, 'px' );
		$tablet  = self::normalize_d4_range_scalar_to_css_string( $d4_attrs['flipbox_height_tablet'] ?? null, 'px' );
		$phone   = self::normalize_d4_range_scalar_to_css_string( $d4_attrs['flipbox_height_phone'] ?? null, 'px' );

		$last_edited = isset( $d4_attrs['flipbox_height_last_edited'] ) && is_string( $d4_attrs['flipbox_height_last_edited'] )
			? $d4_attrs['flipbox_height_last_edited']
			: '';

		$responsive_active = true;
		if ( function_exists( 'et_pb_get_responsive_status' ) ) {
			$responsive_active = et_pb_get_responsive_status( $last_edited );
		}

		if ( ! $responsive_active ) {
			$tablet = null;
			$phone  = null;
		}

		$heights = array(
			'desktop' => $desktop,
			'tablet'  => $tablet,
			'phone'   => $phone,
		);

		$has_any = false;
		foreach ( $heights as $v ) {
			if ( null !== $v && '' !== trim( (string) $v ) ) {
				$has_any = true;
				break;
			}
		}

		if ( $has_any ) {
			self::write_flipbox_d4_height_breakpoints_to_module_sizing( $converted_attrs, $heights );
			return;
		}

		// Fallback: Advanced → Sizing → Height (same target in D5 as `flipbox_height`).
		$adv_desktop = self::normalize_d4_range_scalar_to_css_string( $d4_attrs['height'] ?? null, 'px' );
		$adv_tablet  = self::normalize_d4_range_scalar_to_css_string( $d4_attrs['height_tablet'] ?? null, 'px' );
		$adv_phone   = self::normalize_d4_range_scalar_to_css_string( $d4_attrs['height_phone'] ?? null, 'px' );

		$adv_last = isset( $d4_attrs['height_last_edited'] ) && is_string( $d4_attrs['height_last_edited'] )
			? $d4_attrs['height_last_edited']
			: '';

		$adv_responsive = true;
		if ( function_exists( 'et_pb_get_responsive_status' ) ) {
			$adv_responsive = et_pb_get_responsive_status( $adv_last );
		}
		if ( ! $adv_responsive ) {
			$adv_tablet = null;
			$adv_phone  = null;
		}

		$adv_heights = array(
			'desktop' => $adv_desktop,
			'tablet'  => $adv_tablet,
			'phone'   => $adv_phone,
		);

		foreach ( $adv_heights as $v ) {
			if ( null !== $v && '' !== trim( (string) $v ) ) {
				self::write_flipbox_d4_height_breakpoints_to_module_sizing( $converted_attrs, $adv_heights );
				return;
			}
		}

		// D4 effective default when shortcode omits `flipbox_height` (see `default` + empty `default_on_front` in D4 module).
		self::write_flipbox_d4_height_breakpoints_to_module_sizing(
			$converted_attrs,
			array(
				'desktop' => '200px',
				'tablet'  => null,
				'phone'   => null,
			)
		);
	}

	/**
	 * Move legacy `flipbox_height` innerContent into `module.decoration.sizing.*.value.height` (Design → Sizing).
	 * Coerces `innerContent.*.value` from string or numeric (migrator JSON) via {@see self::normalize_d4_range_scalar_to_css_string()}.
	 * When `$remove_legacy` is true, drops `flipbox_height` after merge (post-convert). When false, only fills
	 * sizing where height is empty/`auto` so existing `module.decoration.sizing` wins (render-time for old saves).
	 *
	 * @param array<string, mixed> $attrs          Attributes.
	 * @param bool                   $remove_legacy Unset `flipbox_height` after merge.
	 * @return array<string, mixed>
	 */
	public static function merge_flipbox_legacy_height_into_module_sizing( array $attrs, bool $remove_legacy ): array {
		if ( ! isset( $attrs['flipbox_height'] ) || ! is_array( $attrs['flipbox_height'] ) ) {
			return $attrs;
		}
		$ic = $attrs['flipbox_height']['innerContent'] ?? null;
		if ( ! is_array( $ic ) ) {
			if ( $remove_legacy ) {
				unset( $attrs['flipbox_height'] );
			}
			return $attrs;
		}
		$bps = array( 'desktop', 'tablet', 'phone', 'phoneWide' );
		if ( ! isset( $attrs['module'] ) || ! is_array( $attrs['module'] ) ) {
			$attrs['module'] = array();
		}
		if ( ! isset( $attrs['module']['decoration'] ) || ! is_array( $attrs['module']['decoration'] ) ) {
			$attrs['module']['decoration'] = array();
		}
		if ( ! isset( $attrs['module']['decoration']['sizing'] ) || ! is_array( $attrs['module']['decoration']['sizing'] ) ) {
			$attrs['module']['decoration']['sizing'] = array();
		}
		foreach ( $bps as $bp ) {
			if ( ! isset( $ic[ $bp ] ) || ! is_array( $ic[ $bp ] ) ) {
				continue;
			}
			$raw_val = $ic[ $bp ]['value'] ?? null;
			$height  = self::normalize_d4_range_scalar_to_css_string( $raw_val, 'px' );
			if ( null === $height || '' === trim( $height ) ) {
				continue;
			}
			$height = trim( $height );
			if ( ! $remove_legacy ) {
				$existing = $attrs['module']['decoration']['sizing'][ $bp ]['value']['height'] ?? null;
				$existing_str = is_string( $existing ) ? trim( $existing ) : '';
				if ( '' !== $existing_str && 'auto' !== strtolower( $existing_str ) ) {
					continue;
				}
			}
			if ( ! isset( $attrs['module']['decoration']['sizing'][ $bp ] ) || ! is_array( $attrs['module']['decoration']['sizing'][ $bp ] ) ) {
				$attrs['module']['decoration']['sizing'][ $bp ] = array();
			}
			if ( ! isset( $attrs['module']['decoration']['sizing'][ $bp ]['value'] ) || ! is_array( $attrs['module']['decoration']['sizing'][ $bp ]['value'] ) ) {
				$attrs['module']['decoration']['sizing'][ $bp ]['value'] = array();
			}
			$attrs['module']['decoration']['sizing'][ $bp ]['value']['height'] = $height;
		}
		if ( $remove_legacy ) {
			unset( $attrs['flipbox_height'] );
		}
		return $attrs;
	}

	/**
	 * D4 parent advanced fonts: mirror `front_*` typography onto `back_*` when the back face font decoration
	 * is still empty (after {@see ConversionAttrs::hydrate_flipbox_face_shared_fonts_into_per_face_when_empty}).
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs.
	 * @return void
	 */
	public static function apply_flipbox_d4_parent_fonts_to_both_faces( array &$converted_attrs ): void {
		$pairs = array(
			array( 'front_title', 'back_title' ),
			array( 'front_subtitle', 'back_subtitle' ),
			array( 'front_content', 'back_content' ),
		);
		foreach ( $pairs as $pair ) {
			$from = $pair[0];
			$to   = $pair[1];
			if ( ! isset( $converted_attrs[ $from ]['decoration']['font'] ) || ! is_array( $converted_attrs[ $from ]['decoration']['font'] ) ) {
				continue;
			}
			if ( ! empty( $converted_attrs[ $to ]['decoration']['font'] ) && is_array( $converted_attrs[ $to ]['decoration']['font'] ) ) {
				continue;
			}
			if ( ! isset( $converted_attrs[ $to ] ) || ! is_array( $converted_attrs[ $to ] ) ) {
				$converted_attrs[ $to ] = array();
			}
			if ( ! isset( $converted_attrs[ $to ]['decoration'] ) || ! is_array( $converted_attrs[ $to ]['decoration'] ) ) {
				$converted_attrs[ $to ]['decoration'] = array();
			}
			$converted_attrs[ $to ]['decoration']['font'] = $converted_attrs[ $from ]['decoration']['font'];
		}
	}

	/**
	 * Copy shared `face_*` typography onto `front_*` / `back_*` when those per-face font decorations are empty
	 * (after migrator maps D4 parent fonts to `face_*`).
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs.
	 * @return void
	 */
	public static function hydrate_flipbox_face_shared_fonts_into_per_face_when_empty( array &$converted_attrs ): void {
		$triples = array(
			array( 'face_title', 'front_title', 'back_title' ),
			array( 'face_subtitle', 'front_subtitle', 'back_subtitle' ),
			array( 'face_content', 'front_content', 'back_content' ),
		);
		foreach ( $triples as $triple ) {
			$face_key  = $triple[0];
			$front_key = $triple[1];
			$back_key  = $triple[2];
			$face_font = $converted_attrs[ $face_key ]['decoration']['font'] ?? null;
			if ( ! is_array( $face_font ) || self::flipbox_decoration_font_is_effectively_empty( $face_font ) ) {
				continue;
			}
			$payload = self::flipbox_deep_copy_array( $face_font );
			foreach ( array( $front_key, $back_key ) as $side_key ) {
				if ( ! isset( $converted_attrs[ $side_key ] ) || ! is_array( $converted_attrs[ $side_key ] ) ) {
					$converted_attrs[ $side_key ] = array();
				}
				if ( ! isset( $converted_attrs[ $side_key ]['decoration'] ) || ! is_array( $converted_attrs[ $side_key ]['decoration'] ) ) {
					$converted_attrs[ $side_key ]['decoration'] = array();
				}
				$existing = $converted_attrs[ $side_key ]['decoration']['font'] ?? null;
				if ( is_array( $existing ) && ! self::flipbox_decoration_font_is_effectively_empty( $existing ) ) {
					continue;
				}
				$converted_attrs[ $side_key ]['decoration']['font'] = self::flipbox_deep_copy_array( $payload );
			}
		}
	}

	/**
	 * True when `decoration.font` has no breakpoint values worth copying.
	 *
	 * @param array<string, mixed> $font Font decoration subtree.
	 * @return bool
	 */
	private static function flipbox_decoration_font_is_effectively_empty( array $font ): bool {
		foreach ( $font as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}
			if ( ! array_key_exists( 'value', $entry ) ) {
				continue;
			}
			$v = $entry['value'];
			if ( is_array( $v ) && array() !== $v ) {
				return false;
			}
			if ( is_string( $v ) && '' !== trim( $v ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Deep copy for font decoration arrays.
	 *
	 * @param array<string, mixed> $data Source.
	 * @return array<string, mixed>
	 */
	private static function flipbox_deep_copy_array( array $data ): array {
		$json = wp_json_encode( $data );
		if ( false === $json || '' === $json ) {
			return $data;
		}
		$decoded = json_decode( $json, true );
		return is_array( $decoded ) ? $decoded : $data;
	}

	/**
	 * Card Carousel (parent/child): D4 uses `hover_transition_*` (see ET_Builder_Element). Migrator outline
	 * maps `advanced.transition` but legacy shortcode props may not hydrate `module.decoration.transition`,
	 * so Advanced → Transition + our transition CommonStyle miss stored values until re-saved.
	 *
	 * @param array<string, mixed> $converted_attrs Reference.
	 * @param array<string, mixed> $d4_attrs        Raw D4 shortcode attrs.
	 */
	public static function hydrate_card_carousel_d4_hover_transition_into_module_decoration( array &$converted_attrs, array $d4_attrs ): void {
		if ( self::d5_module_decoration_transition_has_usable_duration( $converted_attrs ) ) {
			return;
		}

		$built = self::build_module_decoration_transition_from_d4_hover_props( $d4_attrs );
		if ( array() !== $built ) {
			if ( ! isset( $converted_attrs['module'] ) || ! is_array( $converted_attrs['module'] ) ) {
				$converted_attrs['module'] = array();
			}
			if ( ! isset( $converted_attrs['module']['decoration'] ) || ! is_array( $converted_attrs['module']['decoration'] ) ) {
				$converted_attrs['module']['decoration'] = array();
			}
			$converted_attrs['module']['decoration']['transition'] = $built;

			return;
		}

		if ( self::d4_has_any_hover_transition_prop( $d4_attrs ) ) {
			return;
		}

		self::ensure_card_carousel_default_module_decoration_transition( $converted_attrs );
	}

	/**
	 * D4 often omits default hover_transition_* from saved shortcode; new D5 modules get `module.decoration.transition` from defaults.
	 *
	 * @param array<string, mixed> $converted_attrs Reference.
	 */
	private static function ensure_card_carousel_default_module_decoration_transition( array &$converted_attrs ): void {
		if ( ! isset( $converted_attrs['module'] ) || ! is_array( $converted_attrs['module'] ) ) {
			$converted_attrs['module'] = array();
		}
		if ( ! isset( $converted_attrs['module']['decoration'] ) || ! is_array( $converted_attrs['module']['decoration'] ) ) {
			$converted_attrs['module']['decoration'] = array();
		}
		$converted_attrs['module']['decoration']['transition'] = array(
			'desktop' => array(
				'value' => array(
					'duration'   => '300ms',
					'delay'      => '0ms',
					'speedCurve' => 'ease',
				),
			),
		);
	}

	/**
	 * @param array<string, mixed> $attrs Module attrs.
	 */
	private static function d5_module_decoration_transition_has_usable_duration( array $attrs ): bool {
		$t = $attrs['module']['decoration']['transition'] ?? null;
		if ( ! is_array( $t ) ) {
			return false;
		}
		foreach ( array( 'desktop', 'tablet', 'phone', 'phoneWide' ) as $bp ) {
			if ( ! isset( $t[ $bp ]['value'] ) || ! is_array( $t[ $bp ]['value'] ) ) {
				continue;
			}
			$dur = $t[ $bp ]['value']['duration'] ?? '';
			if ( is_string( $dur ) && '' !== trim( $dur ) ) {
				return true;
			}
			if ( is_int( $dur ) || is_float( $dur ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * @param array<string, mixed> $d4_attrs D4 shortcode attrs.
	 */
	private static function d4_has_any_hover_transition_prop( array $d4_attrs ): bool {
		foreach ( array_keys( $d4_attrs ) as $key ) {
			if ( ! is_string( $key ) ) {
				continue;
			}
			if ( str_starts_with( $key, 'hover_transition_' ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * @param array<string, mixed> $d4 D4 shortcode attrs.
	 * @return array<string, array{value: array{duration: string, delay: string, speedCurve: string}}>
	 */
	private static function build_module_decoration_transition_from_d4_hover_props( array $d4 ): array {
		$bps = array(
			'desktop' => '',
			'tablet'  => '_tablet',
			'phone'   => '_phone',
		);
		$out = array();
		foreach ( $bps as $bp_name => $suffix ) {
			$dur = self::d4_hover_transition_field( $d4, 'hover_transition_duration', $suffix );
			$del = self::d4_hover_transition_field( $d4, 'hover_transition_delay', $suffix );
			$spd = self::d4_hover_transition_field( $d4, 'hover_transition_speed_curve', $suffix );
			if ( '' === $dur && '' === $del && '' === $spd ) {
				continue;
			}
			$out[ $bp_name ] = array(
				'value' => array(
					'duration'   => '' !== $dur ? self::normalize_d4_hover_ms_for_d5( $dur, '300ms' ) : '300ms',
					'delay'      => '' !== $del ? self::normalize_d4_hover_ms_for_d5( $del, '0ms' ) : '0ms',
					'speedCurve' => '' !== $spd ? $spd : 'ease',
				),
			);
		}

		return $out;
	}

	/**
	 * @param array<string, mixed> $d4     D4 attrs.
	 * @param string                 $base  e.g. hover_transition_duration.
	 * @param string                 $suffix '', '_tablet', '_phone'.
	 */
	private static function d4_hover_transition_field( array $d4, string $base, string $suffix ): string {
		$key = $base . $suffix;
		if ( ! isset( $d4[ $key ] ) ) {
			return '';
		}
		$v = $d4[ $key ];
		if ( is_string( $v ) ) {
			return trim( $v );
		}
		if ( is_int( $v ) || is_float( $v ) ) {
			return (string) $v;
		}

		return '';
	}

	/**
	 * @param string $raw     D4 ms value (may be numeric without unit).
	 * @param string $default Fallback.
	 */
	private static function normalize_d4_hover_ms_for_d5( string $raw, string $default ): string {
		$t = trim( $raw );
		if ( '' === $t ) {
			return $default;
		}
		if ( preg_match( '/[a-z%]/i', $t ) ) {
			return $t;
		}
		if ( is_numeric( $t ) ) {
			return (string) (int) round( (float) $t ) . 'ms';
		}

		return $t;
	}

	/**
	 * Map D4 global `dsm_modules_popup*` shortcode attrs onto the Divi 5 Supreme Popup extension
	 * (`use_popup` / `popup_layout` innerContent) and optionally sync `dsm-popup-settings` on the
	 * selected Library layout so `Popup::render_module_popup()` finds an enabled popup + widths.
	 *
	 * D4 stored many popup chrome options on each module; D5 stores most design on the Library layout.
	 * We migrate the common "library inline popup" path (`popup_type` inline + layout post ID).
	 *
	 * @param array<string, mixed> $converted_attrs Converted block attrs (modified by reference).
	 * @param array<string, mixed> $attrs           Original D4 shortcode attrs.
	 * @return void
	 */
	public static function migrate_d4_modules_popup_into_supreme_popup_extension( array &$converted_attrs, array $attrs ): void {
		if ( ! isset( $attrs['dsm_modules_popup'] ) || 'on' !== $attrs['dsm_modules_popup'] ) {
			return;
		}

		$popup_type = isset( $attrs['dsm_modules_popup_type'] ) ? (string) $attrs['dsm_modules_popup_type'] : 'inline';

		if ( '' !== $popup_type && 'inline' !== $popup_type ) {
			return;
		}

		$lib_raw = isset( $attrs['dsm_modules_popup_library'] ) ? trim( (string) $attrs['dsm_modules_popup_library'] ) : '';

		if ( '' === $lib_raw || 'none' === strtolower( $lib_raw ) ) {
			return;
		}

		$popup_post_id = absint( $lib_raw );

		if ( $popup_post_id <= 0 ) {
			return;
		}

		if ( apply_filters( 'dsm_migration_should_sync_popup_library_settings_from_d4', true, $popup_post_id, $attrs ) ) {
			self::maybe_merge_d4_popup_into_library_popup_settings_meta( $popup_post_id, $attrs );
		}

		$converted_attrs['use_popup']      = array(
			'innerContent' => array(
				'desktop' => array(
					'value' => 'on',
				),
			),
		);
		$converted_attrs['popup_layout'] = array(
			'innerContent' => array(
				'desktop' => array(
					'value' => (string) $popup_post_id,
				),
			),
		);
	}

	/**
	 * Ensures migrated Library layouts (`et_pb_layout`) have Supreme Popup meta so D5 SSR works.
	 *
	 * Only touches `popup_max_width` breakpoints when D4 supplied a width, and enables the popup when needed.
	 *
	 * @param int                  $popup_post_id Selected Divi Library layout ID.
	 * @param array<string, mixed> $attrs          Original D4 shortcode attrs.
	 * @return void
	 */
	private static function maybe_merge_d4_popup_into_library_popup_settings_meta( int $popup_post_id, array $attrs ): void {
		$post = get_post( $popup_post_id );

		if ( ! $post instanceof \WP_Post || 'et_pb_layout' !== $post->post_type ) {
			return;
		}

		$existing = get_post_meta( $popup_post_id, 'dsm-popup-settings', true );

		if ( ! is_array( $existing ) ) {
			$existing = array();
		}

		$merged                     = $existing;
		$merged['is_enabled']       = true;
		$merged['popup_type']       = 'module';

		if ( ! isset( $merged['popup_max_width'] ) || ! is_array( $merged['popup_max_width'] ) ) {
			$merged['popup_max_width'] = array();
		}

		$width_pairs = array(
			'desktop' => isset( $attrs['dsm_modules_popup_width'] ) ? trim( (string) $attrs['dsm_modules_popup_width'] ) : '',
			'tablet'  => isset( $attrs['dsm_modules_popup_width_tablet'] ) ? trim( (string) $attrs['dsm_modules_popup_width_tablet'] ) : '',
			'phone'   => isset( $attrs['dsm_modules_popup_width_phone'] ) ? trim( (string) $attrs['dsm_modules_popup_width_phone'] ) : '',
		);

		foreach ( $width_pairs as $bp => $w ) {
			if ( '' === $w ) {
				continue;
			}

			$merged['popup_max_width'][ $bp ] = sanitize_text_field( $w );
		}

		$overlay_raw = isset( $attrs['dsm_modules_overlay_background'] ) ? trim( (string) $attrs['dsm_modules_overlay_background'] ) : '';

		if ( '' !== $overlay_raw ) {
			if ( ! isset( $merged['popup_overlay_color'] ) || ! is_array( $merged['popup_overlay_color'] ) ) {
				$merged['popup_overlay_color'] = array();
			}

			$merged['popup_overlay_color']['desktop'] = sanitize_text_field( $overlay_raw );
		}

		$anim_in = isset( $attrs['dsm_modules_popup_animation_in'] ) ? trim( (string) $attrs['dsm_modules_popup_animation_in'] ) : '';

		if ( '' !== $anim_in ) {
			$merged['entrance_animation'] = sanitize_text_field( $anim_in );
		}

		$anim_out = isset( $attrs['dsm_modules_popup_animation_out'] ) ? trim( (string) $attrs['dsm_modules_popup_animation_out'] ) : '';

		if ( '' !== $anim_out ) {
			$merged['exit_animation'] = sanitize_text_field( $anim_out );
		}

		$merged = self::migrate_d4_popup_open_trigger_into_open_popup_trigger( $merged, $attrs );

		// D4 Timed Delay (`dsm_modules_popup_timed_delay`, e.g. "1s") applied after onload / scroll / exit
		// matches D5 "Open Popup Delay" for viewport-style triggers (`popup_viewport_delay`).
		$d4_timed = isset( $attrs['dsm_modules_popup_timed_delay'] ) ? trim( (string) $attrs['dsm_modules_popup_timed_delay'] ) : '';
		if ( '' !== $d4_timed ) {
			$existing_vp = isset( $merged['popup_viewport_delay'] ) ? trim( (string) $merged['popup_viewport_delay'] ) : '';
			if ( '' === $existing_vp ) {
				$merged['popup_viewport_delay'] = sanitize_text_field( $d4_timed );
			}
		}

		$d4_position_type = isset( $attrs['dsm_modules_popup_position_type'] )
			? sanitize_key( (string) $attrs['dsm_modules_popup_position_type'] )
			: '';
		if (
			in_array( $d4_position_type, array( 'fixed', 'absolute' ), true )
			&& ( ! isset( $merged['popup_position_type'] ) || '' === $merged['popup_position_type'] )
		) {
			$merged['popup_position_type'] = $d4_position_type;
		}

		// No D4 field mapped to global post-trigger `popup_open_delay`; `Popup::migrate_legacy_popup_setting_keys`
		// fills `'0s'` on read when the key is missing or empty.

		update_post_meta( $popup_post_id, 'dsm-popup-settings', $merged );
	}

	/**
	 * Map D4 `dsm_modules_popup_trigger` to Supreme Popup settings `open_popup_trigger` where possible.
	 *
	 * @param array<string, mixed> $merged Popup settings row.
	 * @param array<string, mixed> $attrs   D4 shortcode attrs.
	 * @return array<string, mixed>
	 */
	private static function migrate_d4_popup_open_trigger_into_open_popup_trigger( array $merged, array $attrs ): array {
		if ( ! isset( $attrs['dsm_modules_popup_trigger'] ) ) {
			return $merged;
		}

		$tr = trim( (string) $attrs['dsm_modules_popup_trigger'] );

		$map = array(
			'module' => 'click',
			'button' => 'click',
			'image'  => 'click',
			'links'  => 'click',
			'id'     => 'click',
			'class'  => 'click',
			'onload' => 'enter_viewport',
			'scroll' => 'enter_viewport',
			'exit'   => 'leave_viewport',
		);

		if ( isset( $map[ $tr ] ) ) {
			$merged['open_popup_trigger'] = $map[ $tr ];
		}

		return $merged;
	}
}
