<?php
/**
 * Blocksify Shadows — Global Shadows panel storage + emit.
 *
 * Single wp_options-backed registry of shadow presets (defaults + custom)
 * exposed on the Settings REST endpoint so the Blocksify Settings
 * sidebar can read/write via core-data. Mirrors the JS-side store at
 * `src/shared/stores/shadow-tokens.js` byte-for-byte.
 *
 * Output: a single `<style id="bsy-global-shadows">` block emitting
 * `:root { --wp--preset--shadow--{slug}: {value}; ... }` to every
 * frontend page AND the block-editor iframe. Slugs carry the `bsy-`
 * prefix so the resolved CSS var (e.g.
 * `--wp--preset--shadow--bsy-soft`) cannot collide with theme.json or
 * WP core shadow presets that share the same `--wp--preset--shadow--*`
 * namespace.
 *
 * Live edits in the Settings sidebar are picked up by the JS injector
 * `inject-global-shadows.js`, which subscribes to the data store and
 * patches the editor's `:root` declaration in place. This PHP class
 * handles initial render + the frontend.
 *
 * @package Blocksify
 */

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

if ( ! class_exists( 'Blocksify_Shadows' ) ) {

	class Blocksify_Shadows {

		const OPTION_KEY              = 'blocksify_shadow_presets';
		const MAX_CUSTOM_SHADOWS      = 20;
		const MAX_LAYERS_PER_PRESET   = 8;
		const SLUG_PREFIX             = 'bsy-';

		/**
		 * Allowed units on layer X / Y / Blur / Spread fields. Box-shadow
		 * CSS allows mixing units per length but no real-world design tool
		 * does — one unit per layer keeps storage clean and matches the
		 * block-side ShadowControl bag.
		 */
		const ALLOWED_UNITS = array( 'px', 'em', 'rem' );

		/**
		 * Seed defaults — five Blocksify shadows. MUST mirror
		 * `SHADOW_DEFAULT_PRESETS` in `src/shared/constants.js` byte-for-
		 * byte so JS preview and PHP frontend produce identical CSS for
		 * untouched defaults.
		 *
		 * @return array<int, array{slug:string,label:string,layers:array}>
		 */
		public static function get_default_seeds() {
			return array(
				array(
					'slug'   => 'bsy-soft',
					'label'  => 'Soft',
					'layers' => array(
						array( 'x' => 0, 'y' => 1, 'blur' => 3, 'spread' => 0, 'unit' => 'px', 'color' => 'rgba(0,0,0,0.08)', 'inset' => false ),
						array( 'x' => 0, 'y' => 1, 'blur' => 2, 'spread' => 0, 'unit' => 'px', 'color' => 'rgba(0,0,0,0.04)', 'inset' => false ),
					),
				),
				array(
					'slug'   => 'bsy-medium',
					'label'  => 'Medium',
					'layers' => array(
						array( 'x' => 0, 'y' => 4, 'blur' => 6, 'spread' => -1, 'unit' => 'px', 'color' => 'rgba(0,0,0,0.08)', 'inset' => false ),
						array( 'x' => 0, 'y' => 2, 'blur' => 4, 'spread' => -2, 'unit' => 'px', 'color' => 'rgba(0,0,0,0.06)', 'inset' => false ),
					),
				),
				array(
					'slug'   => 'bsy-strong',
					'label'  => 'Strong',
					'layers' => array(
						array( 'x' => 0, 'y' => 10, 'blur' => 15, 'spread' => -3, 'unit' => 'px', 'color' => 'rgba(0,0,0,0.10)', 'inset' => false ),
						array( 'x' => 0, 'y' => 4, 'blur' => 6, 'spread' => -4, 'unit' => 'px', 'color' => 'rgba(0,0,0,0.06)', 'inset' => false ),
					),
				),
				array(
					'slug'   => 'bsy-hard',
					'label'  => 'Hard',
					'layers' => array(
						array( 'x' => 0, 'y' => 4, 'blur' => 0, 'spread' => 0, 'unit' => 'px', 'color' => 'rgba(0,0,0,0.18)', 'inset' => false ),
					),
				),
				array(
					'slug'   => 'bsy-inset',
					'label'  => 'Inset',
					'layers' => array(
						array( 'x' => 0, 'y' => 2, 'blur' => 4, 'spread' => 0, 'unit' => 'px', 'color' => 'rgba(0,0,0,0.06)', 'inset' => true ),
					),
				),
			);
		}

		/**
		 * Boot: register the REST option + emit hooks. Called once from
		 * blocksify.php after Blocksify_Settings::boot() (which provides
		 * the sanitize_css_color / sanitize_slug helpers reused below).
		 */
		public static function boot() {
			add_action( 'init', array( __CLASS__, 'register_option' ) );
			// INVARIANT (file-mode CSS): keep this `:root` shadow emit
			// UNCONDITIONAL on every frontend page — file-mode caches
			// per-instance CSS; named shadow refs (Pro) resolve against 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' ) );
		}

		/**
		 * Register the single option on the Settings REST endpoint. Schema
		 * is permissive at the type level — sanitize_callback does the
		 * shape validation (drop unknown default slugs, force slug prefix
		 * on customs, validate every layer field).
		 */
		public static function register_option() {
			register_setting(
				'blocksify',
				self::OPTION_KEY,
				array(
					'type'              => 'object',
					'default'           => array(),
					'sanitize_callback' => array( __CLASS__, 'sanitize_option' ),
					'show_in_rest'      => array(
						'schema' => array(
							'type'                 => 'object',
							'properties'           => array(
								'defaultEdits' => array(
									'type'                 => 'object',
									'additionalProperties' => array(
										'type'       => 'object',
										'properties' => array(
											'label'  => array( 'type' => 'string' ),
											'layers' => array( 'type' => 'array' ),
										),
									),
								),
								'customs'      => array(
									'type'  => 'array',
									'items' => array(
										'type'       => 'object',
										'properties' => array(
											'slug'   => array( 'type' => 'string' ),
											'name'   => array( 'type' => 'string' ),
											'layers' => array( 'type' => 'array' ),
										),
									),
								),
							),
							'additionalProperties' => false,
						),
					),
				)
			);
		}

		/**
		 * Sanitize the full option. Drops anything that doesn't validate;
		 * the UI surfaces the result (the entry vanishes) so the user can
		 * retry the save.
		 *
		 * @param mixed $value Raw option value from REST or option store.
		 * @return array{defaultEdits:array<string,array>,customs:array<int,array>}
		 */
		public static function sanitize_option( $value ) {
			if ( ! is_array( $value ) ) {
				return array(
					'defaultEdits' => array(),
					'customs'      => array(),
				);
			}

			$default_slugs = array();
			foreach ( self::get_default_seeds() as $seed ) {
				$default_slugs[ $seed['slug'] ] = true;
			}

			$default_edits = array();
			$raw_edits     = isset( $value['defaultEdits'] ) && is_array( $value['defaultEdits'] )
				? $value['defaultEdits']
				: array();
			foreach ( $raw_edits as $slug => $patch ) {
				if ( ! is_string( $slug ) || ! isset( $default_slugs[ $slug ] ) ) {
					continue;
				}
				if ( ! is_array( $patch ) ) {
					continue;
				}
				$clean = array();
				if ( isset( $patch['label'] ) && is_string( $patch['label'] ) ) {
					$label = trim( wp_strip_all_tags( $patch['label'] ) );
					if ( '' !== $label ) {
						$clean['label'] = $label;
					}
				}
				if ( isset( $patch['layers'] ) ) {
					$layers = self::sanitize_layers( $patch['layers'] );
					$clean['layers'] = $layers;
				}
				if ( ! empty( $clean ) ) {
					$default_edits[ $slug ] = $clean;
				}
			}

			$customs     = array();
			$raw_customs = isset( $value['customs'] ) && is_array( $value['customs'] )
				? $value['customs']
				: array();
			$seen_slugs  = array();
			foreach ( $raw_customs as $entry ) {
				if ( ! is_array( $entry ) ) {
					continue;
				}
				$slug = self::sanitize_custom_slug(
					isset( $entry['slug'] ) ? $entry['slug'] : ''
				);
				if ( '' === $slug || 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;
				}

				$layers = isset( $entry['layers'] ) ? self::sanitize_layers( $entry['layers'] ) : array();

				$customs[] = array(
					'slug'   => $slug,
					'name'   => $name,
					'layers' => $layers,
				);
				if ( count( $customs ) >= self::MAX_CUSTOM_SHADOWS ) {
					break;
				}
			}

			return array(
				'defaultEdits' => $default_edits,
				'customs'      => $customs,
			);
		}

		/**
		 * Validate + clean a layers array. Caps at MAX_LAYERS_PER_PRESET.
		 * Drops layers whose color fails sanitize_css_color — the rest of
		 * the bag carries on (caller can still emit a usable shadow if at
		 * least one layer survives).
		 *
		 * @param mixed $raw Raw layers array.
		 * @return array<int, array{x:float,y:float,blur:float,spread:float,unit:string,color:string,inset:bool}>
		 */
		protected static function sanitize_layers( $raw ) {
			if ( ! is_array( $raw ) ) {
				return array();
			}
			$out = array();
			foreach ( $raw as $layer ) {
				if ( ! is_array( $layer ) ) {
					continue;
				}
				$color = Blocksify_Settings::sanitize_css_color(
					isset( $layer['color'] ) ? $layer['color'] : ''
				);
				if ( '' === $color ) {
					continue;
				}
				$unit_raw = isset( $layer['unit'] ) ? (string) $layer['unit'] : 'px';
				$unit     = in_array( $unit_raw, self::ALLOWED_UNITS, true ) ? $unit_raw : 'px';
				$out[]    = array(
					'x'      => self::to_number( isset( $layer['x'] ) ? $layer['x'] : 0 ),
					'y'      => self::to_number( isset( $layer['y'] ) ? $layer['y'] : 0 ),
					'blur'   => max( 0.0, self::to_number( isset( $layer['blur'] ) ? $layer['blur'] : 0 ) ),
					'spread' => self::to_number( isset( $layer['spread'] ) ? $layer['spread'] : 0 ),
					'unit'   => $unit,
					'color'  => $color,
					'inset'  => ! empty( $layer['inset'] ),
				);
				if ( count( $out ) >= self::MAX_LAYERS_PER_PRESET ) {
					break;
				}
			}
			return $out;
		}

		/**
		 * Coerce + bsy-prefix a custom shadow slug. Empty / unstrippable
		 * input → ''. Caller treats empty as "drop this entry."
		 *
		 * @param mixed $value Raw slug.
		 * @return string
		 */
		protected static function sanitize_custom_slug( $value ) {
			$clean = Blocksify_Settings::sanitize_slug( $value );
			if ( '' === $clean ) {
				return '';
			}
			// Strip ALL leading `bsy-` prefixes so users editing slug
			// "bsy-hero" don't get a double-prefixed `bsy-bsy-hero` on
			// save AND a copy-pasted `bsy-bsy-hero` flattens to one prefix.
			while ( 0 === strpos( $clean, self::SLUG_PREFIX ) ) {
				$clean = substr( $clean, strlen( self::SLUG_PREFIX ) );
				$clean = trim( $clean, '-' );
				if ( '' === $clean ) {
					return '';
				}
			}
			return self::SLUG_PREFIX . $clean;
		}

		/**
		 * Coerce value to a finite float. Non-finite / non-numeric → 0.
		 *
		 * @param mixed $value
		 * @return float
		 */
		protected static function to_number( $value ) {
			if ( ! is_numeric( $value ) ) {
				return 0.0;
			}
			$n = (float) $value;
			if ( ! is_finite( $n ) ) {
				return 0.0;
			}
			return $n;
		}

		/**
		 * Stringify a number, dropping trailing `.0` on whole-number floats
		 * so the serialized CSS reads `4px` not `4.0px`. Matches JS
		 * template-literal output byte-for-byte (`${4}` → `"4"` in JS).
		 *
		 * @param float $value
		 * @return string
		 */
		protected static function num( $value ) {
			if ( (float) (int) $value === (float) $value ) {
				return (string) (int) $value;
			}
			return (string) $value;
		}

		/**
		 * Read the sanitized stored option.
		 *
		 * @return array{defaultEdits:array<string,array>,customs:array<int,array>}
		 */
		public static function get_stored() {
			return self::sanitize_option( get_option( self::OPTION_KEY, array() ) );
		}

		/**
		 * Defaults with user edits merged. Each seed pulls its patch (if
		 * any) from `defaultEdits` and overlays label / layers.
		 *
		 * @return array<int, array{slug:string,label:string,layers:array}>
		 */
		public static function get_defaults_resolved() {
			$stored = self::get_stored();
			$edits  = $stored['defaultEdits'];
			$out    = array();
			foreach ( self::get_default_seeds() as $seed ) {
				$patch = isset( $edits[ $seed['slug'] ] ) ? $edits[ $seed['slug'] ] : array();
				$out[] = array(
					'slug'   => $seed['slug'],
					'label'  => isset( $patch['label'] ) ? $patch['label'] : $seed['label'],
					'layers' => isset( $patch['layers'] ) ? $patch['layers'] : $seed['layers'],
				);
			}
			return $out;
		}

		/**
		 * Read the customs list.
		 *
		 * @return array<int, array{slug:string,name:string,layers:array}>
		 */
		public static function get_customs() {
			$stored = self::get_stored();
			return $stored['customs'];
		}

		/**
		 * Resolve a single token's layers array by slug (defaults-resolved
		 * + customs). Returns null when the slug isn't a known shadow
		 * token. Consumed by `Blocksify_Border_Trait::layer_to_css` to
		 * compose the `var(--wp--preset--shadow--{slug}, <fallback>)`
		 * fallback for a block's linked-preset shadow (`[{token: slug}]`).
		 *
		 * @param string $slug Shadow token slug (carries the `bsy-` prefix).
		 * @return array|null Layers array, or null when not found.
		 */
		public static function get_token_layers( $slug ) {
			if ( ! is_string( $slug ) || '' === $slug ) {
				return null;
			}
			foreach ( self::get_defaults_resolved() as $token ) {
				if ( $token['slug'] === $slug ) {
					return $token['layers'];
				}
			}
			foreach ( self::get_customs() as $token ) {
				if ( $token['slug'] === $slug ) {
					return $token['layers'];
				}
			}
			return null;
		}

		/**
		 * Compose a single layer's box-shadow value (no `box-shadow:`
		 * prefix, no `,` separator). Matches JS counterpart's output.
		 *
		 * @param array $layer Layer bag.
		 * @return string
		 */
		protected static function layer_to_css( $layer ) {
			$inset = ! empty( $layer['inset'] ) ? 'inset ' : '';
			$unit  = isset( $layer['unit'] ) && in_array( $layer['unit'], self::ALLOWED_UNITS, true )
				? $layer['unit']
				: 'px';
			return $inset
				. self::num( isset( $layer['x'] ) ? $layer['x'] : 0 ) . $unit . ' '
				. self::num( isset( $layer['y'] ) ? $layer['y'] : 0 ) . $unit . ' '
				. self::num( isset( $layer['blur'] ) ? $layer['blur'] : 0 ) . $unit . ' '
				. self::num( isset( $layer['spread'] ) ? $layer['spread'] : 0 ) . $unit . ' '
				. ( isset( $layer['color'] ) ? $layer['color'] : 'rgba(0,0,0,0.1)' );
		}

		/**
		 * Build the full `:root { ... }` CSS string for all resolved
		 * shadow tokens. Returns empty when neither defaults nor customs
		 * yield emit-able layers (every layer dropped during sanitize).
		 *
		 * @return string
		 */
		public static function build_css() {
			$decls = array();

			$compose = function( $token ) use ( &$decls ) {
				if ( empty( $token['layers'] ) ) {
					return;
				}
				$layer_strings = array();
				foreach ( $token['layers'] as $layer ) {
					$layer_strings[] = self::layer_to_css( $layer );
				}
				if ( empty( $layer_strings ) ) {
					return;
				}
				$decls[] = '--wp--preset--shadow--' . $token['slug']
					. ': ' . implode( ', ', $layer_strings ) . ';';
			};

			foreach ( self::get_defaults_resolved() as $token ) {
				$compose( $token );
			}
			foreach ( self::get_customs() as $token ) {
				$compose( $token );
			}

			return empty( $decls ) ? '' : ':root { ' . implode( ' ', $decls ) . ' }';
		}

		/**
		 * Frontend emitter. wp_head priority 5 puts the style after
		 * Blocksify_Settings (also priority 5) — both feed into the same
		 * head-pre region; ordering within priority is registration order.
		 * Sits before most theme inline CSS so consuming rules see the
		 * vars defined.
		 */
		public static function emit_frontend_css() {
			$css = self::build_css();
			if ( '' === $css ) {
				return;
			}
			echo '<style id="bsy-global-shadows">' . $css . '</style>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		}

		/**
		 * Editor-side emitter. enqueue_block_assets fires inside the
		 * WP 6.5+ block-editor iframe (the JS injector subscribes to the
		 * data store and patches this rule for live edits, but the PHP
		 * snapshot covers the moment between page load and the injector
		 * hydrating the store).
		 */
		public static function emit_editor_css() {
			if ( ! is_admin() ) {
				return;
			}
			$css = self::build_css();
			if ( '' === $css ) {
				return;
			}
			wp_register_style( 'bsy-global-shadows', false, array(), BLOCKSIFY_VERSION );
			wp_enqueue_style( 'bsy-global-shadows' );
			wp_add_inline_style( 'bsy-global-shadows', $css );
		}
	}
}
