<?php
/**
 * Class WC_Payments_Styles_Cache
 *
 * @package WooCommerce\Payments
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * Manages the Stripe Elements appearance styles cache version.
 *
 * The cache version is an MD5 hash derived from the plugin version, active theme,
 * global styles, and theme mods. It is stored as a WP option and used by the
 * frontend to invalidate localStorage appearance caches when the site's visual
 * configuration changes.
 */
class WC_Payments_Styles_Cache {

	/**
	 * Font CDN domains allowed for WooPay appearance font rules.
	 *
	 * Shared between server-side extraction (get_font_rules_from_registered_styles)
	 * and client-submitted validation (WooPay_Session::sanitize_font_rules).
	 *
	 * @var string[]
	 */
	const ALLOWED_FONT_DOMAINS = [
		'fonts.googleapis.com',
		'fonts.gstatic.com',
		'use.typekit.net',
		'fonts.bunny.net',
		'fonts.wp.com',
	];

	/**
	 * Returns the styles cache version string used to invalidate localStorage
	 * appearance caches. Reads from a stored WP option; if missing, computes
	 * and stores it.
	 *
	 * @return string MD5 hash representing the current styles version.
	 */
	public static function get_styles_cache_version(): string {
		$version = get_option( 'wcpay_styles_cache_version' );
		if ( ! empty( $version ) ) {
			return $version;
		}

		$version = self::compute_styles_cache_version();
		update_option( 'wcpay_styles_cache_version', $version, true );
		return $version;
	}

	/**
	 * Deletes the stored cache version so it recomputes on the next page load.
	 * Hooked to after_switch_theme, save_post_wp_global_styles, and customize_save_after.
	 */
	public static function invalidate_styles_cache_version(): void {
		delete_option( 'wcpay_styles_cache_version' );
	}

	/**
	 * Returns the stored WooPay checkout appearance, or null if not set or version mismatch.
	 *
	 * @return array|null The appearance object, or null.
	 */
	public static function get_woopay_appearance(): ?array {
		$stored = get_option( 'wcpay_woopay_checkout_appearance' );
		if ( ! empty( $stored ) && is_array( $stored ) ) {
			if ( ( $stored['version'] ?? '' ) === self::get_styles_cache_version() ) {
				return $stored['appearance'] ?? null;
			}
		}

		// Auto-compute for block themes when no valid stored appearance exists.
		if ( wp_is_block_theme() ) {
			$appearance = self::compute_woopay_appearance_from_theme();
			if ( null !== $appearance && self::validate_appearance_schema( $appearance ) ) {
				$font_rules = self::get_font_rules_from_registered_styles();
				self::set_woopay_appearance( $appearance, $font_rules );
				return $appearance;
			}
		}

		return null;
	}

	/**
	 * Returns the stored WooPay font rules, or an empty array if not set or version mismatch.
	 *
	 * @return array The font rules array.
	 */
	public static function get_woopay_font_rules(): array {
		$stored = get_option( 'wcpay_woopay_checkout_appearance' );
		if ( ! empty( $stored ) && is_array( $stored ) ) {
			if ( ( $stored['version'] ?? '' ) === self::get_styles_cache_version() ) {
				return $stored['font_rules'] ?? [];
			}
		}

		return [];
	}

	/**
	 * Stores the WooPay checkout appearance and font rules alongside the current cache version.
	 *
	 * The stored option (`wcpay_woopay_checkout_appearance`) has the structure:
	 *   [
	 *       'appearance' => [ 'variables' => [...], 'rules' => [...] ],
	 *       'font_rules' => [ [ 'cssSrc' => 'https://fonts.googleapis.com/...' ], ... ],
	 *       'version'    => '<cache_version_hash>',
	 *   ]
	 *
	 * @param array $appearance The appearance object to store.
	 * @param array $font_rules Font CDN stylesheet URLs, each as [ 'cssSrc' => string ].
	 */
	public static function set_woopay_appearance( array $appearance, array $font_rules = [] ): void {
		update_option(
			'wcpay_woopay_checkout_appearance',
			[
				'appearance' => $appearance,
				'font_rules' => $font_rules,
				'version'    => self::get_styles_cache_version(),
			],
			false
		);
	}

	/**
	 * Deletes the stored WooPay checkout appearance so it recomputes on next precomputation.
	 */
	public static function invalidate_woopay_appearance(): void {
		delete_option( 'wcpay_woopay_checkout_appearance' );
	}

	/**
	 * Called on theme/style change hooks. Invalidates the styles cache version
	 * and any stored WooPay appearance so it can be recomputed lazily when
	 * needed (e.g. via get_woopay_appearance()).
	 */
	public static function handle_theme_change(): void {
		self::invalidate_styles_cache_version();
		self::invalidate_woopay_appearance();
	}

	/**
	 * Computes WooPay appearance from theme.json global styles (block themes).
	 *
	 * @return array|null The appearance object, or null if data is insufficient.
	 */
	public static function compute_woopay_appearance_from_theme(): ?array {
		$styles = wp_get_global_styles( [], [ 'transforms' => [ 'resolve-variables' ] ] );

		// Template part styles (used by header/footer in block themes).
		$tp_styles = wp_get_global_styles(
			[],
			[
				'block_name' => 'core/template-part',
				'transforms' => [ 'resolve-variables' ],
			]
		);

		// Extract colors. CSS custom property references are already resolved
		// by the 'resolve-variables' transform. resolve_style_value() handles
		// any remaining ref objects from theme.json.
		$bg_color   = self::resolve_style_value( $styles['color']['background'] ?? '#ffffff', '#ffffff', $styles );
		$text_color = self::resolve_style_value( $styles['color']['text'] ?? '#000000', '#000000', $styles );
		$link_color = self::resolve_style_value(
			$styles['elements']['link']['color']['text']
				?? $styles['elements']['a']['color']['text']
				?? $text_color,
			$text_color,
			$styles
		);

		// `currentColor` is relative and would re-resolve wrong inside WooPay; pin it now.
		$link_color = self::resolve_current_color( $link_color, $text_color );

		// Extract typography.
		$font_family = self::resolve_style_value( $styles['typography']['fontFamily'] ?? 'inherit', 'inherit', $styles );
		$font_size   = self::resolve_style_value( $styles['typography']['fontSize'] ?? '16px', '16px', $styles );

		// Extract heading styles.
		$heading_color       = self::resolve_style_value( $styles['elements']['heading']['color']['text'] ?? $text_color, $text_color, $styles );
		$heading_font_family = self::resolve_style_value( $styles['elements']['heading']['typography']['fontFamily'] ?? $font_family, $font_family, $styles );

		// Extract button styles.
		$button_bg_color   = self::resolve_style_value( $styles['elements']['button']['color']['background'] ?? $bg_color, $bg_color, $styles );
		$button_text_color = self::resolve_style_value( $styles['elements']['button']['color']['text'] ?? $text_color, $text_color, $styles );
		$button_font_size  = self::resolve_style_value( $styles['elements']['button']['typography']['fontSize'] ?? $font_size, $font_size, $styles );

		// Extract input styles. WordPress theme.json uses 'textInput' as the
		// element name (maps to textarea + text-like input types).
		$input_el = $styles['elements']['textInput'] ?? $styles['elements']['input'] ?? [];

		// Input background resolution:
		// 1. elements.textInput.color.background (standard theme.json path).
		// 2. settings.custom.input-background (Woo/WP themes like Assembler).
		// 3. White for light themes, page bg for dark (safe fallback).
		//
		// Most block themes leave input backgrounds undefined (transparent).
		// The visible white comes from a parent container, but getComputedStyle
		// resolves transparent to the page background, making inputs invisible
		// on WooPay. oklch() expressions from step 2 are evaluated via
		// resolve_oklch(); other unresolvable values fall through to default.
		$custom_settings   = wp_get_global_settings( [ 'custom' ] );
		$input_bg_default  = self::is_color_light( $bg_color ) ? '#ffffff' : $bg_color;
		$input_bg_raw      = $input_el['color']['background']
			?? $custom_settings['input-background']
			?? $input_bg_default;
		$input_bg_resolved = self::resolve_style_value( $input_bg_raw, $input_bg_default, $styles );
		$input_bg_resolved = self::resolve_vars_in_expression( $input_bg_resolved );

		// Accept only well-formed hex (#rgb / #rrggbb / #rrggbbaa) and rgb()/rgba()
		// to prevent malformed values from landing verbatim in the appearance.
		if ( preg_match( '/^(#[0-9a-f]{3}([0-9a-f]{3})?([0-9a-f]{2})?|rgba?\([\d\s.,%\/]+\))$/i', $input_bg_resolved ) ) {
			$input_bg_color = $input_bg_resolved;
		} else {
			// Try evaluating CSS color functions (e.g. oklch() from Assembler).
			$input_bg_color = self::resolve_oklch( $input_bg_resolved ) ?? $input_bg_default;
		}
		$input_text_color    = self::resolve_style_value( $input_el['color']['text'] ?? $text_color, $text_color, $styles );
		$input_border_color  = self::resolve_style_value( $input_el['border']['color'] ?? $text_color, $text_color, $styles );
		$input_border_radius = self::resolve_style_value( $input_el['border']['radius'] ?? '0px', '0px', $styles );

		// Extract button font family.
		$button_font_family = self::resolve_style_value( $styles['elements']['button']['typography']['fontFamily'] ?? $font_family, $font_family, $styles );

		// Extract header/footer colors from the checkout template. Handles
		// both core/template-part references and inline blocks with category
		// metadata (e.g. Assembler inlines footer as a styled core/group).
		$checkout_colors   = self::get_checkout_section_colors();
		$header_colors     = $checkout_colors['header'] ?? [];
		$footer_colors     = $checkout_colors['footer'] ?? [];
		$header_bg_color   = $header_colors['background'] ?? self::resolve_style_value( $tp_styles['color']['background'] ?? $bg_color, $bg_color, $tp_styles );
		$header_text_color = $header_colors['text'] ?? self::resolve_style_value( $tp_styles['color']['text'] ?? $text_color, $text_color, $tp_styles );

		// Determine theme (light vs dark) from background color.
		$theme = self::is_color_light( $bg_color ) ? 'stripe' : 'night';

		// Error color for invalid inputs.
		$error_color = '#df1b41';

		return [
			'variables' => [
				'colorBackground' => $bg_color,
				'colorText'       => $text_color,
				'fontFamily'      => $font_family,
				'fontSizeBase'    => $font_size,
			],
			'theme'     => $theme,
			'labels'    => 'floating',
			'rules'     => [
				'.Input'          => [
					'color'             => $input_text_color,
					'fontFamily'        => $font_family,
					'fontSize'          => $font_size,
					'borderColor'       => $input_border_color,
					'borderBottomColor' => $input_border_color,
					'borderRadius'      => $input_border_radius,
					'backgroundColor'   => $input_bg_color,
				],
				'.Input--invalid' => [
					'borderBottomColor' => $error_color,
				],
				'.Label'          => [
					'color'      => $text_color,
					'fontFamily' => $font_family,
					'fontSize'   => $font_size,
				],
				'.Text'           => [
					'color'      => $text_color,
					'fontFamily' => $font_family,
					'fontSize'   => $font_size,
				],
				'.Heading'        => [
					'color'      => $heading_color,
					'fontFamily' => $heading_font_family,
				],
				'.Header'         => [
					'backgroundColor' => $header_bg_color,
					'color'           => $header_text_color,
				],
				'.Footer'         => [
					'backgroundColor' => $footer_colors['background'] ?? $bg_color,
					'color'           => $footer_colors['text'] ?? $text_color,
				],
				'.Footer-link'    => [
					'color' => self::resolve_current_color( $footer_colors['text'] ?? $link_color, $text_color ),
				],
				'.Button'         => [
					'color'           => $button_text_color,
					'backgroundColor' => $button_bg_color,
					'fontFamily'      => $button_font_family,
					'fontSize'        => $button_font_size,
				],
				'.Link'           => [
					'color'      => $link_color,
					'fontFamily' => $font_family,
				],
				'.Tab'            => [
					'color'           => $text_color,
					'backgroundColor' => $bg_color,
					'fontFamily'      => $font_family,
				],
				'.Block'          => [
					'backgroundColor' => $bg_color,
				],
			],
		];
	}

	/**
	 * Extracts font CDN stylesheet URLs from the WordPress registered styles queue.
	 *
	 * Scans wp_styles() for registered stylesheets from allowed font CDN domains.
	 * Used as a server-side fallback for block themes where DOM extraction isn't available.
	 *
	 * @return array Array of font rules, each with a 'cssSrc' key. Capped at 10 entries.
	 */
	public static function get_font_rules_from_registered_styles(): array {
		$wp_styles = wp_styles();

		$font_rules = [];
		foreach ( $wp_styles->registered as $style ) {
			if ( empty( $style->src ) || ! is_string( $style->src ) ) {
				continue;
			}
			$url  = esc_url_raw( $style->src, [ 'https' ] );
			$host = wp_parse_url( $url, PHP_URL_HOST );
			if ( $host && in_array( $host, self::ALLOWED_FONT_DOMAINS, true ) ) {
				$font_rules[] = [ 'cssSrc' => $url ];
			}
		}
		return array_slice( $font_rules, 0, 10 );
	}

	/**
	 * Stores the WooPay appearance if no valid appearance exists for the current version.
	 * Used by the shopper conditional write path.
	 *
	 * @param array $appearance The appearance object to store.
	 * @param array $font_rules Font CDN stylesheet URLs.
	 * @return bool True if stored, false if slot was already filled.
	 */
	public static function maybe_set_woopay_appearance( array $appearance, array $font_rules = [] ): bool {
		$existing = self::get_woopay_appearance();
		if ( null !== $existing ) {
			return false;
		}

		self::set_woopay_appearance( $appearance, $font_rules );
		return true;
	}

	/**
	 * Validates a WooPay appearance payload against the known schema.
	 *
	 * @param array $appearance The appearance array to validate.
	 * @return bool True if valid, false otherwise.
	 */
	public static function validate_appearance_schema( array $appearance ): bool {
		// Validate top-level keys.
		$allowed_top_keys = [ 'variables', 'theme', 'labels', 'rules' ];
		foreach ( array_keys( $appearance ) as $key ) {
			if ( ! in_array( $key, $allowed_top_keys, true ) ) {
				return false;
			}
		}

		// Validate theme.
		if ( isset( $appearance['theme'] ) && ! in_array( $appearance['theme'], [ 'stripe', 'night' ], true ) ) {
			return false;
		}

		// Validate labels.
		if ( isset( $appearance['labels'] ) && ! in_array( $appearance['labels'], [ 'floating', 'above' ], true ) ) {
			return false;
		}

		// Validate variables.
		if ( isset( $appearance['variables'] ) ) {
			if ( ! is_array( $appearance['variables'] ) ) {
				return false;
			}
			$allowed_vars = [ 'colorBackground', 'colorText', 'fontFamily', 'fontSizeBase' ];
			foreach ( array_keys( $appearance['variables'] ) as $key ) {
				if ( ! in_array( $key, $allowed_vars, true ) ) {
					return false;
				}
			}
			if ( ! self::validate_string_values( $appearance['variables'] ) ) {
				return false;
			}
		}

		// Validate rules.
		if ( isset( $appearance['rules'] ) ) {
			if ( ! is_array( $appearance['rules'] ) ) {
				return false;
			}
			$allowed_rules = [
				'.Input',
				'.Input--invalid',
				'.Label',
				'.Label--resting',
				'.Label--floating',
				'.Text',
				'.Text--redirect',
				'.Block',
				'.Tab',
				'.Tab:hover',
				'.Tab--selected',
				'.TabIcon',
				'.TabIcon:hover',
				'.TabIcon--selected',
				'.TabLabel',
				'.Heading',
				'.Header',
				'.Footer',
				'.Footer-link',
				'.Footer--link',
				'.Button',
				'.Link',
				'.Container',
			];
			// Stripe Appearance API CSS properties used by DOM extraction and server-side compute.
			$allowed_properties = [
				'color',
				'backgroundColor',
				'fontFamily',
				'fontSize',
				'fontWeight',
				'fontVariation',
				'lineHeight',
				'letterSpacing',
				'padding',
				'paddingTop',
				'paddingRight',
				'paddingBottom',
				'paddingLeft',
				'border',
				'borderTop',
				'borderRight',
				'borderBottom',
				'borderLeft',
				'borderColor',
				'borderStyle',
				'borderWidth',
				'borderTopColor',
				'borderTopStyle',
				'borderTopWidth',
				'borderRightColor',
				'borderRightStyle',
				'borderRightWidth',
				'borderBottomColor',
				'borderBottomStyle',
				'borderBottomWidth',
				'borderLeftColor',
				'borderLeftStyle',
				'borderLeftWidth',
				'borderRadius',
				'borderTopLeftRadius',
				'borderTopRightRadius',
				'borderBottomRightRadius',
				'borderBottomLeftRadius',
				'outline',
				'outlineColor',
				'outlineWidth',
				'outlineStyle',
				'outlineOffset',
				'boxShadow',
				'textDecoration',
				'textShadow',
				'textTransform',
				'transition',
				'transform',
				'-webkit-font-smoothing',
				'-moz-osx-font-smoothing',
			];
			foreach ( $appearance['rules'] as $rule_key => $rule_value ) {
				if ( ! in_array( $rule_key, $allowed_rules, true ) ) {
					return false;
				}
				if ( ! is_array( $rule_value ) ) {
					return false;
				}
				foreach ( array_keys( $rule_value ) as $prop ) {
					if ( ! in_array( $prop, $allowed_properties, true ) ) {
						return false;
					}
				}
				if ( ! self::validate_string_values( $rule_value ) ) {
					return false;
				}
			}
		}

		return true;
	}

	/**
	 * Validates that all values in an array are strings under 200 characters.
	 *
	 * @param array $values The array to validate.
	 * @return bool True if all values are valid strings.
	 */
	private static function validate_string_values( array $values ): bool {
		foreach ( $values as $value ) {
			if ( ! is_string( $value ) || strlen( $value ) > 200 ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Determines if a hex color is light (brightness > 125).
	 *
	 * @param string $color Hex color (e.g. '#ffffff' or 'ffffff').
	 * @return bool True if light, false if dark.
	 */
	private static function is_color_light( string $color ): bool {
		$rgb = self::parse_color( $color );
		if ( null === $rgb ) {
			return true; // Default to light for unparseable colors.
		}

		// Same formula as tinycolor: (r * 299 + g * 587 + b * 114) / 1000.
		$brightness = ( $rgb[0] * 299 + $rgb[1] * 587 + $rgb[2] * 114 ) / 1000;
		return $brightness > 125;
	}

	/**
	 * Parses a hex color string into an [r, g, b] array of 0-255 integers.
	 * Supports #rgb and #rrggbb formats. Returns null for unparseable values.
	 *
	 * @param string $color The hex color string.
	 * @return array|null [r, g, b] array or null.
	 */
	private static function parse_color( string $color ): ?array {
		$color = ltrim( $color, '#' );
		if ( 3 === strlen( $color ) ) {
			$color = $color[0] . $color[0] . $color[1] . $color[1] . $color[2] . $color[2];
		}
		if ( 6 !== strlen( $color ) || ! ctype_xdigit( $color ) ) {
			return null;
		}
		return [
			hexdec( substr( $color, 0, 2 ) ),
			hexdec( substr( $color, 2, 2 ) ),
			hexdec( substr( $color, 4, 2 ) ),
		];
	}

	/**
	 * Ensures a theme style value is a string. Handles ref objects
	 * (e.g. {"ref": "styles.typography.fontFamily"}) by resolving them
	 * against the provided styles context array. Returns the default for
	 * any non-string value that cannot be resolved.
	 *
	 * @param mixed  $value          The style value — string, ref object array, or other.
	 * @param string $default_value  Fallback value when resolution fails.
	 * @param array  $styles_context The already-resolved styles array to look up refs in.
	 * @return string The resolved string value or the default.
	 */
	private static function resolve_style_value( $value, string $default_value, array $styles_context = [] ): string {
		// Handle ref objects: {"ref": "styles.typography.fontFamily"}.
		if ( is_array( $value ) && isset( $value['ref'] ) ) {
			$path = explode( '.', $value['ref'] );
			// Strip the leading 'styles' segment since $styles_context
			// is already scoped to the styles subtree.
			if ( ! empty( $path ) && 'styles' === $path[0] ) {
				array_shift( $path );
			}
			$value = _wp_array_get( $styles_context, $path );
		}

		if ( ! is_string( $value ) ) {
			return $default_value;
		}

		return $value;
	}

	/**
	 * Resolves the CSS `currentColor` keyword to a concrete color value.
	 *
	 * `currentColor` is relative, so stored verbatim it resolves against WooPay's
	 * wrapper instead of the theme. Returns the resolved theme text color (black
	 * if that is itself `currentColor`); other values pass through.
	 *
	 * @param string $color    The color value, possibly the `currentColor` keyword.
	 * @param string $fallback The resolved theme text color to substitute.
	 * @return string A concrete color value, never `currentColor`.
	 */
	private static function resolve_current_color( string $color, string $fallback ): string {
		if ( 0 !== strcasecmp( trim( $color ), 'currentcolor' ) ) {
			return $color;
		}

		return 0 === strcasecmp( trim( $fallback ), 'currentcolor' ) ? '#000000' : $fallback;
	}

	/**
	 * Substitutes any var(--wp--preset--*) references inside a CSS expression
	 * with their resolved concrete values. Unlike resolve_css_var(), which
	 * only handles values that *are* a var() reference, this walks the string
	 * and replaces var() tokens wherever they appear — needed for expressions
	 * like oklch(from var(--wp--preset--color--theme-1) ...) where the var
	 * is nested inside another CSS function.
	 *
	 * @param string $value The CSS expression possibly containing var() tokens.
	 * @return string The expression with var() tokens substituted.
	 */
	private static function resolve_vars_in_expression( string $value ): string {
		return (string) preg_replace_callback(
			'/var\(\s*--[^)]+\)/',
			static fn( $m ) => self::resolve_css_var( $m[0] ),
			$value
		);
	}

	/**
	 * Attempts to resolve a CSS var() reference to a concrete value using
	 * the global styles presets. Returns the original string if unresolvable.
	 *
	 * @param string $value The CSS value, possibly a var() reference.
	 * @return string The resolved value or the original.
	 */
	private static function resolve_css_var( $value ): string {
		if ( ! is_string( $value ) ) {
			return '';
		}

		if ( 0 !== strpos( $value, 'var(' ) ) {
			return $value;
		}

		// Extract the custom property name: var(--wp--preset--font-family--system-font).
		if ( ! preg_match( '/var\(\s*(--[^,)]+)/', $value, $matches ) ) {
			return $value;
		}

		$property = $matches[1];

		// Map known preset patterns to their settings paths.
		$preset_map = [
			'--wp--preset--font-family--' => 'typography.fontFamilies',
			'--wp--preset--font-size--'   => 'typography.fontSizes',
			'--wp--preset--color--'       => 'color.palette',
		];

		foreach ( $preset_map as $prefix => $settings_path ) {
			if ( 0 === strpos( $property, $prefix ) ) {
				$slug     = substr( $property, strlen( $prefix ) );
				$path     = explode( '.', $settings_path );
				$settings = wp_get_global_settings( $path );

				if ( is_array( $settings ) ) {
					$resolved = self::find_preset_value( $settings, $slug );
					if ( null !== $resolved ) {
						return $resolved;
					}
				}
			}
		}

		return $value;
	}

	/**
	 * Searches a preset array for a matching slug and returns its value.
	 * Handles both flat arrays and origin-keyed arrays (default/theme/custom)
	 * returned by wp_get_global_settings().
	 *
	 * @param array  $settings The presets array.
	 * @param string $slug     The preset slug to find.
	 * @return string|null The resolved value, or null if not found.
	 */
	private static function find_preset_value( array $settings, string $slug ): ?string {
		foreach ( $settings as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}

			// Flat preset entry with a slug key.
			if ( isset( $entry['slug'] ) && $entry['slug'] === $slug ) {
				return $entry['fontFamily'] ?? $entry['size'] ?? $entry['color'] ?? null;
			}

			// Origin-keyed sub-array (default, theme, custom) — recurse.
			if ( ! isset( $entry['slug'] ) ) {
				$nested = self::find_preset_value( $entry, $slug );
				if ( null !== $nested ) {
					return $nested;
				}
			}
		}

		return null;
	}

	/**
	 * Attempts to evaluate a CSS oklch() relative color expression to a hex
	 * string. Supports the form: oklch(from <color> <L> <C> <H>) where <L>,
	 * <C>, <H> can be channel references (l, c, h) optionally wrapped in
	 * calc() with multiplication.
	 *
	 * Returns null if the expression cannot be parsed or evaluated.
	 *
	 * @param string $value The CSS value to evaluate.
	 * @return string|null Hex color string (#rrggbb) or null.
	 */
	private static function resolve_oklch( string $value ): ?string {
		// Match: oklch(from <color> <L-expr> <C-expr> <H-expr>).
		// Each channel expression is either a bare token (\S+) or a calc(...) block.
		$expr = '(calc\([^)]+\)|\S+)';
		if ( ! preg_match( '/^oklch\(\s*from\s+(\S+)\s+' . $expr . '\s+' . $expr . '\s+' . $expr . '\s*\)$/', $value, $m ) ) {
			return null;
		}

		$base_color = $m[1];
		$l_expr     = $m[2];
		$c_expr     = $m[3];
		$h_expr     = $m[4];

		// Parse the base color to linear sRGB.
		$rgb = self::parse_color( $base_color );
		if ( null === $rgb ) {
			return null;
		}

		// sRGB (0-255) → linear sRGB (0-1).
		$lin = array_map(
			function ( $v ) {
				$v /= 255.0;
				return ( $v <= 0.04045 ) ? $v / 12.92 : pow( ( $v + 0.055 ) / 1.055, 2.4 );
			},
			$rgb
		);

		// Linear sRGB → OKLab.
		$l_ = 0.4122214708 * $lin[0] + 0.5363325363 * $lin[1] + 0.0514459929 * $lin[2];
		$m_ = 0.2119034982 * $lin[0] + 0.6806995451 * $lin[1] + 0.1073969566 * $lin[2];
		$s_ = 0.0883024619 * $lin[0] + 0.2817188376 * $lin[1] + 0.6299787005 * $lin[2];

		// cbrt() requires PHP 8.0; pow() returns NAN for negative bases
		// with fractional exponents. Use sign-preserving cube root.
		$cbrt = function ( $v ) {
			return ( $v < 0 ) ? -pow( -$v, 1.0 / 3.0 ) : pow( $v, 1.0 / 3.0 );
		};

		$l_ = $cbrt( $l_ );
		$m_ = $cbrt( $m_ );
		$s_ = $cbrt( $s_ );

		$ok_l = 0.2104542553 * $l_ + 0.7936177850 * $m_ - 0.0040720468 * $s_;
		$ok_a = 1.9779984951 * $l_ - 2.4285922050 * $m_ + 0.4505937099 * $s_;
		$ok_b = 0.0259040371 * $l_ + 0.7827717662 * $m_ - 0.8086757660 * $s_;

		// OKLab → OKLch.
		$lch_l = $ok_l;
		$lch_c = sqrt( $ok_a * $ok_a + $ok_b * $ok_b );
		$lch_h = rad2deg( atan2( $ok_b, $ok_a ) );
		if ( $lch_h < 0 ) {
			$lch_h += 360;
		}

		// Evaluate channel expressions.
		$new_l = self::evaluate_channel_expr( $l_expr, $lch_l, $lch_c, $lch_h );
		$new_c = self::evaluate_channel_expr( $c_expr, $lch_l, $lch_c, $lch_h );
		$new_h = self::evaluate_channel_expr( $h_expr, $lch_l, $lch_c, $lch_h );

		if ( null === $new_l || null === $new_c || null === $new_h ) {
			return null;
		}

		// Clamp L to [0, 1] and C to [0, 0.5].
		$new_l = max( 0, min( 1, $new_l ) );
		$new_c = max( 0, min( 0.5, $new_c ) );

		// OKLch → OKLab.
		$ok_a2 = $new_c * cos( deg2rad( $new_h ) );
		$ok_b2 = $new_c * sin( deg2rad( $new_h ) );

		// OKLab → linear sRGB.
		$l_ = $new_l + 0.3963377774 * $ok_a2 + 0.2158037573 * $ok_b2;
		$m_ = $new_l - 0.1055613458 * $ok_a2 - 0.0638541728 * $ok_b2;
		$s_ = $new_l - 0.0894841775 * $ok_a2 - 1.2914855480 * $ok_b2;

		$l_ = $l_ * $l_ * $l_;
		$m_ = $m_ * $m_ * $m_;
		$s_ = $s_ * $s_ * $s_;

		$r = +4.0767416621 * $l_ - 3.3077115913 * $m_ + 0.2309699292 * $s_;
		$g = -1.2684380046 * $l_ + 2.6097574011 * $m_ - 0.3413193965 * $s_;
		$b = -0.0041960863 * $l_ - 0.7034186147 * $m_ + 1.7076147010 * $s_;

		// Linear sRGB → sRGB (gamma).
		$to_srgb = function ( $v ) {
			$v = max( 0, min( 1, $v ) );
			return ( $v <= 0.0031308 ) ? $v * 12.92 : 1.055 * pow( $v, 1.0 / 2.4 ) - 0.055;
		};

		$r = (int) round( $to_srgb( $r ) * 255 );
		$g = (int) round( $to_srgb( $g ) * 255 );
		$b = (int) round( $to_srgb( $b ) * 255 );

		return sprintf( '#%02x%02x%02x', $r, $g, $b );
	}

	/**
	 * Evaluates a single OKLch channel expression.
	 *
	 * Supports: bare channel refs (l, c, h), numeric literals, and
	 * calc() with a single multiplication (e.g. calc(l * 1.05)).
	 *
	 * @param string $expr  The channel expression.
	 * @param float  $lch_l The L channel value.
	 * @param float  $lch_c The C channel value.
	 * @param float  $lch_h The H channel value.
	 * @return float|null The evaluated value, or null if unparseable.
	 */
	private static function evaluate_channel_expr( string $expr, float $lch_l, float $lch_c, float $lch_h ): ?float {
		$expr = trim( $expr );

		$channels = [
			'l' => $lch_l,
			'c' => $lch_c,
			'h' => $lch_h,
		];

		// Bare channel reference: l, c, h.
		if ( isset( $channels[ $expr ] ) ) {
			return $channels[ $expr ];
		}

		// Numeric literal.
		if ( is_numeric( $expr ) ) {
			return (float) $expr;
		}

		// calc(<channel> * <number>) or calc(<number> * <channel>).
		if ( preg_match( '/^calc\(\s*([a-z]+)\s*\*\s*([\d.]+)\s*\)$/', $expr, $cm ) ) {
			$ch  = $cm[1];
			$mul = (float) $cm[2];
			return isset( $channels[ $ch ] ) ? $channels[ $ch ] * $mul : null;
		}
		if ( preg_match( '/^calc\(\s*([\d.]+)\s*\*\s*([a-z]+)\s*\)$/', $expr, $cm ) ) {
			$mul = (float) $cm[1];
			$ch  = $cm[2];
			return isset( $channels[ $ch ] ) ? $channels[ $ch ] * $mul : null;
		}

		return null;
	}

	/**
	 * Extracts header and footer colors from the checkout page template.
	 *
	 * Walks the checkout template blocks looking for header/footer sections.
	 * These can appear as:
	 * - `core/template-part` blocks with area "header"/"footer" (standard pattern)
	 * - Inline blocks with `metadata.categories` containing "header"/"footer"
	 *   (e.g. Assembler inlines the footer as a core/group with is-style-section-1)
	 * - Blocks with `tagName` "header"/"footer"
	 *
	 * @return array<string, array> Map of area ('header'|'footer') to color arrays
	 *                              with optional 'background' and 'text' keys.
	 */
	private static function get_checkout_section_colors(): array {
		// Theme override takes priority, then WooCommerce's registered template.
		$template = get_block_template( get_stylesheet() . '//page-checkout' )
			?? get_block_template( 'woocommerce//page-checkout' );

		if ( ! $template || empty( $template->content ) ) {
			return [];
		}

		$blocks   = parse_blocks( $template->content );
		$blocks   = self::resolve_pattern_blocks( $blocks );
		$blocks   = self::flatten_blocks( $blocks );
		$sections = [];

		foreach ( $blocks as $block ) {
			$block_name = $block['blockName'] ?? '';
			if ( empty( $block_name ) ) {
				continue;
			}

			$area = self::classify_block_area( $block );
			if ( ! $area || isset( $sections[ $area ] ) ) {
				continue;
			}

			if ( 'core/template-part' === $block_name && ! empty( $block['attrs']['slug'] ) ) {
				$colors = self::get_template_part_colors( $block['attrs']['slug'], $block['attrs']['theme'] ?? '' );
			} else {
				$colors = self::extract_block_colors( $block );
			}

			if ( ! empty( $colors ) ) {
				$sections[ $area ] = $colors;
			}
		}

		return $sections;
	}

	/**
	 * Determines if a block serves as a header or footer section.
	 *
	 * Checks (in priority order):
	 * 1. Template part area attribute or registered entity area
	 * 2. Block metadata categories containing "header" or "footer"
	 * 3. Block tagName attribute ("header" or "footer")
	 *
	 * @param array $block A parsed block.
	 * @return string|null 'header', 'footer', or null if not a section block.
	 */
	private static function classify_block_area( array $block ): ?string {
		$block_name = $block['blockName'] ?? '';

		// Template parts: check area from attrs or registered entity.
		if ( 'core/template-part' === $block_name && ! empty( $block['attrs']['slug'] ) ) {
			$area = $block['attrs']['area'] ?? null;
			if ( ! $area ) {
				$part = get_block_template( get_stylesheet() . '//' . $block['attrs']['slug'], 'wp_template_part' );
				if ( ! $part && ! empty( $block['attrs']['theme'] ) ) {
					$part = get_block_template( $block['attrs']['theme'] . '//' . $block['attrs']['slug'], 'wp_template_part' );
				}
				$area = $part ? $part->area : null;
			}
			if ( in_array( $area, [ 'header', 'footer' ], true ) ) {
				return $area;
			}
			return null;
		}

		// Inline blocks: check metadata categories.
		$categories = $block['attrs']['metadata']['categories'] ?? [];
		if ( in_array( 'footer', $categories, true ) ) {
			return 'footer';
		}
		if ( in_array( 'header', $categories, true ) ) {
			return 'header';
		}

		// Fallback: check tagName.
		$tag = $block['attrs']['tagName'] ?? '';
		if ( 'footer' === $tag ) {
			return 'footer';
		}
		if ( 'header' === $tag ) {
			return 'header';
		}

		return null;
	}

	/**
	 * Extracts background and text colors from a template part (header/footer)
	 * by parsing its outermost block attributes.
	 *
	 * @param string $slug  The template part slug (e.g. 'header', 'checkout-header').
	 * @param string $theme The theme attribute from the template-part block (e.g. 'woocommerce/woocommerce').
	 * @return array Associative array with 'background' and/or 'text' keys, or empty.
	 */
	private static function get_template_part_colors( string $slug, string $theme = '' ): array {
		$template = get_block_template( get_stylesheet() . '//' . $slug, 'wp_template_part' );

		if ( ( ! $template || empty( $template->content ) ) && '' !== $theme ) {
			$template = get_block_template( $theme . '//' . $slug, 'wp_template_part' );
		}

		if ( ! $template || empty( $template->content ) ) {
			return [];
		}

		$blocks = parse_blocks( $template->content );

		// Resolve core/pattern references — template parts commonly contain
		// a single pattern reference instead of inline blocks.
		$blocks = self::resolve_pattern_blocks( $blocks );

		$target = self::find_primary_block( $blocks );

		if ( null === $target ) {
			return [];
		}

		return self::extract_block_colors( $target );
	}

	/**
	 * Finds the primary block in a template part (e.g. the main header, not a minibar).
	 *
	 * Looks for the top-level block containing navigation-related inner blocks
	 * (core/navigation, core/site-title, core/site-logo). Falls back to the last
	 * top-level core/group block, then the first non-empty block.
	 *
	 * @param array $blocks Parsed blocks from a template part.
	 * @return array|null The primary block, or null if none found.
	 */
	private static function find_primary_block( array $blocks ): ?array {
		$nav_markers = [ 'core/navigation', 'core/site-title', 'core/site-logo' ];
		$last_group  = null;
		$first_block = null;

		foreach ( $blocks as $block ) {
			if ( empty( $block['blockName'] ) ) {
				continue;
			}

			if ( null === $first_block ) {
				$first_block = $block;
			}

			if ( 'core/group' === $block['blockName'] ) {
				$last_group = $block;
			}

			if ( self::block_contains_any( $block, $nav_markers ) ) {
				return $block;
			}
		}

		return $last_group ?? $first_block;
	}

	/**
	 * Recursively checks if a block or any of its inner blocks matches one of the given block names.
	 *
	 * @param array    $block       A parsed block.
	 * @param string[] $block_names Block names to search for.
	 * @return bool True if any matching block is found.
	 */
	private static function block_contains_any( array $block, array $block_names ): bool {
		if ( in_array( $block['blockName'], $block_names, true ) ) {
			return true;
		}

		if ( ! empty( $block['innerBlocks'] ) ) {
			foreach ( $block['innerBlocks'] as $inner ) {
				if ( self::block_contains_any( $inner, $block_names ) ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Resolves core/pattern block references to their actual block content.
	 *
	 * Template parts commonly contain a single `<!-- wp:pattern {"slug":"theme/footer"} /-->`
	 * instead of inline blocks. `parse_blocks()` returns the raw pattern reference with no
	 * inner blocks, so we resolve it via the pattern registry.
	 *
	 * @param array $blocks Parsed blocks that may contain core/pattern references.
	 * @return array Blocks with pattern references replaced by their content.
	 */
	private static function resolve_pattern_blocks( array $blocks ): array {
		$registry = WP_Block_Patterns_Registry::get_instance();
		$resolved = [];

		foreach ( $blocks as $block ) {
			if ( 'core/pattern' !== $block['blockName'] || empty( $block['attrs']['slug'] ) ) {
				if ( ! empty( $block['innerBlocks'] ) ) {
					$block['innerBlocks'] = self::resolve_pattern_blocks( $block['innerBlocks'] );
				}
				$resolved[] = $block;
				continue;
			}

			$slug = $block['attrs']['slug'];
			if ( ! $registry->is_registered( $slug ) ) {
				$resolved[] = $block;
				continue;
			}

			$pattern = $registry->get_registered( $slug );
			if ( ! empty( $pattern['content'] ) ) {
				$pattern_blocks = parse_blocks( $pattern['content'] );
				foreach ( $pattern_blocks as $pattern_block ) {
					$resolved[] = $pattern_block;
				}
			}
		}

		return $resolved;
	}

	/**
	 * Recursively flattens a block tree into a single-level array.
	 *
	 * Parent blocks appear before their children, so the first classified
	 * block for a given area is always the outermost one.
	 *
	 * @param array $blocks Parsed blocks (possibly nested via innerBlocks).
	 * @return array Flat array of all blocks at every depth.
	 */
	private static function flatten_blocks( array $blocks ): array {
		$flat = [];
		foreach ( $blocks as $block ) {
			$flat[] = $block;
			if ( ! empty( $block['innerBlocks'] ) ) {
				$flat = array_merge( $flat, self::flatten_blocks( $block['innerBlocks'] ) );
			}
		}
		return $flat;
	}

	/**
	 * Extracts background and text colors from a block's attributes.
	 *
	 * @param array $block A parsed block.
	 * @return array Colors array with optional 'background' and 'text' keys.
	 */
	private static function extract_block_colors( array $block ): array {
		$colors = [];

		// Check preset background (e.g. "backgroundColor": "background").
		if ( ! empty( $block['attrs']['backgroundColor'] ) ) {
			$resolved = self::resolve_css_var( 'var(--wp--preset--color--' . $block['attrs']['backgroundColor'] . ')' );
			if ( 'var(--wp--preset--color--' . $block['attrs']['backgroundColor'] . ')' !== $resolved ) {
				$colors['background'] = $resolved;
			}
		}

		// Check inline background (e.g. "style.color.background": "#fff").
		if ( ! empty( $block['attrs']['style']['color']['background'] ) ) {
			$colors['background'] = self::resolve_css_var( $block['attrs']['style']['color']['background'] );
		}

		// Check preset text color.
		if ( ! empty( $block['attrs']['textColor'] ) ) {
			$resolved = self::resolve_css_var( 'var(--wp--preset--color--' . $block['attrs']['textColor'] . ')' );
			if ( 'var(--wp--preset--color--' . $block['attrs']['textColor'] . ')' !== $resolved ) {
				$colors['text'] = $resolved;
			}
		}

		// Check inline text color.
		if ( ! empty( $block['attrs']['style']['color']['text'] ) ) {
			$colors['text'] = self::resolve_css_var( $block['attrs']['style']['color']['text'] );
		}

		// Fill in missing colors from block style variations (e.g.
		// "is-style-section-1"). Inline attributes take precedence per-key,
		// but the variation provides defaults for keys not set inline.
		// Example: user overrides text color in Site Editor but background
		// still comes from the variation.
		if ( ! empty( $block['attrs']['className'] ) && ! empty( $block['blockName'] ) ) {
			$variation_colors = self::get_style_variation_colors( $block['blockName'], $block['attrs']['className'] );
			$colors           = array_merge( $variation_colors, $colors );
		}

		return $colors;
	}

	/**
	 * Extracts colors from a block style variation by looking up the variation
	 * in the merged theme.json data.
	 *
	 * Modern block themes use CSS class-based color schemes (e.g. "is-style-section-1")
	 * instead of inline color attributes. The variation definitions are stored in
	 * theme.json partial files (e.g. styles/block/section-1.json).
	 *
	 * @param string $block_name Block name (e.g. 'core/group').
	 * @param string $class_name The block's className attribute.
	 * @return array Colors array with optional 'background' and 'text' keys.
	 */
	private static function get_style_variation_colors( string $block_name, string $class_name ): array {
		if ( ! function_exists( 'wp_get_block_style_variation_name_from_class' ) ) {
			return [];
		}

		$variation_names = wp_get_block_style_variation_name_from_class( $class_name );
		if ( empty( $variation_names ) ) {
			return [];
		}

		// Only the first variation with data is used (same as WP core).
		foreach ( $variation_names as $variation ) {
			$variation_color = wp_get_global_styles(
				[ 'variations', $variation, 'color' ],
				[ 'block_name' => $block_name ]
			);

			if ( ! is_array( $variation_color ) ) {
				continue;
			}

			$colors = [];
			if ( ! empty( $variation_color['background'] ) ) {
				$colors['background'] = self::resolve_css_var( $variation_color['background'] );
			}
			if ( ! empty( $variation_color['text'] ) ) {
				$colors['text'] = self::resolve_css_var( $variation_color['text'] );
			}

			if ( ! empty( $colors ) ) {
				return $colors;
			}
		}

		return [];
	}

	/**
	 * Computes a fresh styles cache version hash from plugin version,
	 * theme stylesheet, and global styles (color palettes, style variations).
	 *
	 * @return string MD5 hash.
	 */
	private static function compute_styles_cache_version(): string {
		$parts = WCPAY_VERSION_NUMBER . wp_get_theme()->get_stylesheet();

		$parts .= wp_json_encode( wp_get_global_styles() );

		// Theme mods capture Customizer changes (classic themes).
		$parts .= wp_json_encode( get_theme_mods() );

		// just making sure that it gets updated each time this method is called.
		$parts .= time();

		return md5( $parts );
	}
}
