<?php
/**
 * Register dynamic Gutenberg blocks + shortcodes.
 *
 * @package JOCBM
 *
 * Copyright (C) 2026 JayOnCode
 * License: GPL-2.0-or-later
 */

namespace JOCBM\Blocks;

use JOCBM\Database\Schema;
use JOCBM\Services\BlockIcons;
use JOCBM\Services\BlockMarkup;
use JOCBM\Services\CssSanitizer;
use JOCBM\Services\CssVars;
use JOCBM\Services\CssWriter;
use JOCBM\Services\ElementsOrder;
use JOCBM\Services\ElementsVisibility;
use JOCBM\Services\IconRegistry;
use JOCBM\Services\InstanceScope;
use JOCBM\Services\JsonHelper;
use JOCBM\Services\LibraryBlocks;
use JOCBM\Services\MarkupInterpolator;
use JOCBM\Services\TemplateLibrary;

defined( 'ABSPATH' ) || exit;

/**
 * Class BlockRegistry
 */
final class BlockRegistry {

	/**
	 * Request-scoped index for Gutenberg BM blocks (matches front-edit save).
	 *
	 * @var int
	 */
	private int $fe_block_index = 0;

	/**
	 * Hook registration.
	 */
	public function register(): void {
		// Category must be available before the editor boots (not only on init).
		add_filter( 'block_categories_all', array( $this, 'add_block_category' ) );

		// Themes that whitelist blocks (allowed_block_types_all → array) would otherwise
		// hide JOC Blockora Morph. Append our enabled blocks when a whitelist is in effect.
		add_filter( 'allowed_block_types_all', array( $this, 'ensure_blocks_allowed' ), 100, 2 );
		// After other filters: hide My Blocks not enabled for this post type.
		add_filter( 'allowed_block_types_all', array( $this, 'filter_blocks_by_post_type' ), 110, 2 );

		add_action( 'init', array( $this, 'register_editor_script' ), 5 );
		add_action( 'init', array( $this, 'register_user_blocks' ), 10 );
		add_action( 'init', array( $this, 'register_library_blocks' ), 10 );
		add_action( 'init', array( $this, 'register_shortcode' ) );
		add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_assets' ) );
		// Canvas iframe (WP 6.3+) does not get enqueue_block_editor_assets styles.
		add_action( 'enqueue_block_assets', array( $this, 'enqueue_editor_canvas_styles' ) );
		// Frontend: only CSS for blocks/shortcodes used on this request.
		add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_used_frontend_css' ), 20 );
		add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_editor' ), 30 );
	}

	/**
	 * When a theme/plugin returns a block whitelist, include enabled Blockora Morph blocks.
	 * Also drop My Blocks that are not enabled for the current post type.
	 *
	 * Leaving `true` alone is handled by {@see filter_blocks_by_post_type()}.
	 *
	 * @param bool|string[]            $allowed Allowed block types.
	 * @param \WP_Block_Editor_Context $context Editor context.
	 * @return bool|string[]
	 */
	public function ensure_blocks_allowed( $allowed, $context = null ) {
		if ( ! is_array( $allowed ) ) {
			return $allowed;
		}

		$post_type = $this->resolve_editor_context_post_type( $context );
		$ours      = array();
		foreach ( $this->get_enabled_user_blocks() as $block ) {
			$slug = sanitize_title( (string) ( $block['slug'] ?? '' ) );
			if ( '' === $slug ) {
				continue;
			}
			if ( '' !== $post_type && ! $this->user_block_allowed_for_post_type( $block, $post_type ) ) {
				continue;
			}
			$ours[] = 'joc-blockora-morph/' . $slug;
		}
		foreach ( LibraryBlocks::catalog() as $block ) {
			if ( ! LibraryBlocks::is_inserter_visible( $block ) ) {
				continue;
			}
			$name = (string) ( $block['name'] ?? '' );
			if ( '' !== $name ) {
				$ours[] = $name;
			}
		}

		if ( ! $ours ) {
			return array_values(
				array_diff( $allowed, $this->disallowed_user_block_names( $post_type ) )
			);
		}

		$merged = array_values( array_unique( array_merge( $allowed, $ours ) ) );
		if ( '' !== $post_type ) {
			$merged = array_values(
				array_diff( $merged, $this->disallowed_user_block_names( $post_type ) )
			);
		}
		return $merged;
	}

	/**
	 * Hide My Blocks from the inserter when the current post type is not enabled.
	 *
	 * @param bool|string[]                 $allowed Allowed block types.
	 * @param \WP_Block_Editor_Context|null $context Editor context.
	 * @return bool|string[]
	 */
	public function filter_blocks_by_post_type( $allowed, $context = null ) {
		$post_type = $this->resolve_editor_context_post_type( $context );
		if ( '' === $post_type ) {
			return $allowed;
		}

		$disallowed = $this->disallowed_user_block_names( $post_type );
		if ( ! $disallowed ) {
			return $allowed;
		}

		if ( true === $allowed ) {
			$registry = \WP_Block_Type_Registry::get_instance()->get_all_registered();
			$allowed  = array_keys( $registry );
		}

		if ( ! is_array( $allowed ) ) {
			return $allowed;
		}

		return array_values( array_diff( $allowed, $disallowed ) );
	}

	/**
	 * @param \WP_Block_Editor_Context|null $context Editor context.
	 */
	private function resolve_editor_context_post_type( $context = null ): string {
		if ( is_object( $context ) && isset( $context->name ) && 'core/edit-site' === $context->name ) {
			return '';
		}
		if ( is_object( $context ) && ! empty( $context->post ) && ! empty( $context->post->post_type ) ) {
			return sanitize_key( (string) $context->post->post_type );
		}
		return $this->get_editor_post_type();
	}

	/**
	 * Whether a My Block may appear in the given post type’s editor.
	 *
	 * @param array<string, mixed> $block     User block row.
	 * @param string               $post_type Post type slug.
	 */
	private function user_block_allowed_for_post_type( array $block, string $post_type ): bool {
		$types = JsonHelper::decode_array( $block['post_types'] ?? null, array() );
		$types = array_values(
			array_filter(
				array_map( 'sanitize_key', (array) $types )
			)
		);
		// Legacy empty → post + page (matches Advanced tab defaults).
		if ( ! $types ) {
			$types = array( 'post', 'page' );
		}
		return in_array( sanitize_key( $post_type ), $types, true );
	}

	/**
	 * Block names that must not appear for this post type.
	 *
	 * @param string $post_type Post type slug (empty = none).
	 * @return string[]
	 */
	private function disallowed_user_block_names( string $post_type ): array {
		if ( '' === $post_type ) {
			return array();
		}
		$out = array();
		foreach ( $this->get_enabled_user_blocks() as $block ) {
			$slug = sanitize_title( (string) ( $block['slug'] ?? '' ) );
			if ( '' === $slug ) {
				continue;
			}
			if ( $this->user_block_allowed_for_post_type( $block, $post_type ) ) {
				continue;
			}
			$out[] = 'joc-blockora-morph/' . $slug;
		}
		return $out;
	}

	/**
	 * Custom block category for the inserter.
	 *
	 * @param array<int, array<string, mixed>> $categories Categories.
	 * @return array<int, array<string, mixed>>
	 */
	public function add_block_category( array $categories ): array {
		$has_my  = false;
		$has_lib = false;

		foreach ( $categories as $index => $category ) {
			$slug = (string) ( $category['slug'] ?? '' );
			if ( 'joc-blockora-morph' === $slug ) {
				$categories[ $index ]['title'] = __( 'My Library', 'joc-blockora-morph' );
				$has_my = true;
			}
			if ( LibraryBlocks::CATEGORY_SLUG === $slug ) {
				$categories[ $index ]['title'] = __( 'JBM Library', 'joc-blockora-morph' );
				$has_lib = true;
			}
		}

		$prepend = array();
		if ( ! $has_lib ) {
			$prepend[] = array(
				'slug'  => LibraryBlocks::CATEGORY_SLUG,
				'title' => __( 'JBM Library', 'joc-blockora-morph' ),
				'icon'  => 'category',
			);
		}
		if ( ! $has_my ) {
			$prepend[] = array(
				'slug'  => 'joc-blockora-morph',
				'title' => __( 'My Library', 'joc-blockora-morph' ),
				'icon'  => 'layout',
			);
		}

		if ( $prepend ) {
			return array_merge( $prepend, $categories );
		}

		return $categories;
	}

	/**
	 * Register each enabled user block dynamically.
	 */
	public function register_user_blocks(): void {
		JsonHelper::guard(
			function () {
				foreach ( $this->get_enabled_user_blocks() as $block ) {
					JsonHelper::guard(
						function () use ( $block ) {
							$this->register_single_user_block( $block );
						},
						null,
						'register_user_block:' . ( $block['slug'] ?? '' )
					);
				}
			},
			null,
			'register_user_blocks'
		);
	}

	/**
	 * Register one enabled user block.
	 *
	 * @param array<string, mixed> $block User block row.
	 */
	private function register_single_user_block( array $block ): void {
		$block_name = 'joc-blockora-morph/' . $block['slug'];

		// Skip if already registered (e.g. duplicate init).
		if ( \WP_Block_Type_Registry::get_instance()->is_registered( $block_name ) ) {
			return;
		}

		$style_handle = $this->register_user_block_style( $block );

		$args = array(
			'api_version'     => 3,
			'title'           => $block['name'],
			'category'        => 'joc-blockora-morph',
			'icon'            => \JOCBM\Services\BlockIdentityIcon::php_register_icon(
				(string) ( $block['icon'] ?? 'screenoptions' )
			),
			'description'     => sprintf(
				/* translators: %s: variant name */
				__( 'Imported from %s', 'joc-blockora-morph' ),
				$block['system_name'] . ' (' . $block['variant'] . ')'
			),
			'keywords'        => array_values(
				array_unique(
					array_filter(
						array(
							'blockora-morph',
							'joc blockora-morph',
							'joc',
							$block['slug'],
							$block['variant'],
							$block['system_name'],
							$block['system_slug'] ?? '',
							$block['category_slug'] ?? '',
							$block['category_name'] ?? '',
							$block['name'],
						)
					)
				)
			),
			'attributes'      => $this->build_attributes( $block ),
			'render_callback' => function ( $attributes, $content = '', $wp_block = null ) use ( $block ) {
				return JsonHelper::guard(
					function () use ( $block, $attributes ) {
						return $this->render_block( $block, is_array( $attributes ) ? $attributes : array() );
					},
					'',
					'render_user_block:joc-blockora-morph/' . ( $block['slug'] ?? '' )
				);
			},
			'editor_script'   => 'joc-blockora-morph-editor',
			'supports'        => array(
				'html'      => false,
				'anchor'    => true,
				'className' => true,
				'inserter'  => true,
				// Wide / full width like core Cover / Group (theme must allow alignWide).
				'align'     => array( 'wide', 'full' ),
				'alignWide' => true,
			),
		);

		// Lets WP load this stylesheet only when the block is present (on-demand).
		if ( $style_handle ) {
			$args['style'] = $style_handle;
		}

		register_block_type( $block_name, $args );
	}

	/**
	 * Register JBM Library blocks for direct Gutenberg insert (no My Blocks row).
	 */
	public function register_library_blocks(): void {
		JsonHelper::guard(
			function () {
				LibraryBlocks::sync_stylesheets();

				foreach ( LibraryBlocks::catalog() as $block ) {
					JsonHelper::guard(
						function () use ( $block ) {
							$this->register_single_library_block( $block );
						},
						null,
						'register_library_block:' . ( $block['name'] ?? '' )
					);
				}
			},
			null,
			'register_library_blocks'
		);
	}

	/**
	 * Register one JBM Library block.
	 *
	 * @param array<string, mixed> $block Catalog row.
	 */
	private function register_single_library_block( array $block ): void {
		$block_name = (string) ( $block['name'] ?? '' );
		if ( '' === $block_name ) {
			return;
		}

		if ( \WP_Block_Type_Registry::get_instance()->is_registered( $block_name ) ) {
			return;
		}

		$system_slug  = (string) ( $block['systemSlug'] ?? '' );
		$variant      = (string) ( $block['variant'] ?? 'default' );
		$style_handle = $this->register_library_block_style( $system_slug, $variant );
		$runtime      = $this->library_block_runtime_row( $block );

		$args = array(
			'api_version'     => 3,
			'title'           => (string) ( $block['title'] ?? $block_name ),
			'category'        => LibraryBlocks::CATEGORY_SLUG,
			'icon'            => BlockIcons::for_library_row( $block ),
			'description'     => sprintf(
				/* translators: %s: template name */
				__( 'JBM Library — %s', 'joc-blockora-morph' ),
				(string) ( $block['systemName'] ?? $system_slug )
			),
			'keywords'        => array_values(
				array_unique(
					array_filter(
						array(
							'blockora-morph',
							'jbm library',
							'library',
							$system_slug,
							$variant,
							$block['systemName'] ?? '',
							$block['categorySlug'] ?? '',
							$block['categoryName'] ?? '',
							$block['title'] ?? '',
						)
					)
				)
			),
			'attributes'      => $this->build_attributes( $runtime ),
			'render_callback' => function ( $attributes, $content = '', $wp_block = null ) use ( $block, $runtime ) {
				return JsonHelper::guard(
					function () use ( $block, $runtime, $attributes ) {
						return $this->render_library_block(
							$block,
							$runtime,
							is_array( $attributes ) ? $attributes : array()
						);
					},
					'',
					'render_library_block:' . ( $block['name'] ?? '' )
				);
			},
			'editor_script'   => 'joc-blockora-morph-editor',
			'supports'        => array(
				'html'      => false,
				'anchor'    => true,
				'className' => true,
				'inserter'  => LibraryBlocks::is_inserter_visible( $block ),
				'align'     => array( 'wide', 'full' ),
				'alignWide' => true,
			),
		);

		if ( $style_handle ) {
			$args['style'] = $style_handle;
		}

		register_block_type( $block_name, $args );
	}

	/**
	 * Normalize a library catalog row into a render/build_attributes row.
	 *
	 * @param array<string, mixed> $block Catalog row.
	 * @return array<string, mixed>
	 */
	private function library_block_runtime_row( array $block ): array {
		return array(
			'slug'          => (string) ( $block['slug'] ?? '' ),
			'variant'       => (string) ( $block['variant'] ?? 'default' ),
			'markup'        => (string) ( $block['markup'] ?? '' ),
			'field_schema'  => wp_json_encode( $block['fieldSchema'] ?? array() ),
			'default_attrs' => wp_json_encode( $block['defaultAttrs'] ?? array() ),
			'css_vars'      => wp_json_encode( $block['cssVars'] ?? array() ),
		);
	}

	/**
	 * Register stylesheet for a JBM Library block.
	 *
	 * @param string $slug    System slug.
	 * @param string $variant Variant key.
	 * @return string Handle or empty.
	 */
	private function register_library_block_style( string $slug, string $variant ): string {
		$slug    = sanitize_title( $slug );
		$variant = sanitize_title( $variant ) ?: 'default';
		if ( '' === $slug ) {
			return '';
		}

		$css_file = LibraryBlocks::css_filename( $slug, $variant );
		$path     = trailingslashit( CssWriter::dir() ) . $css_file;
		if ( ! file_exists( $path ) ) {
			$found = LibraryBlocks::find( $slug, $variant );
			if ( $found ) {
				CssWriter::write_library_block(
					$slug,
					$variant,
					$found['cssVars'] ?? array(),
					is_array( $found['defaultAttrs'] ?? null ) ? $found['defaultAttrs'] : array(),
					is_array( $found['fieldSchema'] ?? null ) ? $found['fieldSchema'] : array()
				);
			}
		}
		if ( ! file_exists( $path ) ) {
			return '';
		}

		$handle = LibraryBlocks::style_handle( $slug, $variant );
		if ( ! wp_style_is( $handle, 'registered' ) ) {
			$deps = array();
			if ( wp_style_is( 'joc-blockora-morph-theme-agnostic', 'registered' ) ) {
				$deps[] = 'joc-blockora-morph-theme-agnostic';
			}
			wp_register_style(
				$handle,
				trailingslashit( CssWriter::url() ) . $css_file,
				$deps,
				(string) filemtime( $path )
			);
			wp_style_add_data( $handle, 'path', $path );
		}

		return $handle;
	}

	/**
	 * Enqueue a library block stylesheet by system slug + variant.
	 *
	 * @param string $slug    System slug.
	 * @param string $variant Variant key.
	 */
	private function enqueue_library_block_style( string $slug, string $variant ): void {
		$handle = $this->register_library_block_style( $slug, $variant );
		if ( $handle ) {
			$this->enqueue_style_now( $handle );
		}
	}

	/**
	 * Shortcode for content blocks.
	 */
	public function register_shortcode(): void {
		add_shortcode( JOCBM_SHORTCODE, array( $this, 'render_shortcode' ) );
	}

	/**
	 * Enqueue dynamic CSS via WP style APIs (no hard-coded style tags in markup).
	 *
	 * Safe to call during render — late enqueues print in the footer when needed.
	 *
	 * @param string $handle Unique style handle (already prefixed).
	 * @param string $css    CSS rules without a surrounding style element.
	 */
	private function enqueue_inline_css( string $handle, string $css ): void {
		$css = CssSanitizer::sanitize_inline( $css );
		if ( '' === $css ) {
			return;
		}

		if ( ! wp_style_is( $handle, 'registered' ) ) {
			wp_register_style( $handle, false, array(), JOCBM_VERSION );
		}
		wp_enqueue_style( $handle );
		wp_add_inline_style( $handle, $css );
	}

	/**
	 * Render [jocbm_blockora_morph id="..."].
	 *
	 * @param array<string, string> $atts Attributes.
	 */
	public function render_shortcode( $atts ): string {
		$atts = shortcode_atts( array( 'id' => '' ), $atts, JOCBM_SHORTCODE );
		$key  = sanitize_text_field( $atts['id'] );

		if ( ! $key ) {
			return '';
		}

		global $wpdb;
		$table = Schema::table( 'user_contents' );
		$ub    = Schema::table( 'user_blocks' );
		$sys   = Schema::table( 'system_blocks' );

		$row = $wpdb->get_row(
			// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- %i table identifiers are not counted by PHPCS.
			$wpdb->prepare(
				"SELECT c.*, u.css_vars AS ub_css, u.slug AS ub_slug,
					COALESCE(NULLIF(u.markup, ''), s.markup) AS markup
				FROM %i c
				INNER JOIN %i u ON u.id = c.user_block_id
				INNER JOIN %i s ON s.id = u.system_block_id
				WHERE c.shortcode_key = %s AND c.is_enabled = 1",
				$table,
				$ub,
				$sys,
				$key
			),
			ARRAY_A
		);

		if ( ! $row ) {
			return '';
		}

		$attrs    = JsonHelper::decode_array( $row['content_attrs'] ?? null, array() );
		$css_vars = CssVars::normalize( JsonHelper::decode_array( $row['css_vars'] ?? ( $row['ub_css'] ?? null ), array() ) );
		$html     = $this->interpolate( $row['markup'], array_merge( $attrs, array( 'cssVars' => $css_vars ) ) );
		IconRegistry::enqueue_styles_for( IconRegistry::library_ids_from_attrs( $attrs ) );
		if ( $this->output_needs_slider( $html, $attrs ) ) {
			$this->enqueue_frontend_slider();
		}
		if ( $this->output_needs_tabs( $html, $attrs ) ) {
			$this->enqueue_frontend_tabs();
		}
		if ( $this->output_needs_accordion( $html, $attrs ) ) {
			$this->enqueue_frontend_accordion();
		}
		$class    = 'joc-blockora-morph-content wp-block-joc-blockora-morph-' . sanitize_html_class( $row['ub_slug'] );
		$class   .= ' joc-blockora-morph-content-' . sanitize_html_class( $row['shortcode_key'] );
		$selector = '.joc-blockora-morph-content-' . sanitize_html_class( $row['shortcode_key'] );
		$handle   = 'joc-blockora-morph-sc-' . sanitize_key( $row['shortcode_key'] );

		// Prefer uploaded content CSS; fall back to inline CSS via wp_add_inline_style.
		$content_css = 'content-' . sanitize_file_name( $row['shortcode_key'] ) . '.css';
		$css_path    = trailingslashit( CssWriter::dir() ) . $content_css;

		$inline = '';
		if ( file_exists( $css_path ) ) {
			$this->enqueue_content_style( $row['shortcode_key'] );
		} elseif ( CssVars::has_any( $css_vars ) ) {
			$inline .= CssVars::to_css( $selector, $css_vars );
		}
		$order_css = ElementsOrder::to_css_for_attrs( $selector, $attrs );
		if ( '' !== trim( $order_css ) ) {
			$inline .= ( $inline ? "\n" : '' ) . $order_css;
		}
		$vis_css = ElementsVisibility::to_css_for_attrs( $selector, $attrs );
		if ( '' !== trim( $vis_css ) ) {
			$inline .= ( $inline ? "\n" : '' ) . $vis_css;
		}
		$btn_css = MarkupInterpolator::button_hover_css( $selector, $attrs );
		if ( '' !== $btn_css ) {
			$inline .= ( $inline ? "\n" : '' ) . $btn_css;
		}
		$this->enqueue_inline_css( $handle, $inline );

		$extra_attrs = '';
		if ( $this->can_frontend_edit_current() ) {
			$extra_attrs = sprintf(
				' data-bm-edit="1" data-bm-surface="shortcode" data-bm-content-id="%1$d" data-bm-name="%2$s"',
				(int) $row['id'],
				esc_attr( 'joc-blockora-morph/' . $row['ub_slug'] )
			);
		}

		return BlockMarkup::wrap_content_output( $class, $extra_attrs, $html );
	}

	/**
	 * Register editor script handle early (required before register_block_type editor_script).
	 */
	public function register_editor_script(): void {
		$asset_file = JOCBM_PATH . 'build/editor.asset.php';
		if ( ! file_exists( $asset_file ) ) {
			return;
		}

		$asset = require $asset_file;
		$js_path = JOCBM_PATH . 'build/editor.js';
		// Bust browser/CDN caches aggressively (filemtime changes on every rebuild).
		$version = JOCBM_VERSION . '.' . (string) filemtime( $js_path );

		$this->register_frontend_runtime();

		$deps = array_unique(
			array_merge(
				$asset['dependencies'] ?? array(),
				array( 'wp-element', 'wp-blocks', 'wp-block-editor', 'wp-components', 'wp-i18n', 'editor' )
			)
		);
		// Editor canvas needs Splide API for hero remounts; tabs nav is bundled in editor.js.
		if (
			$this->any_enabled_slider_block() &&
			wp_script_is( 'joc-blockora-morph-frontend-slider', 'registered' )
		) {
			$deps[] = 'joc-blockora-morph-frontend-slider';
		}

		wp_register_script(
			'joc-blockora-morph-editor',
			JOCBM_URL . 'build/editor.js',
			$deps,
			$version,
			true
		);

		\JOCBM\Assets\SharedUi::register();

		$editor_css = JOCBM_PATH . 'build/editor.css';
		if ( file_exists( $editor_css ) ) {
			$editor_deps = array( 'wp-components' );
			if ( wp_style_is( \JOCBM\Assets\SharedUi::STYLE_HANDLE, 'registered' ) ) {
				$editor_deps[] = \JOCBM\Assets\SharedUi::STYLE_HANDLE;
			}
			wp_register_style(
				'joc-blockora-morph-editor',
				JOCBM_URL . 'build/editor.css',
				$editor_deps,
				JOCBM_VERSION . '.' . (string) filemtime( $editor_css )
			);
		}
	}

	/**
	 * Editor script for Blockora Morph inspector UI + client block registration.
	 */
	public function enqueue_editor_assets(): void {
		if ( ! wp_script_is( 'joc-blockora-morph-editor', 'registered' ) ) {
			$this->register_editor_script();
		}

		if ( ! wp_script_is( 'joc-blockora-morph-editor', 'registered' ) ) {
			return;
		}

		wp_enqueue_script( 'joc-blockora-morph-editor' );

		\JOCBM\Assets\SharedUi::enqueue();

		if ( wp_style_is( 'joc-blockora-morph-editor', 'registered' ) ) {
			wp_enqueue_style( 'joc-blockora-morph-editor' );
		}

		\JOCBM\Theme\ThemeConfig::enqueue_configured_fonts();

		// Icon library CSS in the PARENT editor frame (inspector popovers live here,
		// not in the canvas iframe that enqueue_block_assets targets).
		wp_enqueue_style( 'dashicons' );
		$icon_lib_ids = array();
		foreach ( IconRegistry::get_enabled_libraries() as $lib ) {
			if ( ! empty( $lib['id'] ) && 'dashicons' !== $lib['id'] ) {
				$icon_lib_ids[] = (string) $lib['id'];
			}
		}
		if ( $icon_lib_ids ) {
			IconRegistry::enqueue_styles_for( $icon_lib_ids );
		}

		// Interactive runtime for Gutenberg canvas (slider remounts only —
		// tabs burger close is already in editor.js via initBmTabsNav).
		if ( $this->any_enabled_slider_block() ) {
			$this->enqueue_frontend_slider();
		}

		// TinyMCE for sidebar WYSIWYG fields (subheader, etc.).
		wp_enqueue_editor();
		wp_enqueue_media();

		// Editor canvas needs every enabled block stylesheet (inserter + existing content).
		foreach ( $this->get_enabled_user_blocks() as $block ) {
			$handle = $this->register_user_block_style( $block );
			if ( $handle ) {
				wp_enqueue_style( $handle );
			}
		}
		foreach ( LibraryBlocks::catalog() as $block ) {
			if ( ! LibraryBlocks::is_inserter_visible( $block ) ) {
				continue;
			}
			$handle = $this->register_library_block_style(
				(string) ( $block['systemSlug'] ?? '' ),
				(string) ( $block['variant'] ?? 'default' )
			);
			if ( $handle ) {
				wp_enqueue_style( $handle );
			}
		}

		$user_blocks = array_map(
			static function ( $b ) {
				$fields  = TemplateLibrary::decode_field_schema( $b['field_schema'] ?? '[]' );
				$library = TemplateLibrary::decode_field_schema( $b['library_field_schema'] ?? '[]' );
				$fields  = TemplateLibrary::apply_library_field_types( $fields, $library );
				$icon_meta = \JOCBM\Services\BlockIdentityIcon::for_editor_meta(
					(string) ( $b['icon'] ?? 'screenoptions' )
				);

				return array(
					'name'         => 'joc-blockora-morph/' . $b['slug'],
					'slug'         => $b['slug'],
					'id'           => (int) $b['id'],
					'title'        => $b['name'],
					'icon'         => $icon_meta['icon'],
					'iconSvg'      => $icon_meta['iconSvg'],
					'iconEmoji'    => $icon_meta['iconEmoji'],
					'markup'       => $b['markup'],
					'fieldSchema'  => $fields,
					'styleSchema'  => JsonHelper::decode_array( $b['style_schema'] ?? null, array() ),
					'cssVars'      => CssVars::normalize( JsonHelper::decode_array( $b['css_vars'] ?? null, array() ) ),
					'defaultAttrs' => JsonHelper::decode_array( $b['default_attrs'] ?? null, array() ),
					'postTypes'    => ( static function ( $raw ) {
						$types = JsonHelper::decode_array( $raw, array() );
						$types = array_values(
							array_filter(
								array_map( 'sanitize_key', (array) $types )
							)
						);
						return $types ? $types : array( 'post', 'page' );
					} )( $b['post_types'] ?? null ),
					'variant'      => (string) ( $b['variant'] ?? '' ),
					'systemSlug'   => (string) ( $b['system_slug'] ?? '' ),
					'systemName'   => (string) ( $b['system_name'] ?? '' ),
					'categorySlug' => (string) ( $b['category_slug'] ?? '' ),
					'categoryName' => (string) ( $b['category_name'] ?? '' ),
				);
			},
			$this->get_enabled_user_blocks()
		);

		$library_blocks = array_map(
			static function ( $b ) {
				$fields  = TemplateLibrary::decode_field_schema( $b['fieldSchema'] ?? '[]' );
				$variants = array();
				if ( ! empty( $b['variants'] ) && is_array( $b['variants'] ) ) {
					foreach ( $b['variants'] as $variant_row ) {
						if ( ! is_array( $variant_row ) ) {
							continue;
						}
						$v_fields = TemplateLibrary::decode_field_schema( $variant_row['fieldSchema'] ?? '[]' );
						$variants[] = array(
							'name'           => (string) ( $variant_row['name'] ?? '' ),
							'slug'           => (string) ( $variant_row['slug'] ?? '' ),
							'systemSlug'     => (string) ( $variant_row['systemSlug'] ?? '' ),
							'variant'        => (string) ( $variant_row['variant'] ?? 'default' ),
							'id'             => 0,
							'title'          => (string) ( $variant_row['title'] ?? '' ),
							'icon'           => BlockIcons::for_library_row( $variant_row ),
							'markup'         => (string) ( $variant_row['markup'] ?? '' ),
							'fieldSchema'    => $v_fields,
							'styleSchema'    => is_array( $variant_row['styleSchema'] ?? null ) ? $variant_row['styleSchema'] : array(),
							'cssVars'        => CssVars::normalize( $variant_row['cssVars'] ?? array() ),
							'defaultAttrs'   => is_array( $variant_row['defaultAttrs'] ?? null ) ? $variant_row['defaultAttrs'] : array(),
							'variantLabel'   => (string) ( $variant_row['variant'] ?? '' ),
							'systemName'     => (string) ( $variant_row['systemName'] ?? '' ),
							'categorySlug'   => (string) ( $variant_row['categorySlug'] ?? '' ),
							'categoryName'   => (string) ( $variant_row['categoryName'] ?? '' ),
							'isLibraryBlock' => true,
							'isPro'          => LibraryBlocks::is_pro_row( $variant_row ),
							'ownerPack'      => ! empty( $variant_row['ownerPack'] ),
							'packType'       => 'variant',
							'extends'        => is_array( $variant_row['extends'] ?? null ) ? $variant_row['extends'] : null,
							'variants'       => array(),
						);
					}
				}
				return array(
					'name'           => (string) ( $b['name'] ?? '' ),
					'slug'           => (string) ( $b['slug'] ?? '' ),
					'systemSlug'     => (string) ( $b['systemSlug'] ?? '' ),
					'variant'        => (string) ( $b['variant'] ?? 'default' ),
					'id'             => 0,
					'title'          => (string) ( $b['title'] ?? '' ),
					'icon'           => BlockIcons::for_library_row( $b ),
					'markup'         => (string) ( $b['markup'] ?? '' ),
					'fieldSchema'    => $fields,
					'styleSchema'    => is_array( $b['styleSchema'] ?? null ) ? $b['styleSchema'] : array(),
					'cssVars'        => CssVars::normalize( $b['cssVars'] ?? array() ),
					'defaultAttrs'   => is_array( $b['defaultAttrs'] ?? null ) ? $b['defaultAttrs'] : array(),
					'variantLabel'   => (string) ( $b['variant'] ?? '' ),
					'systemName'     => (string) ( $b['systemName'] ?? '' ),
					'categorySlug'   => (string) ( $b['categorySlug'] ?? '' ),
					'categoryName'   => (string) ( $b['categoryName'] ?? '' ),
					'isLibraryBlock' => true,
					'isPro'          => LibraryBlocks::is_pro_row( $b ),
					'ownerPack'      => ! empty( $b['ownerPack'] ),
					'packType'       => sanitize_key( (string) ( $b['packType'] ?? 'standalone' ) ) ?: 'standalone',
					'extends'        => is_array( $b['extends'] ?? null ) ? $b['extends'] : null,
					'variants'       => $variants,
				);
			},
			array_values(
				array_filter(
					LibraryBlocks::catalog(),
					static function ( $b ) {
						return LibraryBlocks::is_inserter_visible( $b );
					}
				)
			)
		);

		$blocks = $user_blocks;

		$runtime = self::frontend_runtime_urls();

		wp_localize_script(
			'joc-blockora-morph-editor',
			'jocbmEditor',
			array(
				'restUrl'         => esc_url_raw( rest_url( 'joc-blockora-morph/v1' ) ),
				'nonce'           => wp_create_nonce( 'wp_rest' ),
				'pluginUrl'       => JOCBM_URL,
				'logoUrl'         => JOCBM_URL . 'assets/images/joc-blockora-morph-logo.gif',
				'interFontUrl'    => \JOCBM\Theme\ThemeConfig::inter_font_url(),
				'themeAgnosticUrl' => \JOCBM\Theme\ThemeConfig::theme_agnostic_url(),
				'adminUrl'        => admin_url( 'admin.php?page=joc-blockora-morph' ),
				'isPro'           => jocbm_is_pro(),
				'blocks'          => $blocks,
				'libraryBlocks'        => $library_blocks,
				'libraryBlockRegistry' => array_map(
					static function ( $block ) {
						return array(
							'name'  => (string) ( $block['name'] ?? '' ),
							'isPro' => LibraryBlocks::is_pro_row( $block ),
						);
					},
					LibraryBlocks::catalog()
				),
				'buttonLibrary'   => $this->get_button_library(),
				'themeConfig'     => \JOCBM\Rest\ThemeConfigController::get_config_with_fonts(),
				'fontFamilies'    => \JOCBM\Theme\ThemeConfig::get_available_font_families(),
				'themeColors'     => \JOCBM\Theme\ThemeConfig::get_theme_origin_colors(),
				'currentPostType' => $this->get_editor_post_type(),
				'frontendJsUrl'   => $runtime['js'],
				'frontendCssUrl'  => $runtime['css'],
				'dashiconsUrl'    => includes_url( 'css/dashicons.min.css' ),
				'iconLibraries'   => IconRegistry::localize_payload(),
			)
		);
	}

	/**
	 * Load editor.css inside the iframed block canvas (selection rings, chrome).
	 * Parent-frame enqueue alone does not style canvas content in WP 6.3+.
	 *
	 * Also enqueue My Block + JBM Library uploads CSS here so library inserts and
	 * My Block inserts share the same stylesheet cascade in the canvas iframe
	 * (enqueue_block_editor_assets only reaches the parent frame).
	 */
	public function enqueue_editor_canvas_styles(): void {
		if ( ! is_admin() ) {
			return;
		}

		// Dashicons live in the admin shell; the iframed canvas needs them too
		// for button icons (.dashicons-*) to paint their :before glyphs.
		wp_enqueue_style( 'dashicons' );

		// Enabled custom icon libraries (FA / Material / custom CSS) for canvas previews.
		$enabled_ids = array();
		foreach ( IconRegistry::get_enabled_libraries() as $lib ) {
			if ( ! empty( $lib['id'] ) && 'dashicons' !== $lib['id'] ) {
				$enabled_ids[] = (string) $lib['id'];
			}
		}
		if ( $enabled_ids ) {
			IconRegistry::enqueue_styles_for( $enabled_ids );
		}

		if ( ! wp_style_is( 'joc-blockora-morph-editor', 'registered' ) ) {
			$this->register_editor_script();
		}

		if ( wp_style_is( 'joc-blockora-morph-editor', 'registered' ) ) {
			wp_enqueue_style( 'joc-blockora-morph-editor' );
		}

		// Same block stylesheets the parent frame gets — required for iframe parity
		// between JBM Library Blocks and My Blocks inserts.
		foreach ( $this->get_enabled_user_blocks() as $block ) {
			$handle = $this->register_user_block_style( $block );
			if ( $handle ) {
				wp_enqueue_style( $handle );
			}
		}
		foreach ( LibraryBlocks::catalog() as $block ) {
			if ( ! LibraryBlocks::is_inserter_visible( $block ) ) {
				continue;
			}
			$handle = $this->register_library_block_style(
				(string) ( $block['systemSlug'] ?? '' ),
				(string) ( $block['variant'] ?? 'default' )
			);
			if ( $handle ) {
				wp_enqueue_style( $handle );
			}
		}

		// Interactive runtime must run inside the iframed block canvas when needed.
		if ( $this->any_enabled_slider_block() ) {
			$this->enqueue_frontend_slider();
		}
		if ( $this->any_enabled_tabs_block() ) {
			$this->enqueue_frontend_tabs();
		}
		if ( $this->any_enabled_accordion_block() ) {
			$this->enqueue_frontend_accordion();
		}
	}

	/**
	 * Current post type in block editor context.
	 */
	private function get_editor_post_type(): string {
		$post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( $post_id ) {
			return get_post_type( $post_id ) ?: '';
		}
		if ( isset( $_GET['post_type'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			return sanitize_key( wp_unslash( $_GET['post_type'] ) ) ?: ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		}
		// post-new.php with no post_type query arg defaults to "post".
		global $pagenow;
		if ( in_array( (string) $pagenow, array( 'post-new.php', 'post.php' ), true ) ) {
			return 'post';
		}
		// Site editor / widgets / other canvases — do not restrict by post type.
		return '';
	}

	/**
	 * Enqueue CSS only for Blockora Morph blocks/shortcodes used on this page.
	 *
	 * Gutenberg block styles are also attached via register_block_type( style ),
	 * so WP on-demand loading covers most cases; this catches shortcodes early
	 * (before wp_head) to avoid FOUC, and covers classic content.
	 */
	public function enqueue_used_frontend_css(): void {
		if ( is_admin() ) {
			return;
		}

		$slugs = $this->detect_used_block_slugs();
		$keys  = $this->detect_used_shortcode_keys();

		if ( $slugs || $keys ) {
			if ( wp_style_is( 'joc-blockora-morph-theme-agnostic', 'registered' ) ) {
				wp_enqueue_style( 'joc-blockora-morph-theme-agnostic' );
			}
			wp_enqueue_style( 'dashicons' );
			\JOCBM\Theme\ThemeConfig::enqueue_configured_fonts();
		}

		foreach ( $slugs as $slug ) {
			if ( 0 === strpos( $slug, 'lib:' ) ) {
				$key = substr( $slug, 4 );
				if ( preg_match( '/^([a-z0-9-]+)--([a-z0-9-]+)$/', $key, $m ) ) {
					$this->enqueue_library_block_style( $m[1], $m[2] );
				}
				continue;
			}
			$this->enqueue_user_block_style_by_slug( $slug );
		}

		foreach ( $keys as $key ) {
			$this->enqueue_content_style( $key );
		}

		if ( $slugs || $keys ) {
			$this->maybe_enqueue_frontend_runtime( $slugs, $keys );
		}
	}

	/**
	 * Register public interactive runtimes (slider / tabs / accordion — separate handles).
	 * Combined `frontend` remains for admin/editor preview iframes.
	 */
	private function register_frontend_runtime(): void {
		$this->register_frontend_script(
			'joc-blockora-morph-frontend-slider',
			'frontend-slider',
			true
		);
		$this->register_frontend_script(
			'joc-blockora-morph-frontend-tabs',
			'frontend-tabs',
			false
		);
		$this->register_frontend_script(
			'joc-blockora-morph-frontend-accordion',
			'frontend-accordion',
			false
		);
		// Combined bundle for preview iframes that load a single URL.
		$this->register_frontend_script(
			'joc-blockora-morph-frontend',
			'frontend',
			true
		);
	}

	/**
	 * Register one frontend build entry (idempotent).
	 *
	 * @param string $handle   Script handle.
	 * @param string $entry    Build basename without extension (e.g. frontend-slider).
	 * @param bool   $with_css Whether to register matching CSS when present.
	 */
	private function register_frontend_script( string $handle, string $entry, bool $with_css ): void {
		if ( wp_script_is( $handle, 'registered' ) ) {
			return;
		}

		$asset_file = JOCBM_PATH . 'build/' . $entry . '.asset.php';
		$js_path    = JOCBM_PATH . 'build/' . $entry . '.js';
		if ( ! file_exists( $asset_file ) || ! file_exists( $js_path ) ) {
			return;
		}

		$asset   = require $asset_file;
		$version = JOCBM_VERSION . '.' . (string) filemtime( $js_path );
		$deps    = is_array( $asset['dependencies'] ?? null ) ? $asset['dependencies'] : array();

		wp_register_script(
			$handle,
			JOCBM_URL . 'build/' . $entry . '.js',
			$deps,
			$version,
			true
		);

		if ( ! $with_css ) {
			return;
		}

		$css_path = JOCBM_PATH . 'build/' . $entry . '.css';
		if ( file_exists( $css_path ) ) {
			wp_register_style(
				$handle,
				JOCBM_URL . 'build/' . $entry . '.css',
				array(),
				JOCBM_VERSION . '.' . (string) filemtime( $css_path )
			);
		}
	}

	/**
	 * Enqueue slider runtime (Splide) — idempotent, once per page.
	 */
	private function enqueue_frontend_slider(): void {
		$this->register_frontend_runtime();
		$handle = 'joc-blockora-morph-frontend-slider';
		if ( ! wp_script_is( $handle, 'registered' ) ) {
			return;
		}
		if ( ! wp_script_is( $handle, 'enqueued' ) ) {
			wp_enqueue_script( $handle );
		}
		if ( wp_style_is( $handle, 'registered' ) && ! wp_style_is( $handle, 'enqueued' ) ) {
			wp_enqueue_style( $handle );
		}
	}

	/**
	 * Enqueue tabs nav runtime — idempotent, once per page.
	 */
	private function enqueue_frontend_tabs(): void {
		$this->register_frontend_runtime();
		$handle = 'joc-blockora-morph-frontend-tabs';
		if ( ! wp_script_is( $handle, 'registered' ) ) {
			return;
		}
		if ( ! wp_script_is( $handle, 'enqueued' ) ) {
			wp_enqueue_script( $handle );
		}
	}

	/**
	 * Enqueue accordion exclusive-open runtime — idempotent, once per page.
	 */
	private function enqueue_frontend_accordion(): void {
		$this->register_frontend_runtime();
		$handle = 'joc-blockora-morph-frontend-accordion';
		if ( ! wp_script_is( $handle, 'registered' ) ) {
			return;
		}
		if ( ! wp_script_is( $handle, 'enqueued' ) ) {
			wp_enqueue_script( $handle );
		}
	}

	/**
	 * Enqueue needed public runtimes for a rendered block (slider / tabs / accordion).
	 *
	 * @param array<string, mixed> $block Block row / runtime meta.
	 * @param string               $html  Interpolated markup.
	 * @param array<string, mixed> $attrs Field attributes.
	 */
	private function enqueue_frontend_for_block( array $block, string $html = '', array $attrs = array() ): void {
		$need_slider =
			$this->block_needs_slider( $block ) ||
			( '' !== $html && $this->output_needs_slider( $html, $attrs ) );
		$need_tabs =
			$this->block_needs_tabs( $block ) ||
			( '' !== $html && $this->output_needs_tabs( $html, $attrs ) );
		$need_accordion =
			$this->block_needs_accordion( $block ) ||
			( '' !== $html && $this->output_needs_accordion( $html, $attrs ) );
		if ( $need_slider ) {
			$this->enqueue_frontend_slider();
		}
		if ( $need_tabs ) {
			$this->enqueue_frontend_tabs();
		}
		if ( $need_accordion ) {
			$this->enqueue_frontend_accordion();
		}
	}

	/**
	 * Enqueue public interactive runtimes (idempotent — WordPress prints each handle once).
	 * Prefer enqueue_frontend_slider / enqueue_frontend_tabs / enqueue_frontend_accordion when the need is known.
	 *
	 * @param bool $slider    Include slider (Splide) runtime.
	 * @param bool $tabs      Include tabs nav runtime.
	 * @param bool $accordion Include accordion exclusive-open runtime.
	 */
	private function enqueue_frontend_runtime( bool $slider = true, bool $tabs = true, bool $accordion = true ): void {
		if ( $slider ) {
			$this->enqueue_frontend_slider();
		}
		if ( $tabs ) {
			$this->enqueue_frontend_tabs();
		}
		if ( $accordion ) {
			$this->enqueue_frontend_accordion();
		}
	}

	/**
	 * Asset URLs for admin / editor preview iframes (always plugin-local).
	 * Uses the combined bundle so a single script tag covers slider + tabs.
	 *
	 * @return array{js: string, css: string, sliderJs: string, sliderCss: string, tabsJs: string}
	 */
	public static function frontend_runtime_urls(): array {
		$entry_url = static function ( string $entry, string $ext ): string {
			$path = JOCBM_PATH . 'build/' . $entry . '.' . $ext;
			if ( ! file_exists( $path ) ) {
				return '';
			}
			return JOCBM_URL . 'build/' . $entry . '.' . $ext . '?ver=' . rawurlencode(
				JOCBM_VERSION . '.' . (string) filemtime( $path )
			);
		};

		return array(
			'js'        => $entry_url( 'frontend', 'js' ),
			'css'       => $entry_url( 'frontend', 'css' ),
			'sliderJs'  => $entry_url( 'frontend-slider', 'js' ),
			'sliderCss' => $entry_url( 'frontend-slider', 'css' ),
			'tabsJs'    => $entry_url( 'frontend-tabs', 'js' ),
		);
	}

	/**
	 * Whether a user/system block row needs the Splide runtime.
	 *
	 * @param array<string, mixed> $block Block row.
	 */
	private function block_needs_slider( array $block ): bool {
		$variant = strtolower( (string) ( $block['variant'] ?? '' ) );
		if ( 'slider' === $variant ) {
			return true;
		}
		$markup = (string) ( $block['markup'] ?? '' );
		return false !== strpos( $markup, 'data-bm-slider' );
	}

	/**
	 * Whether a user/system block row needs tabs nav JS (burger close-on-select).
	 *
	 * @param array<string, mixed> $block Block row.
	 */
	private function block_needs_tabs( array $block ): bool {
		$markup = (string) ( $block['markup'] ?? '' );
		return false !== strpos( $markup, 'bm-tabs' );
	}

	/**
	 * Whether a user/system block row needs accordion exclusive-open JS.
	 *
	 * @param array<string, mixed> $block Block row.
	 */
	private function block_needs_accordion( array $block ): bool {
		$markup = (string) ( $block['markup'] ?? '' );
		return false !== strpos( $markup, 'bm-accordion' );
	}

	/**
	 * Whether a block needs the public frontend runtime (slider / tabs / accordion).
	 *
	 * @param array<string, mixed> $block Block row.
	 */
	private function block_needs_frontend_runtime( array $block ): bool {
		return $this->block_needs_slider( $block )
			|| $this->block_needs_tabs( $block )
			|| $this->block_needs_accordion( $block );
	}

	/**
	 * Whether rendered HTML or attrs imply a slider on this request.
	 *
	 * @param string               $html  Interpolated markup.
	 * @param array<string, mixed> $attrs Field attributes.
	 */
	private function output_needs_slider( string $html, array $attrs = array() ): bool {
		if ( false !== strpos( $html, 'data-bm-slider' ) ) {
			return true;
		}
		return isset( $attrs['slides'] ) && is_array( $attrs['slides'] ) && $attrs['slides'];
	}

	/**
	 * Whether rendered HTML or attrs imply tabs on this request.
	 *
	 * @param string               $html  Interpolated markup.
	 * @param array<string, mixed> $attrs Field attributes.
	 */
	private function output_needs_tabs( string $html, array $attrs = array() ): bool {
		if ( false !== strpos( $html, 'bm-tabs' ) ) {
			return true;
		}
		return isset( $attrs['tabs'] ) && is_array( $attrs['tabs'] ) && $attrs['tabs'];
	}

	/**
	 * Whether rendered HTML or attrs imply accordion on this request.
	 *
	 * @param string               $html  Interpolated markup.
	 * @param array<string, mixed> $attrs Field attributes.
	 */
	private function output_needs_accordion( string $html, array $attrs = array() ): bool {
		if ( false !== strpos( $html, 'bm-accordion' ) ) {
			return true;
		}
		return isset( $attrs['items'] ) && is_array( $attrs['items'] ) && $attrs['items']
			&& ( isset( $attrs['items'][0]['accordionTitle'] ) || isset( $attrs['items'][0]['accordionContent'] ) );
	}

	/**
	 * Whether rendered HTML or attrs need the public frontend runtime.
	 *
	 * @param string               $html  Interpolated markup.
	 * @param array<string, mixed> $attrs Field attributes.
	 */
	private function output_needs_frontend_runtime( string $html, array $attrs = array() ): bool {
		return $this->output_needs_slider( $html, $attrs )
			|| $this->output_needs_tabs( $html, $attrs )
			|| $this->output_needs_accordion( $html, $attrs );
	}

	/**
	 * Look up an enabled user block by slug.
	 *
	 * @param string $slug Block slug.
	 * @return array<string, mixed>|null
	 */
	private function find_enabled_block_by_slug( string $slug ): ?array {
		$slug = sanitize_title( $slug );
		if ( '' === $slug ) {
			return null;
		}
		foreach ( $this->get_enabled_user_blocks() as $block ) {
			if ( sanitize_title( (string) ( $block['slug'] ?? '' ) ) === $slug ) {
				return $block;
			}
		}
		return null;
	}

	/**
	 * Whether any currently enabled Blockora Morph block is a slider.
	 */
	private function any_enabled_slider_block(): bool {
		foreach ( $this->get_enabled_user_blocks() as $block ) {
			if ( $this->block_needs_slider( $block ) ) {
				return true;
			}
		}
		foreach ( LibraryBlocks::catalog() as $block ) {
			if ( $this->block_needs_slider( $this->library_block_runtime_row( $block ) ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Whether any currently enabled Blockora Morph block needs tabs nav JS.
	 */
	private function any_enabled_tabs_block(): bool {
		foreach ( $this->get_enabled_user_blocks() as $block ) {
			if ( $this->block_needs_tabs( $block ) ) {
				return true;
			}
		}
		foreach ( LibraryBlocks::catalog() as $block ) {
			if ( $this->block_needs_tabs( $this->library_block_runtime_row( $block ) ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Whether any currently enabled Blockora Morph block needs accordion JS.
	 */
	private function any_enabled_accordion_block(): bool {
		foreach ( $this->get_enabled_user_blocks() as $block ) {
			if ( $this->block_needs_accordion( $block ) ) {
				return true;
			}
		}
		foreach ( LibraryBlocks::catalog() as $block ) {
			if ( $this->block_needs_accordion( $this->library_block_runtime_row( $block ) ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Whether any currently enabled Blockora Morph block needs the frontend runtime.
	 */
	private function any_enabled_frontend_runtime_block(): bool {
		return $this->any_enabled_slider_block()
			|| $this->any_enabled_tabs_block()
			|| $this->any_enabled_accordion_block();
	}

	/**
	 * Public interactive runtime when a slider / tabs / accordion block/shortcode is on the page.
	 *
	 * @param array<int, string> $slugs Block slugs.
	 * @param array<int, string> $keys  Shortcode keys.
	 */
	private function maybe_enqueue_frontend_runtime( array $slugs, array $keys ): void {
		if ( ! $slugs && ! $keys ) {
			return;
		}

		$need_slider    = false;
		$need_tabs      = false;
		$need_accordion = false;

		foreach ( $slugs as $slug ) {
			if ( 0 === strpos( $slug, 'lib:' ) ) {
				$key = substr( $slug, 4 );
				if ( preg_match( '/^([a-z0-9-]+)--([a-z0-9-]+)$/', $key, $m ) ) {
					$found = LibraryBlocks::find( $m[1], $m[2] );
					if ( $found ) {
						$row = $this->library_block_runtime_row( $found );
						$need_slider    = $need_slider || $this->block_needs_slider( $row );
						$need_tabs      = $need_tabs || $this->block_needs_tabs( $row );
						$need_accordion = $need_accordion || $this->block_needs_accordion( $row );
					}
				}
				continue;
			}
			$block = $this->find_enabled_block_by_slug( (string) $slug );
			if ( $block ) {
				$need_slider    = $need_slider || $this->block_needs_slider( $block );
				$need_tabs      = $need_tabs || $this->block_needs_tabs( $block );
				$need_accordion = $need_accordion || $this->block_needs_accordion( $block );
			}
		}

		if ( $keys ) {
			$flags = $this->shortcodes_frontend_needs( $keys );
			$need_slider    = $need_slider || ! empty( $flags['slider'] );
			$need_tabs      = $need_tabs || ! empty( $flags['tabs'] );
			$need_accordion = $need_accordion || ! empty( $flags['accordion'] );
		}

		// Fallback: rendered content already contains a slider / tabs / accordion root.
		if ( ! $need_slider || ! $need_tabs || ! $need_accordion ) {
			$content = $this->collect_request_content();
			if ( '' !== $content ) {
				if ( ! $need_slider && false !== strpos( $content, 'data-bm-slider' ) ) {
					$need_slider = true;
				}
				if ( ! $need_tabs && false !== strpos( $content, 'bm-tabs' ) ) {
					$need_tabs = true;
				}
				if ( ! $need_accordion && false !== strpos( $content, 'bm-accordion' ) ) {
					$need_accordion = true;
				}
			}
		}

		$this->enqueue_frontend_runtime( $need_slider, $need_tabs, $need_accordion );
	}

	/**
	 * Which runtimes shortcode contents need.
	 *
	 * @param array<int, string> $keys Shortcode keys.
	 * @return array{slider: bool, tabs: bool, accordion: bool}
	 */
	private function shortcodes_frontend_needs( array $keys ): array {
		$out = array(
			'slider'    => false,
			'tabs'      => false,
			'accordion' => false,
		);
		$keys = array_values(
			array_filter(
				array_map(
					static function ( $key ) {
						return sanitize_text_field( (string) $key );
					},
					$keys
				)
			)
		);
		if ( ! $keys ) {
			return $out;
		}
		global $wpdb;

		$rows = $wpdb->get_results(
			// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- %i table identifiers are not counted by PHPCS.
			$wpdb->prepare(
				'SELECT COALESCE(NULLIF(u.markup, \'\'), s.markup) AS markup, s.variant
				FROM %i c
				INNER JOIN %i u ON u.id = c.user_block_id
				INNER JOIN %i s ON s.id = u.system_block_id
				WHERE c.shortcode_key IN (' . implode( ',', array_fill( 0, count( $keys ), '%s' ) ) . ') AND c.is_enabled = 1',
				Schema::table( 'user_contents' ),
				Schema::table( 'user_blocks' ),
				Schema::table( 'system_blocks' ),
				...$keys
			),
			ARRAY_A
		);
		foreach ( $rows ?: array() as $row ) {
			$out['slider']    = $out['slider'] || $this->block_needs_slider( $row );
			$out['tabs']      = $out['tabs'] || $this->block_needs_tabs( $row );
			$out['accordion'] = $out['accordion'] || $this->block_needs_accordion( $row );
		}
		return $out;
	}

	/**
	 * Register (idempotent) a user-block stylesheet handle.
	 *
	 * @param array<string, mixed> $block User block row.
	 * @return string Handle or empty when no file.
	 */
	private function register_user_block_style( array $block ): string {
		$css_file = (string) ( $block['css_file'] ?? '' );
		if ( '' === $css_file ) {
			$css_file = 'block-' . sanitize_file_name( (string) $block['slug'] ) . '.css';
		}

		$path = trailingslashit( CssWriter::dir() ) . $css_file;
		if ( ! file_exists( $path ) ) {
			return '';
		}

		$handle = 'joc-blockora-morph-' . sanitize_title( (string) $block['slug'] );
		if ( ! wp_style_is( $handle, 'registered' ) ) {
			$deps = array();
			if ( wp_style_is( 'joc-blockora-morph-theme-agnostic', 'registered' ) ) {
				$deps[] = 'joc-blockora-morph-theme-agnostic';
			}
			wp_register_style(
				$handle,
				trailingslashit( CssWriter::url() ) . $css_file,
				$deps,
				(string) filemtime( $path )
			);
			// Lets WP inline small styles when on-demand loading is active.
			wp_style_add_data( $handle, 'path', $path );
		}

		return $handle;
	}

	/**
	 * Enqueue stylesheet for a user block slug (render-time / detection).
	 *
	 * @param string $slug Block slug.
	 */
	private function enqueue_user_block_style_by_slug( string $slug ): void {
		$slug = sanitize_title( $slug );
		if ( '' === $slug ) {
			return;
		}

		$handle = $this->register_user_block_style(
			array(
				'slug'     => $slug,
				'css_file' => 'block-' . sanitize_file_name( $slug ) . '.css',
			)
		);
		if ( $handle ) {
			$this->enqueue_style_now( $handle );
		}
	}

	/**
	 * Enqueue a shortcode content stylesheet.
	 *
	 * @param string $shortcode_key Shortcode id attr.
	 */
	private function enqueue_content_style( string $shortcode_key ): void {
		$key = sanitize_file_name( $shortcode_key );
		if ( '' === $key ) {
			return;
		}

		$filename = 'content-' . $key . '.css';
		$path     = trailingslashit( CssWriter::dir() ) . $filename;
		if ( ! file_exists( $path ) ) {
			return;
		}

		$handle = 'joc-blockora-morph-content-' . sanitize_title( $key );
		if ( ! wp_style_is( $handle, 'registered' ) ) {
			wp_register_style(
				$handle,
				trailingslashit( CssWriter::url() ) . $filename,
				array(),
				(string) filemtime( $path )
			);
			wp_style_add_data( $handle, 'path', $path );
		}

		$this->enqueue_style_now( $handle );
	}

	/**
	 * Enqueue a style; if head already ran, print it immediately to avoid FOUC.
	 *
	 * @param string $handle Style handle.
	 */
	private function enqueue_style_now( string $handle ): void {
		if ( ! wp_style_is( $handle, 'registered' ) ) {
			return;
		}

		wp_enqueue_style( $handle );

		if ( did_action( 'wp_print_styles' ) && ! wp_style_is( $handle, 'done' ) ) {
			wp_print_styles( $handle );
		}
	}

	/**
	 * Blockora Morph block slugs present in the main queried content.
	 *
	 * @return array<int, string>
	 */
	private function detect_used_block_slugs(): array {
		$content = $this->collect_request_content();
		if ( '' === $content ) {
			return array();
		}

		$slugs = array();
		if ( preg_match_all( '/<!--\s*wp:joc-blockora-morph\/([a-z0-9_-]+)/i', $content, $matches ) ) {
			foreach ( $matches[1] as $slug ) {
				$slugs[] = sanitize_title( $slug );
			}
		}
		if ( preg_match_all( '/<!--\s*wp:joc-blockora-morph-lib\/([a-z0-9-]+)--([a-z0-9-]+)/i', $content, $lib_matches ) ) {
			$count = count( $lib_matches[0] );
			for ( $i = 0; $i < $count; $i++ ) {
				$slugs[] = 'lib:' . sanitize_title( $lib_matches[1][ $i ] ) . '--' . sanitize_title( $lib_matches[2][ $i ] );
			}
		}

		return array_values( array_unique( array_filter( $slugs ) ) );
	}

	/**
	 * Shortcode keys present in the main queried content.
	 *
	 * @return array<int, string>
	 */
	private function detect_used_shortcode_keys(): array {
		$content = $this->collect_request_content();
		$has_shortcode = false !== stripos( $content, '[' . JOCBM_SHORTCODE );
		if ( '' === $content || ! $has_shortcode ) {
			return array();
		}

		$keys = array();
		if ( preg_match_all( '/\[jocbm_blockora_morph\s+[^\]]*?\bid\s*=\s*[\'"]([^\'"]+)[\'"]/i', $content, $matches ) ) {
			foreach ( $matches[1] as $key ) {
				$keys[] = sanitize_text_field( $key );
			}
		}

		return array_values( array_unique( array_filter( $keys ) ) );
	}

	/**
	 * Post/page content available early in the request (main query + singular).
	 */
	private function collect_request_content(): string {
		static $cached = null;
		if ( null !== $cached ) {
			return $cached;
		}

		$parts = array();

		if ( is_singular() ) {
			$post = get_queried_object();
			if ( $post instanceof \WP_Post ) {
				$parts[] = (string) $post->post_content;
			}
		}

		/**
		 * Extra HTML/content to scan for Blockora Morph assets on this request.
		 *
		 * @param string $extra Extra content.
		 */
		$extra = apply_filters( 'jocbm_asset_scan_content', '' );
		if ( is_string( $extra ) && '' !== $extra ) {
			$parts[] = $extra;
		}

		$cached = implode( "\n", $parts );
		return $cached;
	}

	/**
	 * Enabled user blocks with system join.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	public function get_enabled_user_blocks(): array {
		static $cache = null;
		if ( null !== $cache ) {
			return $cache;
		}

		global $wpdb;

		$table  = Schema::table( 'user_blocks' );
		$system = Schema::table( 'system_blocks' );

		if ( ! $table || ! $system ) {
			$cache = array();
			return $cache;
		}

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

		$cats = Schema::table( 'categories' );
		$rows = $wpdb->get_results(
			// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- %i table identifiers are not counted by PHPCS.
			$wpdb->prepare(
				"SELECT u.*, s.name AS system_name, s.variant, s.slug AS system_slug,
				c.slug AS category_slug, c.name AS category_name,
				COALESCE(NULLIF(u.markup, ''), s.markup) AS markup,
				COALESCE(NULLIF(u.field_schema, ''), s.field_schema) AS field_schema,
				s.field_schema AS library_field_schema,
				COALESCE(NULLIF(u.style_schema, ''), s.style_schema) AS style_schema
			FROM %i u
				INNER JOIN %i s ON s.id = u.system_block_id
			LEFT JOIN %i c ON c.id = s.category_id
			WHERE u.is_enabled = 1
			ORDER BY u.updated_at DESC, u.id DESC",
				$table,
				$system,
				$cats
			),
			ARRAY_A
		);

		$cache = $rows ?: array();
		return $cache;
	}

	/**
	 * Imported button blocks for the hero Style reference picker (styles only).
	 *
	 * Only returns enabled My Blocks in the Buttons category — system templates
	 * alone are not listed until the user downloads them.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	public function get_button_library(): array {
		global $wpdb;
		$table  = Schema::table( 'user_blocks' );
		$system = Schema::table( 'system_blocks' );
		$cats   = Schema::table( 'categories' );

		if ( ! $table || ! $system ) {
			return array();
		}

		// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- %i table identifiers are not counted by PHPCS.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT u.id, u.name, u.css_vars, u.default_attrs, s.slug AS system_slug
			FROM %i u
			INNER JOIN %i s ON s.id = u.system_block_id
			LEFT JOIN %i c ON c.id = s.category_id
			WHERE u.is_enabled = 1
			AND ( c.slug = 'buttons' OR s.slug = 'button' OR s.slug LIKE %s )
			ORDER BY u.updated_at DESC, u.id DESC",
				$table,
				$system,
				$cats,
				'button-%'
			),
			ARRAY_A
		);
		$out  = array();
		foreach ( $rows ?: array() as $row ) {
			$out[] = array(
				'id'         => (int) $row['id'],
				'title'      => (string) $row['name'],
				'source'     => 'user',
				'systemSlug' => (string) ( $row['system_slug'] ?? '' ),
				'styles'     => MarkupInterpolator::button_styles_from_vars_attrs(
					JsonHelper::decode_array( $row['css_vars'] ?? null, array() ),
					JsonHelper::decode_array( $row['default_attrs'] ?? null, array() )
				),
			);
		}
		return $out;
	}

	/**
	 * Build block attributes from schemas.
	 *
	 * @param array<string, mixed> $block Block row.
	 * @return array<string, array<string, mixed>>
	 */
	private function build_attributes( array $block ): array {
		$attrs = array(
			// Bind placements to the My Blocks row id (not display name / slug).
			'userBlockId' => array(
				'type'    => 'number',
				'default' => (int) ( $block['id'] ?? 0 ),
			),
			// Empty by default — library styles live in the user-block CSS file.
			// Baking the full library snapshot here froze placements against later
			// Linked placements follow My Block Design until Customize forks.
			'cssVars' => array(
				'type'    => 'object',
				'default' => CssVars::normalize( array() ),
			),
			'styleScopeChoices' => array(
				'type'    => 'object',
				'default' => array(),
			),
			'librarySaveRev'   => array(
				'type'    => 'number',
				'default' => 0,
			),
		);

		$defaults = JsonHelper::decode_array( $block['default_attrs'] ?? null, array() );
		$schema   = JsonHelper::decode_array( $block['field_schema'] ?? null, array() );

		foreach ( $schema as $field ) {
			$key = $field['key'] ?? '';
			if ( ! $key || ! is_array( $field ) ) {
				continue;
			}
			$attrs[ $key ] = MarkupInterpolator::attribute_for_field( $field, $defaults );

			if ( ( $field['type'] ?? '' ) === 'elementsOrder' ) {
				$vis_keys = array();
				if ( ! empty( $field['visibilityKeys'] ) && is_array( $field['visibilityKeys'] ) ) {
					$vis_keys = $field['visibilityKeys'];
				} elseif ( ! empty( $field['default'] ) && is_array( $field['default'] ) ) {
					$vis_keys = $field['default'];
				}
				if ( ! empty( $defaults['elementsVisibility'] ) && is_array( $defaults['elementsVisibility'] ) ) {
					$vis_norm = ElementsVisibility::normalize( $defaults['elementsVisibility'] );
					$vis_keys = array_values(
						array_unique(
							array_merge( $vis_keys, array_keys( $vis_norm['desktop'] ) )
						)
					);
				}
				$vis_default = ! empty( $defaults['elementsVisibility'] ) && is_array( $defaults['elementsVisibility'] )
					? $defaults['elementsVisibility']
					: array_fill_keys(
						array_values(
							array_filter(
								$vis_keys,
								static function ( $vk ) {
									return is_string( $vk ) && '' !== $vk;
								}
							)
						),
						true
					);
				$attrs['elementsVisibility'] = array(
					'type'    => 'object',
					'default' => ElementsVisibility::sanitize( $vis_default, $vis_keys ),
				);
			}
		}

		return $attrs;
	}

	/**
	 * Server render — markup uses var(); defaults live in uploads CSS.
	 * Instance overrides use wp_add_inline_style with a scoped class + media queries.
	 *
	 * @param array<string, mixed> $block      Block row.
	 * @param array<string, mixed> $attributes Attributes.
	 */
	private function render_block( array $block, array $attributes ): string {
		$row_id       = (int) ( $block['id'] ?? 0 );
		$placement_id = isset( $attributes['userBlockId'] )
			? (int) $attributes['userBlockId']
			: 0;
		// Placement locked to a My Blocks row id — never render against a different row
		// (e.g. same display name re-imported under a new id / block type).
		if ( $placement_id > 0 && $row_id > 0 && $placement_id !== $row_id ) {
			return '';
		}

		// Safety net when on-demand block assets are disabled or block is rendered outside parsed content.
		$handle = $this->register_user_block_style( $block );
		if ( $handle ) {
			$this->enqueue_style_now( $handle );
		}

		$defaults = JsonHelper::decode_array( $block['default_attrs'] ?? null, array() );
		$saved    = JsonHelper::decode_array( $block['css_vars'] ?? null, array() );
		$schema   = TemplateLibrary::decode_field_schema( $block['field_schema'] ?? '[]' );
		$order_defaults = ElementsOrder::defaults_from_schema( $schema, $defaults );
		$merged   = InstanceScope::merge_render_attrs( $defaults, is_array( $attributes ) ? $attributes : array(), $schema );
		unset( $merged['cssVars'], $merged['styleScopeChoices'], $merged['librarySaveRev'], $merged['userBlockId'], $merged['allowedPostTypes'], $merged['align'], $merged['className'], $merged['anchor'] );

		$instance_vars = isset( $attributes['cssVars'] ) && is_array( $attributes['cssVars'] )
			? $attributes['cssVars']
			: array();

		$scope_choices = isset( $attributes['styleScopeChoices'] ) && is_array( $attributes['styleScopeChoices'] )
			? $attributes['styleScopeChoices']
			: array();
		$independent  = InstanceScope::is_independent( $scope_choices );

		// Independent placements use a full forked snapshot — do not diff against live library.
		// Library-linked placements must not treat a baked full-library attribute
		// default as overrides after the My Block Design changes.
		if ( $independent ) {
			$overrides = CssVars::normalize( $instance_vars );
		} elseif ( CssVars::looks_like_library_snapshot( $instance_vars, $saved ) ) {
			$overrides = CssVars::normalize( array() );
		} else {
			$overrides = CssVars::overrides( $instance_vars, $saved );
		}
		$html      = $this->interpolate(
			$block['markup'],
			array_merge(
				$merged,
				array(
					'cssVars' => CssVars::merge( $saved, $overrides ),
				)
			)
		);
		IconRegistry::enqueue_styles_for( IconRegistry::library_ids_from_attrs( $merged ) );
		$this->enqueue_frontend_for_block( $block, $html, $merged );
		$slug      = sanitize_html_class( $block['slug'] );
		$base_cls  = 'wp-block-joc-blockora-morph-' . $slug;
		$selector  = '.' . $base_cls;
		$btn_css   = MarkupInterpolator::button_hover_css( $selector, $merged );
		$slides    = isset( $merged['slides'] ) && is_array( $merged['slides'] ) ? $merged['slides'] : array();
		$slides_css = $slides
			? CssVars::to_css_for_slides( $selector, $slides, $saved )
			: '';
		$order_css = ElementsOrder::to_css_for_attrs( $selector, $merged, $order_defaults );
		$vis_css   = ElementsVisibility::to_css_for_attrs( $selector, $merged );
		$uid       = '';

		if ( CssVars::has_any( $overrides ) || '' !== $btn_css || '' !== trim( $slides_css ) || '' !== trim( $order_css ) || '' !== trim( $vis_css ) ) {
			// Scope instance overrides with a unique class so media queries don't leak.
			$uid      = 'bm-i-' . substr( md5( wp_json_encode( array( $overrides, $btn_css, $slides_css, $order_css, $vis_css ) ) . $slug ), 0, 10 );
			$selector = '.' . $uid . $selector;
			$css      = '';
			if ( CssVars::has_any( $overrides ) ) {
				$css_vars = $independent
					? $overrides
					: CssVars::prepare_instance_css_vars( $overrides, $saved );
				// Older Media Right blocks may lack --bm-columns-dom-order.
				$css_n = CssVars::normalize( $css_vars );
				if (
					empty( $css_n['desktop']['--bm-columns-dom-order'] ) &&
					is_string( $block['markup'] ?? null ) &&
					false !== strpos( $block['markup'], 'bm-hero--image-right' )
				) {
					$css_vars = CssVars::merge(
						array(
							'desktop' => array(
								'--bm-columns-dom-order' => 'content-first',
							),
						),
						$css_vars
					);
				}
				$css .= CssVars::to_css( $selector, $css_vars );
			}
			if ( $slides ) {
				$slide_css = CssVars::to_css_for_slides( $selector, $slides, $saved );
				if ( '' !== trim( $slide_css ) ) {
					$css .= ( $css ? "\n\n" : '' ) . $slide_css;
				}
			}
			$order_css = ElementsOrder::to_css_for_attrs( $selector, $merged, $order_defaults );
			if ( '' !== trim( $order_css ) ) {
				$css .= ( $css ? "\n\n" : '' ) . $order_css;
			}
			$vis_css = ElementsVisibility::to_css_for_attrs( $selector, $merged );
			if ( '' !== trim( $vis_css ) ) {
				$css .= ( $css ? "\n\n" : '' ) . $vis_css;
			}
			$btn_css = MarkupInterpolator::button_hover_css( $selector, $merged );
			if ( '' !== $btn_css ) {
				$css .= ( $css ? "\n\n" : '' ) . $btn_css;
			}
			if ( '' !== $css ) {
				$this->enqueue_inline_css( 'joc-blockora-morph-instance-' . sanitize_key( $uid ), $css );
			}
		}

		$classes = trim( ( $uid ? $uid . ' ' : '' ) . $base_cls );
		$wrapper_args = array(
			'class' => $classes,
		);

		if ( $this->can_frontend_edit_current() ) {
			$index = $this->fe_block_index;
			++$this->fe_block_index;
			$wrapper_args['data-bm-edit']    = '1';
			$wrapper_args['data-bm-surface'] = 'gutenberg';
			$wrapper_args['data-bm-name']    = 'joc-blockora-morph/' . $block['slug'];
			$wrapper_args['data-bm-index']   = (string) $index;
		}

		$wrapper = get_block_wrapper_attributes( $wrapper_args );

		return BlockMarkup::wrap_block_output( $wrapper, $html );
	}

	/**
	 * Server render for a direct JBM Library block placement.
	 *
	 * @param array<string, mixed> $catalog  Catalog row.
	 * @param array<string, mixed> $runtime  Normalized row for interpolation.
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function render_library_block( array $catalog, array $runtime, array $attributes ): string {
		$system_slug = (string) ( $catalog['systemSlug'] ?? '' );
		$variant     = (string) ( $catalog['variant'] ?? 'default' );
		$handle      = $this->register_library_block_style( $system_slug, $variant );
		if ( $handle ) {
			$this->enqueue_style_now( $handle );
		}

		$defaults = JsonHelper::decode_array( $runtime['default_attrs'] ?? null, array() );
		$saved    = JsonHelper::decode_array( $runtime['css_vars'] ?? null, array() );
		$schema   = TemplateLibrary::decode_field_schema( $runtime['field_schema'] ?? '[]' );
		$order_defaults = ElementsOrder::defaults_from_schema( $schema, $defaults );
		$merged   = array_merge( $defaults, $attributes );
		unset( $merged['cssVars'], $merged['styleScopeChoices'], $merged['librarySaveRev'], $merged['allowedPostTypes'], $merged['align'], $merged['className'], $merged['anchor'] );

		$instance_vars = isset( $attributes['cssVars'] ) && is_array( $attributes['cssVars'] )
			? $attributes['cssVars']
			: array();

		if ( CssVars::looks_like_library_snapshot( $instance_vars, $saved ) ) {
			$overrides = CssVars::normalize( array() );
		} else {
			$overrides = CssVars::overrides( $instance_vars, $saved );
		}

		$html = $this->interpolate(
			(string) ( $runtime['markup'] ?? '' ),
			array_merge(
				$merged,
				array(
					'cssVars' => CssVars::merge( $saved, $overrides ),
				)
			)
		);
		IconRegistry::enqueue_styles_for( IconRegistry::library_ids_from_attrs( $merged ) );
		$this->enqueue_frontend_for_block( $runtime, $html, $merged );

		$base_cls = LibraryBlocks::css_class( $system_slug, $variant );
		$selector = '.' . $base_cls;
		$slides   = isset( $merged['slides'] ) && is_array( $merged['slides'] ) ? $merged['slides'] : array();
		$uid      = '';

		$btn_css    = MarkupInterpolator::button_hover_css( $selector, $merged );
		$slides_css = $slides ? CssVars::to_css_for_slides( $selector, $slides, $saved ) : '';
		$order_css  = ElementsOrder::to_css_for_attrs( $selector, $merged, $order_defaults );
		$vis_css    = ElementsVisibility::to_css_for_attrs( $selector, $merged );

		if ( CssVars::has_any( $overrides ) || '' !== $btn_css || '' !== trim( $slides_css ) || '' !== trim( $order_css ) || '' !== trim( $vis_css ) ) {
			$uid      = 'bm-i-' . substr( md5( wp_json_encode( array( $overrides, $btn_css, $slides_css, $order_css, $vis_css ) ) . $base_cls ), 0, 10 );
			$selector = '.' . $uid . $selector;
			$css      = '';
			if ( CssVars::has_any( $overrides ) ) {
				$css .= CssVars::to_css( $selector, CssVars::prepare_instance_css_vars( $overrides, $saved ) );
			}
			if ( $slides ) {
				$slide_css = CssVars::to_css_for_slides( $selector, $slides, $saved );
				if ( '' !== trim( $slide_css ) ) {
					$css .= ( $css ? "\n\n" : '' ) . $slide_css;
				}
			}
			// Regenerate with uid-scoped selector (same as render_block).
			$order_css = ElementsOrder::to_css_for_attrs( $selector, $merged, $order_defaults );
			if ( '' !== trim( $order_css ) ) {
				$css .= ( $css ? "\n\n" : '' ) . $order_css;
			}
			$vis_css = ElementsVisibility::to_css_for_attrs( $selector, $merged );
			if ( '' !== trim( $vis_css ) ) {
				$css .= ( $css ? "\n\n" : '' ) . $vis_css;
			}
			$btn_css = MarkupInterpolator::button_hover_css( $selector, $merged );
			if ( '' !== $btn_css ) {
				$css .= ( $css ? "\n\n" : '' ) . $btn_css;
			}
			if ( '' !== $css ) {
				$this->enqueue_inline_css( 'joc-blockora-morph-instance-' . sanitize_key( $uid ), $css );
			}
		}

		$classes = trim( ( $uid ? $uid . ' ' : '' ) . $base_cls );
		$wrapper_args = array(
			'class' => $classes,
		);

		if ( $this->can_frontend_edit_current() ) {
			$index = $this->fe_block_index;
			++$this->fe_block_index;
			$wrapper_args['data-bm-edit']    = '1';
			$wrapper_args['data-bm-surface'] = 'gutenberg';
			$wrapper_args['data-bm-name']    = (string) ( $catalog['name'] ?? LibraryBlocks::block_name( $system_slug, $variant ) );
			$wrapper_args['data-bm-index']   = (string) $index;
		}

		$wrapper = get_block_wrapper_attributes( $wrapper_args );

		return BlockMarkup::wrap_block_output( $wrapper, $html );
	}

	/**
	 * Whether the current viewer may use frontend BM editing on this request.
	 */
	private function can_frontend_edit_current(): bool {
		if ( is_admin() || wp_is_json_request() ) {
			return false;
		}
		if ( ! \JOCBM\Settings::is_frontend_editor_enabled() ) {
			return false;
		}
		$post_id = (int) get_queried_object_id();
		if ( ! $post_id ) {
			$post_id = (int) get_the_ID();
		}
		return $post_id > 0 && current_user_can( 'edit_post', $post_id );
	}

	/**
	 * Enqueue frontend editor shell when BM blocks exist and user can edit.
	 */
	public function enqueue_frontend_editor(): void {
		if ( is_admin() || ! is_singular() ) {
			return;
		}

		if ( ! \JOCBM\Settings::is_frontend_editor_enabled() ) {
			return;
		}

		$post_id = (int) get_queried_object_id();
		if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {
			return;
		}

		$slugs = $this->detect_used_block_slugs();
		$keys  = $this->detect_used_shortcode_keys();
		if ( ! $slugs && ! $keys ) {
			return;
		}

		$asset_file = JOCBM_PATH . 'build/frontend-editor.asset.php';
		$js_path    = JOCBM_PATH . 'build/frontend-editor.js';
		if ( ! file_exists( $asset_file ) || ! file_exists( $js_path ) ) {
			return;
		}

		$asset   = require $asset_file;
		$version = JOCBM_VERSION . '.' . (string) filemtime( $js_path );
		$deps    = array_unique(
			array_merge(
				$asset['dependencies'] ?? array(),
				array( 'wp-element', 'wp-components', 'wp-i18n', 'wp-api-fetch', 'wp-data' )
			)
		);

		wp_enqueue_script(
			'joc-blockora-morph-frontend-editor',
			JOCBM_URL . 'build/frontend-editor.js',
			$deps,
			$version,
			true
		);

		\JOCBM\Theme\ThemeConfig::enqueue_configured_fonts();

		\JOCBM\Assets\SharedUi::enqueue();

		$css_path = JOCBM_PATH . 'build/frontend-editor.css';
		if ( file_exists( $css_path ) ) {
			wp_enqueue_style(
				'joc-blockora-morph-frontend-editor',
				JOCBM_URL . 'build/frontend-editor.css',
				array(
					'wp-components',
					'dashicons',
					'joc-blockora-morph-fonts',
					\JOCBM\Assets\SharedUi::STYLE_HANDLE,
				),
				JOCBM_VERSION . '.' . (string) filemtime( $css_path )
			);
		} else {
			wp_enqueue_style( 'dashicons' );
		}

		// Reuse editor.css for Gutenberg-specific editor chrome still in that bundle.
		if ( ! wp_style_is( 'joc-blockora-morph-editor', 'registered' ) ) {
			$this->register_editor_script();
		}
		if ( wp_style_is( 'joc-blockora-morph-editor', 'registered' ) ) {
			wp_enqueue_style( 'joc-blockora-morph-editor' );
		}

		wp_enqueue_media();
		wp_enqueue_editor();

		$meta_by_name = $this->build_frontend_block_meta( $slugs, $keys );
		$instances    = $this->collect_frontend_instances( $post_id );

		wp_localize_script(
			'joc-blockora-morph-frontend-editor',
			'jocbmFront',
			array(
				'restUrl'       => esc_url_raw( rest_url( 'joc-blockora-morph/v1' ) ),
				'nonce'         => wp_create_nonce( 'wp_rest' ),
				'postId'        => $post_id,
				'postType'      => get_post_type( $post_id ) ?: '',
				'pluginUrl'     => JOCBM_URL,
				'logoUrl'       => JOCBM_URL . 'assets/images/joc-blockora-morph-logo.gif',
				'interFontUrl'  => \JOCBM\Theme\ThemeConfig::inter_font_url(),
				'themeAgnosticUrl' => \JOCBM\Theme\ThemeConfig::theme_agnostic_url(),
				'adminUrl'      => admin_url( 'admin.php?page=joc-blockora-morph' ),
				'blocks'        => array_values( $meta_by_name ),
				'blocksByName'  => $meta_by_name,
				'instances'     => $instances,
				'buttonLibrary' => $this->get_button_library(),
				'themeConfig'   => \JOCBM\Rest\ThemeConfigController::get_config_with_fonts(),
				'fontFamilies'  => \JOCBM\Theme\ThemeConfig::get_available_font_families(),
				'themeColors'   => \JOCBM\Theme\ThemeConfig::get_theme_origin_colors(),
				'dashiconsUrl'  => includes_url( 'css/dashicons.min.css' ),
				'iconLibraries' => IconRegistry::localize_payload(),
			)
		);
	}

	/**
	 * Block meta for used slugs (and shortcode parent slugs).
	 *
	 * @param array<int, string> $slugs Gutenberg slugs.
	 * @param array<int, string> $keys  Shortcode keys.
	 * @return array<string, array<string, mixed>>
	 */
	private function build_frontend_block_meta( array $slugs, array $keys ): array {
		$needed = array_fill_keys( $slugs, true );

		if ( $keys ) {
			global $wpdb;
			$table = Schema::table( 'user_contents' );
			$ub    = Schema::table( 'user_blocks' );
			$rows = $wpdb->get_col(
				// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- %i table identifiers are not counted by PHPCS.
				$wpdb->prepare(
					'SELECT u.slug FROM %i c INNER JOIN %i u ON u.id = c.user_block_id WHERE c.shortcode_key IN (' . implode( ',', array_fill( 0, count( $keys ), '%s' ) ) . ')',
					$table,
					$ub,
					...$keys
				)
			);
			foreach ( $rows ?: array() as $slug ) {
				$needed[ sanitize_title( (string) $slug ) ] = true;
			}
		}

		$out = array();
		foreach ( $this->get_enabled_user_blocks() as $b ) {
			$slug = sanitize_title( (string) $b['slug'] );
			if ( empty( $needed[ $slug ] ) ) {
				continue;
			}
			$fields  = TemplateLibrary::decode_field_schema( $b['field_schema'] ?? '[]' );
			$library = TemplateLibrary::decode_field_schema( $b['library_field_schema'] ?? '[]' );
			$fields  = TemplateLibrary::apply_library_field_types( $fields, $library );
			$name    = 'joc-blockora-morph/' . $b['slug'];
			$out[ $name ] = array(
				'name'         => $name,
				'slug'         => $b['slug'],
				'id'           => (int) $b['id'],
				'title'        => $b['name'],
				'markup'       => $b['markup'],
				'fieldSchema'  => $fields,
				'styleSchema'  => JsonHelper::decode_array( $b['style_schema'] ?? null, array() ),
				'cssVars'      => CssVars::normalize( JsonHelper::decode_array( $b['css_vars'] ?? null, array() ) ),
				'defaultAttrs' => JsonHelper::decode_array( $b['default_attrs'] ?? null, array() ),
			);
		}

		return $out;
	}

	/**
	 * Instance list from post content (Gutenberg BM blocks + shortcodes).
	 *
	 * @param int $post_id Post ID.
	 * @return array<int, array<string, mixed>>
	 */
	private function collect_frontend_instances( int $post_id ): array {
		$post = get_post( $post_id );
		if ( ! $post ) {
			return array();
		}

		$instances = array();
		$index     = 0;
		$this->walk_bm_instances( parse_blocks( $post->post_content ), $index, $instances );

		if ( preg_match_all( '/\[jocbm_blockora_morph\s+[^\]]*id=["\']([^"\']+)["\']/i', $post->post_content, $matches ) ) {
			global $wpdb;
			$table = Schema::table( 'user_contents' );
			$ub    = Schema::table( 'user_blocks' );
			foreach ( $matches[1] as $key ) {
				$key = sanitize_text_field( $key );
				$row = $wpdb->get_row(
					// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- %i table identifiers are not counted by PHPCS.
					$wpdb->prepare(
						"SELECT c.id, c.content_attrs, c.css_vars, u.slug, u.css_vars AS ub_css
						FROM %i c
						INNER JOIN %i u ON u.id = c.user_block_id
						WHERE c.shortcode_key = %s AND c.is_enabled = 1
						LIMIT 1",
						$table,
						$ub,
						$key
					),
					ARRAY_A
				);
				if ( ! $row ) {
					continue;
				}
				$attrs = JsonHelper::decode_array( $row['content_attrs'] ?? null, array() );
				$vars  = CssVars::normalize(
					JsonHelper::decode_array( $row['css_vars'] ?? ( $row['ub_css'] ?? null ), array() )
				);
				$attrs['cssVars'] = $vars;
				$instances[]      = array(
					'surface'   => 'shortcode',
					'contentId' => (int) $row['id'],
					'name'      => 'joc-blockora-morph/' . $row['slug'],
					'attributes'=> $attrs,
				);
			}
		}

		return $instances;
	}

	/**
	 * @param array<int, array<string, mixed>> $blocks     Parsed blocks.
	 * @param int                              $index      BM counter.
	 * @param array<int, array<string, mixed>> $instances  Out list.
	 */
	private function walk_bm_instances( array $blocks, int &$index, array &$instances ): void {
		foreach ( $blocks as $block ) {
			$name = (string) ( $block['blockName'] ?? '' );
			if ( 0 === strpos( $name, 'joc-blockora-morph/' ) ) {
				$attrs = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array();
				$instances[] = array(
					'surface'    => 'gutenberg',
					'blockIndex' => $index,
					'name'       => $name,
					'attributes' => $attrs,
				);
				++$index;
			} elseif ( 0 === strpos( $name, LibraryBlocks::BLOCK_NAMESPACE . '/' ) ) {
				$parsed = LibraryBlocks::parse_block_name( $name );
				$attrs  = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array();
				$instances[] = array(
					'surface'    => 'gutenberg',
					'blockIndex' => $index,
					'name'       => $name,
					'attributes' => $attrs,
					'isLibrary'  => true,
					'systemSlug' => $parsed ? $parsed[0] : '',
					'variant'    => $parsed ? $parsed[1] : 'default',
				);
				++$index;
			}
			if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
				$this->walk_bm_instances( $block['innerBlocks'], $index, $instances );
			}
		}
	}

	/**
	 * Replace {{tokens}} in markup.
	 *
	 * @param string               $markup Markup.
	 * @param array<string, mixed> $attrs  Values.
	 */
	private function interpolate( string $markup, array $attrs ): string {
		return MarkupInterpolator::interpolate( $markup, $attrs );
	}
}
