<?php
/**
 * Frontend Assets Handler.
 *
 * Handles loading of CSS for price layouts using Server-Side Rendering (SSR).
 * CSS is loaded in the <head> section for immediate styling without FOUC.
 *
 * @package OpusPriceBuilder\Frontend
 */

declare(strict_types=1);

namespace OpusPriceBuilder\Frontend;

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

use OpusPriceBuilder\Locations;

/**
 * Handles enqueueing of frontend assets.
 *
 * The SSR approach loads CSS in <head> so prices are styled immediately.
 * No JavaScript is required for the initial display.
 */
class FrontendAssets {

	/**
	 * Whether assets are registered.
	 *
	 * @var bool
	 */
	private static bool $registered = false;

	/**
	 * Whether assets were enqueued for this request.
	 *
	 * @var bool
	 */
	private static bool $assetsEnqueued = false;

	/**
	 * Whether web component mode is enabled for this request.
	 *
	 * @var bool|null
	 */
	private static ?bool $webComponentMode = null;

	/**
	 * Register frontend assets.
	 *
	 * @return void
	 */
	public static function register(): void {
		if ( self::$registered ) {
			return;
		}

		// Enqueue styles early, priority 5 to run before most themes/plugins.
		add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueueAssets' ), 5 );

		self::$registered = true;
	}

	/**
	 * Enqueue frontend styles.
	 *
	 * @return void
	 */
	public static function enqueueAssets(): void {
		// Don't load in admin.
		if ( is_admin() ) {
			return;
		}

		// Check if we should load assets.
		if ( ! self::shouldLoadAssets() ) {
			return;
		}

		$version = OPPB_VERSION;

		// Register and enqueue base styles (no external file, just inline).
		wp_register_style( 'oppb-frontend', false, array(), $version );
		wp_enqueue_style( 'oppb-frontend' );

		$layout_css = self::buildLayoutCssContent();
		if ( '' !== $layout_css ) {
			wp_add_inline_style( 'oppb-frontend', $layout_css );
		}

		self::enqueueLayoutScripts();

		// Check if web component mode is requested.
		if ( self::isWebComponentModeEnabled() ) {
			self::enqueueWebComponentAssets();
		}

		self::$assetsEnqueued = true;
	}

	/**
	 * Check if web component render mode is enabled.
	 *
	 * This checks the 'oppb_render_mode' filter to determine if any price
	 * on the page might use web component rendering.
	 *
	 * @return bool True if web component mode might be used.
	 */
	public static function isWebComponentModeEnabled(): bool {
		if ( null !== self::$webComponentMode ) {
			return self::$webComponentMode;
		}

		/**
		 * Filter to force web component assets loading.
		 *
		 * Use this to ensure web component JS is loaded even if no prices
		 * are currently using web component mode (e.g., for AJAX scenarios).
		 *
		 * @param bool $force Whether to force load web component assets.
		 * @return bool
		 */
		self::$webComponentMode = apply_filters( 'oppb_force_web_component_assets', false ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

		return self::$webComponentMode;
	}

	/**
	 * Enable web component mode for this request.
	 *
	 * Called internally when a price is rendered using web component mode.
	 *
	 * @return void
	 */
	public static function enableWebComponentMode(): void {
		if ( self::$webComponentMode ) {
			return;
		}

		self::$webComponentMode = true;

		// If assets were already enqueued, add the web component script now.
		if ( self::$assetsEnqueued && ! did_action( 'wp_footer' ) ) {
			self::enqueueWebComponentAssets();
		}
	}

	/**
	 * Enqueue web component JavaScript and related assets.
	 *
	 * @return void
	 */
	private static function enqueueWebComponentAssets(): void {
		$version = OPPB_VERSION;

		// Enqueue the web component script.
		wp_enqueue_script(
			'oppb-web-component',
			OPPB_PLUGIN_URL . 'assets/js/web-component.js',
			array(),
			$version,
			false // Load in header.
		);

		// Get all active layouts and pass their data to the web component.
		$layouts_data = self::getLayoutsDataForWebComponent();

		wp_localize_script(
			'oppb-web-component',
			'oppbLayouts',
			$layouts_data
		);
	}

	/**
	 * Get layouts data formatted for the web component.
	 *
	 * @return array The layouts data.
	 */
	private static function getLayoutsDataForWebComponent(): array {
		$locations = \OpusPriceBuilder\Locations::getKeys();
		$layouts   = array();
		$seen_ids  = array();

		foreach ( $locations as $location ) {
			$location_layouts = PriceRenderer::getAllActiveLayouts( $location );

			foreach ( $location_layouts as $scenario => $layout ) {
				if ( empty( $layout['id'] ) ) {
					continue;
				}

				$layout_id = (int) $layout['id'];

				if ( isset( $seen_ids[ $layout_id ] ) ) {
					continue;
				}

				$layouts[ $layout_id ] = array(
					'html'     => $layout['html'] ?? '',
					'css'      => $layout['css'] ?? '',
					'customJs' => $layout['customJs'] ?? '',
				);

				$seen_ids[ $layout_id ] = true;
			}
		}

		return $layouts;
	}

	/**
	 * Build scoped layout CSS for inline enqueue.
	 *
	 * Each layout's CSS is scoped with a unique class (.oppb-layout-{id})
	 * to prevent style conflicts between different layouts.
	 *
	 * @return string CSS content, or empty string when there is nothing to output.
	 */
	private static function buildLayoutCssContent(): string {
		if ( is_admin() ) {
			return '';
		}

		// Get available locations from the centralized class.
		$locations    = Locations::getKeys();
		$css_parts    = array();
		$seen_ids     = array(); // Track which layouts we've already processed.
		$google_fonts = array(); // Collect Google fonts from all layouts.

		foreach ( $locations as $location ) {
			$layouts = PriceRenderer::getAllActiveLayouts( $location );

			if ( empty( $layouts ) ) {
				continue;
			}

			foreach ( $layouts as $scenario => $layout ) {
				if ( empty( $layout['css'] ) || empty( $layout['id'] ) ) {
					continue;
				}

				$layout_id = (int) $layout['id'];

				// Skip if we've already processed this layout.
				if ( isset( $seen_ids[ $layout_id ] ) ) {
					continue;
				}

				// Extract Google fonts from canvas_data.
				if ( ! empty( $layout['canvas_data'] ) ) {
					$layout_fonts = self::extractGoogleFontsFromLayout( $layout['canvas_data'] );
					$google_fonts = array_merge( $google_fonts, $layout_fonts );
				}

				// Scope CSS to prevent conflicts between layouts.
				$scoped_css = self::scopeCss( $layout['css'], $layout_id );

				$css_parts[]            = "/* Layout: {$layout_id} */\n" . $scoped_css;
				$seen_ids[ $layout_id ] = true;
			}
		}

		if ( empty( $css_parts ) ) {
			return '';
		}

		// Check font loading settings (match SettingsController defaults and legacy migration).
		$stored   = get_option( 'oppb_settings', array() );
		$defaults = array(
			'google_fonts_local'    => true,
			'google_fonts_cdn'      => false,
			'google_fonts_disabled' => false,
		);
		$settings = wp_parse_args( $stored, $defaults );

		if ( ! array_key_exists( 'google_fonts_cdn', $stored ) && empty( $settings['google_fonts_local'] ) ) {
			$settings['google_fonts_cdn'] = true;
		}

		$google_fonts_local    = ! empty( $settings['google_fonts_local'] );
		$google_fonts_cdn      = ! empty( $settings['google_fonts_cdn'] );
		$google_fonts_disabled = ! empty( $settings['google_fonts_disabled'] );
		$unique_fonts          = array_unique( $google_fonts );

		// Generate font imports вЂ” never phone home unless the store owner opts in to CDN.
		$font_imports = '';
		if ( ! $google_fonts_disabled ) {
			if ( $google_fonts_local ) {
				$font_imports = self::generateLocalFontImports( $unique_fonts, $settings['google_fonts_cache'] ?? array() );
			} elseif ( $google_fonts_cdn ) {
				$font_imports = self::generateGoogleFontImports( $unique_fonts );
			}
		}

		// Build complete CSS content.
		return "\n/* OPPB Layout Styles */\n" . $font_imports . implode( "\n\n", $css_parts ) . "\n";
	}

	/**
	 * Enqueue layout custom JavaScript in the footer.
	 * This runs after the DOM is ready for animation scripts.
	 *
	 * @return void
	 */
	private static function enqueueLayoutScripts(): void {
		if ( is_admin() ) {
			return;
		}

		// Get available locations from the centralized class.
		$locations      = Locations::getKeys();
		$js_parts       = array();
		$countup_parts  = array();
		$seen_ids       = array();
		$needs_count_up = false;

		foreach ( $locations as $location ) {
			$layouts = PriceRenderer::getAllActiveLayouts( $location );

			if ( empty( $layouts ) ) {
				continue;
			}

			foreach ( $layouts as $scenario => $layout ) {
				if ( empty( $layout['id'] ) ) {
					continue;
				}

				$layout_id = (int) $layout['id'];

				// Skip if we've already processed this layout.
				if ( isset( $seen_ids[ $layout_id ] ) ) {
					continue;
				}
				$seen_ids[ $layout_id ] = true;

				if ( ! empty( $layout['customJs'] ) ) {
					$scoped_js  = self::scopeJs( $layout['customJs'], $layout_id );
					$js_parts[] = "/* Layout: {$layout_id} */\n" . $scoped_js;
				}

				/**
				 * Allow Pro (or extensions) to generate CountUp animation JS for a layout.
				 *
				 * @param string|null $countup_js Generated CountUp JS, or null.
				 * @param array       $layout     Layout row.
				 * @param int         $layout_id  Layout ID.
				 */
				$countup_js = apply_filters( 'oppb_generate_layout_countup_js', null, $layout, $layout_id );
				if ( ! empty( $countup_js ) ) {
					$countup_parts[] = $countup_js;
					$needs_count_up  = true;
				}
			}
		}

		if ( $needs_count_up ) {
			do_action( 'oppb_enqueue_countup_script' );
		}

		// Combine all JS parts.
		$all_js = array_merge( $countup_parts, $js_parts );

		if ( empty( $all_js ) ) {
			return;
		}

		$js_content = implode( "\n\n", $all_js );

		// Build the script wrapper.
		$script_output = "\n/* OPPB Layout Scripts */\n" .
			"(function() {\n" .
			"	'use strict';\n" .
			"	var oppbInit = function() {\n" .
			'		' . str_replace( "\n", "\n\t\t", $js_content ) . "\n" .
			"	};\n" .
			"\n" .
			"	if (document.readyState === 'loading') {\n" .
			"		document.addEventListener('DOMContentLoaded', oppbInit);\n" .
			"	} else {\n" .
			"		oppbInit();\n" .
			"	}\n" .
			"})();\n";

		// Register a dummy script handle for inline script.
		wp_register_script( 'oppb-layout-scripts', false, array(), OPPB_VERSION, true );
		wp_enqueue_script( 'oppb-layout-scripts' );
		wp_add_inline_script( 'oppb-layout-scripts', $script_output );
	}


	/**
	 * Scope JavaScript to run for each price container of a specific layout.
	 *
	 * @param string $js        The JavaScript code.
	 * @param int    $layout_id The layout ID.
	 * @return string Scoped JavaScript.
	 */
	private static function scopeJs( string $js, int $layout_id ): string {
		// Wrap JS to execute for each container with this layout class.
		$scope_class = 'oppb-layout-' . $layout_id;

		return "document.querySelectorAll('.{$scope_class}').forEach(function(container) {\n" .
			"  (function() {\n" .
			"    // Container reference available as 'container'\n" .
			"    const priceContainer = container;\n" .
			'    ' . str_replace( "\n", "\n    ", trim( $js ) ) . "\n" .
			"  }).call(container);\n" .
			"});\n";
	}

	/**
	 * Scope CSS by prepending layout-specific class to selectors.
	 *
	 * Transforms:
	 *   .oppb-price-container { ... }
	 *   .oppb-regular-price { ... }
	 *
	 * Into:
	 *   .oppb-layout-123.oppb-price-container { ... }
	 *   .oppb-layout-123 .oppb-regular-price { ... }
	 *
	 * @param string $css       The CSS to scope.
	 * @param int    $layout_id The layout ID.
	 * @return string Scoped CSS.
	 */
	private static function scopeCss( string $css, int $layout_id ): string {
		$scope_class = '.oppb-layout-' . $layout_id;

		// Extract CSS blocks properly, respecting brace nesting.
		$blocks = self::extractCssBlocks( $css );
		$output = '';

		foreach ( $blocks as $block ) {
			$block = trim( $block );
			if ( empty( $block ) ) {
				continue;
			}

			// Handle media queries.
			if ( preg_match( '/^@media\s*([^{]+)\s*\{(.+)\}\s*$/s', $block, $matches ) ) {
				$media_query  = trim( $matches[1] );
				$inner_css    = trim( $matches[2] );
				$scoped_inner = self::scopeMultipleRules( $inner_css, $scope_class );
				$output      .= "@media {$media_query} {\n{$scoped_inner}}\n\n";

			} elseif ( preg_match( '/^@keyframes\s/', $block ) ) {
				// Don't modify keyframes.
				$output .= $block . "\n\n";

			} elseif ( preg_match( '/^\/\*/', $block ) ) {
				// Block starts with a comment - extract comment and check what follows.
				if ( preg_match( '/^(\/\*.*?\*\/\s*)(.+)$/s', $block, $comment_matches ) ) {
					$comment = trim( $comment_matches[1] );
					$rest    = trim( $comment_matches[2] );

					// Output the comment.
					$output .= $comment . "\n";

					// Check if the rest is a media query.
					if ( preg_match( '/^@media\s*([^{]+)\s*\{(.+)\}\s*$/s', $rest, $media_matches ) ) {
						$media_query  = trim( $media_matches[1] );
						$inner_css    = trim( $media_matches[2] );
						$scoped_inner = self::scopeMultipleRules( $inner_css, $scope_class );
						$output      .= "@media {$media_query} {\n{$scoped_inner}}\n\n";
					} elseif ( preg_match( '/^@keyframes\s/', $rest ) ) {
						// Keyframes - preserve as-is.
						$output .= $rest . "\n\n";
					} elseif ( ! empty( $rest ) ) {
						// Regular rule after comment.
						$output .= self::scopeSingleRule( $rest, $scope_class );
					}
				} else {
					// Just a standalone comment.
					$output .= $block . "\n";
				}
			} else {
				// Regular rule block.
				$output .= self::scopeSingleRule( $block, $scope_class );
			}
		}

		return $output;
	}

	/**
	 * Extract CSS blocks while respecting brace nesting.
	 *
	 * This properly handles nested braces in media queries and at-rules,
	 * ensuring that @media { .selector { ... } } is kept as one block.
	 *
	 * @param string $css The CSS string.
	 * @return array Array of CSS blocks.
	 */
	private static function extractCssBlocks( string $css ): array {
		$blocks     = array();
		$current    = '';
		$depth      = 0;
		$in_comment = false;
		$len        = strlen( $css );

		for ( $i = 0; $i < $len; $i++ ) {
			$char = $css[ $i ];

			// Check for comment start.
			if ( ! $in_comment && '/' === $char && $i + 1 < $len && '*' === $css[ $i + 1 ] ) {
				$current   .= $char;
				$in_comment = true;
				continue;
			}

			// Check for comment end.
			if ( $in_comment && '*' === $char && $i + 1 < $len && '/' === $css[ $i + 1 ] ) {
				$current .= '*/';
				++$i; // Skip the '/'.
				$in_comment = false;
				continue;
			}

			$current .= $char;

			if ( $in_comment ) {
				continue;
			}

			if ( '{' === $char ) {
				++$depth;
			} elseif ( '}' === $char ) {
				--$depth;
				if ( 0 === $depth ) {
					// End of a top-level block.
					$trimmed = trim( $current );
					if ( '' !== $trimmed ) {
						$blocks[] = $trimmed;
					}
					$current = '';
				}
			}
		}

		// Handle any remaining content (comments or malformed CSS).
		$remaining = trim( $current );
		if ( '' !== $remaining ) {
			$blocks[] = $remaining;
		}

		return $blocks;
	}

	/**
	 * Scope multiple CSS rules (used inside media queries).
	 *
	 * @param string $css         The CSS containing multiple rules.
	 * @param string $scope_class The scoping class.
	 * @return string Scoped CSS.
	 */
	private static function scopeMultipleRules( string $css, string $scope_class ): string {
		$output       = '';
		$inner_blocks = self::extractCssBlocks( $css );

		foreach ( $inner_blocks as $inner_block ) {
			$inner_block = trim( $inner_block );
			if ( ! empty( $inner_block ) ) {
				$output .= '  ' . self::scopeSingleRule( $inner_block, $scope_class );
			}
		}

		return $output;
	}

	/**
	 * Scope a single CSS rule block.
	 *
	 * @param string $block       The CSS block.
	 * @param string $scope_class The scoping class.
	 * @return string Scoped block.
	 */
	private static function scopeSingleRule( string $block, string $scope_class ): string {
		// Extract and preserve leading comments.
		$leading_comment = '';
		if ( preg_match( '/^(\s*\/\*.*?\*\/\s*)+/s', $block, $comment_matches ) ) {
			$leading_comment = $comment_matches[0];
			$block           = substr( $block, strlen( $leading_comment ) );
		}

		// Split by opening brace to get selector and properties.
		$parts = preg_split( '/\s*{\s*/', $block, 2 );
		if ( count( $parts ) !== 2 ) {
			return $leading_comment . $block;
		}

		$selectors     = trim( $parts[0] );
		$properties    = rtrim( trim( $parts[1] ), '}' );
		$new_selectors = array();

		// Process each selector.
		foreach ( explode( ',', $selectors ) as $selector ) {
			$selector = trim( $selector );
			if ( empty( $selector ) ) {
				continue;
			}

			// Skip inline comments within selectors.
			if ( strpos( $selector, '/*' ) === 0 ) {
				$new_selectors[] = $selector;
				continue;
			}

			// For .oppb-price-container, combine with scope class (same element).
			if ( strpos( $selector, '.oppb-price-container' ) === 0 ) {
				$rest            = substr( $selector, strlen( '.oppb-price-container' ) );
				$new_selectors[] = $scope_class . '.oppb-price-container' . $rest;
			} elseif ( strpos( $selector, '.oppb-price-row' ) === 0 ) {
				// For row classes, prefix with scoped container.
				$new_selectors[] = $scope_class . ' ' . $selector;
			} elseif ( strpos( $selector, '.oppb-' ) === 0 ) {
				// For all other .oppb- classes, add scope as ancestor.
				$new_selectors[] = $scope_class . ' ' . $selector;
			} else {
				// Non-pbo selectors - leave unchanged.
				$new_selectors[] = $selector;
			}
		}

		return $leading_comment . implode( ",\n", $new_selectors ) . " {\n  " . $properties . "}\n";
	}

	/**
	 * Check if assets should be loaded on current page.
	 *
	 * @return bool
	 */
	private static function shouldLoadAssets(): bool {
		// Always load if forced via filter.
		if ( apply_filters( 'oppb_force_load_assets', false ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
			return true;
		}

		// In most cases, just load the assets - they're lightweight CSS only.
		return true;
	}


	/**
	 * Extract Google font names from layout canvas_data.
	 *
	 * Recursively searches the canvas_data JSON for fontFamily values
	 * and returns those that are Google fonts.
	 *
	 * @param string $canvas_data The canvas_data JSON string.
	 * @return array<string> Array of Google font names.
	 */
	private static function extractGoogleFontsFromLayout( string $canvas_data ): array {
		$data = json_decode( $canvas_data, true );
		if ( ! is_array( $data ) ) {
			return array();
		}

		$fonts = array();
		self::extractFontFamiliesRecursive( $data, $fonts );

		// Deduplicate fonts first - elements may use the same font.
		$unique_fonts = array_unique( $fonts );

		// Filter to only Google fonts.
		$google_fonts = self::getGoogleFontsList();

		return array_values( array_intersect( $unique_fonts, $google_fonts ) );
	}

	/**
	 * Recursively extract fontFamily values from an array structure.
	 *
	 * @param array         $data   The data to search.
	 * @param array<string> &$fonts Reference to collect font names.
	 * @return void
	 */
	private static function extractFontFamiliesRecursive( array $data, array &$fonts ): void {
		foreach ( $data as $key => $value ) {
			if ( 'fontFamily' === $key && is_string( $value ) && ! empty( $value ) && '__default__' !== $value ) {
				// Extract the primary font name (before any comma for fallback fonts).
				$font_name = self::extractPrimaryFontName( $value );
				if ( ! empty( $font_name ) ) {
					$fonts[] = $font_name;
				}
			} elseif ( is_array( $value ) ) {
				self::extractFontFamiliesRecursive( $value, $fonts );
			}
		}
	}

	/**
	 * Extract the primary font name from a font-family CSS value.
	 *
	 * Strips fallback fonts like 'sans-serif', 'monospace', 'serif' etc.
	 * "Archivo Black, sans-serif" -> "Archivo Black"
	 * "JetBrains Mono, monospace" -> "JetBrains Mono"
	 *
	 * @param string $font_family The font-family CSS value.
	 * @return string The primary font name, or empty string if generic.
	 */
	private static function extractPrimaryFontName( string $font_family ): string {
		// Generic font families that should not be imported.
		$generic_fonts = array(
			'serif',
			'sans-serif',
			'monospace',
			'cursive',
			'fantasy',
			'system-ui',
			'ui-serif',
			'ui-sans-serif',
			'ui-monospace',
			'ui-rounded',
			'emoji',
			'math',
			'fangsong',
			'inherit',
			'initial',
			'unset',
		);

		// Split by comma and get the first font.
		$parts        = explode( ',', $font_family );
		$primary_font = trim( $parts[0] );

		// Remove quotes if present.
		$primary_font = trim( $primary_font, "'\"" );

		// Check if it's a generic font.
		if ( in_array( strtolower( $primary_font ), $generic_fonts, true ) ) {
			return '';
		}

		return $primary_font;
	}

	/**
	 * Get list of available Google fonts.
	 *
	 * Uses the same list as AdminAssets but is filterable for frontend.
	 *
	 * @return array<string> Array of Google font names.
	 */
	private static function getGoogleFontsList(): array {
		$google_fonts = array(
			'Roboto',
			'Open Sans',
			'Lato',
			'Montserrat',
			'Poppins',
			'Inter',
			'Oswald',
			'Raleway',
			'Nunito',
			'Playfair Display',
			'Merriweather',
			'Source Sans Pro',
			'Ubuntu',
			'Rubik',
			'Work Sans',
			'Fira Sans',
			'Barlow',
			'DM Sans',
			'Quicksand',
			'Libre Baskerville',
			'Crimson Text',
			'Josefin Sans',
			'Archivo',
			'Archivo Black',
			'JetBrains Mono',
			'Caveat',
			'Bebas Neue',
			'Anton',
			'Fjalla One',
			'Space Grotesk',
			'Outfit',
			'Bitter',
			'Cabin',
			'Catamaran',
			'Dosis',
			'Exo 2',
			'Heebo',
			'IBM Plex Sans',
			'Inconsolata',
			'Karla',
			'Lora',
			'Manrope',
			'Mukta',
			'Noto Sans',
			'Overpass',
			'PT Sans',
			'Questrial',
			'Righteous',
			'Signika',
			'Titillium Web',
			'Varela Round',
			'Yanone Kaffeesatz',
			'Zilla Slab',
		);

		/**
		 * Filter the list of available Google fonts for frontend.
		 *
		 * @param array<string> $google_fonts Array of Google font names.
		 * @return array<string>
		 */
		return apply_filters( 'oppb_google_fonts_list', $google_fonts );
	}

	/**
	 * Generate a single @import statement for all Google fonts.
	 *
	 * Combines multiple fonts into one URL for better performance.
	 * Example: https://fonts.googleapis.com/css2?family=Archivo+Black:wght@400;700&family=JetBrains+Mono:wght@400;700&display=swap
	 *
	 * @param array<string> $font_names Array of Google font names.
	 * @return string CSS @import statement.
	 */
	private static function generateGoogleFontImports( array $font_names ): string {
		if ( empty( $font_names ) ) {
			return '';
		}

		// Build family parameters for each font.
		$family_params = array();

		foreach ( $font_names as $font_name ) {
			$encoded_name    = str_replace( ' ', '+', $font_name );
			$family_params[] = "family={$encoded_name}:wght@100;200;300;400;500;600;700;800;900";
		}

		// Combine all fonts into one URL.
		$url = 'https://fonts.googleapis.com/css2?' . implode( '&', $family_params ) . '&display=swap';

		return "/* Google Fonts */\n@import url('{$url}');\n\n";
	}

	/**
	 * Generate @font-face declarations for locally hosted fonts.
	 *
	 * Loads fonts from the oppb-fonts upload directory instead of Google CDN.
	 *
	 * @param array<string> $font_names  Array of Google font names.
	 * @param array         $fonts_cache Cache of downloaded font files info.
	 * @return string CSS @font-face declarations.
	 */
	private static function generateLocalFontImports( array $font_names, array $fonts_cache ): string {
		if ( empty( $font_names ) ) {
			return '';
		}

		// Ensure unique font names (extra safety).
		$font_names = array_values( array_unique( $font_names ) );

		$upload_dir    = wp_upload_dir();
		$fonts_url     = $upload_dir['baseurl'] . '/oppb-fonts';
		$fonts_path    = $upload_dir['basedir'] . '/oppb-fonts';
		$css_parts     = array();
		$seen_fonts    = array(); // Track processed fonts to avoid duplicates.
		$seen_fontface = array(); // Track generated @font-face to avoid duplicates.

		foreach ( $font_names as $font_name ) {
			// Skip if we've already processed this font.
			if ( isset( $seen_fonts[ $font_name ] ) ) {
				continue;
			}
			$seen_fonts[ $font_name ] = true;
			$font_slug                = sanitize_title( $font_name );
			$font_dir                 = $fonts_path . '/' . $font_slug;
			$font_url                 = $fonts_url . '/' . $font_slug;

			// Check if we have cached info for this font.
			if ( isset( $fonts_cache[ $font_name ] ) && ! empty( $fonts_cache[ $font_name ]['files'] ) ) {
				// Use cached file info.
				foreach ( $fonts_cache[ $font_name ]['files'] as $file_info ) {
					$file_url = $font_url . '/' . basename( $file_info['file'] ?? $file_info );
					$weight   = $file_info['weight'] ?? '400';
					$style    = $file_info['style'] ?? 'normal';

					// Create unique key to avoid duplicate @font-face declarations.
					$fontface_key = "{$font_name}|{$weight}|{$style}";
					if ( isset( $seen_fontface[ $fontface_key ] ) ) {
						continue;
					}
					$seen_fontface[ $fontface_key ] = true;

					$css_parts[] = "@font-face {\n" .
						"  font-family: '{$font_name}';\n" .
						"  font-style: {$style};\n" .
						"  font-weight: {$weight};\n" .
						"  font-display: swap;\n" .
						"  src: url('{$file_url}') format('woff2');\n" .
						"}\n";
				}
			} elseif ( is_dir( $font_dir ) ) {
				// Fallback: scan directory for font files.
				$font_files = glob( $font_dir . '/*.woff2' );

				if ( empty( $font_files ) ) {
					// No local files yet вЂ” skip until the administrator downloads them.
					continue;
				}

				foreach ( $font_files as $font_file ) {
					$file_name = basename( $font_file );
					$file_url  = $font_url . '/' . $file_name;

					// Try to extract weight from filename (e.g., font-400.woff2, font-700-italic.woff2).
					$weight = '400';
					$style  = 'normal';

					if ( preg_match( '/(\d{3})/', $file_name, $matches ) ) {
						$weight = $matches[1];
					}
					if ( stripos( $file_name, 'italic' ) !== false ) {
						$style = 'italic';
					}

					// Create unique key to avoid duplicate @font-face declarations.
					$fontface_key = "{$font_name}|{$weight}|{$style}";
					if ( isset( $seen_fontface[ $fontface_key ] ) ) {
						continue;
					}
					$seen_fontface[ $fontface_key ] = true;

					$css_parts[] = "@font-face {\n" .
						"  font-family: '{$font_name}';\n" .
						"  font-style: {$style};\n" .
						"  font-weight: {$weight};\n" .
						"  font-display: swap;\n" .
						"  src: url('{$file_url}') format('woff2');\n" .
						"}\n";
				}
			}
			// Font directory missing: skip until fonts are downloaded locally.
		}

		if ( empty( $css_parts ) ) {
			return '';
		}

		return "/* Local Google Fonts */\n" . implode( "\n", $css_parts ) . "\n";
	}

	/**
	 * Unregister assets.
	 *
	 * @return void
	 */
	public static function unregister(): void {
		remove_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueueAssets' ), 5 );

		self::$registered       = false;
		self::$assetsEnqueued   = false;
		self::$webComponentMode = null;
	}
}
