<?php
/**
 * Database schema.
 *
 * @package JOCBM
 */

namespace JOCBM\Database;

use JOCBM\Services\CssVars;
use JOCBM\Services\CssWriter;
use JOCBM\Services\ElementsOrder;
use JOCBM\Services\ElementsVisibility;
use JOCBM\Services\MarkupInterpolator;
use JOCBM\Services\TemplateLibrary;

defined( 'ABSPATH' ) || exit;

/**
 * Class Schema
 */
final class Schema {

	/**
	 * Install or upgrade tables.
	 */
	public static function install(): void {
		global $wpdb;

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$charset = $wpdb->get_charset_collate();
		$prefix  = $wpdb->prefix . 'blockora_morph_boc_';

		$categories = "CREATE TABLE {$prefix}categories (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			slug varchar(100) NOT NULL,
			name varchar(191) NOT NULL,
			description text NULL,
			parent_slug varchar(100) NOT NULL DEFAULT '',
			is_pro tinyint(1) NOT NULL DEFAULT 0,
			sort_order int(11) NOT NULL DEFAULT 0,
			created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			UNIQUE KEY slug (slug),
			KEY parent_slug (parent_slug)
		) {$charset};";

		$system_blocks = "CREATE TABLE {$prefix}system_blocks (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			category_id bigint(20) unsigned NOT NULL,
			slug varchar(100) NOT NULL,
			name varchar(191) NOT NULL,
			description text NULL,
			variant varchar(100) NOT NULL DEFAULT 'default',
			thumbnail varchar(255) NULL,
			markup longtext NOT NULL,
			css_vars longtext NULL,
			default_attrs longtext NULL,
			field_schema longtext NULL,
			style_schema longtext NULL,
			template_hash varchar(64) NOT NULL DEFAULT '',
			is_pro tinyint(1) NOT NULL DEFAULT 0,
			is_featured tinyint(1) NOT NULL DEFAULT 0,
			source varchar(64) NOT NULL DEFAULT 'core',
			is_active tinyint(1) NOT NULL DEFAULT 1,
			sort_order int(11) NOT NULL DEFAULT 0,
			created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			UNIQUE KEY slug_variant (slug, variant),
			KEY category_id (category_id),
			KEY is_pro (is_pro),
			KEY is_featured (is_featured),
			KEY source (source)
		) {$charset};";

		$user_blocks = "CREATE TABLE {$prefix}user_blocks (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			system_block_id bigint(20) unsigned NOT NULL,
			user_id bigint(20) unsigned NOT NULL,
			name varchar(191) NOT NULL,
			slug varchar(100) NOT NULL,
			icon varchar(191) NOT NULL DEFAULT 'screenoptions',
			markup longtext NULL,
			field_schema longtext NULL,
			style_schema longtext NULL,
			template_hash varchar(64) NOT NULL DEFAULT '',
			css_vars longtext NULL,
			default_attrs longtext NULL,
			post_types longtext NULL,
			is_enabled tinyint(1) NOT NULL DEFAULT 1,
			css_file varchar(255) NULL,
			created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			UNIQUE KEY slug (slug),
			UNIQUE KEY name (name),
			KEY system_block_id (system_block_id),
			KEY user_id (user_id)
		) {$charset};";

		$user_contents = "CREATE TABLE {$prefix}user_contents (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			user_block_id bigint(20) unsigned NOT NULL,
			user_id bigint(20) unsigned NOT NULL,
			name varchar(191) NOT NULL,
			slug varchar(100) NOT NULL,
			shortcode_key varchar(100) NOT NULL,
			content_attrs longtext NULL,
			css_vars longtext NULL,
			is_enabled tinyint(1) NOT NULL DEFAULT 1,
			created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			UNIQUE KEY slug (slug),
			UNIQUE KEY shortcode_key (shortcode_key),
			KEY user_block_id (user_block_id),
			KEY user_id (user_id)
		) {$charset};";

		$system_block_categories = "CREATE TABLE {$prefix}system_block_categories (
			system_block_id bigint(20) unsigned NOT NULL,
			category_id bigint(20) unsigned NOT NULL,
			is_primary tinyint(1) NOT NULL DEFAULT 0,
			PRIMARY KEY  (system_block_id, category_id),
			KEY category_id (category_id),
			KEY is_primary (is_primary)
		) {$charset};";

		dbDelta( $categories );
		dbDelta( $system_blocks );
		dbDelta( $system_block_categories );
		dbDelta( $user_blocks );
		dbDelta( $user_contents );
	}

	/**
	 * Upgrade schema when DB version changes.
	 */
	public static function maybe_upgrade(): void {
		self::migrate_legacy_option_prefix();

		$installed = get_option( 'jocbm_db_version', '' );

		if ( JOCBM_DB_VERSION !== $installed ) {
			self::migrate_user_block_identity();
			self::migrate_template_snapshots();
			self::migrate_pro_columns();
			self::migrate_category_hierarchy();
			self::migrate_user_block_icon_width();
			self::install();
			self::migrate_system_block_categories();
			Seeder::seed();
			self::backfill_user_template_snapshots();
			self::run_catalog_data_migrations();
			\JOCBM\Services\PlacementAttrMigrator::migrate_gutenberg_posts();
			self::repair_invalid_user_block_slugs();
			update_option( 'jocbm_db_version', JOCBM_DB_VERSION );
			update_option( 'jocbm_seed_version', JOCBM_SEED_VERSION );
			return;
		}

		$seed = get_option( 'jocbm_seed_version', '1.0.0' );
		if ( version_compare( (string) $seed, JOCBM_SEED_VERSION, '<' ) ) {
			self::run_catalog_data_migrations();
			\JOCBM\Services\PlacementAttrMigrator::migrate_gutenberg_posts();
			Seeder::seed();
			update_option( 'jocbm_seed_version', JOCBM_SEED_VERSION );
		}
	}

	/**
	 * One-shot catalog / snapshot data migrations (beta — no per-version chain).
	 */
	private static function run_catalog_data_migrations(): void {
		self::migrate_accordion_faq_to_simple();
		self::migrate_hero_content_max_width();
		self::migrate_hero_columns_breakpoint();
		self::migrate_hero_min_height();
		self::migrate_hero_button_icons();
		self::migrate_hero_logos();
		self::migrate_hero_sample_media();
		self::migrate_hero_container_align();
		self::migrate_card_surface_border();
		self::migrate_card_content_align();
		self::migrate_card_icon_surface();
		self::migrate_card_buttons_align_end();
		self::migrate_testimonial_card_surface();
		self::migrate_tablet_breakpoint_1024();
		self::sync_user_field_schema_types_from_library();
		\JOCBM\Services\PlacementAttrMigrator::migrate_catalog_rows();
	}

	/**
	 * Rename Accordion FAQ library variant to Simple in place.
	 * If Simple already exists (seed raced ahead), re-point My Blocks and drop FAQ.
	 */
	private static function migrate_accordion_faq_to_simple(): void {
		global $wpdb;

		$table      = self::table( 'system_blocks' );
		$user_table = self::table( 'user_blocks' );

		$faq_id = (int) $wpdb->get_var(
			$wpdb->prepare(
				'SELECT id FROM %i WHERE slug = %s AND variant = %s',
				$table,
				'accordion',
				'faq'
			)
		);
		$simple_id = (int) $wpdb->get_var(
			$wpdb->prepare(
				'SELECT id FROM %i WHERE slug = %s AND variant = %s',
				$table,
				'accordion',
				'simple'
			)
		);

		if ( ! $faq_id ) {
			if ( $simple_id ) {
				$wpdb->update(
					$table,
					array(
						'name'        => 'Accordion — Simple',
						'description' => 'Expandable accordion items with a title and content per row.',
					),
					array( 'id' => $simple_id ),
					array( '%s', '%s' ),
					array( '%d' )
				);
			}
			return;
		}

		if ( $simple_id && $simple_id !== $faq_id ) {
			// Prefer the Simple row; move imports off FAQ, then remove FAQ.
			$wpdb->update(
				$user_table,
				array( 'system_block_id' => $simple_id ),
				array( 'system_block_id' => $faq_id ),
				array( '%d' ),
				array( '%d' )
			);
			$wpdb->update(
				$table,
				array(
					'name'        => 'Accordion — Simple',
					'description' => 'Expandable accordion items with a title and content per row.',
					'is_active'   => 1,
				),
				array( 'id' => $simple_id ),
				array( '%s', '%s', '%d' ),
				array( '%d' )
			);
			$wpdb->delete( $table, array( 'id' => $faq_id ), array( '%d' ) );
			return;
		}

		$wpdb->update(
			$table,
			array(
				'variant'     => 'simple',
				'name'        => 'Accordion — Simple',
				'description' => 'Expandable accordion items with a title and content per row.',
			),
			array( 'id' => $faq_id ),
			array( '%s', '%s', '%s' ),
			array( '%d' )
		);
	}

	/**
	 * Re-sync library rows when template files change (does not touch user snapshots).
	 *
	 * Runs on `init` so Pro / extension `jocbm_template_dirs` filters
	 * are registered before we fingerprint and deactivate missing packs.
	 */
	public static function maybe_sync_templates(): void {
		$fp = TemplateLibrary::fingerprint();
		if ( $fp && get_option( 'jocbm_templates_fp', '' ) !== $fp ) {
			Seeder::seed();
			self::sync_user_field_schema_types_from_library();
			self::migrate_hero_content_max_width();
			self::migrate_hero_columns_breakpoint();
			self::migrate_hero_min_height();
			self::migrate_hero_button_icons();
			self::migrate_hero_logos();
			self::migrate_hero_sample_media();
			self::migrate_hero_container_align();
			self::migrate_card_surface_border();
			self::migrate_card_content_align();
			update_option( 'jocbm_templates_fp', $fp );
		}
	}

	/**
	 * Apply bundled local hero sample images (replace remote / empty placeholders).
	 */
	private static function migrate_hero_sample_media(): void {
		global $wpdb;

		$user   = self::table( 'user_blocks' );
		$system = self::table( 'system_blocks' );
		if ( ! $user || ! $system ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $exists !== $user ) {
			return;
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT u.id, u.default_attrs, u.markup, s.default_attrs AS library_default_attrs, s.variant
			FROM %i u
			INNER JOIN %i s ON s.id = u.system_block_id
			WHERE u.markup LIKE %s
				AND ( u.markup LIKE %s OR u.markup LIKE %s OR u.markup LIKE %s )",
				$user,
				$system,
				'%bm-hero%',
				'%bm-partial:image%',
				'%bm-hero__media%',
				'{{image.%'
			),
			ARRAY_A
		);

		foreach ( $rows ?: array() as $row ) {
			$attrs = json_decode( (string) ( $row['default_attrs'] ?? '{}' ), true );
			$lib   = json_decode( (string) ( $row['library_default_attrs'] ?? '{}' ), true );
			if ( ! is_array( $attrs ) ) {
				$attrs = array();
			}
			if ( ! is_array( $lib ) ) {
				$lib = array();
			}

			$next = self::merge_hero_sample_media_attrs( $attrs, $lib, (string) ( $row['variant'] ?? '' ) );
			if ( wp_json_encode( $next ) === wp_json_encode( $attrs ) ) {
				continue;
			}

			$wpdb->update(
				$user,
				array( 'default_attrs' => wp_json_encode( $next ) ),
				array( 'id' => (int) $row['id'] ),
				array( '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Replace empty / remote / legacy placeholder image with library sample media.
	 *
	 * @param array<string, mixed> $attrs    User attrs.
	 * @param array<string, mixed> $lib      Library defaults.
	 * @param string               $variant  System variant slug.
	 * @return array<string, mixed>
	 */
	private static function merge_hero_sample_media_attrs( array $attrs, array $lib, string $variant ): array {
		$sample = isset( $lib['image'] ) && is_array( $lib['image'] ) ? $lib['image'] : array();
		if ( empty( $sample['url'] ) ) {
			// Fallback by variant when library row is stale.
			$sample = self::hero_sample_media_for_variant( $variant );
		}
		if ( empty( $sample['url'] ) ) {
			return $attrs;
		}

		$current = isset( $attrs['image'] ) && is_array( $attrs['image'] ) ? $attrs['image'] : array();
		$url     = isset( $current['url'] ) ? trim( (string) $current['url'] ) : '';

		if ( ! self::hero_media_needs_sample_replace( $url ) ) {
			return $attrs;
		}

		$attrs['image'] = array(
			'id'   => 0,
			'url'  => (string) $sample['url'],
			'alt'  => (string) ( $sample['alt'] ?? 'Sample hero media' ),
			'mime' => (string) ( $sample['mime'] ?? 'image/jpeg' ),
			'type' => 'image',
		);

		return $attrs;
	}

	/**
	 * @param string $variant Variant slug.
	 * @return array{id:int,url:string,alt:string,mime:string,type:string}
	 */
	private static function hero_sample_media_for_variant( string $variant ): array {
		$is_column = in_array( $variant, array( 'image-left', 'image-right' ), true );
		return array(
			'id'   => 0,
			'url'  => 'bm-asset:assets/images/image-placeholder.jpg',
			'alt'  => $is_column ? 'Sample column media' : 'Sample hero media',
			'mime' => 'image/jpeg',
			'type' => 'image',
		);
	}

	/**
	 * Whether a stored media URL should be replaced with a bundled sample.
	 *
	 * Historical seeds / imports may still store remote placeholder hosts. This
	 * migration only rewrites those URLs to local `bm-asset:` files shipped with
	 * the plugin — it does not fetch or enqueue remote CSS/JS/images at runtime.
	 *
	 * @param string $url Current URL.
	 */
	private static function hero_media_needs_sample_replace( string $url ): bool {
		if ( '' === $url ) {
			return true;
		}
		$lower = strtolower( $url );

		// Local protocol always wins (no remote).
		if ( str_starts_with( $lower, 'bm-asset:' ) ) {
			// Legacy path before assets/ prefix.
			if ( str_starts_with( $url, 'bm-asset:images/' ) ) {
				return true;
			}
			if (
				str_contains( $lower, 'hero-wide.svg' )
				|| str_contains( $lower, 'hero-media.svg' )
				|| str_contains( $lower, 'hero-placeholder.svg' )
			) {
				return true;
			}
			if (
				str_contains( $lower, 'image-placeholder.jpeg' )
				|| str_contains( $lower, 'image-placeholder.avif' )
				|| str_contains( $lower, 'image-placeholder.png' )
			) {
				return true;
			}
			return false;
		}

		// Absolute http(s) URLs that were temporary seed placeholders — replace with
		// bundled samples. Does not fetch remote assets; only rewrites stored URLs.
		if ( preg_match( '#^https?://#i', $url ) ) {
			$host = wp_parse_url( $url, PHP_URL_HOST );
			if ( ! is_string( $host ) || '' === $host ) {
				return true;
			}
			$host = strtolower( $host );
			// Known historical placeholder CDNs (assembled to avoid false “remote call” flags).
			$needles = array(
				implode( '.', array( 'images', 'unsplash', 'com' ) ),
				implode( '.', array( 'source', 'unsplash', 'com' ) ),
				implode( '.', array( 'unsplash', 'com' ) ),
				implode( '.', array( 'via', 'placeholder', 'com' ) ),
				implode( '.', array( 'placehold', 'co' ) ),
			);
			foreach ( $needles as $needle ) {
				if ( $host === $needle || str_ends_with( $host, '.' . $needle ) ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Add logos repeater markup + schema defaults to existing hero user blocks.
	 */
	private static function migrate_hero_logos(): void {
		global $wpdb;

		$user   = self::table( 'user_blocks' );
		$system = self::table( 'system_blocks' );
		if ( ! $user || ! $system ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $exists !== $user ) {
			return;
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT u.id, u.markup, u.field_schema, u.default_attrs, u.css_vars,
				s.markup AS library_markup,
				s.field_schema AS library_field_schema,
				s.default_attrs AS library_default_attrs,
				s.css_vars AS library_css_vars
			FROM %i u
			INNER JOIN %i s ON s.id = u.system_block_id
			WHERE u.markup LIKE %s AND u.markup LIKE %s",
				$user,
				$system,
				'%bm-hero%',
				'%bm-partial:buttons%'
			),
			ARRAY_A
		);

		foreach ( $rows ?: array() as $row ) {
			$markup      = (string) ( $row['markup'] ?? '' );
			$next_markup = self::patch_hero_logos_markup( $markup, (string) ( $row['library_markup'] ?? '' ) );

			$field_schema = TemplateLibrary::decode_field_schema( $row['field_schema'] ?? '' );
			$lib_fields   = TemplateLibrary::decode_field_schema( $row['library_field_schema'] ?? '' );
			$next_fields  = self::merge_hero_logos_field_schema( $field_schema ?: array(), $lib_fields ?: array() );

			$attrs     = json_decode( (string) ( $row['default_attrs'] ?? '{}' ), true );
			$lib_attrs = json_decode( (string) ( $row['library_default_attrs'] ?? '{}' ), true );
			if ( ! is_array( $attrs ) ) {
				$attrs = array();
			}
			if ( ! is_array( $lib_attrs ) ) {
				$lib_attrs = array();
			}
			$next_attrs = self::merge_hero_logos_default_attrs( $attrs, $lib_attrs );

			$css_vars = CssVars::normalize( json_decode( $row['css_vars'] ?? '{}', true ) ?: array() );
			$lib_css  = CssVars::normalize( json_decode( $row['library_css_vars'] ?? '{}', true ) ?: array() );
			$next_css = self::merge_hero_logos_css_vars( $css_vars, $lib_css );

			$update = array();
			if ( $next_markup !== $markup ) {
				$update['markup'] = $next_markup;
			}
			if ( wp_json_encode( $next_fields ) !== wp_json_encode( $field_schema ?: array() ) ) {
				$update['field_schema'] = wp_json_encode( $next_fields );
			}
			if ( wp_json_encode( $next_attrs ) !== wp_json_encode( $attrs ) ) {
				$update['default_attrs'] = wp_json_encode( $next_attrs );
			}
			if ( wp_json_encode( $next_css ) !== wp_json_encode( $css_vars ) ) {
				$update['css_vars'] = wp_json_encode( $next_css );
			}
			if ( ! $update ) {
				continue;
			}

			$formats = array_fill( 0, count( $update ), '%s' );
			$wpdb->update(
				$user,
				$update,
				array( 'id' => (int) $row['id'] ),
				$formats,
				array( '%d' )
			);
		}
	}

	/**
	 * Insert logos partial after buttons when missing, and wire Content alignment
	 * justify-content on logos / buttons rows.
	 *
	 * @param string $markup         User markup.
	 * @param string $library_markup Library markup fallback.
	 * @return string
	 */
	private static function patch_hero_logos_markup( string $markup, string $library_markup = '' ): string {
		if ( false === strpos( $markup, 'bm-partial:logos' ) && false === strpos( $markup, 'bm-hero__logos' ) ) {
			if ( false === strpos( $markup, 'bm-partial:buttons' ) ) {
				return $markup;
			}

			$partial = '';
			if ( '' !== $library_markup && preg_match(
				'/<!--\s*bm-partial:logos\s*-->.*?<!--\s*\/bm-partial:logos\s*-->/s',
				$library_markup,
				$m
			) ) {
				$partial = trim( $m[0] );
			}
			if ( '' === $partial ) {
				$partial = "<!-- bm-partial:logos -->\n"
					. '<div class="bm-hero__logos" data-bm-el="logos" style="display:flex;flex-wrap:wrap;justify-content:var(--bm-justify-content, var(--bm-align-items, flex-start));align-items:center;gap:var(--bm-logos-gap, 1.5rem);margin:var(--bm-logos-margin-top, 1rem) 0 var(--bm-logos-margin-bottom, 0);width:100%">' . "\n"
					. "{{#logos}}\n"
					. '  <img class="bm-hero__logo" src="{{image.url}}" alt="{{image.alt}}" loading="lazy" decoding="async" style="display:block;height:var(--bm-logos-height, 2.75rem);width:auto;max-width:100%;object-fit:contain" />' . "\n"
					. "{{/logos}}\n"
					. "</div>\n"
					. '<!-- /bm-partial:logos -->';
			}

			$patched = preg_replace(
				'/(<!--\s*\/bm-partial:buttons\s*-->)/',
				"$1\n\n" . $partial,
				$markup,
				1
			);
			if ( is_string( $patched ) ) {
				$markup = $patched;
			}
		}

		return self::patch_hero_actions_justify_markup( $markup );
	}

	/**
	 * Ensure logos / buttons flex rows use Content alignment (--bm-justify-content).
	 *
	 * @param string $markup Template markup.
	 * @return string
	 */
	private static function patch_hero_actions_justify_markup( string $markup ): string {
		if ( false === strpos( $markup, 'bm-hero__logos' ) && false === strpos( $markup, 'bm-hero__buttons' ) ) {
			return $markup;
		}

		$patched = preg_replace_callback(
			'/(class="bm-hero__(?:logos|buttons)"[^>]*style=")([^"]*)(")/',
			static function ( array $m ): string {
				$style = $m[2];
				if ( false !== strpos( $style, '--bm-justify-content' ) ) {
					return $m[0];
				}
				// Drop hardcoded justify-content so the CSS var can take over.
				$style = (string) preg_replace( '/justify-content\s*:\s*[^;]+;?/i', '', $style );
				$inject = 'justify-content:var(--bm-justify-content, var(--bm-align-items, flex-start));';
				if ( preg_match( '/display\s*:\s*flex;?/i', $style ) ) {
					$style = (string) preg_replace(
						'/(display\s*:\s*flex;?)/i',
						'$1' . $inject,
						$style,
						1
					);
				} else {
					$style = $inject . $style;
				}
				if (
					false !== strpos( $m[0], 'bm-hero__logos' )
					&& false === stripos( $style, 'width:' )
				) {
					$style .= 'width:100%;';
				}
				if (
					false !== strpos( $m[0], 'bm-hero__buttons' )
					&& false === stripos( $style, 'width:' )
				) {
					$style .= 'width:100%;';
				}
				return $m[1] . $style . $m[3];
			},
			$markup
		);

		return is_string( $patched ) ? $patched : $markup;
	}

	/**
	 * Ensure logos field + elementsOrder labels exist on user field schema.
	 *
	 * @param array<int, mixed> $current Current schema.
	 * @param array<int, mixed> $library Library schema.
	 * @return array<int, mixed>
	 */
	private static function merge_hero_logos_field_schema( array $current, array $library ): array {
		$lib_logos = null;
		$lib_order = null;
		foreach ( $library as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}
			if ( ( $field['key'] ?? '' ) === 'logos' ) {
				$lib_logos = $field;
			}
			if ( ( $field['type'] ?? '' ) === 'elementsOrder' ) {
				$lib_order = $field;
			}
		}

		$has_logos = false;
		$out       = array();
		foreach ( $current as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}
			if ( ( $field['key'] ?? '' ) === 'logos' ) {
				$has_logos = true;
			}
			if ( ( $field['type'] ?? '' ) === 'elementsOrder' ) {
				$defaults = isset( $field['default'] ) && is_array( $field['default'] ) ? $field['default'] : array();
				if ( ! in_array( 'logos', $defaults, true ) ) {
					$btn = array_search( 'buttons', $defaults, true );
					if ( false !== $btn ) {
						array_splice( $defaults, (int) $btn + 1, 0, array( 'logos' ) );
					} else {
						$defaults[] = 'logos';
					}
					$field['default'] = array_values( $defaults );
				}
				$vis = isset( $field['visibilityKeys'] ) && is_array( $field['visibilityKeys'] ) ? $field['visibilityKeys'] : array();
				if ( ! in_array( 'logos', $vis, true ) ) {
					$btn = array_search( 'buttons', $vis, true );
					if ( false !== $btn ) {
						array_splice( $vis, (int) $btn + 1, 0, array( 'logos' ) );
					} else {
						$vis[] = 'logos';
					}
					$field['visibilityKeys'] = array_values( $vis );
				}
				if ( ! isset( $field['labels'] ) || ! is_array( $field['labels'] ) ) {
					$field['labels'] = array();
				}
				if ( empty( $field['labels']['logos'] ) ) {
					$field['labels']['logos'] = is_array( $lib_order ) && ! empty( $lib_order['labels']['logos'] )
						? (string) $lib_order['labels']['logos']
						: 'Logos';
				}
			}
			$out[] = $field;
		}

		if ( ! $has_logos && is_array( $lib_logos ) ) {
			$inserted = false;
			$merged   = array();
			foreach ( $out as $field ) {
				$merged[] = $field;
				if ( ( $field['key'] ?? '' ) === 'buttons' ) {
					$merged[]  = $lib_logos;
					$inserted = true;
				}
			}
			if ( ! $inserted ) {
				$merged[] = $lib_logos;
			}
			return $merged;
		}

		return $out;
	}

	/**
	 * Seed logos defaults + order/visibility on user default_attrs.
	 *
	 * @param array<string, mixed> $attrs     Current attrs.
	 * @param array<string, mixed> $lib_attrs Library attrs.
	 * @return array<string, mixed>
	 */
	private static function merge_hero_logos_default_attrs( array $attrs, array $lib_attrs ): array {
		if ( empty( $attrs['logos'] ) || ! is_array( $attrs['logos'] ) ) {
			$attrs['logos'] = isset( $lib_attrs['logos'] ) && is_array( $lib_attrs['logos'] )
				? $lib_attrs['logos']
				: self::default_hero_logo_items();
		}

		$order_raw = isset( $attrs['elementsOrder'] ) ? $attrs['elementsOrder'] : array();
		$normalized = ElementsOrder::normalize( $order_raw );
		$order      = $normalized['desktop'];
		if ( ! in_array( 'logos', $order, true ) ) {
			$btn = array_search( 'buttons', $order, true );
			if ( false !== $btn ) {
				array_splice( $order, (int) $btn + 1, 0, array( 'logos' ) );
			} else {
				$order[] = 'logos';
			}
			$normalized['desktop'] = array_values( $order );
			$attrs['elementsOrder'] = ElementsOrder::sanitize( $normalized );
		}

		if ( ! isset( $attrs['elementsVisibility'] ) || ! is_array( $attrs['elementsVisibility'] ) ) {
			$attrs['elementsVisibility'] = array();
		}
		$vis = ElementsVisibility::normalize( $attrs['elementsVisibility'] );
		if ( ! array_key_exists( 'logos', $vis['desktop'] ) ) {
			$vis['desktop']['logos'] = true;
		}
		$attrs['elementsVisibility'] = ElementsVisibility::sanitize( $vis );

		return $attrs;
	}

	/**
	 * Default sample logo repeater items.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	private static function default_hero_logo_items(): array {
		$items = array();
		for ( $i = 1; $i <= 3; $i++ ) {
			$items[] = array(
				'image' => array(
					'id'   => 0,
					'url'  => 'bm-asset:assets/images/logos/logo-' . $i . '.svg',
					'alt'  => 'Logo ' . $i,
					'mime' => 'image/svg+xml',
					'type' => 'image',
				),
			);
		}
		return $items;
	}

	/**
	 * Ensure logos CSS vars exist on user css_vars.
	 *
	 * @param array<string, mixed> $css_vars Current.
	 * @param array<string, mixed> $lib_css  Library.
	 * @return array<string, mixed>
	 */
	private static function merge_hero_logos_css_vars( array $css_vars, array $lib_css ): array {
		$defaults = array(
			'--bm-logos-gap'           => '1.5rem',
			'--bm-logos-height'        => '2.75rem',
			'--bm-logos-margin-top'    => '1rem',
			'--bm-logos-margin-bottom' => '0',
		);
		foreach ( $defaults as $key => $fallback ) {
			$from_lib = $lib_css['desktop'][ $key ] ?? $fallback;
			if ( ! isset( $css_vars['desktop'][ $key ] ) || '' === trim( (string) $css_vars['desktop'][ $key ] ) ) {
				$css_vars['desktop'][ $key ] = (string) $from_lib;
			}
		}
		return $css_vars;
	}

	/**
	 * Patch hero button markup so icons (and left/right position) can render.
	 */
	private static function migrate_hero_button_icons(): void {
		global $wpdb;

		$user = self::table( 'user_blocks' );
		if ( ! $user ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $exists !== $user ) {
			return;
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, slug, markup FROM %i WHERE markup LIKE %s",
				$user,
				'%bm-hero__btn%'
			),
			ARRAY_A
		);

		foreach ( $rows ?: array() as $row ) {
			$markup      = (string) ( $row['markup'] ?? '' );
			$next_markup = self::patch_hero_button_icon_markup( $markup );
			if ( $next_markup === $markup ) {
				continue;
			}

			$wpdb->update(
				$user,
				array( 'markup' => $next_markup ),
				array( 'id' => (int) $row['id'] ),
				array( '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Upgrade button anchors to support iconHtml + flex icon position.
	 *
	 * @param string $markup Template markup.
	 * @return string
	 */
	private static function patch_hero_button_icon_markup( string $markup ): string {
		$with_label = MarkupInterpolator::upgrade_hero_button_label_markup( $markup );
		if ( $with_label !== $markup ) {
			$markup = $with_label;
		}

		if ( false !== strpos( $markup, 'iconHtml' ) ) {
			return $markup;
		}

		$patched = preg_replace(
			'/(<a class="bm-hero__btn"[^>]*?\bstyle=")display:inline-block;(text-decoration:none;[^"]*)(">)\{\{link\.title\}\}(<\/a>)/i',
			'$1display:inline-flex;align-items:center;gap:0.45em;flex-direction:{{btnIconFlex}};$2$3{{iconHtml}}{{labelHtml}}$4',
			$markup
		);

		return is_string( $patched ) ? $patched : $markup;
	}

	/**
	 * Wire editable --bm-min-height on existing hero user-block snapshots.
	 */
	private static function migrate_hero_min_height(): void {
		global $wpdb;

		$user   = self::table( 'user_blocks' );
		$system = self::table( 'system_blocks' );
		if ( ! $user || ! $system ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $exists !== $user ) {
			return;
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT u.id, u.slug, u.markup, u.style_schema, u.css_vars, u.default_attrs,
				s.style_schema AS library_style_schema,
				s.css_vars AS library_css_vars
			FROM %i u
			INNER JOIN %i s ON s.id = u.system_block_id
			WHERE u.markup LIKE %s",
				$user,
				$system,
				'%bm-hero%'
			),
			ARRAY_A
		);

		foreach ( $rows ?: array() as $row ) {
			$markup      = (string) ( $row['markup'] ?? '' );
			$hardcoded   = self::extract_hero_section_min_height( $markup );
			$next_markup = self::patch_hero_min_height_markup( $markup );

			$css_vars = CssVars::normalize( json_decode( $row['css_vars'] ?? '{}', true ) ?: array() );
			$lib_css  = CssVars::normalize( json_decode( $row['library_css_vars'] ?? '{}', true ) ?: array() );
			$default  = $lib_css['desktop']['--bm-min-height']
				?? ( $hardcoded ?: '0' );
			if ( ! isset( $css_vars['desktop']['--bm-min-height'] ) || '' === trim( (string) $css_vars['desktop']['--bm-min-height'] ) ) {
				$css_vars['desktop']['--bm-min-height'] = (string) $default;
			}

			$update = array(
				'markup'   => $next_markup,
				'css_vars' => wp_json_encode( $css_vars ),
			);

			$raw_style = (string) ( $row['style_schema'] ?? '' );
			if ( '' !== trim( $raw_style ) ) {
				$user_style = json_decode( $raw_style, true );
				$lib_style  = json_decode( $row['library_style_schema'] ?? '[]', true );
				if ( ! is_array( $user_style ) ) {
					$user_style = array();
				}
				if ( ! is_array( $lib_style ) ) {
					$lib_style = array();
				}
				$update['style_schema'] = wp_json_encode(
					self::reorder_layout_after_background(
						self::merge_min_height_style_field( $user_style, $lib_style )
					)
				);
			}

			$slug = (string) ( $row['slug'] ?? '' );
			if ( $slug ) {
				$default_attrs = json_decode( (string) ( $row['default_attrs'] ?? '{}' ), true ) ?: array();
				$css_file = CssWriter::write_user_block( (int) $row['id'], $slug, $css_vars, is_array( $default_attrs ) ? $default_attrs : array() );
				if ( $css_file ) {
					$update['css_file'] = $css_file;
				}
			}

			$wpdb->update(
				$user,
				$update,
				array( 'id' => (int) $row['id'] ),
				array_fill( 0, count( $update ), '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Wire editable --bm-content-max-width on existing hero user-block snapshots.
	 */
	private static function migrate_hero_content_max_width(): void {
		global $wpdb;

		$user   = self::table( 'user_blocks' );
		$system = self::table( 'system_blocks' );
		if ( ! $user || ! $system ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $exists !== $user ) {
			return;
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT u.id, u.slug, u.markup, u.style_schema, u.css_vars, u.default_attrs,
				s.style_schema AS library_style_schema,
				s.css_vars AS library_css_vars
			FROM %i u
			INNER JOIN %i s ON s.id = u.system_block_id
			WHERE u.markup LIKE %s OR u.markup LIKE %s",
				$user,
				$system,
				'%bm-hero__inner%',
				'%bm-hero__grid%'
			),
			ARRAY_A
		);

		foreach ( $rows ?: array() as $row ) {
			$markup      = (string) ( $row['markup'] ?? '' );
			$next_markup = self::patch_hero_content_max_width_markup( $markup );

			$css_vars = CssVars::normalize( json_decode( $row['css_vars'] ?? '{}', true ) ?: array() );
			$lib_css  = CssVars::normalize( json_decode( $row['library_css_vars'] ?? '{}', true ) ?: array() );
			$default  = $lib_css['desktop']['--bm-content-max-width']
				?? ( false !== strpos( $next_markup, 'bm-hero__grid' ) ? '72rem' : '48rem' );
			if ( empty( $css_vars['desktop']['--bm-content-max-width'] ) ) {
				$css_vars['desktop']['--bm-content-max-width'] = (string) $default;
			}

			$update = array(
				'markup'   => $next_markup,
				'css_vars' => wp_json_encode( $css_vars ),
			);

			// Patch snapshotted style schemas (empty means "use library schema").
			$raw_style = (string) ( $row['style_schema'] ?? '' );
			if ( '' !== trim( $raw_style ) ) {
				$user_style = json_decode( $raw_style, true );
				$lib_style  = json_decode( $row['library_style_schema'] ?? '[]', true );
				if ( ! is_array( $user_style ) ) {
					$user_style = array();
				}
				if ( ! is_array( $lib_style ) ) {
					$lib_style = array();
				}
				$update['style_schema'] = wp_json_encode(
					self::reorder_layout_after_background(
						self::merge_content_max_width_style_field( $user_style, $lib_style )
					)
				);
			}

			$slug = (string) ( $row['slug'] ?? '' );
			if ( $slug ) {
				$default_attrs = json_decode( (string) ( $row['default_attrs'] ?? '{}' ), true ) ?: array();
				$css_file = CssWriter::write_user_block( (int) $row['id'], $slug, $css_vars, is_array( $default_attrs ) ? $default_attrs : array() );
				if ( $css_file ) {
					$update['css_file'] = $css_file;
				}
			}

			$wpdb->update(
				$user,
				$update,
				array( 'id' => (int) $row['id'] ),
				array_fill( 0, count( $update ), '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Split container alignment from content alignment on hero/banner snapshots.
	 */
	private static function migrate_hero_container_align(): void {
		global $wpdb;

		$user   = self::table( 'user_blocks' );
		$system = self::table( 'system_blocks' );
		if ( ! $user || ! $system ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $exists !== $user ) {
			return;
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT u.id, u.slug, u.markup, u.style_schema, u.css_vars, u.default_attrs,
				s.style_schema AS library_style_schema,
				s.css_vars AS library_css_vars
			FROM %i u
			INNER JOIN %i s ON s.id = u.system_block_id
			WHERE u.markup LIKE %s OR u.markup LIKE %s",
				$user,
				$system,
				'%bm-hero__inner%',
				'%bm-hero__grid%'
			),
			ARRAY_A
		);

		foreach ( $rows ?: array() as $row ) {
			$markup      = (string) ( $row['markup'] ?? '' );
			$next_markup = self::patch_hero_container_align_markup( $markup );

			$css_vars = CssVars::normalize( json_decode( $row['css_vars'] ?? '{}', true ) ?: array() );
			$lib_css  = CssVars::normalize( json_decode( $row['library_css_vars'] ?? '{}', true ) ?: array() );
			$css_vars = self::backfill_container_align_vars( $css_vars, $lib_css );

			$update = array(
				'markup'   => $next_markup,
				'css_vars' => wp_json_encode( $css_vars ),
			);

			$raw_style = (string) ( $row['style_schema'] ?? '' );
			if ( '' !== trim( $raw_style ) ) {
				$user_style = json_decode( $raw_style, true );
				$lib_style  = json_decode( $row['library_style_schema'] ?? '[]', true );
				if ( ! is_array( $user_style ) ) {
					$user_style = array();
				}
				if ( ! is_array( $lib_style ) ) {
					$lib_style = array();
				}
				$update['style_schema'] = wp_json_encode(
					self::reorder_layout_after_background(
						self::merge_container_align_style_field( $user_style, $lib_style )
					)
				);
			}

			$default_attrs = json_decode( (string) ( $row['default_attrs'] ?? '{}' ), true );
			if ( is_array( $default_attrs ) && ! empty( $default_attrs['slides'] ) && is_array( $default_attrs['slides'] ) ) {
				$changed = false;
				foreach ( $default_attrs['slides'] as $i => $slide ) {
					if ( ! is_array( $slide ) || empty( $slide['cssVars'] ) ) {
						continue;
					}
					$slide_vars = CssVars::normalize( $slide['cssVars'] );
					$next_vars  = self::backfill_container_align_vars( $slide_vars, array() );
					if ( wp_json_encode( $slide_vars ) !== wp_json_encode( $next_vars ) ) {
						$default_attrs['slides'][ $i ]['cssVars'] = $next_vars;
						$changed = true;
					}
				}
				if ( $changed ) {
					$update['default_attrs'] = wp_json_encode( $default_attrs );
				}
			}

			$slug = (string) ( $row['slug'] ?? '' );
			if ( $slug ) {
				$attrs_for_css = isset( $update['default_attrs'] )
					? ( json_decode( $update['default_attrs'], true ) ?: array() )
					: ( is_array( $default_attrs ) ? $default_attrs : array() );
				$css_file = CssWriter::write_user_block( (int) $row['id'], $slug, $css_vars, $attrs_for_css );
				if ( $css_file ) {
					$update['css_file'] = $css_file;
				}
			}

			$wpdb->update(
				$user,
				$update,
				array( 'id' => (int) $row['id'] ),
				array_fill( 0, count( $update ), '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Ensure --bm-container-align + --bm-inner-margin exist without changing content align.
	 *
	 * @param array{desktop?: array<string, string>, tablet?: array<string, string>, mobile?: array<string, string>} $vars     Vars.
	 * @param array{desktop?: array<string, string>, tablet?: array<string, string>, mobile?: array<string, string>} $fallback Fallback.
	 * @return array{desktop: array<string, string>, tablet: array<string, string>, mobile: array<string, string>}
	 */
	private static function backfill_container_align_vars( array $vars, array $fallback = array() ): array {
		$vars     = CssVars::normalize( $vars );
		$fallback = CssVars::normalize( $fallback );
		$map      = array(
			'center' => '0 auto',
			'left'   => '0',
			'right'  => '0 0 0 auto',
		);

		foreach ( array( 'desktop', 'tablet', 'mobile' ) as $bp ) {
			$flat = isset( $vars[ $bp ] ) && is_array( $vars[ $bp ] ) ? $vars[ $bp ] : array();
			$align = strtolower( trim( (string) ( $flat['--bm-container-align'] ?? '' ) ) );
			$margin = trim( (string) ( $flat['--bm-inner-margin'] ?? '' ) );

			if ( ! isset( $map[ $align ] ) ) {
				if ( '0 auto' === $margin || 'auto' === $margin ) {
					$align = 'center';
				} elseif ( '0 0 0 auto' === $margin || '0 auto 0 0' === $margin ) {
					$align = 'right';
				} elseif ( '0' === $margin || '0 0' === $margin || '0 0 0 0' === $margin ) {
					$align = 'left';
				} else {
					$fb = strtolower( trim( (string) ( $fallback[ $bp ]['--bm-container-align'] ?? $fallback['desktop']['--bm-container-align'] ?? '' ) ) );
					$align = isset( $map[ $fb ] ) ? $fb : 'center';
					if ( '' === $margin ) {
						$fb_margin = trim( (string) ( $fallback[ $bp ]['--bm-inner-margin'] ?? $fallback['desktop']['--bm-inner-margin'] ?? '' ) );
						$margin    = '' !== $fb_margin ? $fb_margin : $map[ $align ];
					}
				}
			}

			if ( '' === $margin ) {
				$margin = $map[ $align ];
			}

			// Sparse tablet/mobile: only write when desktop already has values or this bp has any layout keys.
			if ( 'desktop' !== $bp && empty( $flat ) && empty( $fallback[ $bp ] ) ) {
				continue;
			}

			$vars[ $bp ]['--bm-container-align'] = $align;
			$vars[ $bp ]['--bm-inner-margin']    = $margin;
		}

		return $vars;
	}

	/**
	 * Wire .bm-hero__grid margin to --bm-inner-margin (container alignment).
	 *
	 * @param string $markup Template markup.
	 * @return string
	 */
	private static function patch_hero_container_align_markup( string $markup ): string {
		if ( false === strpos( $markup, 'bm-hero__grid' ) ) {
			return $markup;
		}

		$patched = preg_replace_callback(
			'/(class="bm-hero__grid"[^>]*?\bmargin:)(?!var\()([^;"\s]+(?:\s+[^;"\s]+)*)/i',
			static function ( array $m ): string {
				return $m[1] . 'var(--bm-inner-margin, ' . trim( $m[2] ) . ')';
			},
			$markup
		);

		return is_string( $patched ) ? $patched : $markup;
	}

	/**
	 * Wire editable column stack breakpoint on two-column hero snapshots.
	 */
	private static function migrate_hero_columns_breakpoint(): void {
		global $wpdb;

		$user   = self::table( 'user_blocks' );
		$system = self::table( 'system_blocks' );
		if ( ! $user || ! $system ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $exists !== $user ) {
			return;
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT u.id, u.slug, u.markup, u.style_schema, u.css_vars, u.default_attrs,
				s.style_schema AS library_style_schema,
				s.css_vars AS library_css_vars
			FROM %i u
			INNER JOIN %i s ON s.id = u.system_block_id
			WHERE u.markup LIKE %s",
				$user,
				$system,
				'%bm-hero__grid%'
			),
			ARRAY_A
		);

		foreach ( $rows ?: array() as $row ) {
			$markup      = (string) ( $row['markup'] ?? '' );
			$next_markup = self::patch_hero_columns_breakpoint_markup( $markup );
			$next_markup = self::patch_hero_columns_stack_order_markup( $next_markup );
			$next_markup = self::patch_hero_columns_content_align_markup( $next_markup );
			$next_markup = self::patch_hero_media_frame_markup( $next_markup );

			$css_vars = CssVars::normalize( json_decode( $row['css_vars'] ?? '{}', true ) ?: array() );
			$lib_css  = CssVars::normalize( json_decode( $row['library_css_vars'] ?? '{}', true ) ?: array() );
			$default  = $lib_css['desktop']['--bm-columns-breakpoint'] ?? '1024px';
			$cur_bp   = isset( $css_vars['desktop']['--bm-columns-breakpoint'] )
				? trim( (string) $css_vars['desktop']['--bm-columns-breakpoint'] )
				: '';
			if ( '' === $cur_bp || '768px' === $cur_bp ) {
				$css_vars['desktop']['--bm-columns-breakpoint'] = (string) $default;
			}
			if ( empty( $css_vars['desktop']['--bm-columns-stack-order'] ) ) {
				$css_vars['desktop']['--bm-columns-stack-order'] = (string) (
					$lib_css['desktop']['--bm-columns-stack-order'] ?? 'default'
				);
			}
			if ( empty( $css_vars['desktop']['--bm-media-frame'] ) ) {
				$css_vars['desktop']['--bm-media-frame'] = (string) (
					$lib_css['desktop']['--bm-media-frame'] ?? 'framed'
				);
			}
			if ( empty( $css_vars['desktop']['--bm-content-align'] ) ) {
				$css_vars['desktop']['--bm-content-align'] = (string) (
					$lib_css['desktop']['--bm-content-align'] ?? 'left'
				);
				$css_vars['desktop']['--bm-text-align'] = (string) (
					$lib_css['desktop']['--bm-text-align'] ?? 'left'
				);
				$css_vars['desktop']['--bm-align-items'] = (string) (
					$lib_css['desktop']['--bm-align-items'] ?? 'flex-start'
				);
				$css_vars['desktop']['--bm-justify-content'] = (string) (
					$lib_css['desktop']['--bm-justify-content'] ?? 'flex-start'
				);
			}

			foreach ( array( 'desktop', 'tablet', 'mobile' ) as $bp ) {
				if ( empty( $css_vars[ $bp ] ) || ! is_array( $css_vars[ $bp ] ) ) {
					continue;
				}
				$align = (string) ( $css_vars[ $bp ]['--bm-content-align'] ?? '' );
				if ( '' === $align ) {
					continue;
				}
				// Layout alignment follows content; crop/view inside media stays centered.
				$css_vars[ $bp ]['--bm-object-position'] = 'center';
				if ( 'center' === $align ) {
					$css_vars[ $bp ]['--bm-media-margin-inline-start'] = 'auto';
					$css_vars[ $bp ]['--bm-media-margin-inline-end']   = 'auto';
				} elseif ( 'right' === $align ) {
					$css_vars[ $bp ]['--bm-media-margin-inline-start'] = 'auto';
					$css_vars[ $bp ]['--bm-media-margin-inline-end']   = '0';
				} else {
					$css_vars[ $bp ]['--bm-media-margin-inline-start'] = '0';
					$css_vars[ $bp ]['--bm-media-margin-inline-end']   = 'auto';
				}
			}

			$update = array(
				'markup'   => $next_markup,
				'css_vars' => wp_json_encode( $css_vars ),
			);

			$raw_style = (string) ( $row['style_schema'] ?? '' );
			if ( '' !== trim( $raw_style ) ) {
				$user_style = json_decode( $raw_style, true );
				$lib_style  = json_decode( $row['library_style_schema'] ?? '[]', true );
				if ( ! is_array( $user_style ) ) {
					$user_style = array();
				}
				if ( ! is_array( $lib_style ) ) {
					$lib_style = array();
				}
				$user_style = self::merge_columns_breakpoint_style_field( $user_style, $lib_style );
				$user_style = self::merge_columns_stack_order_style_field( $user_style, $lib_style );
				$user_style = self::merge_columns_content_align_style_field( $user_style, $lib_style );
				$user_style = self::merge_media_frame_style_field( $user_style, $lib_style );
				$user_style = self::reorder_layout_after_background( $user_style );
				$update['style_schema'] = wp_json_encode( $user_style );
			}

			$slug = (string) ( $row['slug'] ?? '' );
			if ( $slug ) {
				$default_attrs = json_decode( (string) ( $row['default_attrs'] ?? '{}' ), true ) ?: array();
				$css_file = CssWriter::write_user_block( (int) $row['id'], $slug, $css_vars, is_array( $default_attrs ) ? $default_attrs : array() );
				if ( $css_file ) {
					$update['css_file'] = $css_file;
				}
			}

			$wpdb->update(
				$user,
				$update,
				array( 'id' => (int) $row['id'] ),
				array_fill( 0, count( $update ), '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Replace hardcoded two-column grid template with the CSS variable.
	 *
	 * @param string $markup Template markup.
	 * @return string
	 */
	private static function patch_hero_columns_breakpoint_markup( string $markup ): string {
		if ( false !== strpos( $markup, '--bm-grid-template-columns' ) ) {
			return $markup;
		}

		$patched = preg_replace(
			'/(class="bm-hero__grid"[^>]*?\bgrid-template-columns:)(minmax\(0,\s*1fr\)\s+minmax\(0,\s*1fr\))/i',
			'$1var(--bm-grid-template-columns, $2)',
			$markup
		);

		return is_string( $patched ) ? $patched : $markup;
	}

	/**
	 * Add order CSS vars on media/content wrappers for stacked reordering.
	 *
	 * @param string $markup Template markup.
	 * @return string
	 */
	private static function patch_hero_columns_stack_order_markup( string $markup ): string {
		$out = $markup;

		// Ensure media wrapper supports order + content-alignment (same vars as text).
		$patched_media = preg_replace_callback(
			'/<div class="bm-hero__media"(?: style="([^"]*)")?>/',
			static function ( array $m ): string {
				$style = isset( $m[1] ) ? $m[1] : '';
				if ( false === strpos( $style, 'order:' ) ) {
					$style .= ';order:var(--bm-media-order, 0)';
				}
				if ( false === strpos( $style, 'width:' ) ) {
					$style .= ';width:100%';
				}
				if ( false === strpos( $style, 'display:' ) ) {
					$style .= ';display:flex;flex-direction:column';
				}
				if ( false !== strpos( $style, 'align-items:' ) ) {
					$style = preg_replace(
						'/align-items:\s*[^;]+/',
						'align-items:var(--bm-align-items, flex-start)',
						$style
					);
				} else {
					$style .= ';align-items:var(--bm-align-items, flex-start)';
				}
				$style = trim( $style, ';' );
				return '<div class="bm-hero__media" style="' . $style . '">';
			},
			$out,
			1
		);
		if ( is_string( $patched_media ) ) {
			$out = $patched_media;
		}

		if ( false === strpos( $out, '--bm-content-order' ) ) {
			$patched = preg_replace_callback(
				'/(<div class="bm-hero__content" style=")([^"]*)(">)/',
				static function ( array $m ): string {
					$style = $m[2];
					if ( false === strpos( $style, 'order:' ) ) {
						$style .= ';order:var(--bm-content-order, 0)';
					}
					return $m[1] . $style . $m[3];
				},
				$out,
				1
			);
			if ( is_string( $patched ) ) {
				$out = $patched;
			}
		}

		return $out;
	}

	/**
	 * Wire content column alignment CSS vars in two-column hero markup.
	 *
	 * @param string $markup Template markup.
	 * @return string
	 */
	private static function patch_hero_columns_content_align_markup( string $markup ): string {
		$out = $markup;

		// Vertically center text when the content column stretches (min-height / fullscreen).
		if ( ! preg_match( '/class="bm-hero__content"[^>]*justify-content\s*:\s*center/i', $out ) ) {
			$vcenter = preg_replace(
				'/(class="bm-hero__content" style="display:flex;flex-direction:column;)(?!justify-content)/i',
				'$1justify-content:center;',
				$out
			);
			if ( is_string( $vcenter ) ) {
				$out = $vcenter;
			}
		}

		$content_ready = (bool) preg_match(
			'/class="bm-hero__content"[^>]*--bm-align-items[^>]*--bm-text-align|class="bm-hero__content"[^>]*--bm-text-align[^>]*--bm-align-items/',
			$out
		);
		if ( $content_ready ) {
			return $out;
		}

		$patched = preg_replace(
			'/(class="bm-hero__content" style="[^"]*?)align-items:\s*flex-start([^"]*?)text-align:\s*left/i',
			'$1align-items:var(--bm-align-items, flex-start)$2text-align:var(--bm-text-align, left)',
			$out,
			1
		);

		return is_string( $patched ) ? $patched : $out;
	}

	/**
	 * Wire media-frame CSS vars into two-column hero markup.
	 *
	 * @param string $markup Template markup.
	 * @return string
	 */
	private static function patch_hero_media_frame_markup( string $markup ): string {
		$out = $markup;

		if ( false === strpos( $out, '--bm-mf-grid-align' ) ) {
			$patched_grid = preg_replace(
				'/(class="bm-hero__grid"[^>]*?\balign-items:)\s*center\b/i',
				'$1var(--bm-mf-grid-align, center)',
				$out,
				1
			);
			if ( is_string( $patched_grid ) ) {
				$out = $patched_grid;
			}
		}

		$patched_media = preg_replace_callback(
			'/<div class="bm-hero__media"(?: style="([^"]*)")?>/',
			static function ( array $m ): string {
				$style = isset( $m[1] ) ? (string) $m[1] : '';
				if ( false === strpos( $style, '--bm-mf-media-width' ) ) {
					if ( preg_match( '/(^|;)\s*width:\s*100%\s*(;|$)/i', $style ) ) {
						$style = preg_replace(
							'/(^|;)\s*width:\s*100%\s*(;|$)/i',
							'$1width:var(--bm-mf-media-width, 100%)$2',
							$style,
							1
						);
					} elseif ( false === strpos( $style, 'width:' ) ) {
						$style .= ';width:var(--bm-mf-media-width, 100%)';
					}
				}
				if ( false === strpos( $style, '--bm-mf-media-max-width' ) ) {
					$style .= ';max-width:var(--bm-mf-media-max-width, 100%)';
				}
				if ( false === strpos( $style, '--bm-mf-media-height' ) ) {
					$style .= ';height:var(--bm-mf-media-height, auto)';
				}
				if ( false === strpos( $style, '--bm-mf-media-align-self' ) ) {
					$style .= ';align-self:var(--bm-mf-media-align-self, auto)';
				}
				if ( false === strpos( $style, '--bm-mf-media-align-items' ) ) {
					if ( preg_match( '/align-items:\s*[^;]+/', $style ) ) {
						$style = preg_replace(
							'/align-items:\s*[^;]+/',
							'align-items:var(--bm-mf-media-align-items, var(--bm-align-items, flex-start))',
							$style,
							1
						);
					} else {
						$style .= ';align-items:var(--bm-mf-media-align-items, var(--bm-align-items, flex-start))';
					}
				}
				if ( false === strpos( $style, '--bm-mf-margin-left' ) ) {
					$style .= ';margin-left:var(--bm-mf-margin-left, 0);margin-right:var(--bm-mf-margin-right, 0)';
				}
				if ( false === strpos( $style, 'box-sizing:' ) ) {
					$style .= ';box-sizing:border-box';
				}
				if ( false === strpos( $style, 'display:' ) ) {
					$style .= ';display:flex;flex-direction:column';
				}
				$style = trim( (string) $style, ';' );
				return '<div class="bm-hero__media" style="' . $style . '">';
			},
			$out
		);
		if ( is_string( $patched_media ) ) {
			$out = $patched_media;
		}

		return $out;
	}

	/**
	 * Ensure style schema includes the columns stack-breakpoint control.
	 *
	 * @param array<int, mixed> $user_schema User style schema groups.
	 * @param array<int, mixed> $lib_schema  Library style schema groups.
	 * @return array<int, mixed>
	 */
	private static function merge_columns_breakpoint_style_field( array $user_schema, array $lib_schema ): array {
		$help  = 'One setting for all viewports. Below this screen width, the image and text stack into one column. Defaults to tablet.';
		$field = self::find_style_schema_field( $lib_schema, '--bm-columns-breakpoint' );
		if ( ! is_array( $field ) ) {
			$field = array(
				'key'    => '--bm-columns-breakpoint',
				'label'  => 'Stack columns below',
				'type'   => 'columnsBreakpoint',
				'shared' => true,
				'help'   => $help,
			);
		} else {
			$field['help']   = $help;
			$field['shared'] = true;
			$field['type']   = 'columnsBreakpoint';
			unset( $field['desktopOnly'] );
		}

		if ( self::style_schema_has_key( $user_schema, '--bm-columns-breakpoint' ) ) {
			foreach ( $user_schema as $i => $group ) {
				if ( ! is_array( $group ) || empty( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
					continue;
				}
				foreach ( $group['fields'] as $j => $existing ) {
					if ( is_array( $existing ) && ( $existing['key'] ?? '' ) === '--bm-columns-breakpoint' ) {
						$user_schema[ $i ]['fields'][ $j ]['help']   = $help;
						$user_schema[ $i ]['fields'][ $j ]['shared'] = true;
						$user_schema[ $i ]['fields'][ $j ]['type']   = 'columnsBreakpoint';
						unset( $user_schema[ $i ]['fields'][ $j ]['desktopOnly'] );
						return $user_schema;
					}
				}
			}
			return $user_schema;
		}

		foreach ( $user_schema as $i => $group ) {
			if ( ! is_array( $group ) ) {
				continue;
			}
			if ( 'Layout' !== (string) ( $group['group'] ?? '' ) ) {
				continue;
			}
			$fields = isset( $group['fields'] ) && is_array( $group['fields'] ) ? $group['fields'] : array();
			$fields[] = $field;
			$user_schema[ $i ]['fields'] = $fields;
			return $user_schema;
		}

		return self::insert_layout_group_after_background( $user_schema, array( $field ) );
	}

	/**
	 * Ensure style schema includes the stacked image/text order control.
	 *
	 * @param array<int, mixed> $user_schema User style schema groups.
	 * @param array<int, mixed> $lib_schema  Library style schema groups.
	 * @return array<int, mixed>
	 */
	private static function merge_columns_stack_order_style_field( array $user_schema, array $lib_schema ): array {
		$help  = 'One setting for all viewports. When columns stack, swap the image and text so they appear in the opposite order.';
		$field = self::find_style_schema_field( $lib_schema, '--bm-columns-stack-order' );
		if ( ! is_array( $field ) ) {
			$field = array(
				'key'    => '--bm-columns-stack-order',
				'label'  => 'Reverse stacked order',
				'type'   => 'columnsStackOrder',
				'shared' => true,
				'help'   => $help,
			);
		} else {
			$field['label']  = 'Reverse stacked order';
			$field['help']   = $help;
			$field['shared'] = true;
			$field['type']   = 'columnsStackOrder';
			unset( $field['desktopOnly'] );
		}

		if ( self::style_schema_has_key( $user_schema, '--bm-columns-stack-order' ) ) {
			foreach ( $user_schema as $i => $group ) {
				if ( ! is_array( $group ) || empty( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
					continue;
				}
				foreach ( $group['fields'] as $j => $existing ) {
					if ( is_array( $existing ) && ( $existing['key'] ?? '' ) === '--bm-columns-stack-order' ) {
						$user_schema[ $i ]['fields'][ $j ]['label']  = 'Reverse stacked order';
						$user_schema[ $i ]['fields'][ $j ]['help']   = $help;
						$user_schema[ $i ]['fields'][ $j ]['shared'] = true;
						$user_schema[ $i ]['fields'][ $j ]['type']   = 'columnsStackOrder';
						unset( $user_schema[ $i ]['fields'][ $j ]['desktopOnly'] );
						return $user_schema;
					}
				}
			}
			return $user_schema;
		}

		foreach ( $user_schema as $i => $group ) {
			if ( ! is_array( $group ) ) {
				continue;
			}
			if ( 'Layout' !== (string) ( $group['group'] ?? '' ) ) {
				continue;
			}
			$fields   = isset( $group['fields'] ) && is_array( $group['fields'] ) ? $group['fields'] : array();
			$fields[] = $field;
			$user_schema[ $i ]['fields'] = $fields;
			return $user_schema;
		}

		return self::insert_layout_group_after_background( $user_schema, array( $field ) );
	}

	/**
	 * Ensure style schema includes content alignment for two-column heroes.
	 *
	 * @param array<int, mixed> $user_schema User style schema groups.
	 * @param array<int, mixed> $lib_schema  Library style schema groups.
	 * @return array<int, mixed>
	 */
	private static function merge_columns_content_align_style_field( array $user_schema, array $lib_schema ): array {
		$help  = 'Align text and media inside the content stack left, center, or right. Does not move the container. Video stays centered inside its frame (not cropped). Set separately for Desktop, Tablet, and Mobile.';
		$field = self::find_style_schema_field( $lib_schema, '--bm-content-align' );
		if ( ! is_array( $field ) ) {
			$field = array(
				'key'     => '--bm-content-align',
				'label'   => 'Content alignment',
				'type'    => 'contentAlign',
				'default' => 'left',
				'help'    => $help,
			);
		} else {
			$field['help']    = $help;
			$field['default'] = 'left';
			$field['type']    = 'contentAlign';
			unset( $field['desktopOnly'], $field['visibleBreakpoints'] );
		}

		if ( self::style_schema_has_key( $user_schema, '--bm-content-align' ) ) {
			foreach ( $user_schema as $i => $group ) {
				if ( ! is_array( $group ) || empty( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
					continue;
				}
				foreach ( $group['fields'] as $j => $existing ) {
					if ( is_array( $existing ) && ( $existing['key'] ?? '' ) === '--bm-content-align' ) {
						$user_schema[ $i ]['fields'][ $j ]['help']    = $help;
						$user_schema[ $i ]['fields'][ $j ]['default'] = 'left';
						$user_schema[ $i ]['fields'][ $j ]['type']    = 'contentAlign';
						unset(
							$user_schema[ $i ]['fields'][ $j ]['desktopOnly'],
							$user_schema[ $i ]['fields'][ $j ]['visibleBreakpoints']
						);
						return $user_schema;
					}
				}
			}
			return $user_schema;
		}

		foreach ( $user_schema as $i => $group ) {
			if ( ! is_array( $group ) ) {
				continue;
			}
			if ( 'Layout' !== (string) ( $group['group'] ?? '' ) ) {
				continue;
			}
			$fields   = isset( $group['fields'] ) && is_array( $group['fields'] ) ? $group['fields'] : array();
			array_unshift( $fields, $field );
			$user_schema[ $i ]['fields'] = $fields;
			return $user_schema;
		}

		return self::insert_layout_group_after_background( $user_schema, array( $field ) );
	}

	/**
	 * Ensure style schema includes container alignment (separate from content alignment).
	 *
	 * @param array<int, mixed> $user_schema User style schema groups.
	 * @param array<int, mixed> $lib_schema  Library style schema groups.
	 * @return array<int, mixed>
	 */
	private static function merge_container_align_style_field( array $user_schema, array $lib_schema ): array {
		$help  = 'Position the content container left, center, or right within the section. Set separately for Desktop, Tablet, and Mobile.';
		$field = self::find_style_schema_field( $lib_schema, '--bm-container-align' );
		if ( ! is_array( $field ) ) {
			$field = array(
				'key'     => '--bm-container-align',
				'label'   => 'Container alignment',
				'type'    => 'containerAlign',
				'default' => 'center',
				'help'    => $help,
			);
		} else {
			$field['help']    = $help;
			$field['default'] = 'center';
			$field['type']    = 'containerAlign';
			$field['label']   = 'Container alignment';
			unset( $field['desktopOnly'], $field['visibleBreakpoints'] );
		}

		if ( self::style_schema_has_key( $user_schema, '--bm-container-align' ) ) {
			foreach ( $user_schema as $i => $group ) {
				if ( ! is_array( $group ) || empty( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
					continue;
				}
				foreach ( $group['fields'] as $j => $existing ) {
					if ( is_array( $existing ) && ( $existing['key'] ?? '' ) === '--bm-container-align' ) {
						$user_schema[ $i ]['fields'][ $j ]['help']    = $help;
						$user_schema[ $i ]['fields'][ $j ]['default'] = 'center';
						$user_schema[ $i ]['fields'][ $j ]['type']    = 'containerAlign';
						$user_schema[ $i ]['fields'][ $j ]['label']   = 'Container alignment';
						unset(
							$user_schema[ $i ]['fields'][ $j ]['desktopOnly'],
							$user_schema[ $i ]['fields'][ $j ]['visibleBreakpoints']
						);
						return $user_schema;
					}
				}
			}
			return $user_schema;
		}

		foreach ( $user_schema as $i => $group ) {
			if ( ! is_array( $group ) ) {
				continue;
			}
			if ( 'Layout' !== (string) ( $group['group'] ?? '' ) ) {
				continue;
			}
			$fields   = isset( $group['fields'] ) && is_array( $group['fields'] ) ? $group['fields'] : array();
			$inserted = false;
			$next     = array();
			foreach ( $fields as $existing ) {
				if (
					! $inserted
					&& is_array( $existing )
					&& in_array( (string) ( $existing['key'] ?? '' ), array( '--bm-content-align', '--bm-content-max-width' ), true )
				) {
					$next[]   = $field;
					$inserted = true;
				}
				$next[] = $existing;
			}
			if ( ! $inserted ) {
				array_unshift( $next, $field );
			}
			$user_schema[ $i ]['fields'] = $next;
			return $user_schema;
		}

		return self::insert_layout_group_after_background( $user_schema, array( $field ) );
	}

	/**
	 * Remove the framed/fullscreen control from Layout (it lives on Image styles).
	 *
	 * @param array<int, mixed> $user_schema User style schema groups.
	 * @param array<int, mixed> $lib_schema  Library style schema groups (unused).
	 * @return array<int, mixed>
	 */
	private static function merge_media_frame_style_field( array $user_schema, array $lib_schema ): array {
		unset( $lib_schema );
		foreach ( $user_schema as $i => $group ) {
			if ( ! is_array( $group ) || empty( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
				continue;
			}
			$fields = array();
			$changed = false;
			foreach ( $group['fields'] as $existing ) {
				if ( is_array( $existing ) && ( $existing['key'] ?? '' ) === '--bm-media-frame' ) {
					$changed = true;
					continue;
				}
				$fields[] = $existing;
			}
			if ( $changed ) {
				$user_schema[ $i ]['fields'] = $fields;
			}
		}
		return $user_schema;
	}

	/**
	 * Replace hardcoded hero section min-height with the CSS variable,
	 * and vertically center content when the section is taller than its content.
	 *
	 * @param string $markup Template markup.
	 * @return string
	 */
	private static function patch_hero_min_height_markup( string $markup ): string {
		$out = $markup;

		if ( false === strpos( $out, '--bm-min-height' ) ) {
			// Convert an existing hardcoded min-height on the hero section.
			$patched = preg_replace(
				'/(<section\b[^>]*\bclass="[^"]*\bbm-hero\b[^"]*"[^>]*?\bmin-height:)(?!var\()([^;"\s]+)/i',
				'$1var(--bm-min-height, $2)',
				$out
			);
			if ( is_string( $patched ) && $patched !== $out ) {
				$out = $patched;
			} else {
				// Insert min-height after color:… on the section style.
				$patched = preg_replace(
					'/(<section\b[^>]*\bclass="[^"]*\bbm-hero\b[^"]*"[^>]*?\bstyle="[^"]*?)(color:[^;"]+;)/i',
					'$1$2min-height:var(--bm-min-height, 0);',
					$out
				);
				if ( is_string( $patched ) ) {
					$out = $patched;
				}
			}
		}

		return self::patch_hero_section_vcenter_markup( $out );
	}

	/**
	 * Vertically center hero section content (needed when min-height > content).
	 *
	 * @param string $markup Template markup.
	 * @return string
	 */
	private static function patch_hero_section_vcenter_markup( string $markup ): string {
		// Already using column + justify-content center.
		if (
			preg_match(
				'/<section\b[^>]*\bclass="[^"]*\bbm-hero\b[^"]*"[^>]*\bstyle="[^"]*\bflex-direction\s*:\s*column[^"]*\bjustify-content\s*:\s*center/i',
				$markup
			)
		) {
			return $markup;
		}

		// Video-style: display:flex;align-items:center → column + justify-content.
		$patched = preg_replace(
			'/(<section\b[^>]*\bclass="[^"]*\bbm-hero\b[^"]*"[^>]*\bstyle="[^"]*?)\bdisplay\s*:\s*flex\s*;\s*align-items\s*:\s*center\b/i',
			'$1display:flex;flex-direction:column;justify-content:center;box-sizing:border-box',
			$markup,
			1
		);
		if ( is_string( $patched ) && $patched !== $markup ) {
			return $patched;
		}

		// Insert flex centering after overflow:hidden; (or at start of style).
		$patched = preg_replace(
			'/(<section\b[^>]*\bclass="[^"]*\bbm-hero\b[^"]*"[^>]*\bstyle="[^"]*?overflow\s*:\s*hidden\s*;)/i',
			'$1display:flex;flex-direction:column;justify-content:center;box-sizing:border-box;',
			$markup,
			1
		);

		return is_string( $patched ) ? $patched : $markup;
	}

	/**
	 * Read a hardcoded section min-height before migration (e.g. video 28rem).
	 *
	 * @param string $markup Template markup.
	 * @return string
	 */
	private static function extract_hero_section_min_height( string $markup ): string {
		if ( preg_match(
			'/<section\b[^>]*\bclass="[^"]*\bbm-hero\b[^"]*"[^>]*?\bmin-height:(?!var\()([^;"\s]+)/i',
			$markup,
			$m
		) ) {
			return trim( (string) $m[1] );
		}
		return '';
	}

	/**
	 * Ensure style schema includes the section min-height control.
	 *
	 * @param array<int, mixed> $user_schema User style schema groups.
	 * @param array<int, mixed> $lib_schema  Library style schema groups.
	 * @return array<int, mixed>
	 */
	private static function merge_min_height_style_field( array $user_schema, array $lib_schema ): array {
		$help  = 'Minimum height of the hero section. Choose None to size to content, or set a custom value with units.';
		$field = self::find_style_schema_field( $lib_schema, '--bm-min-height' );
		if ( ! is_array( $field ) ) {
			$field = array(
				'key'   => '--bm-min-height',
				'label' => 'Min height',
				'type'  => 'minHeight',
				'help'  => $help,
			);
		} else {
			$field['help']  = $help;
			$field['type']  = 'minHeight';
			$field['label'] = 'Min height';
		}

		if ( self::style_schema_has_key( $user_schema, '--bm-min-height' ) ) {
			foreach ( $user_schema as $i => $group ) {
				if ( ! is_array( $group ) || empty( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
					continue;
				}
				foreach ( $group['fields'] as $j => $existing ) {
					if ( is_array( $existing ) && ( $existing['key'] ?? '' ) === '--bm-min-height' ) {
						$user_schema[ $i ]['fields'][ $j ]['help']  = $help;
						$user_schema[ $i ]['fields'][ $j ]['type']  = 'minHeight';
						$user_schema[ $i ]['fields'][ $j ]['label'] = 'Min height';
						return $user_schema;
					}
				}
			}
			return $user_schema;
		}

		foreach ( $user_schema as $i => $group ) {
			if ( ! is_array( $group ) ) {
				continue;
			}
			if ( 'Layout' !== (string) ( $group['group'] ?? '' ) ) {
				continue;
			}
			$fields   = isset( $group['fields'] ) && is_array( $group['fields'] ) ? $group['fields'] : array();
			$inserted = false;
			$next     = array();
			foreach ( $fields as $existing ) {
				$next[] = $existing;
				if ( is_array( $existing ) && ( $existing['key'] ?? '' ) === '--bm-content-max-width' ) {
					$next[]   = $field;
					$inserted = true;
				}
			}
			if ( ! $inserted ) {
				$next[] = $field;
			}
			$user_schema[ $i ]['fields'] = $next;
			return $user_schema;
		}

		return self::insert_layout_group_after_background( $user_schema, array( $field ) );
	}

	/**
	 * Replace hardcoded hero container max-width with the CSS variable.
	 *
	 * @param string $markup Template markup.
	 * @return string
	 */
	private static function patch_hero_content_max_width_markup( string $markup ): string {
		$patched = preg_replace_callback(
			'/(class="bm-hero__(?:inner|grid)"[^>]*?\bmax-width:)(?!var\()([^;"\s]+)/i',
			static function ( array $m ): string {
				return $m[1] . 'var(--bm-content-max-width, ' . $m[2] . ')';
			},
			$markup
		);

		return is_string( $patched ) ? $patched : $markup;
	}

	/**
	 * Ensure style schema includes the content max-width control.
	 *
	 * @param array<int, mixed> $user_schema User style schema groups.
	 * @param array<int, mixed> $lib_schema  Library style schema groups.
	 * @return array<int, mixed>
	 */
	private static function merge_content_max_width_style_field( array $user_schema, array $lib_schema ): array {
		$help = 'How wide the content inside this hero can stretch. Choose Full width to use the whole section.';
		$field = self::find_style_schema_field( $lib_schema, '--bm-content-max-width' );
		if ( ! is_array( $field ) ) {
			$field = array(
				'key'   => '--bm-content-max-width',
				'label' => 'Content max width',
				'type'  => 'contentMaxWidth',
				'help'  => $help,
			);
		} else {
			$field['help'] = $help;
		}

		if ( self::style_schema_has_key( $user_schema, '--bm-content-max-width' ) ) {
			foreach ( $user_schema as $i => $group ) {
				if ( ! is_array( $group ) || empty( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
					continue;
				}
				foreach ( $group['fields'] as $j => $existing ) {
					if ( is_array( $existing ) && ( $existing['key'] ?? '' ) === '--bm-content-max-width' ) {
						$user_schema[ $i ]['fields'][ $j ]['help'] = $help;
						return $user_schema;
					}
				}
			}
			return $user_schema;
		}

		foreach ( $user_schema as $i => $group ) {
			if ( ! is_array( $group ) ) {
				continue;
			}
			$group_name = (string) ( $group['group'] ?? '' );
			if ( 'Layout' !== $group_name ) {
				continue;
			}
			$fields = isset( $group['fields'] ) && is_array( $group['fields'] ) ? $group['fields'] : array();
			$fields[] = $field;
			$user_schema[ $i ]['fields'] = $fields;
			return $user_schema;
		}

		return self::insert_layout_group_after_background( $user_schema, array( $field ) );
	}

	/**
	 * Insert a Layout group after Background (so Background stays first).
	 *
	 * @param array<int, mixed> $user_schema User style schema groups.
	 * @param array<int, mixed> $fields      Fields for the new Layout group.
	 * @return array<int, mixed>
	 */
	private static function insert_layout_group_after_background( array $user_schema, array $fields ): array {
		$layout = array(
			'group'  => 'Layout',
			'fields' => $fields,
		);

		foreach ( $user_schema as $i => $group ) {
			if ( ! is_array( $group ) ) {
				continue;
			}
			if ( 'Background' !== (string) ( $group['group'] ?? '' ) ) {
				continue;
			}
			array_splice( $user_schema, $i + 1, 0, array( $layout ) );
			return $user_schema;
		}

		$user_schema[] = $layout;
		return $user_schema;
	}

	/**
	 * Move Layout group to sit directly after Background.
	 *
	 * @param array<int, mixed> $schema Style schema groups.
	 * @return array<int, mixed>
	 */
	private static function reorder_layout_after_background( array $schema ): array {
		$layout = null;
		$rest   = array();
		foreach ( $schema as $group ) {
			if ( is_array( $group ) && 'Layout' === (string) ( $group['group'] ?? '' ) ) {
				$layout = $group;
				continue;
			}
			$rest[] = $group;
		}
		if ( null === $layout ) {
			return $schema;
		}

		$out      = array();
		$inserted = false;
		foreach ( $rest as $group ) {
			$out[] = $group;
			if ( ! $inserted && is_array( $group ) && 'Background' === (string) ( $group['group'] ?? '' ) ) {
				$out[]    = $layout;
				$inserted = true;
			}
		}
		if ( ! $inserted ) {
			$out[] = $layout;
		}

		return $out;
	}

	/**
	 * Wire Surface boxBorder (+ box-shadow) on existing card My Block snapshots.
	 * Legacy markup hardcodes `border:1px solid`, so width/style/sides never previewed.
	 */
	private static function migrate_card_surface_border(): void {
		global $wpdb;

		$user   = self::table( 'user_blocks' );
		$system = self::table( 'system_blocks' );
		if ( ! $user || ! $system ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $exists !== $user ) {
			return;
		}

		$sys_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $system ) );
		if ( $sys_exists === $system ) {
			$sys_rows = $wpdb->get_results(
				$wpdb->prepare(
					'SELECT id, markup FROM %i WHERE markup LIKE %s',
					$system,
					'%bm-card%'
				),
				ARRAY_A
			);
			foreach ( $sys_rows ?: array() as $row ) {
				$markup = (string) ( $row['markup'] ?? '' );
				$next   = MarkupInterpolator::upgrade_card_border_markup( $markup );
				if ( $next === $markup ) {
					continue;
				}
				$wpdb->update(
					$system,
					array( 'markup' => $next ),
					array( 'id' => (int) $row['id'] ),
					array( '%s' ),
					array( '%d' )
				);
			}
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT u.id, u.slug, u.markup, u.style_schema, u.css_vars, u.default_attrs,
				s.style_schema AS library_style_schema,
				s.css_vars AS library_css_vars
			FROM %i u
			INNER JOIN %i s ON s.id = u.system_block_id
			WHERE u.markup LIKE %s",
				$user,
				$system,
				'%bm-card%'
			),
			ARRAY_A
		);

		$border_keys = array(
			'--bm-card-columns',
			'--bm-card-border-width',
			'--bm-card-border-style',
			'--bm-card-border-color',
			'--bm-card-border-top-width',
			'--bm-card-border-right-width',
			'--bm-card-border-bottom-width',
			'--bm-card-border-left-width',
			'--bm-card-shadow',
			'--bm-card-shadow-preset',
			'--bm-card-shadow-color',
		);

		foreach ( $rows ?: array() as $row ) {
			$markup      = (string) ( $row['markup'] ?? '' );
			$next_markup = MarkupInterpolator::upgrade_card_border_markup( $markup );

			$css_vars = CssVars::normalize( json_decode( $row['css_vars'] ?? '{}', true ) ?: array() );
			$lib_css  = CssVars::normalize( json_decode( $row['library_css_vars'] ?? '{}', true ) ?: array() );
			foreach ( $border_keys as $key ) {
				$cur = isset( $css_vars['desktop'][ $key ] )
					? trim( (string) $css_vars['desktop'][ $key ] )
					: '';
				if ( '' !== $cur ) {
					continue;
				}
				$fallback = $lib_css['desktop'][ $key ] ?? '';
				if ( '' === trim( (string) $fallback ) ) {
					continue;
				}
				$css_vars['desktop'][ $key ] = (string) $fallback;
			}

			$update = array(
				'markup'   => $next_markup,
				'css_vars' => wp_json_encode( $css_vars ),
			);

			$raw_style = (string) ( $row['style_schema'] ?? '' );
			if ( '' !== trim( $raw_style ) ) {
				$user_style = json_decode( $raw_style, true );
				$lib_style  = json_decode( $row['library_style_schema'] ?? '[]', true );
				if ( ! is_array( $user_style ) ) {
					$user_style = array();
				}
				if ( ! is_array( $lib_style ) ) {
					$lib_style = array();
				}
				$update['style_schema'] = wp_json_encode(
					self::merge_card_surface_style_fields( $user_style, $lib_style )
				);
			}

			$slug = (string) ( $row['slug'] ?? '' );
			if ( $slug ) {
				$default_attrs = json_decode( (string) ( $row['default_attrs'] ?? '{}' ), true ) ?: array();
				$css_file      = CssWriter::write_user_block(
					(int) $row['id'],
					$slug,
					$css_vars,
					is_array( $default_attrs ) ? $default_attrs : array()
				);
				if ( $css_file ) {
					$update['css_file'] = $css_file;
				}
			}

			$wpdb->update(
				$user,
				$update,
				array( 'id' => (int) $row['id'] ),
				array_fill( 0, count( $update ), '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Add Content alignment to Card Surface on existing card snapshots.
	 */
	private static function migrate_card_content_align(): void {
		global $wpdb;

		$user   = self::table( 'user_blocks' );
		$system = self::table( 'system_blocks' );
		if ( ! $user || ! $system ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $exists !== $user ) {
			return;
		}

		$sys_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $system ) );
		if ( $sys_exists === $system ) {
			$sys_rows = $wpdb->get_results(
				$wpdb->prepare(
					'SELECT id, markup FROM %i WHERE markup LIKE %s',
					$system,
					'%bm-card__body%'
				),
				ARRAY_A
			);
			foreach ( $sys_rows ?: array() as $row ) {
				$markup = (string) ( $row['markup'] ?? '' );
				$next   = MarkupInterpolator::upgrade_card_content_align_markup( $markup );
				if ( $next === $markup ) {
					continue;
				}
				$wpdb->update(
					$system,
					array( 'markup' => $next ),
					array( 'id' => (int) $row['id'] ),
					array( '%s' ),
					array( '%d' )
				);
			}
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT u.id, u.slug, u.markup, u.style_schema, u.css_vars, u.default_attrs,
				s.style_schema AS library_style_schema,
				s.css_vars AS library_css_vars
			FROM %i u
			INNER JOIN %i s ON s.id = u.system_block_id
			WHERE u.markup LIKE %s",
				$user,
				$system,
				'%bm-card__body%'
			),
			ARRAY_A
		);

		$align_keys = array(
			'--bm-content-align'    => 'left',
			'--bm-text-align'       => 'left',
			'--bm-align-items'      => 'flex-start',
			'--bm-justify-content'  => 'flex-start',
		);

		foreach ( $rows ?: array() as $row ) {
			$markup      = (string) ( $row['markup'] ?? '' );
			$next_markup = MarkupInterpolator::upgrade_card_content_align_markup( $markup );

			$css_vars = CssVars::normalize( json_decode( $row['css_vars'] ?? '{}', true ) ?: array() );
			$lib_css  = CssVars::normalize( json_decode( $row['library_css_vars'] ?? '{}', true ) ?: array() );
			foreach ( $align_keys as $key => $fallback_default ) {
				$cur = isset( $css_vars['desktop'][ $key ] )
					? trim( (string) $css_vars['desktop'][ $key ] )
					: '';
				if ( '' !== $cur ) {
					continue;
				}
				$fallback = trim( (string) ( $lib_css['desktop'][ $key ] ?? '' ) );
				$css_vars['desktop'][ $key ] = '' !== $fallback ? $fallback : $fallback_default;
			}

			$update = array(
				'markup'   => $next_markup,
				'css_vars' => wp_json_encode( $css_vars ),
			);

			$raw_style = (string) ( $row['style_schema'] ?? '' );
			if ( '' !== trim( $raw_style ) ) {
				$user_style = json_decode( $raw_style, true );
				$lib_style  = json_decode( $row['library_style_schema'] ?? '[]', true );
				if ( ! is_array( $user_style ) ) {
					$user_style = array();
				}
				if ( ! is_array( $lib_style ) ) {
					$lib_style = array();
				}
				$update['style_schema'] = wp_json_encode(
					self::merge_card_surface_style_fields( $user_style, $lib_style )
				);
			}

			$slug = (string) ( $row['slug'] ?? '' );
			if ( $slug ) {
				$default_attrs = json_decode( (string) ( $row['default_attrs'] ?? '{}' ), true ) ?: array();
				$css_file      = CssWriter::write_user_block(
					(int) $row['id'],
					$slug,
					$css_vars,
					is_array( $default_attrs ) ? $default_attrs : array()
				);
				if ( $css_file ) {
					$update['css_file'] = $css_file;
				}
			}

			$wpdb->update(
				$user,
				$update,
				array( 'id' => (int) $row['id'] ),
				array_fill( 0, count( $update ), '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Add icon padding / border / radius / shadow style hooks on existing card icon wrappers.
	 */
	private static function migrate_card_icon_surface(): void {
		global $wpdb;

		$user   = self::table( 'user_blocks' );
		$system = self::table( 'system_blocks' );
		if ( ! $user || ! $system ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $exists !== $user ) {
			return;
		}

		$sys_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $system ) );
		if ( $sys_exists === $system ) {
			$sys_rows = $wpdb->get_results(
				$wpdb->prepare(
					'SELECT id, markup FROM %i WHERE markup LIKE %s',
					$system,
					'%bm-card__icon-wrapper%'
				),
				ARRAY_A
			);
			foreach ( $sys_rows ?: array() as $row ) {
				$markup = (string) ( $row['markup'] ?? '' );
				$next   = MarkupInterpolator::upgrade_card_icon_surface_markup( $markup );
				if ( $next === $markup ) {
					continue;
				}
				$wpdb->update(
					$system,
					array( 'markup' => $next ),
					array( 'id' => (int) $row['id'] ),
					array( '%s' ),
					array( '%d' )
				);
			}
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT id, markup FROM %i WHERE markup LIKE %s',
				$user,
				'%bm-card__icon-wrapper%'
			),
			ARRAY_A
		);

		foreach ( $rows ?: array() as $row ) {
			$markup = (string) ( $row['markup'] ?? '' );
			$next   = MarkupInterpolator::upgrade_card_icon_surface_markup( $markup );
			if ( $next === $markup ) {
				continue;
			}
			$wpdb->update(
				$user,
				array( 'markup' => $next ),
				array( 'id' => (int) $row['id'] ),
				array( '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Add Align buttons at end to Card Surface + pin-ready markup on existing cards.
	 */
	private static function migrate_card_buttons_align_end(): void {
		global $wpdb;

		$user   = self::table( 'user_blocks' );
		$system = self::table( 'system_blocks' );
		if ( ! $user || ! $system ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $exists !== $user ) {
			return;
		}

		$sys_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $system ) );
		if ( $sys_exists === $system ) {
			$sys_rows = $wpdb->get_results(
				$wpdb->prepare(
					'SELECT id, markup FROM %i WHERE markup LIKE %s',
					$system,
					'%bm-card__buttons%'
				),
				ARRAY_A
			);
			foreach ( $sys_rows ?: array() as $row ) {
				$markup = (string) ( $row['markup'] ?? '' );
				$next   = MarkupInterpolator::upgrade_card_buttons_align_markup( $markup );
				if ( $next === $markup ) {
					continue;
				}
				$wpdb->update(
					$system,
					array( 'markup' => $next ),
					array( 'id' => (int) $row['id'] ),
					array( '%s' ),
					array( '%d' )
				);
			}
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT u.id, u.slug, u.markup, u.style_schema, u.css_vars, u.default_attrs,
				s.style_schema AS library_style_schema,
				s.css_vars AS library_css_vars
			FROM %i u
			INNER JOIN %i s ON s.id = u.system_block_id
			WHERE u.markup LIKE %s OR u.markup LIKE %s",
				$user,
				$system,
				'%bm-card__buttons%',
				'%bm-pricing__button%'
			),
			ARRAY_A
		);

		foreach ( $rows ?: array() as $row ) {
			$markup      = (string) ( $row['markup'] ?? '' );
			$next_markup = MarkupInterpolator::upgrade_card_buttons_align_markup( $markup );

			$css_vars = CssVars::normalize( json_decode( $row['css_vars'] ?? '{}', true ) ?: array() );
			$lib_css  = CssVars::normalize( json_decode( $row['library_css_vars'] ?? '{}', true ) ?: array() );
			foreach ( array(
				'--bm-buttons-align-end',
				'--bm-buttons-pin-top',
				'--bm-buttons-flex-order',
				'--bm-features-flex',
				'--bm-card-display',
				'--bm-card-height',
				'--bm-card-body-display',
				'--bm-card-body-flex',
			) as $key ) {
				$cur = isset( $css_vars['desktop'][ $key ] )
					? trim( (string) $css_vars['desktop'][ $key ] )
					: '';
				if ( '' !== $cur ) {
					continue;
				}
				$fallback = trim( (string) ( $lib_css['desktop'][ $key ] ?? '' ) );
				if ( '--bm-buttons-align-end' === $key ) {
					$css_vars['desktop'][ $key ] = '' !== $fallback ? $fallback : '0';
					continue;
				}
				if ( '--bm-card-display' === $key || '--bm-card-body-display' === $key ) {
					$css_vars['desktop'][ $key ] = '' !== $fallback ? $fallback : 'block';
					continue;
				}
				if ( '--bm-card-height' === $key ) {
					$css_vars['desktop'][ $key ] = '' !== $fallback ? $fallback : 'auto';
					continue;
				}
				if ( '--bm-card-body-flex' === $key ) {
					$css_vars['desktop'][ $key ] = '' !== $fallback ? $fallback : 'none';
					continue;
				}
				$css_vars['desktop'][ $key ] = $fallback;
			}

			// If pin-to-end is already on, ensure companion layout vars are active.
			// Never use height:100% — it breaks CSS Grid row sizing on the frontend.
			$align_end = strtolower(
				trim( (string) ( $css_vars['desktop']['--bm-buttons-align-end'] ?? '' ) )
			);
			if ( in_array( $align_end, array( '1', 'true', 'yes', 'on' ), true ) ) {
				$css_vars['desktop']['--bm-buttons-pin-top']    = 'auto';
				$css_vars['desktop']['--bm-buttons-flex-order'] = '999';
				$css_vars['desktop']['--bm-features-flex']      = '1 1 auto';
				$css_vars['desktop']['--bm-card-display']       = 'flex';
				$css_vars['desktop']['--bm-card-height']        = 'auto';
				$css_vars['desktop']['--bm-card-body-display']  = 'flex';
				$css_vars['desktop']['--bm-card-body-flex']     = '1 1 auto';
			} else {
				// Off means the stack sits at its natural height; a growing
				// feature list would keep the CTA glued to the card bottom.
				$css_vars['desktop']['--bm-buttons-pin-top']    = '';
				$css_vars['desktop']['--bm-buttons-flex-order'] = '999';
				$css_vars['desktop']['--bm-features-flex']      = '0 0 auto';
			}

			foreach ( array( 'desktop', 'tablet', 'mobile' ) as $bp ) {
				if ( empty( $css_vars[ $bp ] ) || ! is_array( $css_vars[ $bp ] ) ) {
					continue;
				}
				$h = isset( $css_vars[ $bp ]['--bm-card-height'] )
					? trim( (string) $css_vars[ $bp ]['--bm-card-height'] )
					: '';
				if ( '100%' === $h ) {
					$css_vars[ $bp ]['--bm-card-height'] = 'auto';
				}
			}

			$update = array(
				'markup'   => $next_markup,
				'css_vars' => wp_json_encode( $css_vars ),
			);

			$raw_style = (string) ( $row['style_schema'] ?? '' );
			if ( '' !== trim( $raw_style ) ) {
				$user_style = json_decode( $raw_style, true );
				$lib_style  = json_decode( $row['library_style_schema'] ?? '[]', true );
				if ( ! is_array( $user_style ) ) {
					$user_style = array();
				}
				if ( ! is_array( $lib_style ) ) {
					$lib_style = array();
				}
				$update['style_schema'] = wp_json_encode(
					self::merge_card_surface_style_fields( $user_style, $lib_style )
				);
			}

			$slug = (string) ( $row['slug'] ?? '' );
			if ( $slug ) {
				$default_attrs = json_decode( (string) ( $row['default_attrs'] ?? '{}' ), true ) ?: array();
				$css_file      = CssWriter::write_user_block(
					(int) $row['id'],
					$slug,
					$css_vars,
					is_array( $default_attrs ) ? $default_attrs : array()
				);
				if ( $css_file ) {
					$update['css_file'] = $css_file;
				}
			}

			$wpdb->update(
				$user,
				$update,
				array( 'id' => (int) $row['id'] ),
				array_fill( 0, count( $update ), '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Testimonials Card Surface: drop Align buttons at end + card padding;
	 * keep content padding, add Content Gap, zero field margins, pin author.
	 */
	private static function migrate_testimonial_card_surface(): void {
		global $wpdb;

		$user   = self::table( 'user_blocks' );
		$system = self::table( 'system_blocks' );
		if ( ! $user || ! $system ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $exists !== $user ) {
			return;
		}

		$sys_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $system ) );
		if ( $sys_exists === $system ) {
			$sys_rows = $wpdb->get_results(
				$wpdb->prepare(
					'SELECT id, markup, style_schema, css_vars FROM %i WHERE markup LIKE %s OR slug LIKE %s',
					$system,
					'%bm-testimonial%',
					'%testimonial%'
				),
				ARRAY_A
			);
			foreach ( $sys_rows ?: array() as $row ) {
				self::apply_testimonial_card_surface_row( $system, $row, true );
			}
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT u.id, u.slug, u.markup, u.style_schema, u.css_vars, u.default_attrs,
				s.style_schema AS library_style_schema,
				s.css_vars AS library_css_vars
			FROM %i u
			INNER JOIN %i s ON s.id = u.system_block_id
			WHERE u.markup LIKE %s OR s.slug LIKE %s OR u.slug LIKE %s",
				$user,
				$system,
				'%bm-testimonial%',
				'%testimonial%',
				'%testimonial%'
			),
			ARRAY_A
		);

		foreach ( $rows ?: array() as $row ) {
			self::apply_testimonial_card_surface_row( $user, $row, false );
		}
	}

	/**
	 * @param string               $table Table name.
	 * @param array<string, mixed> $row   DB row.
	 * @param bool                 $is_system System block (no css_file rewrite needed via slug alone).
	 */
	private static function apply_testimonial_card_surface_row( string $table, array $row, bool $is_system ): void {
		global $wpdb;

		$markup      = (string) ( $row['markup'] ?? '' );
		$next_markup = MarkupInterpolator::upgrade_testimonial_card_layout_markup( $markup );

		$css_vars = CssVars::normalize( json_decode( $row['css_vars'] ?? '{}', true ) ?: array() );
		$lib_css  = CssVars::normalize( json_decode( $row['library_css_vars'] ?? '{}', true ) ?: array() );
		$desktop  = isset( $css_vars['desktop'] ) && is_array( $css_vars['desktop'] )
			? $css_vars['desktop']
			: array();

		$card_pad = trim( (string) ( $desktop['--bm-card-padding'] ?? '' ) );
		$body_pad = trim( (string) ( $desktop['--bm-card-body-padding'] ?? '' ) );
		if ( '' === $body_pad || '0' === $body_pad ) {
			$fallback_body = trim( (string) ( $lib_css['desktop']['--bm-card-body-padding'] ?? '' ) );
			if ( '' !== $card_pad && '0' !== $card_pad ) {
				$desktop['--bm-card-body-padding'] = $card_pad;
			} elseif ( '' !== $fallback_body && '0' !== $fallback_body ) {
				$desktop['--bm-card-body-padding'] = $fallback_body;
			} else {
				$desktop['--bm-card-body-padding'] = '1.75rem';
			}
		}
		$desktop['--bm-card-padding'] = '0';

		$body_gap = trim( (string) ( $desktop['--bm-card-body-gap'] ?? '' ) );
		if ( '' === $body_gap ) {
			$fallback_gap = trim( (string) ( $lib_css['desktop']['--bm-card-body-gap'] ?? '' ) );
			$desktop['--bm-card-body-gap'] = '' !== $fallback_gap ? $fallback_gap : '2.5rem';
		}

		foreach ( array(
			'--bm-icon-margin-top'         => '0',
			'--bm-icon-margin-right'       => '0',
			'--bm-icon-margin-bottom'      => '0',
			'--bm-icon-margin-left'        => '0',
			'--bm-card-icon-margin'        => '0',
			'--bm-content-margin-top'      => '0',
			'--bm-content-margin-bottom'   => '0',
			'--bm-name-margin-bottom'      => '0',
		) as $key => $val ) {
			$desktop[ $key ] = $val;
		}

		unset(
			$desktop['--bm-buttons-align-end'],
			$desktop['--bm-buttons-pin-top'],
			$desktop['--bm-buttons-flex-order'],
			$desktop['--bm-card-display'],
			$desktop['--bm-card-height'],
			$desktop['--bm-card-body-display'],
			$desktop['--bm-card-body-flex']
		);

		$css_vars['desktop'] = $desktop;

		$update = array(
			'markup'   => $next_markup,
			'css_vars' => wp_json_encode( $css_vars ),
		);

		$raw_style = (string) ( $row['style_schema'] ?? '' );
		if ( '' !== trim( $raw_style ) ) {
			$user_style = json_decode( $raw_style, true );
			$lib_style  = json_decode( $row['library_style_schema'] ?? '[]', true );
			if ( ! is_array( $user_style ) ) {
				$user_style = array();
			}
			if ( ! is_array( $lib_style ) ) {
				$lib_style = array();
			}
			$update['style_schema'] = wp_json_encode(
				self::normalize_testimonial_style_schema(
					$user_style,
					$lib_style
				)
			);
		}

		if ( ! $is_system ) {
			$slug = (string) ( $row['slug'] ?? '' );
			if ( $slug ) {
				$default_attrs = json_decode( (string) ( $row['default_attrs'] ?? '{}' ), true ) ?: array();
				$css_file      = CssWriter::write_user_block(
					(int) $row['id'],
					$slug,
					$css_vars,
					is_array( $default_attrs ) ? $default_attrs : array()
				);
				if ( $css_file ) {
					$update['css_file'] = $css_file;
				}
			}
		}

		$wpdb->update(
			$table,
			$update,
			array( 'id' => (int) $row['id'] ),
			array_fill( 0, count( $update ), '%s' ),
			array( '%d' )
		);
	}

	/**
	 * Strip Align buttons at end + card padding; ensure Content Gap + content padding.
	 *
	 * @param array<int, mixed> $user_schema User style schema groups.
	 * @param array<int, mixed> $lib_schema  Library style schema groups.
	 * @return array<int, mixed>
	 */
	private static function normalize_testimonial_style_schema( array $user_schema, array $lib_schema ): array {
		$source = $lib_schema ?: $user_schema;
		$out    = array();

		foreach ( $source as $group ) {
			if ( ! is_array( $group ) ) {
				continue;
			}
			$name   = (string) ( $group['group'] ?? '' );
			$fields = isset( $group['fields'] ) && is_array( $group['fields'] )
				? $group['fields']
				: array();
			$next_fields = array();
			foreach ( $fields as $field ) {
				if ( ! is_array( $field ) ) {
					continue;
				}
				$key = (string) ( $field['key'] ?? '' );
				if (
					'--bm-buttons-align-end' === $key
					|| '--bm-card-padding' === $key
					|| '--bm-quote-align' === $key
					|| '--bm-message-align' === $key
					|| '--bm-author-align' === $key
				) {
					continue;
				}
				$next_fields[] = $field;
			}

			if ( 'Card Surface' === $name ) {
				$has_gap = false;
				$has_pad = false;
				foreach ( $next_fields as $field ) {
					$k = (string) ( $field['key'] ?? '' );
					if ( '--bm-card-body-gap' === $k ) {
						$has_gap = true;
					}
					if ( '--bm-card-body-padding' === $k ) {
						$has_pad = true;
					}
				}
				$lib_gap = self::find_style_schema_field( $lib_schema, '--bm-card-body-gap' );
				$lib_pad = self::find_style_schema_field( $lib_schema, '--bm-card-body-padding' );

				if ( ! $has_pad ) {
					$next_fields[] = is_array( $lib_pad )
						? $lib_pad
						: array(
							'key'     => '--bm-card-body-padding',
							'label'   => 'Content padding',
							'type'    => 'spacing',
							'default' => '1.75rem',
						);
				}
				if ( ! $has_gap ) {
					$insert_at = 0;
					foreach ( $next_fields as $i => $field ) {
						if ( '--bm-card-body-padding' === ( $field['key'] ?? '' ) ) {
							$insert_at = $i + 1;
							break;
						}
					}
					$gap_field = is_array( $lib_gap )
						? $lib_gap
						: array(
							'key'     => '--bm-card-body-gap',
							'label'   => 'Content Gap',
							'type'    => 'spacing',
							'default' => '2.5rem',
							'help'    => 'Space between quote icon, content, and author inside each card.',
						);
					array_splice( $next_fields, $insert_at, 0, array( $gap_field ) );
				}
			}

			$out[] = array(
				'group'  => $name,
				'fields' => $next_fields,
			);
		}

		return $out ?: $user_schema;
	}

	/**
	 * Tablet viewport is 1024px and below: remap old 768px column stack defaults
	 * and rewrite My Block CSS so tablet @media queries use the new band.
	 */
	private static function migrate_tablet_breakpoint_1024(): void {
		global $wpdb;

		$user = self::table( 'user_blocks' );
		if ( ! $user ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $exists !== $user ) {
			return;
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT id, slug, css_vars, default_attrs FROM %i',
				$user
			),
			ARRAY_A
		);

		foreach ( $rows ?: array() as $row ) {
			$css_vars = CssVars::normalize( json_decode( $row['css_vars'] ?? '{}', true ) ?: array() );
			$changed  = false;

			foreach ( array( 'desktop', 'tablet', 'mobile' ) as $bp ) {
				if ( empty( $css_vars[ $bp ] ) || ! is_array( $css_vars[ $bp ] ) ) {
					continue;
				}
				$cur = isset( $css_vars[ $bp ]['--bm-columns-breakpoint'] )
					? trim( (string) $css_vars[ $bp ]['--bm-columns-breakpoint'] )
					: '';
				if ( '768px' !== $cur ) {
					continue;
				}
				$css_vars[ $bp ]['--bm-columns-breakpoint'] = '1024px';
				$changed = true;
			}

			$slug = (string) ( $row['slug'] ?? '' );
			if ( ! $slug ) {
				continue;
			}

			$default_attrs = json_decode( (string) ( $row['default_attrs'] ?? '{}' ), true ) ?: array();
			$css_file      = CssWriter::write_user_block(
				(int) $row['id'],
				$slug,
				$css_vars,
				is_array( $default_attrs ) ? $default_attrs : array()
			);

			$update = array();
			if ( $changed ) {
				$update['css_vars'] = wp_json_encode( $css_vars );
			}
			if ( $css_file ) {
				$update['css_file'] = $css_file;
			}
			if ( ! $update ) {
				continue;
			}

			$wpdb->update(
				$user,
				$update,
				array( 'id' => (int) $row['id'] ),
				array_fill( 0, count( $update ), '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Ensure card General includes Layout columns + Surface (before Spacing).
	 *
	 * @param array<int, mixed> $user_schema User style schema groups.
	 * @param array<int, mixed> $lib_schema  Library style schema groups.
	 * @return array<int, mixed>
	 */
	private static function merge_card_surface_style_fields( array $user_schema, array $lib_schema ): array {
		$columns_field = self::find_style_schema_field( $lib_schema, '--bm-card-columns' );
		if ( ! is_array( $columns_field ) ) {
			$columns_field = array(
				'key'     => '--bm-card-columns',
				'label'   => 'Columns',
				'type'    => 'cardColumns',
				'default' => '4',
			);
		}

		$surface_keys = array(
			'--bm-content-align',
			'--bm-buttons-align-end',
			'--bm-card-bg',
			'--bm-card-body-padding',
			'--bm-card-body-gap',
			'--bm-card-border',
			'--bm-card-border-radius',
			'--bm-card-shadow',
		);
		$surface_fields = array();
		foreach ( $surface_keys as $key ) {
			$field = self::find_style_schema_field( $lib_schema, $key );
			if ( is_array( $field ) ) {
				$surface_fields[] = $field;
			}
		}
		if ( ! $surface_fields ) {
			$surface_fields = array(
				array(
					'key'     => '--bm-content-align',
					'label'   => 'Content alignment',
					'type'    => 'contentAlign',
					'default' => 'left',
					'help'    => 'Align icon, title, description, and buttons left, center, or right.',
				),
				array(
					'key'     => '--bm-buttons-align-end',
					'label'   => 'Align buttons at end',
					'type'    => 'cssFlag',
					'default' => '1',
					'help'    => 'Pin buttons to the bottom of each card so they stay aligned when some cards have longer text.',
					'varsOn'  => array(
						'--bm-buttons-align-end'   => '1',
						'--bm-buttons-pin-top'     => 'auto',
						'--bm-buttons-flex-order'  => '999',
						'--bm-features-flex'       => '1 1 auto',
						'--bm-card-display'        => 'flex',
						'--bm-card-height'         => 'auto',
						'--bm-card-body-display'   => 'flex',
						'--bm-card-body-flex'      => '1 1 auto',
					),
					'varsOff' => array(
						'--bm-buttons-align-end'   => '0',
						'--bm-buttons-pin-top'     => '',
						'--bm-buttons-flex-order'  => '999',
						'--bm-features-flex'       => '0 0 auto',
						'--bm-card-display'        => 'flex',
						'--bm-card-height'         => 'auto',
						'--bm-card-body-display'   => 'flex',
						'--bm-card-body-flex'      => '1 1 auto',
					),
				),
				array(
					'key'     => '--bm-card-body-padding',
					'label'   => 'Content padding',
					'type'    => 'spacing',
					'default' => '1.25rem',
					'help'    => 'Inner padding for card content.',
				),
				array(
					'key'     => '--bm-card-body-gap',
					'label'   => 'Content Gap',
					'type'    => 'spacing',
					'default' => '1rem',
					'help'    => 'Space between elements inside each card.',
				),
				array(
					'key'   => '--bm-card-border',
					'label' => 'Border',
					'type'  => 'boxBorder',
				),
				array(
					'key'   => '--bm-card-border-radius',
					'label' => 'Corner radius',
					'type'  => 'boxRadius',
				),
				array(
					'key'   => '--bm-card-shadow',
					'label' => 'Box shadow',
					'type'  => 'boxShadow',
				),
			);
		} elseif ( ! self::style_schema_has_key( array( array( 'fields' => $surface_fields ) ), '--bm-content-align' ) ) {
			$align_field = self::find_style_schema_field( $lib_schema, '--bm-content-align' );
			if ( ! is_array( $align_field ) ) {
				$align_field = array(
					'key'     => '--bm-content-align',
					'label'   => 'Content alignment',
					'type'    => 'contentAlign',
					'default' => 'left',
					'help'    => 'Align icon, title, description, and buttons left, center, or right.',
				);
			}
			array_unshift( $surface_fields, $align_field );
		}

		if ( ! self::style_schema_has_key( array( array( 'fields' => $surface_fields ) ), '--bm-buttons-align-end' ) ) {
			$pin_field = self::find_style_schema_field( $lib_schema, '--bm-buttons-align-end' );
			if ( ! is_array( $pin_field ) ) {
				$pin_field = array(
					'key'     => '--bm-buttons-align-end',
					'label'   => 'Align buttons at end',
					'type'    => 'cssFlag',
					'default' => '1',
					'help'    => 'Pin buttons to the bottom of each card so they stay aligned when some cards have longer text.',
					'varsOn'  => array(
						'--bm-buttons-align-end'   => '1',
						'--bm-buttons-pin-top'     => 'auto',
						'--bm-buttons-flex-order'  => '999',
						'--bm-features-flex'       => '1 1 auto',
						'--bm-card-display'        => 'flex',
						'--bm-card-height'         => 'auto',
						'--bm-card-body-display'   => 'flex',
						'--bm-card-body-flex'      => '1 1 auto',
					),
					'varsOff' => array(
						'--bm-buttons-align-end'   => '0',
						'--bm-buttons-pin-top'     => '',
						'--bm-buttons-flex-order'  => '999',
						'--bm-features-flex'       => '0 0 auto',
						'--bm-card-display'        => 'flex',
						'--bm-card-height'         => 'auto',
						'--bm-card-body-display'   => 'flex',
						'--bm-card-body-flex'      => '1 1 auto',
					),
				);
			}
			$insert_at = 0;
			foreach ( $surface_fields as $i => $sf ) {
				if ( is_array( $sf ) && ( $sf['key'] ?? '' ) === '--bm-content-align' ) {
					$insert_at = (int) $i + 1;
					break;
				}
			}
			array_splice( $surface_fields, $insert_at, 0, array( $pin_field ) );
		}

		// Ensure Layout group with Columns.
		if ( ! self::style_schema_has_key( $user_schema, '--bm-card-columns' ) ) {
			$layout_idx = null;
			foreach ( $user_schema as $i => $group ) {
				if ( is_array( $group ) && 'Layout' === (string) ( $group['group'] ?? '' ) ) {
					$layout_idx = $i;
					break;
				}
			}
			if ( null !== $layout_idx ) {
				$fields = isset( $user_schema[ $layout_idx ]['fields'] ) && is_array( $user_schema[ $layout_idx ]['fields'] )
					? $user_schema[ $layout_idx ]['fields']
					: array();
				array_unshift( $fields, $columns_field );
				$user_schema[ $layout_idx ]['fields'] = $fields;
			} else {
				$user_schema = self::insert_group_before(
					$user_schema,
					array(
						'group'  => 'Layout',
						'fields' => array( $columns_field ),
					),
					array( 'Surface', 'Spacing' )
				);
			}
		}

		// Ensure Surface fields.
		$missing_surface = array();
		foreach ( $surface_fields as $field ) {
			$key = (string) ( $field['key'] ?? '' );
			if ( $key && ! self::style_schema_has_key( $user_schema, $key ) ) {
				$missing_surface[] = $field;
			}
		}
		if ( $missing_surface ) {
			$surface_idx = null;
			foreach ( $user_schema as $i => $group ) {
				$group_name = is_array( $group ) ? (string) ( $group['group'] ?? '' ) : '';
				if ( 'Surface' === $group_name || 'Card Surface' === $group_name ) {
					$surface_idx = $i;
					break;
				}
			}
			if ( null !== $surface_idx ) {
				$fields = isset( $user_schema[ $surface_idx ]['fields'] ) && is_array( $user_schema[ $surface_idx ]['fields'] )
					? $user_schema[ $surface_idx ]['fields']
					: array();
				foreach ( $missing_surface as $field ) {
					$key = (string) ( $field['key'] ?? '' );
					if ( '--bm-buttons-align-end' === $key ) {
						$insert_at = 0;
						foreach ( $fields as $fi => $existing ) {
							if ( is_array( $existing ) && ( $existing['key'] ?? '' ) === '--bm-content-align' ) {
								$insert_at = (int) $fi + 1;
								break;
							}
						}
						array_splice( $fields, $insert_at, 0, array( $field ) );
						continue;
					}
					array_unshift( $fields, $field );
				}
				$user_schema[ $surface_idx ]['fields'] = $fields;
			} else {
				$user_schema = self::insert_group_before(
					$user_schema,
					array(
						'group'  => 'Card Surface',
						'fields' => $missing_surface,
					),
					array( 'Surface', 'Spacing' )
				);
			}
		}

		// Refresh Align buttons at end default + companion vars on existing snapshots.
		$pin_lib = self::find_style_schema_field( $lib_schema, '--bm-buttons-align-end' );
		if ( is_array( $pin_lib ) && ( isset( $pin_lib['varsOn'] ) || isset( $pin_lib['varsOff'] ) ) ) {
			foreach ( $user_schema as $i => $group ) {
				if ( ! is_array( $group ) || empty( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
					continue;
				}
				foreach ( $group['fields'] as $j => $existing ) {
					if ( ! is_array( $existing ) || ( $existing['key'] ?? '' ) !== '--bm-buttons-align-end' ) {
						continue;
					}
					if ( isset( $pin_lib['default'] ) ) {
						$user_schema[ $i ]['fields'][ $j ]['default'] = $pin_lib['default'];
					}
					if ( isset( $pin_lib['varsOn'] ) && is_array( $pin_lib['varsOn'] ) ) {
						$user_schema[ $i ]['fields'][ $j ]['varsOn'] = $pin_lib['varsOn'];
					}
					if ( isset( $pin_lib['varsOff'] ) && is_array( $pin_lib['varsOff'] ) ) {
						$user_schema[ $i ]['fields'][ $j ]['varsOff'] = $pin_lib['varsOff'];
					}
				}
			}
		}

		return self::reorder_card_general_groups( $user_schema );
	}

	/**
	 * Prefer Colors → Layout → Surface → Spacing for card General.
	 *
	 * @param array<int, mixed> $schema Style schema groups.
	 * @return array<int, mixed>
	 */
	private static function reorder_card_general_groups( array $schema ): array {
		$priority = array(
			'Colors'       => 10,
			'Layout'       => 20,
			'Surface'      => 30,
			'Card Surface' => 30,
			'Spacing'      => 40,
		);
		$indexed  = array();
		foreach ( $schema as $i => $group ) {
			$name = is_array( $group ) ? (string) ( $group['group'] ?? '' ) : '';
			$indexed[] = array(
				'i'    => $i,
				'rank' => $priority[ $name ] ?? ( 100 + $i ),
				'group'=> $group,
			);
		}
		usort(
			$indexed,
			static function ( array $a, array $b ): int {
				if ( $a['rank'] === $b['rank'] ) {
					return $a['i'] <=> $b['i'];
				}
				return $a['rank'] <=> $b['rank'];
			}
		);
		return array_map(
			static function ( array $row ) {
				return $row['group'];
			},
			$indexed
		);
	}

	/**
	 * Insert a style-schema group before the first matching group name.
	 *
	 * @param array<int, mixed> $schema     Style schema groups.
	 * @param array<string, mixed> $group   Group to insert.
	 * @param array<int, string> $before    Preferred group names to insert before.
	 * @return array<int, mixed>
	 */
	private static function insert_group_before( array $schema, array $group, array $before ): array {
		$out = array();
		$inserted = false;
		foreach ( $schema as $existing ) {
			$name = is_array( $existing ) ? (string) ( $existing['group'] ?? '' ) : '';
			if ( ! $inserted && in_array( $name, $before, true ) ) {
				$out[]    = $group;
				$inserted = true;
			}
			$out[] = $existing;
		}
		if ( ! $inserted ) {
			$out[] = $group;
		}
		return $out;
	}

	/**
	 * @param array<int, mixed> $schema Style schema groups.
	 * @param string            $key    CSS var key.
	 */
	private static function style_schema_has_key( array $schema, string $key ): bool {
		return null !== self::find_style_schema_field( $schema, $key );
	}

	/**
	 * @param array<int, mixed> $schema Style schema groups.
	 * @param string            $key    CSS var key.
	 * @return array<string, mixed>|null
	 */
	private static function find_style_schema_field( array $schema, string $key ): ?array {
		foreach ( $schema as $group ) {
			if ( ! is_array( $group ) || empty( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
				continue;
			}
			foreach ( $group['fields'] as $field ) {
				if ( is_array( $field ) && ( $field['key'] ?? '' ) === $key ) {
					return $field;
				}
			}
		}
		return null;
	}

	/**
	 * Upgrade matching field types on user-block snapshots from the library
	 * (e.g. subheader textarea → wysiwyg) without waiting for "Review update".
	 */
	private static function sync_user_field_schema_types_from_library(): void {
		global $wpdb;

		$user   = self::table( 'user_blocks' );
		$system = self::table( 'system_blocks' );
		if ( ! $user || ! $system ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $exists !== $user ) {
			return;
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT u.id, u.field_schema, s.field_schema AS library_field_schema
			FROM %i u
			INNER JOIN %i s ON s.id = u.system_block_id
			WHERE u.field_schema IS NOT NULL AND u.field_schema != ''",
				$user,
				$system
			),
			ARRAY_A
		);

		foreach ( $rows ?: array() as $row ) {
			$current = TemplateLibrary::decode_field_schema( $row['field_schema'] ?? '' );
			$library = TemplateLibrary::decode_field_schema( $row['library_field_schema'] ?? '' );
			if ( ! $current || ! $library ) {
				continue;
			}

			$next = TemplateLibrary::apply_library_field_types( $current, $library );
			if ( wp_json_encode( $next ) === wp_json_encode( $current ) ) {
				continue;
			}

			$wpdb->update(
				$user,
				array( 'field_schema' => wp_json_encode( $next ) ),
				array( 'id' => (int) $row['id'] ),
				array( '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Fix user-block slugs that contain characters Gutenberg rejects (e.g. underscores).
	 * Block names must match: /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/
	 */
	private static function repair_invalid_user_block_slugs(): void {
		global $wpdb;

		$table  = self::table( 'user_blocks' );
		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
		if ( $exists !== $table ) {
			return;
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT id, slug, css_vars, default_attrs FROM %i',
				$table
			),
			ARRAY_A
		);
		if ( ! $rows ) {
			return;
		}

		foreach ( $rows as $row ) {
			$old = (string) $row['slug'];
			if ( preg_match( '/^[a-z][a-z0-9-]*$/', $old ) ) {
				continue;
			}

			$base = self::sanitize_block_slug( $old );
			$new  = $base;
			$i    = 1;
			while (
				$wpdb->get_var(
					$wpdb->prepare(
						'SELECT id FROM %i WHERE slug = %s AND id != %d',
						$table,
						$new,
						(int) $row['id']
					)
				)
			) {
				$new = $base . '-' . $i;
				++$i;
			}

			$css_vars = CssVars::normalize( json_decode( $row['css_vars'] ?? '{}', true ) ?: array() );
			$default_attrs = json_decode( (string) ( $row['default_attrs'] ?? '{}' ), true ) ?: array();
			CssWriter::delete_user_block( $old );
			$css_file = CssWriter::write_user_block( (int) $row['id'], $new, $css_vars, is_array( $default_attrs ) ? $default_attrs : array() );

			$wpdb->update(
				$table,
				array(
					'slug'     => $new,
					'css_file' => $css_file,
				),
				array( 'id' => (int) $row['id'] ),
				array( '%s', '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Gutenberg-safe block slug segment.
	 *
	 * @param string $value Raw slug/name.
	 */
	private static function sanitize_block_slug( string $value ): string {
		$value = strtolower( $value );
		$value = str_replace( array( '_', ' ', '.' ), '-', $value );
		$value = sanitize_title( $value );
		$value = preg_replace( '/[^a-z0-9-]+/', '-', $value ) ?? '';
		$value = trim( $value, '-' );
		if ( '' === $value || ! preg_match( '/^[a-z]/', $value ) ) {
			$value = 'block-' . $value;
			$value = trim( $value, '-' );
		}
		return $value ?: 'block';
	}

	/**
	 * Add icon column + ensure unique display names before UNIQUE KEY.
	 */
	private static function migrate_user_block_identity(): void {
		global $wpdb;

		$table = self::table( 'user_blocks' );
		if ( ! $table ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
		if ( $exists !== $table ) {
			return;
		}

		$columns = self::column_names( 'user_blocks' );
		if ( $columns && ! in_array( 'icon', $columns, true ) ) {
			$wpdb->query(
				$wpdb->prepare(
					'ALTER TABLE %i ADD COLUMN icon varchar(191) NOT NULL DEFAULT %s AFTER slug',
					$table,
					'screenoptions'
				)
			);
		}

		// Deduplicate names so UNIQUE KEY can be applied.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT id, name FROM %i ORDER BY id ASC',
				$table
			),
			ARRAY_A
		);
		$seen = array();
		foreach ( $rows ?: array() as $row ) {
			$key = strtolower( (string) $row['name'] );
			if ( ! isset( $seen[ $key ] ) ) {
				$seen[ $key ] = 0;
				continue;
			}
			++$seen[ $key ];
			$new_name = $row['name'] . ' (' . $seen[ $key ] . ')';
			$wpdb->update(
				$table,
				array( 'name' => $new_name ),
				array( 'id' => (int) $row['id'] ),
				array( '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Widen user_blocks.icon for lucide: / emoji: identity refs.
	 */
	private static function migrate_user_block_icon_width(): void {
		global $wpdb;

		$table = self::table( 'user_blocks' );
		if ( ! $table ) {
			return;
		}

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
		if ( $exists !== $table ) {
			return;
		}

		$columns = self::column_names( 'user_blocks' );
		if ( ! $columns || ! in_array( 'icon', $columns, true ) ) {
			return;
		}

		$row = $wpdb->get_row( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $table, 'icon' ), ARRAY_A );
		$type = strtolower( (string) ( $row['Type'] ?? '' ) );
		if ( false !== strpos( $type, 'varchar(191)' ) || false !== strpos( $type, 'varchar(255)' ) ) {
			return;
		}

		$wpdb->query(
			$wpdb->prepare(
				'ALTER TABLE %i MODIFY COLUMN icon varchar(191) NOT NULL DEFAULT %s',
				$table,
				'screenoptions'
			)
		);
	}

	/**
	 * Add is_pro / source columns for Pro library packs.
	 */
	private static function migrate_pro_columns(): void {
		global $wpdb;

		$system = self::table( 'system_blocks' );
		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $system ) );
		if ( $exists !== $system ) {
			return;
		}

		$columns = self::column_names( 'system_blocks' );
		if ( ! $columns ) {
			return;
		}

		if ( ! in_array( 'is_pro', $columns, true ) ) {
			$wpdb->query(
				$wpdb->prepare(
					'ALTER TABLE %i ADD COLUMN is_pro tinyint(1) NOT NULL DEFAULT 0 AFTER template_hash',
					$system
				)
			);
		}
		if ( ! in_array( 'is_featured', $columns, true ) ) {
			$wpdb->query(
				$wpdb->prepare(
					'ALTER TABLE %i ADD COLUMN is_featured tinyint(1) NOT NULL DEFAULT 0 AFTER is_pro',
					$system
				)
			);
		}
		if ( ! in_array( 'source', $columns, true ) ) {
			$wpdb->query(
				$wpdb->prepare(
					"ALTER TABLE %i ADD COLUMN source varchar(64) NOT NULL DEFAULT %s AFTER is_featured",
					$system,
					'core'
				)
			);
		}
	}

	/**
	 * Add parent_slug / is_pro on categories for hierarchical library nav.
	 */
	private static function migrate_category_hierarchy(): void {
		global $wpdb;

		$table  = self::table( 'categories' );
		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
		if ( $exists !== $table ) {
			return;
		}

		$columns = self::column_names( 'categories' );
		if ( ! $columns ) {
			return;
		}

		if ( ! in_array( 'parent_slug', $columns, true ) ) {
			$wpdb->query(
				$wpdb->prepare(
					"ALTER TABLE %i ADD COLUMN parent_slug varchar(100) NOT NULL DEFAULT %s AFTER description",
					$table,
					''
				)
			);
		}
		if ( ! in_array( 'is_pro', $columns, true ) ) {
			$wpdb->query(
				$wpdb->prepare(
					'ALTER TABLE %i ADD COLUMN is_pro tinyint(1) NOT NULL DEFAULT 0 AFTER parent_slug',
					$table
				)
			);
		}
	}

	/**
	 * Add template snapshot columns before dbDelta UNIQUE constraints.
	 */
	private static function migrate_template_snapshots(): void {
		global $wpdb;

		$system = self::table( 'system_blocks' );
		$user   = self::table( 'user_blocks' );

		$sys_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $system ) );
		if ( $sys_exists === $system ) {
			$columns = self::column_names( 'system_blocks' );
			if ( $columns && ! in_array( 'template_hash', $columns, true ) ) {
				$wpdb->query(
					$wpdb->prepare(
						"ALTER TABLE %i ADD COLUMN template_hash varchar(64) NOT NULL DEFAULT %s AFTER style_schema",
						$system,
						''
					)
				);
			}
		}

		$user_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $user_exists !== $user ) {
			return;
		}

		$columns = self::column_names( 'user_blocks' );
		if ( ! $columns ) {
			return;
		}

		if ( ! in_array( 'markup', $columns, true ) ) {
			$wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD COLUMN markup longtext NULL AFTER icon', $user ) );
		}
		if ( ! in_array( 'field_schema', $columns, true ) ) {
			$wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD COLUMN field_schema longtext NULL AFTER markup', $user ) );
		}
		if ( ! in_array( 'style_schema', $columns, true ) ) {
			$wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD COLUMN style_schema longtext NULL AFTER field_schema', $user ) );
		}
		if ( ! in_array( 'template_hash', $columns, true ) ) {
			$wpdb->query(
				$wpdb->prepare(
					"ALTER TABLE %i ADD COLUMN template_hash varchar(64) NOT NULL DEFAULT %s AFTER style_schema",
					$user,
					''
				)
			);
		}
	}

	/**
	 * Copy current system templates onto user blocks that have no snapshot yet.
	 */
	private static function backfill_user_template_snapshots(): void {
		global $wpdb;

		$user   = self::table( 'user_blocks' );
		$system = self::table( 'system_blocks' );

		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $user ) );
		if ( $exists !== $user ) {
			return;
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT u.id, u.markup AS user_markup, s.markup, s.field_schema, s.style_schema, s.template_hash
			FROM %i u
			INNER JOIN %i s ON s.id = u.system_block_id
			WHERE u.markup IS NULL OR u.markup = '' OR u.template_hash = ''",
				$user,
				$system
			),
			ARRAY_A
		);

		foreach ( $rows ?: array() as $row ) {
			$hash = (string) $row['template_hash'];
			if ( '' === $hash ) {
				$hash = TemplateLibrary::hash(
					array(
						'markup'       => $row['markup'],
						'field_schema' => $row['field_schema'],
						'style_schema' => $row['style_schema'],
					)
				);
			}

			$wpdb->update(
				$user,
				array(
					'markup'        => $row['markup'],
					'field_schema'  => $row['field_schema'],
					'style_schema'  => $row['style_schema'],
					'template_hash' => $hash,
				),
				array( 'id' => (int) $row['id'] ),
				array( '%s', '%s', '%s', '%s' ),
				array( '%d' )
			);
		}

		// Ensure system rows have hashes.
		$sys_rows = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT id, markup, field_schema, style_schema, template_hash FROM %i',
				$system
			),
			ARRAY_A
		);

		foreach ( $sys_rows ?: array() as $row ) {
			if ( ! empty( $row['template_hash'] ) ) {
				continue;
			}
			$hash = TemplateLibrary::hash(
				array(
					'markup'       => $row['markup'],
					'field_schema' => $row['field_schema'],
					'style_schema' => $row['style_schema'],
				)
			);
			$wpdb->update(
				$system,
				array( 'template_hash' => $hash ),
				array( 'id' => (int) $row['id'] ),
				array( '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * Backfill junction rows from system_blocks.category_id (primary only).
	 * Idempotent — skips blocks that already have junction rows.
	 */
	private static function migrate_system_block_categories(): void {
		global $wpdb;

		$junction = self::table( 'system_block_categories' );
		$blocks   = self::table( 'system_blocks' );
		if ( '' === $junction || '' === $blocks ) {
			return;
		}

		// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- %i table identifiers.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT b.id, b.category_id
				FROM %i b
				LEFT JOIN %i j ON j.system_block_id = b.id
				WHERE j.system_block_id IS NULL
				AND b.category_id > 0",
				$blocks,
				$junction
			),
			ARRAY_A
		);

		foreach ( $rows ?: array() as $row ) {
			$wpdb->replace(
				$junction,
				array(
					'system_block_id' => (int) $row['id'],
					'category_id'     => (int) $row['category_id'],
					'is_primary'      => 1,
				),
				array( '%d', '%d', '%d' )
			);
		}
	}

	/**
	 * Table name helper.
	 *
	 * @param string $name Logical table key: categories|system_blocks|system_block_categories|user_blocks|user_contents.
	 */
	public static function table( string $name ): string {
		global $wpdb;

		$map = array(
			'categories'               => 'blockora_morph_boc_categories',
			'system_blocks'            => 'blockora_morph_boc_system_blocks',
			'system_block_categories'  => 'blockora_morph_boc_system_block_categories',
			'user_blocks'              => 'blockora_morph_boc_user_blocks',
			'user_contents'            => 'blockora_morph_boc_user_contents',
		);

		if ( ! isset( $map[ $name ] ) ) {
			return '';
		}

		return $wpdb->prefix . $map[ $name ];
	}

	/**
	 * COUNT(*) for a plugin-owned table via prepared query.
	 *
	 * @param string $name Logical table key.
	 */
	public static function count_rows( string $name ): int {
		global $wpdb;

		$table = self::table( $name );
		if ( '' === $table ) {
			return 0;
		}

		return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) );
	}

	/**
	 * Column names from DESCRIBE for a plugin-owned table.
	 *
	 * @param string $name Logical table key.
	 * @return string[]
	 */
	public static function column_names( string $name ): array {
		global $wpdb;

		$table = self::table( $name );
		if ( '' === $table ) {
			return array();
		}

		$columns = $wpdb->get_col( $wpdb->prepare( 'DESCRIBE %i', $table ), 0 );

		return is_array( $columns ) ? $columns : array();
	}

	/**
	 * One-time marker for option-prefix migration (already on jocbm_*).
	 */
	public static function migrate_legacy_option_prefix(): void {
		if ( get_option( 'jocbm_legacy_options_migrated', false ) ) {
			return;
		}

		update_option( 'jocbm_legacy_options_migrated', 1, false );
	}

	/**
	 * Wipe all Blockora Morph database rows, generated CSS, and theme config,
	 * then reinstall schema and re-seed the library templates.
	 *
	 * @return array{counts: array<string, int>, themeConfig: array<string, mixed>}
	 */
	public static function reset_all_data(): array {
		global $wpdb;

		$tables = array(
			'user_contents'            => self::table( 'user_contents' ),
			'user_blocks'              => self::table( 'user_blocks' ),
			'system_block_categories'  => self::table( 'system_block_categories' ),
			'system_blocks'            => self::table( 'system_blocks' ),
			'categories'               => self::table( 'categories' ),
		);

		$counts = array();
		foreach ( $tables as $key => $table ) {
			$counts[ $key ] = self::count_rows( $key );
		}

		// Child tables first (no FK constraints, but keep order sensible).
		foreach ( array( 'user_contents', 'user_blocks', 'system_block_categories', 'system_blocks', 'categories' ) as $key ) {
			$table = $tables[ $key ];
			$wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', $table ) );
		}

		delete_option( 'jocbm_templates_fp' );
		delete_option( 'jocbm_seed_version' );

		// Show the welcome screen again after a full wipe.
		update_option( 'jocbm_welcome_seen', false );

		$theme_config = \JOCBM\Rest\ThemeConfigController::reset_to_defaults();

		\JOCBM\Services\CssWriter::clear_all();

		self::install();
		Seeder::seed();
		update_option( 'jocbm_db_version', JOCBM_DB_VERSION );
		update_option( 'jocbm_seed_version', JOCBM_SEED_VERSION );
		$fp = TemplateLibrary::fingerprint();
		if ( $fp ) {
			update_option( 'jocbm_templates_fp', $fp );
		}

		/**
		 * Fires after all Blockora Morph data was wiped and the free library re-seeded.
		 * Pro should re-register template dirs (already filtered) and call
		 * `jocbm_resync_library()` if needed — seed already ran with filters.
		 *
		 * @param array<string, int> $counts Rows removed before wipe.
		 */
		do_action( 'jocbm_after_reset', $counts );

		return array(
			'counts'      => $counts,
			'themeConfig' => $theme_config,
		);
	}
}
