<?php
/**
 * Icon library registry — namespaced refs + render adapters.
 *
 * @package JOCBM
 *
 * Copyright (C) 2026 JayOnCode
 * License: GPL-2.0-or-later
 */

namespace JOCBM\Services;

defined( 'ABSPATH' ) || exit;

/**
 * Class IconRegistry
 */
final class IconRegistry {

	public const OPTION = 'jocbm_icon_libraries';

	/**
	 * Default library definitions (merged with saved option).
	 *
	 * @return array<int, array<string, mixed>>
	 */
	public static function default_libraries(): array {
		return array(
			array(
				'id'            => 'dashicons',
				'label'         => 'Dashicons',
				'enabled'       => true,
				'builtin'       => true,
				'type'          => 'font-class',
				'stylesheetUrl' => '',
				'classTemplate' => 'dashicons dashicons-{id}',
				'textContent'   => false,
				'spriteUrl'     => '',
				'catalog'       => self::dashicons_catalog(),
			),
			array(
				'id'            => 'fa',
				'label'         => 'Font Awesome',
				'enabled'       => false,
				'builtin'       => true,
				'type'          => 'font-class',
				'stylesheetUrl' => '',
				'classTemplate' => 'fa-solid fa-{id}',
				'textContent'   => false,
				'spriteUrl'     => '',
				'catalog'       => array(),
			),
			array(
				'id'            => 'material',
				'label'         => 'Material Icons',
				'enabled'       => false,
				'builtin'       => true,
				'type'          => 'font-class',
				'stylesheetUrl' => '',
				'classTemplate' => 'material-icons',
				'textContent'   => true,
				'spriteUrl'     => '',
				'catalog'       => array(),
			),
			array(
				'id'            => 'lucide',
				'label'         => 'Lucide',
				'enabled'       => true,
				'builtin'       => true,
				'type'          => 'svg-inline',
				'stylesheetUrl' => '',
				'classTemplate' => '',
				'textContent'   => false,
				'spriteUrl'     => '',
				'catalog'       => self::lucide_catalog(),
			),
		);
	}

	/**
	 * Merged libraries for admin / REST / localize.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	public static function get_libraries(): array {
		$saved = get_option( self::OPTION, null );
		if ( ! is_array( $saved ) ) {
			return self::default_libraries();
		}
		return self::merge_with_defaults( $saved );
	}

	/**
	 * Enabled libraries only.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	public static function get_enabled_libraries(): array {
		return array_values(
			array_filter(
				self::get_libraries(),
				static function ( $lib ) {
					return ! empty( $lib['enabled'] );
				}
			)
		);
	}

	/**
	 * Persist libraries from REST/admin.
	 * For font-class libraries with a stylesheet URL, refreshes the icon catalog
	 * from CSS when the URL or class template changed (so pickers load from stored data).
	 *
	 * @param array<int, mixed> $libraries Raw list.
	 * @return array<int, array<string, mixed>>
	 */
	public static function save_libraries( array $libraries ): array {
		$previous = get_option( self::OPTION, null );
		$prev_by_id = array();
		if ( is_array( $previous ) ) {
			foreach ( $previous as $row ) {
				if ( is_array( $row ) && ! empty( $row['id'] ) ) {
					$prev_by_id[ sanitize_key( (string) $row['id'] ) ] = $row;
				}
			}
		}

		$sanitized = self::sanitize_libraries( $libraries );

		foreach ( $sanitized as $index => $lib ) {
			$id  = (string) ( $lib['id'] ?? '' );
			$url = trim( (string) ( $lib['stylesheetUrl'] ?? '' ) );

			// Font Awesome / Material: clearing the stylesheet drops the stored catalog.
			if ( in_array( $id, array( 'fa', 'material' ), true ) && '' === $url ) {
				$sanitized[ $index ]['catalog'] = array();
				continue;
			}

			// Material Icons / Symbols are ligature fonts — normalize render settings from URL.
			if ( $url && self::is_material_stylesheet_url( $url ) ) {
				$sanitized[ $index ]['textContent'] = true;
				if ( self::is_material_icons_package_url( $url ) ) {
					$sanitized[ $index ]['classTemplate'] = 'material-icons';
				} elseif ( false !== stripos( $url, 'material-symbols' ) ) {
					$tpl = (string) ( $sanitized[ $index ]['classTemplate'] ?? '' );
					if ( '' === $tpl || false === strpos( $tpl, 'material' ) ) {
						$sanitized[ $index ]['classTemplate'] = 'material-symbols-outlined';
					}
				}
				$lib = $sanitized[ $index ];
			}

			if ( ! self::library_can_auto_scan( $lib ) ) {
				continue;
			}

			$id   = (string) ( $lib['id'] ?? '' );
			$prev = $prev_by_id[ $id ] ?? null;
			$url  = (string) ( $lib['stylesheetUrl'] ?? '' );
			$tpl  = (string) ( $lib['classTemplate'] ?? '' );

			$url_changed = ! is_array( $prev )
				|| (string) ( $prev['stylesheetUrl'] ?? '' ) !== $url
				|| (string) ( $prev['classTemplate'] ?? '' ) !== $tpl;

			$catalog_empty = empty( $lib['catalog'] ) || ! is_array( $lib['catalog'] );
			// Heal earlier saves that kept the tiny starter catalog after a failed scan.
			$looks_starter = is_array( $lib['catalog'] ) && count( $lib['catalog'] ) <= 40;

			// Skip network fetch when config is unchanged and a full catalog already exists.
			if ( ! $url_changed && ! $catalog_empty && ! $looks_starter ) {
				continue;
			}

			$result = self::scan_stylesheet_catalog( $url, $tpl );
			if ( is_wp_error( $result ) ) {
				continue;
			}
			$sanitized[ $index ]['catalog'] = $result['catalog'];
		}

		update_option( self::OPTION, $sanitized, false );
		return self::get_libraries();
	}

	/**
	 * Whether this library should auto-build its catalog on save.
	 *
	 * @param array<string, mixed> $lib Library row.
	 */
	private static function library_can_auto_scan( array $lib ): bool {
		if ( 'dashicons' === ( $lib['id'] ?? '' ) ) {
			return false;
		}
		if ( 'font-class' !== ( $lib['type'] ?? 'font-class' ) ) {
			return false;
		}
		$url = trim( (string) ( $lib['stylesheetUrl'] ?? '' ) );
		if ( '' === $url ) {
			return false;
		}
		// Ligature fonts (Material): catalog comes from package metadata, not CSS classes.
		if ( ! empty( $lib['textContent'] ) || self::is_material_stylesheet_url( $url ) ) {
			return true;
		}
		$tpl = (string) ( $lib['classTemplate'] ?? '' );
		return false !== strpos( $tpl, '{id}' );
	}

	/**
	 * @param string $url Stylesheet URL.
	 */
	private static function is_material_stylesheet_url( string $url ): bool {
		return (bool) preg_match( '#material-icons|material-symbols|Material\+Icons|Material\+Symbols#i', $url );
	}

	/**
	 * npm/jsDelivr `material-icons` package (ligature class `.material-icons`).
	 *
	 * @param string $url Stylesheet URL.
	 */
	private static function is_material_icons_package_url( string $url ): bool {
		return (bool) preg_match( '#material-icons#i', $url )
			&& ! preg_match( '#material-symbols#i', $url );
	}

	/**
	 * Payload for localize_script.
	 *
	 * @return array{libraries: array<int, array<string, mixed>>}
	 */
	public static function localize_payload(): array {
		return array(
			'libraries' => self::get_libraries(),
		);
	}

	/**
	 * @param string $ref Icon ref.
	 * @return array{libraryId: string, iconId: string}|null
	 */
	public static function parse_ref( string $ref ): ?array {
		$ref = trim( $ref );
		if ( '' === $ref ) {
			return null;
		}
		if ( false !== strpos( $ref, ':' ) ) {
			$parts = explode( ':', $ref, 2 );
			$lib   = sanitize_key( $parts[0] );
			$id    = trim( (string) ( $parts[1] ?? '' ) );
			if ( '' === $lib || '' === $id || ! preg_match( '/^[a-z0-9_-]+$/i', $id ) ) {
				return null;
			}
			return array(
				'libraryId' => $lib,
				'iconId'    => $id,
			);
		}
		if ( preg_match( '/^[a-z0-9-]+$/i', $ref ) ) {
			return array(
				'libraryId' => 'dashicons',
				'iconId'    => $ref,
			);
		}
		return null;
	}

	/**
	 * Normalize to namespaced ref or empty.
	 *
	 * @param string $ref Raw.
	 */
	public static function normalize_ref( string $ref ): string {
		$parsed = self::parse_ref( $ref );
		if ( ! $parsed ) {
			return '';
		}
		return $parsed['libraryId'] . ':' . $parsed['iconId'];
	}

	/**
	 * Find library by id.
	 *
	 * @param string $id Library id.
	 * @return array<string, mixed>|null
	 */
	public static function get_library( string $id ): ?array {
		$id = sanitize_key( $id );
		foreach ( self::get_libraries() as $lib ) {
			if ( ( $lib['id'] ?? '' ) === $id ) {
				return $lib;
			}
		}
		return null;
	}

	/**
	 * Build icon HTML for a namespaced or legacy ref.
	 *
	 * @param string $ref   Icon ref.
	 * @param string $style Inline style declarations.
	 * @param string $extra Extra class names.
	 */
	public static function render_html( string $ref, string $style = '', string $extra = '' ): string {
		$parsed = self::parse_ref( $ref );
		if ( ! $parsed ) {
			return '';
		}
		$lib = self::get_library( $parsed['libraryId'] );
		if ( ! $lib || empty( $lib['enabled'] ) ) {
			if ( 'dashicons' === $parsed['libraryId'] || ! $lib ) {
				$lib = array(
					'type'          => 'font-class',
					'classTemplate' => 'dashicons dashicons-{id}',
					'textContent'   => false,
				);
			} elseif ( 'lucide' === $parsed['libraryId'] ) {
				$lib = array(
					'type'        => 'svg-inline',
					'textContent' => false,
				);
			} else {
				return '';
			}
		}

		$type    = (string) ( $lib['type'] ?? 'font-class' );
		$icon_id = $parsed['iconId'];
		$extra   = trim( $extra );
		$style   = trim( $style );

		if ( 'svg-sprite' === $type ) {
			$sprite = esc_url( (string) ( $lib['spriteUrl'] ?? '' ) );
			if ( '' === $sprite ) {
				return '';
			}
			$cls = trim( 'bm-icon bm-icon--svg ' . $extra );
			return sprintf(
				'<svg class="%1$s" style="%2$s" aria-hidden="true" focusable="false"><use href="%3$s#%4$s" xlink:href="%3$s#%4$s"></use></svg>',
				esc_attr( $cls ),
				esc_attr( $style ),
				esc_url( $sprite ),
				esc_attr( $icon_id )
			);
		}

		if ( 'svg-inline' === $type ) {
			$raw = self::resolve_inline_svg( (string) ( $lib['id'] ?? $parsed['libraryId'] ), $icon_id );
			if ( '' === $raw ) {
				return '';
			}
			return self::decorate_inline_svg( $raw, trim( 'bm-icon bm-icon--svg ' . $extra ), $style );
		}

		if ( 'css-mask' === $type ) {
			$mask = (string) ( $lib['classTemplate'] ?? '' );
			$mask = str_replace( '{id}', rawurlencode( $icon_id ), $mask );
			$mask_style = $style;
			if ( $mask ) {
				$mask_style .= ( $mask_style ? ';' : '' ) . 'mask-image:url(' . esc_url( $mask ) . ');-webkit-mask-image:url(' . esc_url( $mask ) . ');mask-size:contain;-webkit-mask-size:contain;mask-repeat:no-repeat;background-color:currentColor';
			}
			$cls = trim( 'bm-icon bm-icon--mask ' . $extra );
			return sprintf(
				'<span class="%1$s" style="%2$s" aria-hidden="true"></span>',
				esc_attr( $cls ),
				esc_attr( $mask_style )
			);
		}

		$tpl   = (string) ( $lib['classTemplate'] ?? 'dashicons dashicons-{id}' );
		$class = trim( str_replace( '{id}', $icon_id, $tpl ) . ' ' . $extra );
		$text  = ! empty( $lib['textContent'] ) ? esc_html( $icon_id ) : '';
		return sprintf(
			'<span class="%1$s" style="%2$s" aria-hidden="true">%3$s</span>',
			esc_attr( $class ),
			esc_attr( $style ),
			$text
		);
	}

	/**
	 * Collect library ids referenced in attrs.
	 *
	 * @param array<string, mixed> $attrs Attributes.
	 * @return array<int, string>
	 */
	public static function library_ids_from_attrs( array $attrs ): array {
		$ids = array();
		self::collect_refs_from_value( $attrs, $ids );
		return array_values( array_unique( $ids ) );
	}

	/**
	 * Enqueue stylesheets for given library ids (idempotent).
	 *
	 * @param array<int, string> $library_ids Ids.
	 */
	public static function enqueue_styles_for( array $library_ids ): void {
		foreach ( $library_ids as $id ) {
			$lib = self::get_library( (string) $id );
			if ( ! $lib || empty( $lib['enabled'] ) ) {
				continue;
			}
			if ( 'dashicons' === ( $lib['id'] ?? '' ) ) {
				wp_enqueue_style( 'dashicons' );
				continue;
			}
			$url = esc_url_raw( (string) ( $lib['stylesheetUrl'] ?? '' ) );
			if ( ! $url ) {
				continue;
			}
			if ( str_starts_with( $url, '/' ) && ! str_starts_with( $url, '//' ) ) {
				$url = home_url( $url );
			}
			foreach ( self::expand_stylesheet_urls( $url ) as $i => $sheet_url ) {
				$handle = 'joc-blockora-morph-icon-' . sanitize_title( (string) $lib['id'] );
				if ( $i > 0 ) {
					$handle .= '-' . $i;
				}
				if ( ! wp_style_is( $handle, 'registered' ) ) {
					wp_register_style( $handle, $sheet_url, array(), JOCBM_VERSION );
				}
				if ( ! wp_style_is( $handle, 'enqueued' ) ) {
					wp_enqueue_style( $handle );
				}
			}
		}
	}

	/**
	 * FA `fontawesome.css` maps icons but has no @font-face — also load solid.css.
	 *
	 * @param string $url Primary stylesheet URL.
	 * @return array<int, string>
	 */
	public static function expand_stylesheet_urls( string $url ): array {
		$url = trim( $url );
		if ( '' === $url ) {
			return array();
		}
		$urls = array( $url );
		// Prefer all.min.css when the user pasted core-only fontawesome.css.
		if ( preg_match( '#^(.*)/fontawesome(\.min)?\.css(\?.*)?$#i', $url, $m ) ) {
			$solid = $m[1] . '/solid' . ( $m[2] ?? '' ) . '.css' . ( $m[3] ?? '' );
			$urls[] = $solid;
		}
		return array_values( array_unique( $urls ) );
	}

	/**
	 * @param mixed              $value Value.
	 * @param array<int, string> $ids   Collector.
	 */
	private static function collect_refs_from_value( $value, array &$ids ): void {
		if ( is_string( $value ) ) {
			$parsed = self::parse_ref( $value );
			if ( $parsed ) {
				$ids[] = $parsed['libraryId'];
			}
			return;
		}
		if ( ! is_array( $value ) ) {
			return;
		}
		if ( isset( $value['icon'] ) && is_string( $value['icon'] ) ) {
			self::collect_refs_from_value( $value['icon'], $ids );
		}
		foreach ( $value as $key => $child ) {
			if ( 'icon' === $key ) {
				continue;
			}
			if ( is_array( $child ) || is_string( $child ) ) {
				self::collect_refs_from_value( $child, $ids );
			}
		}
	}

	/**
	 * @param array<int, mixed> $saved Saved option.
	 * @return array<int, array<string, mixed>>
	 */
	private static function merge_with_defaults( array $saved ): array {
		$defaults = self::default_libraries();
		$by_id    = array();
		foreach ( $saved as $row ) {
			if ( ! is_array( $row ) || empty( $row['id'] ) ) {
				continue;
			}
			$by_id[ sanitize_key( (string) $row['id'] ) ] = $row;
		}

		$out = array();
		foreach ( $defaults as $def ) {
			$id = $def['id'];
			if ( isset( $by_id[ $id ] ) ) {
				$merged = array_merge( $def, $by_id[ $id ] );
				$merged['builtin'] = true;
				$merged['id']      = $id;
				$saved_url         = trim( (string) ( $by_id[ $id ]['stylesheetUrl'] ?? '' ) );
				// FA / Material with no stylesheet must not revive a starter catalog.
				if ( in_array( $id, array( 'fa', 'material' ), true ) && '' === $saved_url ) {
					$merged['catalog'] = array();
				} elseif ( 'lucide' === $id ) {
					// Keep type + catalog in sync with the shipped PHP icon map.
					// Always on — same as Dashicons (not user-togglable).
					$merged['type']            = 'svg-inline';
					$merged['spriteUrl']       = '';
					$merged['stylesheetUrl']   = '';
					$merged['classTemplate']   = '';
					$merged['textContent']     = false;
					$merged['label']           = $def['label'];
					$merged['catalog']         = $def['catalog'];
					$merged['enabled']         = true;
				} elseif ( 'dashicons' === $id ) {
					$merged = $def;
					$merged['enabled'] = true;
				} elseif ( empty( $by_id[ $id ]['catalog'] ) ) {
					$merged['catalog'] = $def['catalog'];
				}
				$out[] = self::sanitize_library( $merged );
				unset( $by_id[ $id ] );
			} else {
				$out[] = $def;
			}
		}
		foreach ( $by_id as $row ) {
			$out[] = self::sanitize_library( $row );
		}
		return $out;
	}

	/**
	 * @param array<int, mixed> $libraries List.
	 * @return array<int, array<string, mixed>>
	 */
	private static function sanitize_libraries( array $libraries ): array {
		$out = array();
		foreach ( $libraries as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$out[] = self::sanitize_library( $row );
		}
		return $out ?: self::default_libraries();
	}

	/**
	 * @param array<string, mixed> $row Library.
	 * @return array<string, mixed>
	 */
	private static function sanitize_library( array $row ): array {
		$id = sanitize_key( (string) ( $row['id'] ?? '' ) );
		if ( '' === $id ) {
			$id = 'custom-' . substr( md5( (string) wp_json_encode( $row ) ), 0, 8 );
		}
		$type = (string) ( $row['type'] ?? 'font-class' );
		if ( ! in_array( $type, array( 'font-class', 'svg-sprite', 'svg-inline', 'css-mask' ), true ) ) {
			$type = 'font-class';
		}
		$catalog = array();
		if ( ! empty( $row['catalog'] ) && is_array( $row['catalog'] ) ) {
			foreach ( $row['catalog'] as $item ) {
				if ( ! is_array( $item ) ) {
					continue;
				}
				$cid = preg_replace( '/[^a-z0-9_-]/i', '', (string) ( $item['id'] ?? '' ) );
				if ( '' === $cid ) {
					continue;
				}
				$keywords = array();
				if ( ! empty( $item['keywords'] ) && is_array( $item['keywords'] ) ) {
					foreach ( $item['keywords'] as $kw ) {
						$kw = sanitize_text_field( (string) $kw );
						if ( '' !== $kw ) {
							$keywords[] = $kw;
						}
					}
				}
				$catalog[] = array(
					'id'       => $cid,
					'label'    => sanitize_text_field( (string) ( $item['label'] ?? $cid ) ),
					'keywords' => $keywords,
				);
			}
		}

		$url    = self::sanitize_asset_url( (string) ( $row['stylesheetUrl'] ?? '' ) );
		$sprite = self::sanitize_asset_url( (string) ( $row['spriteUrl'] ?? '' ) );

		return array(
			'id'            => $id,
			'label'         => sanitize_text_field( (string) ( $row['label'] ?? $id ) ),
			'enabled'       => in_array( $id, array( 'dashicons', 'lucide' ), true )
				? true
				: ! empty( $row['enabled'] ),
			'builtin'       => ! empty( $row['builtin'] ) || in_array( $id, array( 'dashicons', 'lucide' ), true ),
			'type'          => $type,
			'stylesheetUrl' => $url,
			'classTemplate' => sanitize_text_field( (string) ( $row['classTemplate'] ?? '' ) ),
			'textContent'   => ! empty( $row['textContent'] ),
			'spriteUrl'     => $sprite,
			'catalog'       => $catalog,
		);
	}

	/**
	 * Allow http(s) and root-relative stylesheet/sprite URLs.
	 *
	 * @param string $raw Raw URL.
	 */
	private static function sanitize_asset_url( string $raw ): string {
		$raw = trim( $raw );
		if ( '' === $raw ) {
			return '';
		}
		if ( str_starts_with( $raw, '/' ) && ! str_starts_with( $raw, '//' ) ) {
			$path = esc_url_raw( $raw );
			return is_string( $path ) ? $path : '';
		}
		return (string) esc_url_raw( $raw, array( 'http', 'https' ) );
	}

	/**
	 * Resolve and validate a remote asset URL for server-side fetches (SSRF-safe).
	 *
	 * @param string $raw Absolute or root-relative URL.
	 * @return string|\WP_Error Absolute http(s) URL, or error.
	 */
	private static function prepare_fetch_url( string $raw ) {
		$url = self::sanitize_asset_url( $raw );
		if ( '' === $url ) {
			return new \WP_Error(
				'jocbm_icon_css_url',
				__( 'Enter a valid stylesheet URL first.', 'joc-blockora-morph' ),
				array( 'status' => 400 )
			);
		}

		if ( str_starts_with( $url, '/' ) ) {
			$url = home_url( $url );
		}

		if ( ! wp_http_validate_url( $url ) ) {
			return new \WP_Error(
				'jocbm_icon_css_url_unsafe',
				__( 'That URL is not allowed for remote requests.', 'joc-blockora-morph' ),
				array( 'status' => 400 )
			);
		}

		return $url;
	}

	/**
	 * Fetch a stylesheet and extract icon catalog entries from glyph :before/:after rules.
	 *
	 * Uses the class template token that contains `{id}` (e.g. `fa-{id}` → prefix `fa-`).
	 *
	 * @param string $stylesheet_url Stylesheet URL (absolute or root-relative).
	 * @param string $class_template Class template with `{id}`.
	 * @return array{catalog: array<int, array{id: string, label: string, keywords: array<int, string>}>, count: int}|\WP_Error
	 */
	public static function scan_stylesheet_catalog( string $stylesheet_url, string $class_template ) {
		$url = self::prepare_fetch_url( $stylesheet_url );
		if ( is_wp_error( $url ) ) {
			return $url;
		}

		$response = wp_safe_remote_get(
			$url,
			array(
				'timeout'     => 20,
				'redirection' => 3,
				'headers'     => array(
					'Accept' => 'text/css,*/*;q=0.1',
				),
			)
		);

		$css     = '';
		$catalog = array();

		if ( ! is_wp_error( $response ) ) {
			$code = (int) wp_remote_retrieve_response_code( $response );
			if ( $code >= 200 && $code < 300 ) {
				$css = (string) wp_remote_retrieve_body( $response );
				if ( strlen( $css ) > 5 * 1024 * 1024 ) {
					return new \WP_Error(
						'jocbm_icon_css_large',
						__( 'Stylesheet is too large to scan (max 5 MB).', 'joc-blockora-morph' ),
						array( 'status' => 413 )
					);
				}
				$catalog = self::extract_catalog_from_css( $css, $class_template );
			}
		}

		// Material Icons / Symbols: CSS is mostly @font-face — load package metadata.
		if ( count( $catalog ) < 50 ) {
			$ligature = self::fetch_ligature_icon_catalog( $url );
			if ( $ligature ) {
				$catalog = $ligature;
			}
		}

		if ( ! $catalog ) {
			if ( is_wp_error( $response ) ) {
				return new \WP_Error(
					'jocbm_icon_css_fetch',
					sprintf(
						/* translators: %s: error message */
						__( 'Could not download stylesheet: %s', 'joc-blockora-morph' ),
						$response->get_error_message()
					),
					array( 'status' => 502 )
				);
			}
			$code = (int) wp_remote_retrieve_response_code( $response );
			if ( $code < 200 || $code >= 300 ) {
				return new \WP_Error(
					'jocbm_icon_css_http',
					sprintf(
						/* translators: %d: HTTP status */
						__( 'Stylesheet request failed (HTTP %d).', 'joc-blockora-morph' ),
						$code
					),
					array( 'status' => 502 )
				);
			}
			return new \WP_Error(
				'jocbm_icon_css_empty',
				__(
					'No icons found. For Font Awesome use all.min.css (or a CSS file with icon rules). For Material Icons use a package URL like …/material-icons@…/iconfont/material-icons.min.css.',
					'joc-blockora-morph'
				),
				array( 'status' => 422 )
			);
		}

		return array(
			'catalog' => $catalog,
			'count'   => count( $catalog ),
		);
	}

	/**
	 * Load icon name list for ligature fonts (Material Icons package metadata).
	 *
	 * @param string $stylesheet_url CSS URL.
	 * @return array<int, array{id: string, label: string, keywords: array<int, string>}>
	 */
	private static function fetch_ligature_icon_catalog( string $stylesheet_url ): array {
		foreach ( self::material_metadata_urls( $stylesheet_url ) as $meta_url ) {
			$safe = self::prepare_fetch_url( $meta_url );
			if ( is_wp_error( $safe ) ) {
				continue;
			}
			$response = wp_safe_remote_get(
				$safe,
				array(
					'timeout'     => 20,
					'redirection' => 3,
					'headers'     => array(
						'Accept' => 'application/json,text/plain,*/*;q=0.1',
					),
				)
			);
			if ( is_wp_error( $response ) ) {
				continue;
			}
			$code = (int) wp_remote_retrieve_response_code( $response );
			if ( $code < 200 || $code >= 300 ) {
				continue;
			}
			$body = (string) wp_remote_retrieve_body( $response );
			$ids  = self::parse_material_metadata_body( $body );
			if ( $ids ) {
				return self::catalog_from_ids( $ids );
			}
		}
		return array();
	}

	/**
	 * Candidate metadata URLs derived from a Material Icons stylesheet URL.
	 *
	 * @param string $css_url CSS URL.
	 * @return array<int, string>
	 */
	private static function material_metadata_urls( string $css_url ): array {
		$urls = array();
		// https:// + cdn + .jsdelivr.net/npm/... example comment only (Material Icons docs).
		// Actual stylesheet URL is always admin-provided and fetched via wp_safe_remote_get.
		if ( preg_match( '~^(https?://[^?]+/npm/material-icons(?:@[^/]+)?)/~i', $css_url, $m ) ) {
			$urls[] = $m[1] . '/_data/versions.json';
		}
		if ( preg_match( '~^(https?://[^?]+/material-icons(?:@[^/]+)?)/~i', $css_url, $m ) ) {
			$urls[] = $m[1] . '/_data/versions.json';
		}
		if ( preg_match( '~^(https?://.+)/iconfont/[^/?#]+\.css~i', $css_url, $m ) ) {
			$urls[] = $m[1] . '/_data/versions.json';
		}
		if ( preg_match( '~^(https?://[^?]+/material-icons(?:@[^/]+)?)/iconfont/~i', $css_url, $m ) ) {
			$urls[] = $m[1] . '/_data/versions.json';
		}
		return array_values( array_unique( $urls ) );
	}

	/**
	 * @param string $body Response body (JSON object/array or TS string union).
	 * @return array<int, string>
	 */
	private static function parse_material_metadata_body( string $body ): array {
		$body = trim( $body );
		if ( '' === $body ) {
			return array();
		}
		$json = json_decode( $body, true );
		$ids  = array();
		if ( is_array( $json ) ) {
			if ( self::is_list_array( $json ) ) {
				foreach ( $json as $item ) {
					if ( is_string( $item ) ) {
						$ids[] = $item;
					} elseif ( is_array( $item ) && ! empty( $item['id'] ) ) {
						$ids[] = (string) $item['id'];
					} elseif ( is_array( $item ) && ! empty( $item['name'] ) ) {
						$ids[] = (string) $item['name'];
					}
				}
			} else {
				$ids = array_map( 'strval', array_keys( $json ) );
			}
		} elseif ( preg_match_all( '/"([a-z0-9_]+)"/', $body, $m ) ) {
			$ids = $m[1];
		}
		$out = array();
		foreach ( $ids as $id ) {
			$id = preg_replace( '/[^a-z0-9_-]/i', '', (string) $id );
			if ( '' === $id ) {
				continue;
			}
			$out[ strtolower( $id ) ] = $id;
		}
		return array_values( $out );
	}

	/**
	 * @param array<int, string> $ids Icon ids.
	 * @return array<int, array{id: string, label: string, keywords: array<int, string>}>
	 */
	private static function catalog_from_ids( array $ids ): array {
		sort( $ids, SORT_STRING );
		$out = array();
		foreach ( $ids as $id ) {
			$out[] = array(
				'id'       => $id,
				'label'    => $id,
				'keywords' => array( str_replace( array( '-', '_' ), ' ', $id ) ),
			);
		}
		return $out;
	}

	/**
	 * @param array<mixed> $arr Array.
	 */
	private static function is_list_array( array $arr ): bool {
		// Polyfill only — array_is_list() needs WP 6.5+, plugin Requires 6.4.
		$i = 0;
		foreach ( $arr as $k => $_ ) {
			if ( $k !== $i ) {
				return false;
			}
			++$i;
		}
		return true;
	}

	/**
	 * Parse Font Awesome–style CSS for icon class names.
	 *
	 * Supports classic `.fa-house:before{content:…}` and FA6/7 `.fa-house{--fa:"\f015"}`.
	 *
	 * @param string $css            Stylesheet text.
	 * @param string $class_template Template containing `{id}`.
	 * @return array<int, array{id: string, label: string, keywords: array<int, string>}>
	 */
	public static function extract_catalog_from_css( string $css, string $class_template ): array {
		$token = self::icon_id_token_from_template( $class_template );
		if ( ! $token ) {
			return array();
		}

		$prefix = $token['prefix'];
		$suffix = $token['suffix'];
		if ( '' === $prefix && '' === $suffix ) {
			return array();
		}

		$prefix_q = preg_quote( $prefix, '/' );
		$suffix_q = preg_quote( $suffix, '/' );
		$ids      = array();

		// FA6/7: .fa-home,.fa-house{--fa:"\f015"}
		if ( preg_match_all( '/([^{};]+)\{[^{}]*--fa\s*:/i', $css, $fa_blocks ) ) {
			foreach ( $fa_blocks[1] as $selectors ) {
				if ( ! preg_match_all( '/\.' . $prefix_q . '([a-zA-Z0-9_-]+)' . $suffix_q . '\b/', (string) $selectors, $m ) ) {
					continue;
				}
				foreach ( $m[1] as $id ) {
					$ids[ strtolower( (string) $id ) ] = true;
				}
			}
		}

		// Classic: .fa-house:before { content: "…" }
		if ( ! $ids ) {
			$pattern = '/\.' . $prefix_q . '([a-zA-Z0-9_-]+)' . $suffix_q . '(?::(?:before|after)|::(?:before|after))\s*(?:,|\{)/';
			if ( preg_match_all( $pattern, $css, $matches ) ) {
				foreach ( $matches[1] as $id ) {
					$ids[ strtolower( (string) $id ) ] = true;
				}
			}
		}

		// Fallback: any .prefix{id}…
		if ( ! $ids ) {
			$pattern2 = '/\.' . $prefix_q . '([a-zA-Z0-9_-]+)' . $suffix_q . '(?=[\s.:#{,>+~\[\]])/';
			if ( preg_match_all( $pattern2, $css, $matches2 ) ) {
				foreach ( $matches2[1] as $id ) {
					$key = strtolower( (string) $id );
					if ( self::is_utility_icon_class( $key ) ) {
						continue;
					}
					$ids[ $key ] = true;
				}
			}
		}

		$out  = array();
		$keys = array_keys( $ids );
		sort( $keys, SORT_STRING );
		foreach ( $keys as $id ) {
			if ( self::is_utility_icon_class( $id ) ) {
				continue;
			}
			$out[] = array(
				'id'       => $id,
				'label'    => $id,
				'keywords' => array( str_replace( array( '-', '_' ), ' ', $id ) ),
			);
		}
		return $out;
	}

	/**
	 * @param string $class_template Template.
	 * @return array{prefix: string, suffix: string}|null
	 */
	private static function icon_id_token_from_template( string $class_template ): ?array {
		$parts = preg_split( '/\s+/', trim( $class_template ) ) ?: array();
		foreach ( $parts as $part ) {
			if ( false === strpos( $part, '{id}' ) ) {
				continue;
			}
			$pos = strpos( $part, '{id}' );
			return array(
				'prefix' => substr( $part, 0, $pos ),
				'suffix' => substr( $part, $pos + 4 ),
			);
		}
		return null;
	}

	/**
	 * Skip FA / icon-font utility class suffixes (not glyph icons).
	 *
	 * @param string $id Icon id candidate.
	 */
	private static function is_utility_icon_class( string $id ): bool {
		static $deny = null;
		if ( null === $deny ) {
			$deny = array_fill_keys(
				array(
					'solid',
					'regular',
					'brands',
					'light',
					'thin',
					'duotone',
					'sharp',
					'classic',
					'fw',
					'ul',
					'li',
					'border',
					'pull-left',
					'pull-right',
					'spin',
					'pulse',
					'rotate-90',
					'rotate-180',
					'rotate-270',
					'flip-horizontal',
					'flip-vertical',
					'flip-both',
					'stack',
					'stack-1x',
					'stack-2x',
					'inverse',
					'sr-only',
					'sr-only-focusable',
					'2xs',
					'xs',
					'sm',
					'lg',
					'xl',
					'2xl',
					'1x',
					'2x',
					'3x',
					'4x',
					'5x',
					'6x',
					'7x',
					'8x',
					'9x',
					'10x',
				),
				true
			);
		}
		return isset( $deny[ $id ] );
	}

	/**
	 * Lucide + brand SVG map (id => markup). Loaded once per request when needed.
	 *
	 * @return array<string, string>
	 */
	public static function lucide_svg_map(): array {
		static $map = null;
		if ( null !== $map ) {
			return $map;
		}

		$file = defined( 'JOCBM_PATH' ) ? JOCBM_PATH . 'includes/data/lucide-icons.php' : '';
		$raw  = ( $file && is_readable( $file ) ) ? include $file : array();
		$map  = array();
		if ( ! is_array( $raw ) ) {
			return $map;
		}

		foreach ( $raw as $id => $svg ) {
			$id = preg_replace( '/[^a-z0-9_-]/i', '', (string) $id );
			if ( '' === $id || ! is_string( $svg ) || '' === $svg ) {
				continue;
			}
			$map[ $id ] = $svg;
		}

		return $map;
	}

	/**
	 * Normalize inline SVG for UI (single-line, stroke-safe for Gutenberg fill rules).
	 * Only mutates the root <svg> tag — never child shape width/height (e.g. calendar rect).
	 *
	 * @param string $svg Raw SVG.
	 */
	public static function normalize_inline_svg( string $svg ): string {
		$svg = trim( $svg );
		// Collapse newlines/indentation from the shipped Lucide map first.
		$svg = preg_replace( '/\s+/', ' ', $svg ) ?? $svg;
		if ( ! preg_match( '/^<svg\b[^>]*>/i', $svg, $m ) ) {
			return '';
		}
		$tag = $m[0];
		$tag = preg_replace( '/\s(fill|stroke|width|height|stroke-width|stroke-linecap|stroke-linejoin)="[^"]*"/i', '', $tag ) ?? $tag;
		$tag = preg_replace(
			'/^<svg\b/i',
			'<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="24" height="24"',
			$tag,
			1
		);
		return $tag . substr( $svg, strlen( $m[0] ) );
	}

	/**
	 * Resolve raw SVG markup for an inline library icon.
	 *
	 * @param string $library_id Library id.
	 * @param string $icon_id    Icon id.
	 */
	public static function resolve_inline_svg( string $library_id, string $icon_id ): string {
		$library_id = sanitize_key( $library_id );
		$icon_id    = preg_replace( '/[^a-z0-9_-]/i', '', $icon_id );
		if ( '' === $library_id || '' === $icon_id ) {
			return '';
		}

		if ( 'lucide' === $library_id ) {
			$map = self::lucide_svg_map();
			$raw = isset( $map[ $icon_id ] ) ? (string) $map[ $icon_id ] : '';
			return $raw ? self::normalize_inline_svg( $raw ) : '';
		}

		/**
		 * Allow other svg-inline libraries to supply markup.
		 *
		 * @param string $svg        Markup.
		 * @param string $library_id Library.
		 * @param string $icon_id    Icon.
		 */
		$raw = (string) apply_filters( 'jocbm_icon_inline_svg', '', $library_id, $icon_id );
		return $raw ? self::normalize_inline_svg( $raw ) : '';
	}

	/**
	 * Inject class/style onto a trusted SVG root element.
	 *
	 * @param string $svg   Raw svg.
	 * @param string $class Class attr.
	 * @param string $style Style attr.
	 */
	public static function decorate_inline_svg( string $svg, string $class, string $style = '' ): string {
		$svg = trim( $svg );
		if ( ! preg_match( '/^<svg\b[^>]*>/i', $svg, $m ) ) {
			return '';
		}
		// Strip scripts from untrusted filter output.
		if ( preg_match( '/<script\b/i', $svg ) ) {
			return '';
		}

		$tag = $m[0];
		$tag = preg_replace( '/\s(class|style|aria-hidden|focusable)="[^"]*"/i', '', $tag ) ?? $tag;
		$tag = preg_replace(
			'/^<svg\b/i',
			sprintf(
				'<svg class="%1$s" style="%2$s" aria-hidden="true" focusable="false"',
				esc_attr( $class ),
				esc_attr( $style )
			),
			$tag,
			1
		);

		return $tag . substr( $svg, strlen( $m[0] ) );
	}

	/**
	 * Lucide + brand icons (catalog ids only — SVGs resolve per use).
	 *
	 * @return array<int, array{id: string, label: string, keywords: array<int, string>}>
	 */
	private static function lucide_catalog(): array {
		static $catalog = null;
		if ( null !== $catalog ) {
			return $catalog;
		}

		$catalog = array();
		foreach ( array_keys( self::lucide_svg_map() ) as $id ) {
			$catalog[] = array(
				'id'       => $id,
				'label'    => $id,
				'keywords' => array( $id ),
			);
		}

		return $catalog;
	}

	/**
	 * @return array<int, array{id: string, label: string, keywords: array<int, string>}>
	 */
	private static function dashicons_catalog(): array {
		$slugs = array(
			'screenoptions',
			'layout',
			'columns',
			'table-col-after',
			'cover-image',
			'format-image',
			'format-video',
			'format-quote',
			'format-status',
			'format-gallery',
			'button',
			'embed-generic',
			'text',
			'editor-paragraph',
			'editor-ul',
			'heading',
			'align-center',
			'align-left',
			'align-right',
			'grid-view',
			'list-view',
			'star-filled',
			'heart',
			'smiley',
			'admin-users',
			'admin-site-alt3',
			'admin-home',
			'admin-appearance',
			'admin-customizer',
			'admin-generic',
			'admin-page',
			'admin-post',
			'media-document',
			'media-code',
			'portfolio',
			'products',
			'cart',
			'tag',
			'category',
			'calendar-alt',
			'clock',
			'location',
			'email',
			'phone',
			'share',
			'yes-alt',
			'awards',
			'buddicons-groups',
			'groups',
			'id',
			'id-alt',
			'info-outline',
			'lightbulb',
			'megaphone',
			'performance',
			'shield',
			'superhero',
			'tickets-alt',
			'welcome-widgets-menus',
			'welcome-write-blog',
			'arrow-right-alt',
			'arrow-left-alt',
			'external',
			'download',
			'migrate',
			'update',
			'plus-alt',
			'minus',
			'dismiss',
			'warning',
			'flag',
			'bookmark',
			'visibility',
			'lock',
			'unlock',
			'edit',
			'trash',
		);
		$out = array();
		foreach ( $slugs as $slug ) {
			$out[] = array(
				'id'       => $slug,
				'label'    => $slug,
				'keywords' => array( $slug ),
			);
		}
		return $out;
	}
}
