<?php
/**
 * Shared Google Fonts helpers for CSS2 stylesheet URLs.
 *
 * @package VoidPulse
 */

namespace VoidPulse\Shared;

defined( 'ABSPATH' ) || exit;

/**
 * Sanitize font families/weights and build CSS2 URLs from campaign style.
 */
final class GoogleFonts {
	const MIN_WEIGHT = 100;
	const MAX_WEIGHT = 900;

	/**
	 * Browser / web-safe family names that must never be loaded via Google Fonts CSS.
	 *
	 * @var array<int,string>
	 */
	const BROWSER_FAMILIES = array(
		'Arial',
		'Helvetica',
		'Georgia',
		'Times New Roman',
		'Courier New',
		'Verdana',
		'Trebuchet MS',
		'Palatino',
		'sans-serif',
		'serif',
		'monospace',
	);

	/**
	 * Whether a family is a browser/web-safe font.
	 *
	 * @param string $family Family name.
	 * @return bool
	 */
	public static function is_browser_family( $family ): bool {
		return in_array( (string) $family, self::BROWSER_FAMILIES, true );
	}

	/**
	 * Sanitize a font family name (Google or browser/web-safe).
	 *
	 * @param mixed $value Raw value.
	 * @return string Empty string when invalid.
	 */
	public static function sanitize_family( $value ): string {
		if ( ! is_string( $value ) && ! is_numeric( $value ) ) {
			return '';
		}
		$family = trim( (string) $value );
		if ( '' === $family || strlen( $family ) > 80 ) {
			return '';
		}
		if ( ! preg_match( '/^[A-Za-z0-9][A-Za-z0-9 \-]*$/', $family ) ) {
			return '';
		}
		return $family;
	}

	/**
	 * Sanitize a stored font reference (empty, browser/Google family, or custom:<id>).
	 *
	 * @param mixed $value Raw value.
	 * @return string Empty string when invalid.
	 */
	public static function sanitize_font_ref( $value ): string {
		if ( ! is_string( $value ) && ! is_numeric( $value ) ) {
			return '';
		}
		$ref = trim( (string) $value );
		if ( '' === $ref ) {
			return '';
		}
		if ( 0 === strpos( $ref, 'custom:' ) ) {
			return preg_match( '/^custom:[A-Za-z0-9\-]+$/', $ref ) ? $ref : '';
		}
		return self::sanitize_family( $ref );
	}

	/**
	 * Sanitize a CSS font-weight value (any integer 100–900 inclusive).
	 *
	 * Preserves product weights such as 450, 550, and 750.
	 *
	 * @param mixed $value    Raw value.
	 * @param int   $fallback Fallback when non-numeric.
	 * @return int
	 */
	public static function sanitize_weight( $value, $fallback = 400 ): int {
		$fallback = self::clamp_weight( is_numeric( $fallback ) ? (int) $fallback : 400 );
		if ( ! is_numeric( $value ) ) {
			return $fallback;
		}
		return self::clamp_weight( (int) abs( (float) $value ) );
	}

	/**
	 * Clamp a weight into the CSS 100–900 range.
	 *
	 * @param int $weight Weight.
	 * @return int
	 */
	private static function clamp_weight( $weight ): int {
		$weight = (int) $weight;
		if ( $weight < self::MIN_WEIGHT ) {
			return self::MIN_WEIGHT;
		}
		if ( $weight > self::MAX_WEIGHT ) {
			return self::MAX_WEIGHT;
		}
		return $weight;
	}

	/**
	 * Snap a weight to the nearest hundred for Google Fonts CSS2 static faces.
	 *
	 * Product weights such as 550 still apply via CSS; the stylesheet request
	 * loads the closest available face (preferring the lighter neighbour).
	 *
	 * @param int $weight Weight.
	 * @return int
	 */
	private static function snap_google_weight( $weight ): int {
		$weight = self::clamp_weight( (int) $weight );
		$lower  = (int) ( floor( $weight / 100 ) * 100 );
		$upper  = min( self::MAX_WEIGHT, $lower + 100 );
		if ( $lower < self::MIN_WEIGHT ) {
			return self::MIN_WEIGHT;
		}
		if ( abs( $weight - $lower ) <= abs( $weight - $upper ) ) {
			return max( self::MIN_WEIGHT, $lower );
		}
		return $upper;
	}

	/**
	 * Build a Google Fonts CSS2 URL from notification style font fields.
	 *
	 * @param array<string,mixed> $style Style map (label/headline/message fonts + weights).
	 * @return string Empty string when no fonts are requested.
	 */
	public static function css_url_from_style( array $style ): string {
		return self::css_url_from_styles( array( $style ) );
	}

	/**
	 * Build a Google Fonts CSS2 URL from one or more style maps (unique families merged).
	 *
	 * Browser/web-safe families are skipped so they never enqueue Google Fonts CSS.
	 *
	 * @param array<int,array<string,mixed>> $styles Style maps.
	 * @return string Empty string when no fonts are requested.
	 */
	public static function css_url_from_styles( array $styles ): string {
		$slots = array(
			array( 'label_font', 'label_weight' ),
			array( 'headline_font', 'headline_weight' ),
			array( 'message_font', 'message_weight' ),
		);

		$by_family = array();
		foreach ( $styles as $style ) {
			if ( ! is_array( $style ) ) {
				continue;
			}
			foreach ( $slots as $slot ) {
				$family = self::sanitize_font_ref( isset( $style[ $slot[0] ] ) ? $style[ $slot[0] ] : '' );
				if ( '' === $family || self::is_browser_family( $family ) || 0 === strpos( $family, 'custom:' ) ) {
					continue;
				}
				$weight = self::sanitize_weight( isset( $style[ $slot[1] ] ) ? $style[ $slot[1] ] : 400, 400 );
				// Google Fonts CSS2 static faces use hundred-step weights.
				$weight = self::snap_google_weight( $weight );
				if ( ! isset( $by_family[ $family ] ) ) {
					$by_family[ $family ] = array();
				}
				$by_family[ $family ][ $weight ] = $weight;
			}
		}

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

		$parts = array();
		foreach ( $by_family as $family => $weights ) {
			$sorted = array_values( $weights );
			sort( $sorted, SORT_NUMERIC );
			$encoded = str_replace( '%20', '+', rawurlencode( $family ) );
			$parts[] = 'family=' . $encoded . ':wght@' . implode( ';', $sorted );
		}

		return 'https://fonts.googleapis.com/css2?' . implode( '&', $parts ) . '&display=swap';
	}
}
