<?php
/**
 * Brand tokens for Blockora Morph — theme-agnostic.
 *
 * Adds Blockora Morph colors/sizes/fonts as a *user* theme.json layer with `bm-`
 * prefixed slugs so the active theme’s own presets are never replaced.
 * Works the same on classic and block themes.
 *
 * @package JOCBM
 *
 * Copyright (C) 2026 JayOnCode
 * License: GPL-2.0-or-later
 */

namespace JOCBM\Theme;

use JOCBM\Rest\ThemeConfigController;

defined( 'ABSPATH' ) || exit;

/**
 * Class ThemeConfig
 */
final class ThemeConfig {

	/**
	 * Keep WordPress global preset output lean.
	 *
	 * These BM palette entries are exported into theme.json, which causes core to
	 * generate sitewide `.has-bm-*-{color,background-color,border-color}` rules.
	 * The plugin UI still supports fully custom colors; this subset only limits
	 * which BM swatches become global utility classes.
	 *
	 * @var array<string, true>
	 */
	private const GLOBAL_PRESET_COLOR_SLUGS = array(
		'primary'           => true,
		'primary-container' => true,
		'secondary'         => true,
		'success'           => true,
		'warning'           => true,
		'error'             => true,
		'surface'           => true,
		'on-surface'        => true,
		'on-primary'        => true,
		'outline'           => true,
	);

	/**
	 * Register filters / asset hooks.
	 */
	public function register(): void {
		// User origin — never overwrite the active theme’s theme.json presets.
		add_filter( 'wp_theme_json_data_user', array( $this, 'merge_theme_json' ) );

		add_action( 'init', array( $this, 'register_theme_agnostic_style' ), 5 );
		add_action( 'enqueue_block_assets', array( $this, 'maybe_enqueue_fonts' ) );
		add_action( 'enqueue_block_assets', array( $this, 'enqueue_fonts_in_editor_canvas' ) );
		add_action( 'enqueue_block_assets', array( $this, 'enqueue_theme_agnostic_in_editor_canvas' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_fonts_admin' ) );
		add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_theme_agnostic_css' ), 5 );
		add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_theme_agnostic_css' ), 5 );

		// Markup uses inset / pointer-events; core safecss strips them via wp_kses.
		add_filter( 'safe_style_css', array( $this, 'allow_block_markup_css_props' ) );
		/*
		 * Core safecss_filter_attr rejects any declaration whose value still
		 * contains "(" after stripping var()/calc(). That drops box-shadow and
		 * --bm-*-shadow custom props that use rgba()/hsla(), so hero/banner
		 * button shadows fall back to none on the frontend.
		 */
		add_filter( 'safecss_filter_attr_allow_css', array( $this, 'allow_color_function_css' ), 10, 2 );
	}

	/**
	 * Allow CSS props used in BM interpolated markup (survive frontend wp_kses).
	 *
	 * @param string[] $styles Allowed style properties.
	 * @return string[]
	 */
	public function allow_block_markup_css_props( array $styles ): array {
		foreach ( array( 'inset', 'pointer-events', 'object-fit', 'object-position', 'order' ) as $prop ) {
			if ( ! in_array( $prop, $styles, true ) ) {
				$styles[] = $prop;
			}
		}
		return $styles;
	}

	/**
	 * Keep rgba()/hsla() values that core safecss otherwise discards.
	 *
	 * @param bool   $allow_css       Whether the CSS part is already considered safe.
	 * @param string $css_test_string CSS fragment under test (functions may already be stripped).
	 * @return bool
	 */
	public function allow_color_function_css( bool $allow_css, string $css_test_string ): bool {
		if ( $allow_css ) {
			return true;
		}
		$test = preg_replace(
			'/\b(?:rgba?|hsla?)\((?:[^()]*)\)/i',
			'',
			(string) $css_test_string
		);
		return ! preg_match( '%[\\\\(&=}]|/\*%', (string) $test );
	}

	/**
	 * Absolute URL for the self-hosted Inter stylesheet.
	 */
	public static function inter_font_url(): string {
		return JOCBM_URL . 'assets/fonts/inter/inter.css';
	}

	/**
	 * CSS font-family value used as the library / block default.
	 */
	public static function inter_font_family_css(): string {
		return '"Inter", sans-serif';
	}

	/**
	 * Picker entry for built-in Inter (always available, even if removed from Global Settings).
	 *
	 * @return array{name: string, slug: string, fontFamily: string}
	 */
	public static function inter_font_family_entry(): array {
		return array(
			'name'       => 'Inter',
			'slug'       => 'inter',
			'fontFamily' => self::inter_font_family_css(),
		);
	}

	/**
	 * Register self-hosted Inter (idempotent).
	 */
	public static function register_inter_font(): void {
		if ( wp_style_is( 'joc-blockora-morph-fonts', 'registered' ) ) {
			return;
		}
		wp_register_style(
			'joc-blockora-morph-fonts',
			self::inter_font_url(),
			array(),
			JOCBM_VERSION
		);
	}

	/**
	 * Enqueue self-hosted Inter for block previews / front / editor.
	 */
	public static function enqueue_inter_font(): void {
		self::register_inter_font();
		wp_enqueue_style( 'joc-blockora-morph-fonts' );
	}

	/**
	 * Absolute URL to theme-agnostic.css with filemtime cache-bust query.
	 *
	 * @return string
	 */
	public static function theme_agnostic_url(): string {
		$path = JOCBM_PATH . 'assets/css/theme-agnostic.css';
		$url  = JOCBM_URL . 'assets/css/theme-agnostic.css';
		if ( is_readable( $path ) ) {
			return add_query_arg( 'ver', (string) filemtime( $path ), $url );
		}
		return $url;
	}

	/**
	 * Register shared layout CSS early so block styles can depend on it.
	 */
	public function register_theme_agnostic_style(): void {
		self::register_inter_font();

		$path = JOCBM_PATH . 'assets/css/theme-agnostic.css';
		if ( ! is_readable( $path ) ) {
			return;
		}

		wp_register_style(
			'joc-blockora-morph-theme-agnostic',
			JOCBM_URL . 'assets/css/theme-agnostic.css',
			array( 'dashicons', 'joc-blockora-morph-fonts' ),
			(string) filemtime( $path )
		);
	}

	/**
	 * Append Blockora Morph brand tokens without replacing the active theme.
	 *
	 * @param \WP_Theme_JSON_Data $theme_json Theme JSON data object.
	 * @return \WP_Theme_JSON_Data
	 */
	public function merge_theme_json( $theme_json ) {
		if ( ! is_object( $theme_json ) || ! method_exists( $theme_json, 'update_with' ) ) {
			return $theme_json;
		}

		$config = ThemeConfigController::get_config();

		$palette = array();
		foreach ( ( $config['colors'] ?? array() ) as $color ) {
			$slug = self::bm_slug( (string) ( $color['slug'] ?? '' ) );
			if ( '' === $slug || ! self::allow_global_preset_color_slug( $slug ) ) {
				continue;
			}
			$palette[] = array(
				'slug'  => $slug,
				'name'  => self::bm_label( (string) ( $color['name'] ?? $slug ) ),
				'color' => (string) ( $color['color'] ?? '' ),
			);
		}

		$font_sizes = array();
		foreach ( ( $config['fontSizes'] ?? array() ) as $size ) {
			$slug = self::bm_slug( (string) ( $size['slug'] ?? '' ) );
			if ( '' === $slug ) {
				continue;
			}
			$font_sizes[] = array(
				'slug' => $slug,
				'name' => self::bm_label( (string) ( $size['name'] ?? $slug ) ),
				'size' => (string) ( $size['size'] ?? '' ),
			);
		}

		$font_families = array();
		foreach (
			self::merge_font_families(
				array( self::inter_font_family_entry() ),
				self::config_font_families( $config )
			) as $font
		) {
			$font['slug'] = self::bm_slug( (string) ( $font['slug'] ?? '' ) );
			$font['name'] = self::bm_label( (string) ( $font['name'] ?? $font['slug'] ) );
			if ( '' === $font['slug'] ) {
				continue;
			}
			$font_families[] = $font;
		}

		$settings = array();
		if ( $palette ) {
			$settings['color'] = array( 'palette' => $palette );
		}
		$typography = array();
		if ( $font_sizes ) {
			$typography['fontSizes'] = $font_sizes;
		}
		if ( $font_families ) {
			$typography['fontFamilies'] = $font_families;
		}
		if ( $typography ) {
			$settings['typography'] = $typography;
		}

		if ( ! $settings ) {
			return $theme_json;
		}

		$theme_json->update_with(
			array(
				'version'  => 2,
				'settings' => $settings,
			)
		);

		return $theme_json;
	}

	/**
	 * Whether a BM color slug should be exported into WordPress global presets.
	 *
	 * @param string $slug Kebab-case BM slug, already prefixed with `bm-`.
	 */
	private static function allow_global_preset_color_slug( string $slug ): bool {
		$base = preg_replace( '/^bm-/', '', strtolower( trim( $slug ) ) ) ?? '';
		return '' !== $base && isset( self::GLOBAL_PRESET_COLOR_SLUGS[ $base ] );
	}

	/**
	 * Font families available for Blockora Morph pickers (active theme + plugin config).
	 * Theme list updates when the site theme changes; plugin fonts always remain.
	 *
	 * @return array<int, array{name: string, slug: string, fontFamily: string}>
	 */
	public static function get_available_font_families(): array {
		$config   = ThemeConfigController::get_config();
		$bm_fonts = self::config_font_families( $config );

		// Built-in Inter first, then Blockora Morph Global Settings fonts, then theme fonts.
		return self::merge_font_families(
			array( self::inter_font_family_entry() ),
			self::merge_font_families( $bm_fonts, self::get_theme_origin_font_families() )
		);
	}

	/**
	 * Font families defined by the active theme (theme.json), excluding BM-managed fonts.
	 * Shown read-only in Global Settings; still available in all font pickers via the merged list.
	 *
	 * @return array<int, array{name: string, slug: string, fontFamily: string}>
	 */
	public static function get_theme_font_families_for_display(): array {
		$theme = self::get_theme_origin_font_families();
		if ( ! $theme ) {
			return array();
		}

		$config   = ThemeConfigController::get_config();
		$bm_fonts = self::merge_font_families(
			array( self::inter_font_family_entry() ),
			self::config_font_families( $config )
		);

		$blocked = array();
		foreach ( $bm_fonts as $font ) {
			$slug = (string) ( $font['slug'] ?? '' );
			$css  = isset( $font['fontFamily'] )
				? strtolower( preg_replace( '/\s+/', '', (string) $font['fontFamily'] ) )
				: '';
			if ( $slug ) {
				$blocked[ $slug ] = true;
			}
			if ( $css ) {
				$blocked[ 'css:' . $css ] = true;
			}
		}

		$out = array();
		foreach ( $theme as $font ) {
			$slug = (string) ( $font['slug'] ?? '' );
			$css  = isset( $font['fontFamily'] )
				? strtolower( preg_replace( '/\s+/', '', (string) $font['fontFamily'] ) )
				: '';
			if ( $slug && isset( $blocked[ $slug ] ) ) {
				continue;
			}
			if ( $css && isset( $blocked[ 'css:' . $css ] ) ) {
				continue;
			}
			$out[] = $font;
		}
		return $out;
	}

	/**
	 * Active theme theme.json colors (excluding BM-managed `bm-*` presets).
	 *
	 * @return array<int, array{name: string, slug: string, color: string}>
	 */
	public static function get_theme_origin_colors(): array {
		$theme_colors = array();

		if ( class_exists( '\WP_Theme_JSON_Resolver' ) && method_exists( '\WP_Theme_JSON_Resolver', 'get_theme_data' ) ) {
			$theme_data = \WP_Theme_JSON_Resolver::get_theme_data();
			if ( is_object( $theme_data ) && method_exists( $theme_data, 'get_settings' ) ) {
				$raw          = $theme_data->get_settings();
				$theme_colors = self::flatten_color_palette( $raw['color']['palette'] ?? array() );
			}
		}

		if ( function_exists( 'wp_get_global_settings' ) ) {
			$theme_colors = self::merge_color_palettes(
				$theme_colors,
				self::flatten_color_palette(
					wp_get_global_settings( array( 'color', 'palette' ) )
				)
			);
		}

		if ( ! $theme_colors && class_exists( '\WP_Theme_JSON_Resolver' ) ) {
			$settings     = \WP_Theme_JSON_Resolver::get_merged_data()->get_settings();
			$theme_colors = self::flatten_color_palette( $settings['color']['palette'] ?? array() );
		}

		$out = array();
		foreach ( $theme_colors as $color ) {
			$slug = strtolower( trim( (string) ( $color['slug'] ?? '' ) ) );
			// Skip Blockora Morph brand tokens (bm-*) so Theme tab stays site-theme only.
			if ( '' !== $slug && str_starts_with( $slug, 'bm-' ) ) {
				continue;
			}
			$hex = trim( (string) ( $color['color'] ?? '' ) );
			if ( '' === $hex ) {
				continue;
			}
			$out[] = array(
				'name'  => (string) ( $color['name'] ?? $slug ),
				'slug'  => $slug,
				'color' => $hex,
			);
		}

		return $out;
	}

	/**
	 * Flatten theme.json color palette (origin-keyed or flat list).
	 *
	 * @param mixed $raw Raw palette setting.
	 * @return array<int, array{name: string, slug: string, color: string}>
	 */
	public static function flatten_color_palette( $raw ): array {
		if ( ! is_array( $raw ) || ! $raw ) {
			return array();
		}

		$is_origin = isset( $raw['theme'] ) || isset( $raw['custom'] ) || isset( $raw['default'] ) || isset( $raw['user'] );
		$list      = array();
		if ( $is_origin ) {
			foreach ( $raw as $colors ) {
				if ( ! is_array( $colors ) ) {
					continue;
				}
				if ( isset( $colors['color'] ) ) {
					$list[] = $colors;
					continue;
				}
				foreach ( $colors as $color ) {
					if ( is_array( $color ) ) {
						$list[] = $color;
					}
				}
			}
		} else {
			$list = $raw;
		}

		$out = array();
		foreach ( $list as $color ) {
			if ( ! is_array( $color ) ) {
				continue;
			}
			$hex = trim( (string) ( $color['color'] ?? '' ) );
			if ( '' === $hex ) {
				continue;
			}
			$out[] = array(
				'name'  => (string) ( $color['name'] ?? ( $color['slug'] ?? $hex ) ),
				'slug'  => sanitize_title( (string) ( $color['slug'] ?? '' ) ),
				'color' => $hex,
			);
		}

		return $out;
	}

	/**
	 * Merge color palettes, deduping by slug then color value.
	 *
	 * @param array<int, array{name: string, slug: string, color: string}> $a First list.
	 * @param array<int, array{name: string, slug: string, color: string}> $b Second list.
	 * @return array<int, array{name: string, slug: string, color: string}>
	 */
	public static function merge_color_palettes( array $a, array $b ): array {
		$out  = array();
		$seen = array();
		foreach ( array_merge( $a, $b ) as $color ) {
			$slug = strtolower( trim( (string) ( $color['slug'] ?? '' ) ) );
			$hex  = strtolower( trim( (string) ( $color['color'] ?? '' ) ) );
			$key  = '' !== $slug ? 's:' . $slug : 'c:' . $hex;
			if ( '' === $hex || isset( $seen[ $key ] ) ) {
				continue;
			}
			$seen[ $key ] = true;
			$out[]        = $color;
		}
		return $out;
	}

	/**
	 * Active theme theme.json font families (and merged global settings fallback).
	 *
	 * @return array<int, array{name: string, slug: string, fontFamily: string}>
	 */
	public static function get_theme_origin_font_families(): array {
		$theme_fonts = array();

		// Prefer the active theme's own theme.json (not the BM user layer).
		if ( class_exists( '\WP_Theme_JSON_Resolver' ) && method_exists( '\WP_Theme_JSON_Resolver', 'get_theme_data' ) ) {
			$theme_data = \WP_Theme_JSON_Resolver::get_theme_data();
			if ( is_object( $theme_data ) && method_exists( $theme_data, 'get_settings' ) ) {
				$raw         = $theme_data->get_settings();
				$theme_fonts = self::flatten_font_families( $raw['typography']['fontFamilies'] ?? array() );
			}
		}

		if ( function_exists( 'wp_get_global_settings' ) ) {
			$theme_fonts = self::merge_font_families(
				$theme_fonts,
				self::flatten_font_families(
					wp_get_global_settings( array( 'typography', 'fontFamilies' ) )
				)
			);
		}

		if ( ! $theme_fonts && class_exists( '\WP_Theme_JSON_Resolver' ) ) {
			$settings    = \WP_Theme_JSON_Resolver::get_merged_data()->get_settings();
			$theme_fonts = self::flatten_font_families( $settings['typography']['fontFamilies'] ?? array() );
		}

		return $theme_fonts;
	}

	/**
	 * Build font family entries from Blockora Morph theme config.
	 *
	 * @param array<string, mixed> $config Config.
	 * @return array<int, array{name: string, slug: string, fontFamily: string}>
	 */
	public static function config_font_families( array $config ): array {
		$font_families = array();
		foreach ( ( $config['fonts'] ?? array() ) as $font ) {
			$family = sanitize_text_field( $font['family'] ?? '' );
			if ( ! $family ) {
				continue;
			}

			$css_family = ( false !== strpos( $family, ',' ) || 'system-ui' === $family )
				? $family
				: '"' . $family . '", sans-serif';

			$font_families[] = array(
				'fontFamily' => $css_family,
				'name'       => $family,
				'slug'       => sanitize_title( $font['slug'] ?? $family ),
			);
		}
		return $font_families;
	}

	/**
	 * Flatten theme.json fontFamilies (origin-keyed or flat list).
	 *
	 * @param mixed $raw Raw fontFamilies setting.
	 * @return array<int, array{name: string, slug: string, fontFamily: string}>
	 */
	public static function flatten_font_families( $raw ): array {
		if ( ! is_array( $raw ) || ! $raw ) {
			return array();
		}

		// Origin-keyed (default/theme/custom/user/…) or a flat list of font definitions.
		$is_origin = isset( $raw['theme'] ) || isset( $raw['custom'] ) || isset( $raw['default'] ) || isset( $raw['user'] );
		$list      = array();
		if ( $is_origin ) {
			foreach ( $raw as $fonts ) {
				if ( ! is_array( $fonts ) ) {
					continue;
				}
				// Single font object mistakenly nested under an origin key.
				if ( isset( $fonts['fontFamily'] ) ) {
					$list[] = $fonts;
					continue;
				}
				foreach ( $fonts as $font ) {
					if ( is_array( $font ) ) {
						$list[] = $font;
					}
				}
			}
		} else {
			$list = $raw;
		}

		$out = array();
		foreach ( $list as $font ) {
			if ( ! is_array( $font ) ) {
				continue;
			}
			$css = (string) ( $font['fontFamily'] ?? '' );
			if ( '' === $css ) {
				continue;
			}
			$name = (string) ( $font['name'] ?? $font['slug'] ?? $css );
			$slug = sanitize_title( (string) ( $font['slug'] ?? $name ) );
			if ( '' === $slug ) {
				continue;
			}
			$out[] = array(
				'fontFamily' => $css,
				'name'       => $name,
				'slug'       => $slug,
			);
		}
		return $out;
	}

	/**
	 * Merge font family lists by slug (base first, extras appended).
	 *
	 * @param array<int, array{name: string, slug: string, fontFamily: string}> $base   Base.
	 * @param array<int, array{name: string, slug: string, fontFamily: string}> $extras Extras.
	 * @return array<int, array{name: string, slug: string, fontFamily: string}>
	 */
	public static function merge_font_families( array $base, array $extras ): array {
		$seen = array();
		$out  = array();
		foreach ( array_merge( $base, $extras ) as $font ) {
			$slug = $font['slug'] ?? '';
			$css  = isset( $font['fontFamily'] ) ? strtolower( preg_replace( '/\s+/', '', (string) $font['fontFamily'] ) ) : '';
			$key  = $slug ?: $css;
			if ( ! $key || isset( $seen[ $key ] ) ) {
				continue;
			}
			if ( $css && isset( $seen[ 'css:' . $css ] ) ) {
				continue;
			}
			$seen[ $key ] = true;
			if ( $css ) {
				$seen[ 'css:' . $css ] = true;
			}
			$out[] = $font;
		}
		return $out;
	}

	/**
	 * Front block-asset context: enqueue remote fonts when needed.
	 */
	public function maybe_enqueue_fonts(): void {
		if ( is_admin() ) {
			return;
		}
		if ( ! self::frontend_needs_fonts() ) {
			return;
		}
		self::enqueue_configured_fonts();
	}

	/**
	 * Load remote brand fonts inside the iframed block editor canvas (WP 6.3+).
	 *
	 * Parent-frame admin_enqueue_scripts alone does not style canvas content.
	 */
	public function enqueue_fonts_in_editor_canvas(): void {
		if ( ! is_admin() ) {
			return;
		}
		self::enqueue_configured_fonts();
	}

	/**
	 * Admin: only Blockora Morph screens + block editor (inspector / parent frame).
	 *
	 * @param string $hook Current admin page hook.
	 */
	public function maybe_enqueue_fonts_admin( $hook = '' ): void {
		if ( ! is_admin() ) {
			return;
		}

		$allow = false;
		$hook  = (string) $hook;
		if ( false !== strpos( $hook, 'joc-blockora-morph' ) ) {
			$allow = true;
		} elseif ( function_exists( 'get_current_screen' ) ) {
			$screen = get_current_screen();
			if ( $screen ) {
				if ( ! empty( $screen->id ) && false !== strpos( (string) $screen->id, 'joc-blockora-morph' ) ) {
					$allow = true;
				} elseif ( method_exists( $screen, 'is_block_editor' ) && $screen->is_block_editor() ) {
					$allow = true;
				}
			}
		}

		/**
		 * Filters whether remote brand fonts may load in wp-admin.
		 *
		 * @param bool   $allow Whether to enqueue.
		 * @param string $hook  Admin hook.
		 */
		$allow = (bool) apply_filters( 'jocbm_enqueue_remote_fonts_admin', $allow, $hook );
		if ( ! $allow ) {
			return;
		}

		self::enqueue_configured_fonts();
	}

	/**
	 * Schedule @font-face output for active theme fonts (picker preview).
	 * Must only print on style-print hooks — never during enqueue_*, or headers break.
	 */
	public static function enqueue_theme_font_faces(): void {
		if ( ! function_exists( 'wp_print_font_faces' ) || ! class_exists( '\WP_Font_Face_Resolver' ) ) {
			return;
		}

		static $registered = false;
		if ( $registered ) {
			return;
		}
		$registered = true;

		$printer = static function () {
			static $done = false;
			if ( $done ) {
				return;
			}
			$done = true;

			// Core resolver injects kebab-case `font-family` on each face (required by WP_Font_Face).
			$fonts = \WP_Font_Face_Resolver::get_fonts_from_theme_json();
			if ( empty( $fonts ) ) {
				return;
			}
			wp_print_font_faces( $fonts );
		};

		// Echoes <style> — only safe after headers (admin_print_styles / wp_print_styles).
		if ( is_admin() ) {
			add_action( 'admin_print_styles', $printer, 5 );
		}
		add_action( 'wp_print_styles', $printer, 5 );
	}

	/**
	 * Wide / full alignment fallbacks so BM blocks behave on classic themes.
	 */
	public function enqueue_theme_agnostic_css(): void {
		if ( ! wp_style_is( 'joc-blockora-morph-theme-agnostic', 'registered' ) ) {
			$this->register_theme_agnostic_style();
		}
		if ( ! wp_style_is( 'joc-blockora-morph-theme-agnostic', 'registered' ) ) {
			return;
		}

		if ( is_admin() ) {
			wp_enqueue_style( 'joc-blockora-morph-theme-agnostic' );
			return;
		}

		if ( self::frontend_uses_blockora_morph() ) {
			wp_enqueue_style( 'joc-blockora-morph-theme-agnostic' );
		}
	}

	/**
	 * Load layout CSS inside the iframed editor canvas (WP 6.3+).
	 */
	public function enqueue_theme_agnostic_in_editor_canvas(): void {
		if ( ! is_admin() ) {
			return;
		}

		if ( ! wp_style_is( 'joc-blockora-morph-theme-agnostic', 'registered' ) ) {
			$this->register_theme_agnostic_style();
		}

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

	/**
	 * Enqueue user-configured font stylesheets (opt-in remote URLs only),
	 * plus the built-in Inter stylesheet used as the library default.
	 */
	public static function enqueue_configured_fonts(): void {
		self::enqueue_inter_font();
		self::enqueue_theme_font_faces();

		$config = ThemeConfigController::get_config();

		foreach ( ( $config['fonts'] ?? array() ) as $index => $font ) {
			$source = isset( $font['source'] ) ? esc_url_raw( (string) $font['source'], array( 'http', 'https' ) ) : '';

			if ( ! $source || ! wp_http_validate_url( $source ) ) {
				continue;
			}

			// Skip if it points at the bundled Inter CSS (already enqueued above).
			if ( false !== strpos( $source, 'assets/fonts/inter/inter.css' ) ) {
				continue;
			}

			/**
			 * Filters whether a remote font stylesheet may be enqueued.
			 *
			 * @param bool   $allow  Whether to enqueue. Default true when URL is valid.
			 * @param string $source Font stylesheet URL.
			 * @param array  $font   Font config row.
			 */
			$allow = (bool) apply_filters( 'jocbm_enqueue_remote_font', true, $source, $font );
			if ( ! $allow ) {
				continue;
			}

			wp_enqueue_style(
				'joc-blockora-morph-font-' . sanitize_title( $font['slug'] ?? (string) $index ),
				$source,
				array(),
				JOCBM_VERSION
			);
		}
	}

	/**
	 * Whether the current front request likely needs BM remote fonts.
	 */
	public static function frontend_needs_fonts(): bool {
		return self::frontend_uses_blockora_morph();
	}

	/**
	 * Detect Blockora Morph blocks or shortcodes on the current front view.
	 */
	public static function frontend_uses_blockora_morph(): bool {
		/**
		 * Force or skip front detection.
		 *
		 * @param null|bool $use Null = auto-detect.
		 */
		$forced = apply_filters( 'jocbm_frontend_uses_blockora_morph', null );
		if ( null !== $forced ) {
			return (bool) $forced;
		}

		if ( is_singular() ) {
			$post = get_post();
			if ( $post instanceof \WP_Post ) {
				$content = (string) $post->post_content;
				if ( self::content_uses_blockora_morph( $content ) ) {
					return true;
				}
			}
		}

		// FSE / widgets: check queried object when available.
		if ( function_exists( 'get_the_block_template_html' ) ) {
			// Too late / expensive to parse full template here; rely on block style
			// enqueue path calling enqueue_configured_fonts when BM CSS loads.
			return false;
		}

		return false;
	}

	/**
	 * @param string $content Post or template content.
	 */
	public static function content_uses_blockora_morph( string $content ): bool {
		if ( '' === $content ) {
			return false;
		}
		if ( false !== strpos( $content, 'joc-blockora-morph/' ) ) {
			return true;
		}
		if ( function_exists( 'has_shortcode' ) && has_shortcode( $content, JOCBM_SHORTCODE ) ) {
			return true;
		}
		return false !== strpos( $content, '[' . JOCBM_SHORTCODE );
	}

	/**
	 * Prefix slug for theme.json so BM tokens never collide with theme presets.
	 *
	 * @param string $slug Raw slug.
	 */
	public static function bm_slug( string $slug ): string {
		$slug = sanitize_title( $slug );
		if ( '' === $slug ) {
			return '';
		}
		if ( 0 === strpos( $slug, 'bm-' ) ) {
			return $slug;
		}
		return 'bm-' . $slug;
	}

	/**
	 * Human label that clarifies origin in the Site Editor palette.
	 *
	 * @param string $name Original name.
	 */
	public static function bm_label( string $name ): string {
		$name = trim( $name );
		if ( '' === $name ) {
			return 'Blockora Morph';
		}
		if ( 0 === stripos( $name, 'Blockora Morph' ) ) {
			return $name;
		}
		return 'Blockora Morph — ' . $name;
	}
}
