<?php
/**
 * Site-wide Blocksify settings: user-managed color + gradient palettes.
 *
 * Zero opinionated defaults: Blocksify doesn't duplicate theme.json's
 * color roles (Primary/Secondary/etc). Users add palette entries as needed
 * via the Blocksify Settings sidebar, and block CSS references theme
 * palette tokens (--wp--preset--color--*) with hardcoded hex fallbacks.
 * This keeps Blocksify's responsibility narrow — the palette is whatever
 * the user or their theme provides.
 *
 * @package Blocksify
 */

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

/**
 * Settings handler: register the user-palette options + REST schema, and
 * emit the CSS variables on both frontend and editor.
 */
class Blocksify_Settings {

	const OPTION_CUSTOM_COLORS    = 'blocksify_custom_colors';
	const OPTION_CUSTOM_GRADIENTS = 'blocksify_custom_gradients';

	/**
	 * Upper bound on palette entries. Keeps the inline <style> payload sane
	 * and rules out accidental-append loops in the UI.
	 */
	const MAX_CUSTOM_COLORS    = 40;
	const MAX_CUSTOM_GRADIENTS = 20;

	/**
	 * Hook into init for REST registration, and the asset/head hooks for CSS
	 * emission. Called once from blocksify.php.
	 */
	public static function boot() {
		add_action( 'init', array( __CLASS__, 'register_option' ) );
		// INVARIANT (file-mode CSS): keep this `:root` emit UNCONDITIONAL on
		// every frontend page — file-mode caches per-instance CSS that only
		// references these custom color/gradient vars; freshness relies on
		// this live emit. Gating it ⇒ bump `blocksify_css_generation`.
		// FOUNDATION §9.4 / docs/SPEC-css-output-mode.md §3.1.
		add_action( 'wp_head', array( __CLASS__, 'emit_frontend_css' ), 5 );
		add_action( 'enqueue_block_assets', array( __CLASS__, 'emit_editor_css' ) );
	}

	/**
	 * Expose the palette options on the Settings REST endpoint so the sidebar
	 * can read/write via core-data. Both options validate on write via
	 * sanitize_callback so reads don't need to re-check structure.
	 */
	public static function register_option() {
		register_setting(
			'blocksify',
			self::OPTION_CUSTOM_COLORS,
			array(
				'type'              => 'array',
				'default'           => array(),
				'sanitize_callback' => array( __CLASS__, 'sanitize_custom_colors' ),
				'show_in_rest'      => array(
					'schema' => array(
						'type'     => 'array',
						'maxItems' => self::MAX_CUSTOM_COLORS,
						'items'    => array(
							'type'                 => 'object',
							'properties'           => array(
								'slug'  => array( 'type' => 'string' ),
								'name'  => array( 'type' => 'string' ),
								'color' => array( 'type' => 'string' ),
							),
							'required'             => array( 'slug', 'color' ),
							'additionalProperties' => false,
						),
					),
				),
			)
		);

		register_setting(
			'blocksify',
			self::OPTION_CUSTOM_GRADIENTS,
			array(
				'type'              => 'array',
				'default'           => array(),
				'sanitize_callback' => array( __CLASS__, 'sanitize_custom_gradients' ),
				'show_in_rest'      => array(
					'schema' => array(
						'type'     => 'array',
						'maxItems' => self::MAX_CUSTOM_GRADIENTS,
						'items'    => array(
							'type'                 => 'object',
							'properties'           => array(
								'slug'     => array( 'type' => 'string' ),
								'name'     => array( 'type' => 'string' ),
								'gradient' => array( 'type' => 'string' ),
							),
							'required'             => array( 'slug', 'gradient' ),
							'additionalProperties' => false,
						),
					),
				),
			)
		);
	}

	/**
	 * Read the sanitized custom colors array.
	 *
	 * @return array<int, array{slug:string,name:string,color:string}>
	 */
	public static function get_custom_colors() {
		return self::sanitize_custom_colors( get_option( self::OPTION_CUSTOM_COLORS, array() ) );
	}

	/**
	 * Read the sanitized custom gradients array.
	 *
	 * @return array<int, array{slug:string,name:string,gradient:string}>
	 */
	public static function get_custom_gradients() {
		return self::sanitize_custom_gradients( get_option( self::OPTION_CUSTOM_GRADIENTS, array() ) );
	}

	/**
	 * Normalize + validate a raw custom-colors array from the REST API or
	 * option store. Invalid entries are dropped silently — the user sees
	 * the result (the entry vanishes) and the admin UI can retry the save.
	 *
	 * @param mixed $value Raw value.
	 * @return array<int, array{slug:string,name:string,color:string}>
	 */
	public static function sanitize_custom_colors( $value ) {
		if ( ! is_array( $value ) ) {
			return array();
		}
		$seen_slugs = array();
		$result     = array();
		foreach ( $value as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}
			$slug  = self::sanitize_slug( isset( $entry['slug'] ) ? $entry['slug'] : '' );
			$color = self::sanitize_css_color( isset( $entry['color'] ) ? $entry['color'] : '' );
			if ( '' === $slug || '' === $color ) {
				continue;
			}
			if ( isset( $seen_slugs[ $slug ] ) ) {
				continue;
			}
			$seen_slugs[ $slug ] = true;

			$name_raw = isset( $entry['name'] ) ? $entry['name'] : '';
			$name     = is_string( $name_raw ) ? trim( wp_strip_all_tags( $name_raw ) ) : '';
			if ( '' === $name ) {
				$name = $slug;
			}
			$result[] = array(
				'slug'  => $slug,
				'name'  => $name,
				'color' => $color,
			);
			if ( count( $result ) >= self::MAX_CUSTOM_COLORS ) {
				break;
			}
		}
		return $result;
	}

	/**
	 * Same rules as sanitize_custom_colors, but for gradient entries.
	 *
	 * @param mixed $value Raw value.
	 * @return array<int, array{slug:string,name:string,gradient:string}>
	 */
	public static function sanitize_custom_gradients( $value ) {
		if ( ! is_array( $value ) ) {
			return array();
		}
		$seen_slugs = array();
		$result     = array();
		foreach ( $value as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}
			$slug     = self::sanitize_slug( isset( $entry['slug'] ) ? $entry['slug'] : '' );
			$gradient = self::sanitize_css_gradient( isset( $entry['gradient'] ) ? $entry['gradient'] : '' );
			if ( '' === $slug || '' === $gradient ) {
				continue;
			}
			if ( isset( $seen_slugs[ $slug ] ) ) {
				continue;
			}
			$seen_slugs[ $slug ] = true;

			$name_raw = isset( $entry['name'] ) ? $entry['name'] : '';
			$name     = is_string( $name_raw ) ? trim( wp_strip_all_tags( $name_raw ) ) : '';
			if ( '' === $name ) {
				$name = $slug;
			}
			$result[] = array(
				'slug'     => $slug,
				'name'     => $name,
				'gradient' => $gradient,
			);
			if ( count( $result ) >= self::MAX_CUSTOM_GRADIENTS ) {
				break;
			}
		}
		return $result;
	}

	/**
	 * Coerce an arbitrary string into a CSS-safe slug. Lowercased, trimmed to
	 * a-z/0-9/hyphen, length-capped. Return '' for anything that can't be
	 * represented; caller treats empty as "drop this entry."
	 *
	 * @param mixed $value Raw slug input.
	 * @return string
	 */
	public static function sanitize_slug( $value ) {
		if ( ! is_string( $value ) ) {
			return '';
		}
		$trimmed = strtolower( trim( $value ) );
		$clean   = preg_replace( '/[^a-z0-9-]+/', '-', $trimmed );
		$clean   = preg_replace( '/-+/', '-', (string) $clean );
		$clean   = trim( (string) $clean, '-' );
		if ( '' === $clean ) {
			return '';
		}
		if ( strlen( $clean ) > 60 ) {
			$clean = substr( $clean, 0, 60 );
			$clean = trim( $clean, '-' );
		}
		return $clean;
	}

	/**
	 * Build the CSS rule body for :root. Emits only user-added entries —
	 * Blocksify ships zero default palette slots to avoid duplicating
	 * theme.json. Returns empty string when the user hasn't added anything.
	 *
	 * @return string
	 */
	public static function build_css() {
		$decls = '';

		foreach ( self::get_custom_colors() as $entry ) {
			$decls .= '--wp--preset--color--' . $entry['slug'] . ':' . $entry['color'] . ';';
		}
		foreach ( self::get_custom_gradients() as $entry ) {
			$decls .= '--wp--preset--gradient--' . $entry['slug'] . ':' . $entry['gradient'] . ';';
		}

		return '' === $decls ? '' : ':root{' . $decls . '}';
	}

	/**
	 * Frontend emitter. Priority 5 puts the style before wp_print_head_scripts
	 * and most theme inline CSS, so any theme rule that consumes the vars
	 * already sees them defined.
	 */
	public static function emit_frontend_css() {
		$css = self::build_css();
		if ( '' === $css ) {
			return;
		}
		echo '<style id="bsy-global-colors">' . $css . '</style>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Editor-side emitter. enqueue_block_assets fires inside the WP 6.5+
	 * block-editor iframe (the newer hook path; enqueue_block_editor_assets
	 * only loads into the outer admin page and never reaches the iframe),
	 * so this is how the sidebar preview actually sees the vars while the
	 * user is editing.
	 */
	public static function emit_editor_css() {
		if ( ! is_admin() ) {
			return;
		}
		$css = self::build_css();
		if ( '' === $css ) {
			return;
		}
		wp_register_style( 'bsy-global-colors', false, array(), BLOCKSIFY_VERSION );
		wp_enqueue_style( 'bsy-global-colors' );
		wp_add_inline_style( 'bsy-global-colors', $css );
	}

	/**
	 * Accept only recognized CSS color shapes: #hex (3/4/6/8), rgb(), rgba(),
	 * hsl(), hsla(), and plain CSS named colors via a lightweight whitelist.
	 * A broader regex would risk injecting arbitrary content into the inline
	 * <style> emitted on every page — this is the boundary where user input
	 * leaves the option store and enters rendered HTML.
	 *
	 * @param string $value Raw color value from the option.
	 * @return string Cleaned color, or '' if it doesn't match any accepted form.
	 */
	public static function sanitize_css_color( $value ) {
		if ( ! is_string( $value ) ) {
			return '';
		}
		$trimmed = trim( $value );
		if ( '' === $trimmed ) {
			return '';
		}
		if ( preg_match( '/^#([0-9a-fA-F]{3}){1,2}([0-9a-fA-F]{2})?$/', $trimmed ) ) {
			return $trimmed;
		}
		if ( preg_match( '/^rgba?\(\s*[\d\s.,%\/]+\s*\)$/i', $trimmed ) ) {
			return $trimmed;
		}
		if ( preg_match( '/^hsla?\(\s*[\d\s.,%\/deg]+\s*\)$/i', $trimmed ) ) {
			return $trimmed;
		}
		if ( preg_match( '/^[a-zA-Z]+$/', $trimmed ) ) {
			// Plain CSS keyword (e.g. "transparent", "currentColor"). Let it
			// through unmodified — the browser validates; invalid keywords
			// render the property as if it were absent.
			return $trimmed;
		}
		return '';
	}

	/**
	 * Accept only linear/radial/conic gradient expressions. The content
	 * between the parens must not include `<`, `>`, quotes, or backslash to
	 * keep the string from escaping the <style> tag or smuggling CSS-escape
	 * sequences. Anything else is dropped.
	 *
	 * @param mixed $value Raw gradient value.
	 * @return string
	 */
	public static function sanitize_css_gradient( $value ) {
		if ( ! is_string( $value ) ) {
			return '';
		}
		$trimmed = trim( $value );
		if ( '' === $trimmed ) {
			return '';
		}
		if ( strlen( $trimmed ) > 1024 ) {
			return '';
		}
		if ( ! preg_match( '/^(linear|radial|conic)-gradient\(\s*[^<>"\'\\\\]+\)\s*$/i', $trimmed ) ) {
			return '';
		}
		return $trimmed;
	}
}
