<?php
/**
 * Dashboard settings schema — single source of truth for the Settings tab.
 *
 * Drives:
 *  - GET  /blocksify/v1/settings         → merged saved + defaults
 *  - POST /blocksify/v1/settings         → sanitize + save + return final
 *  - GET  /blocksify/v1/settings/schema  → panels payload for the JS form
 *
 * Add a setting → edit defaults() + metadata() and the REST endpoints,
 * JS form, and sanitizer all pick it up. JS NEVER redeclares field
 * metadata; SPEC §5.4.
 *
 * @package Blocksify
 */

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

/**
 * Settings shape + sanitize + schema payload builder.
 */
class Blocksify_Dashboard_Schema {

	const OPTION_NAME = 'blocksify_dashboard_settings';

	/**
	 * Default settings shape. Sanitizer falls back to these when the incoming
	 * payload omits a key, and GET /settings deep-merges saved-over-defaults.
	 *
	 * @return array
	 */
	public static function defaults() {
		return array(
			'performance'         => array(
				'css_output_mode' => 'file',
			),
			'editor_enhancements' => array(
				'design_library_button' => 'toolbar',
			),
			'version'             => array(),
		);
	}

	/**
	 * Per-field metadata for the schema endpoint. Labels + descriptions are
	 * translatable; types drive the JS render dispatch (boolean → Toggle,
	 * select → Select, etc. per SPEC §8.2.2).
	 *
	 * @return array
	 */
	public static function metadata() {
		return array(
			'performance' => array(
				'label'       => __( 'Performance', 'blocksify' ),
				'description' => __( 'How Blocksify delivers the CSS it generates for your blocks.', 'blocksify' ),
				'fields'      => array(
					'css_output_mode' => array(
						'label'       => __( 'CSS output mode', 'blocksify' ),
						'description' => __( 'External file writes one cached .css file per page in the uploads folder — best for caching and PageSpeed (recommended). Inline prints the CSS in a style tag in the page head.', 'blocksify' ),
						'type'        => 'select',
						'options'     => array(
							array(
								'value' => 'file',
								'label' => __( 'External file (recommended)', 'blocksify' ),
							),
							array(
								'value' => 'inline',
								'label' => __( 'Inline in head', 'blocksify' ),
							),
						),
					),
				),
			),
			'editor_enhancements' => array(
				'label'       => __( 'Editor Enhancements', 'blocksify' ),
				'description' => __( 'Tinker with editor-side surfaces — control where Blocksify features live in the block editor.', 'blocksify' ),
				'fields'      => array(
					'design_library_button' => array(
						'label'       => __( 'Design Library button', 'blocksify' ),
						'description' => __( 'Where the Design Library launcher appears in the block editor. Reload the editor after changing.', 'blocksify' ),
						'type'        => 'select',
						'options'     => array(
							array(
								'value' => 'toolbar',
								'label' => __( 'Document toolbar (top-left)', 'blocksify' ),
							),
							array(
								'value' => 'sidebar',
								'label' => __( 'Blocksify settings sidebar', 'blocksify' ),
							),
							array(
								'value' => 'hidden',
								'label' => __( 'Hide', 'blocksify' ),
							),
						),
					),
				),
			),
			'version'             => array(
				'label'       => __( 'Version control', 'blocksify' ),
				'description' => __( 'Manage the installed Blocksify version.', 'blocksify' ),
				'fields'      => array(),
			),
		);
	}

	/**
	 * Read the persisted option deep-merged with defaults. Saved values win;
	 * any group / field added since the option was last saved falls back to
	 * the default so JS always sees a complete shape.
	 *
	 * @return array
	 */
	public static function get_settings() {
		$defaults = self::defaults();
		$saved    = get_option( self::OPTION_NAME, array() );
		if ( ! is_array( $saved ) ) {
			$saved = array();
		}

		$out = array();
		foreach ( $defaults as $group => $fields ) {
			$saved_group = isset( $saved[ $group ] ) && is_array( $saved[ $group ] ) ? $saved[ $group ] : array();
			$out[ $group ] = array();
			foreach ( $fields as $key => $default ) {
				$out[ $group ][ $key ] = array_key_exists( $key, $saved_group )
					? $saved_group[ $key ]
					: $default;
			}
		}

		// Re-sanitize on read so any legacy invalid stored value is normalized
		// before reaching the client. Cheap — defaults() is two groups.
		return self::sanitize( $out );
	}

	/**
	 * Sanitize an incoming POST /settings body, write to the option store,
	 * and return the final shape. Empty / non-array body resets to defaults
	 * (handles the JS reset flow per SPEC §8.2.3).
	 *
	 * @param array $incoming Raw body from the REST request.
	 * @return array Sanitized shape (what was saved).
	 */
	public static function update_settings( $incoming ) {
		if ( ! is_array( $incoming ) ) {
			$incoming = array();
		}

		$previous  = get_option( self::OPTION_NAME, array() );
		$sanitized = self::sanitize( $incoming );

		update_option( self::OPTION_NAME, $sanitized, false );

		/**
		 * Fires after a successful settings save. Pro hooks here to invalidate
		 * caches or queue jobs. SPEC §11.2.
		 *
		 * @param array $sanitized Final saved shape.
		 * @param array $previous  Previously persisted shape (may be empty array).
		 */
		do_action( 'blocksify_dashboard_settings_saved', $sanitized, is_array( $previous ) ? $previous : array() );

		return $sanitized;
	}

	/**
	 * Walk defaults() and metadata() to coerce every incoming value to its
	 * declared type. Unknown keys dropped, enums whitelisted, bools coerced.
	 * SPEC §13.3.
	 *
	 * @param array $incoming Raw payload.
	 * @return array
	 */
	public static function sanitize( array $incoming ) {
		$defaults = self::defaults();
		$meta     = self::metadata();
		$out      = array();

		foreach ( $defaults as $group => $fields ) {
			$in = isset( $incoming[ $group ] ) && is_array( $incoming[ $group ] ) ? $incoming[ $group ] : array();
			$out[ $group ] = array();
			foreach ( $fields as $key => $default ) {
				$field_meta            = isset( $meta[ $group ]['fields'][ $key ] ) ? $meta[ $group ]['fields'][ $key ] : array();
				$type                  = isset( $field_meta['type'] ) ? $field_meta['type'] : 'text';
				$raw                   = array_key_exists( $key, $in ) ? $in[ $key ] : $default;
				$out[ $group ][ $key ] = self::coerce( $raw, $type, $field_meta, $default );
			}
		}

		/**
		 * Final pass on the sanitized payload before it's returned. Used by
		 * Pro to enforce policy or normalize derived fields.
		 *
		 * @param array $out      Sanitized payload.
		 * @param array $incoming Raw payload before sanitize.
		 */
		return apply_filters( 'blocksify_dashboard_settings_sanitized', $out, $incoming );
	}

	/**
	 * Coerce a single value to the declared field type. Falls back to the
	 * field default when the value can't be represented (invalid enum,
	 * non-numeric for number, etc.). Never throws.
	 *
	 * @param mixed  $value      Raw value.
	 * @param string $type       Declared field type from metadata().
	 * @param array  $field_meta Field metadata (options, min, max, …).
	 * @param mixed  $default    Field default from defaults().
	 * @return mixed
	 */
	private static function coerce( $value, $type, $field_meta, $default ) {
		switch ( $type ) {
			case 'boolean':
				return (bool) $value;
			case 'select':
			case 'radio':
				$allowed = array();
				if ( isset( $field_meta['options'] ) && is_array( $field_meta['options'] ) ) {
					foreach ( $field_meta['options'] as $option ) {
						if ( isset( $option['value'] ) ) {
							$allowed[] = $option['value'];
						}
					}
				}
				return in_array( $value, $allowed, true ) ? $value : $default;
			case 'number':
				if ( ! is_numeric( $value ) ) {
					return $default;
				}
				$num = (float) $value;
				if ( isset( $field_meta['min'] ) && $num < (float) $field_meta['min'] ) {
					$num = (float) $field_meta['min'];
				}
				if ( isset( $field_meta['max'] ) && $num > (float) $field_meta['max'] ) {
					$num = (float) $field_meta['max'];
				}
				return $num;
			case 'text':
			default:
				return is_string( $value ) ? sanitize_text_field( $value ) : (string) $default;
		}
	}

	/**
	 * Build the `{ panels: [...] }` payload served by GET /settings/schema.
	 * Walks defaults() × metadata() so the shape can't drift from the
	 * underlying option store. Pro can mutate via the filter below before
	 * the REST handler returns.
	 *
	 * @return array
	 */
	public static function get_schema() {
		$defaults = self::defaults();
		$meta     = self::metadata();
		$panels   = array();

		foreach ( $defaults as $group => $fields ) {
			$group_meta = isset( $meta[ $group ] ) ? $meta[ $group ] : array();

			$panel = array(
				'id'     => $group,
				'label'  => isset( $group_meta['label'] ) ? (string) $group_meta['label'] : $group,
				'fields' => array(),
			);
			if ( isset( $group_meta['description'] ) ) {
				$panel['description'] = (string) $group_meta['description'];
			}

			foreach ( $fields as $key => $default ) {
				$field_meta = isset( $group_meta['fields'][ $key ] ) ? $group_meta['fields'][ $key ] : array();

				$field = array(
					'id'      => $key,
					'label'   => isset( $field_meta['label'] ) ? (string) $field_meta['label'] : $key,
					'type'    => isset( $field_meta['type'] ) ? (string) $field_meta['type'] : 'text',
					'default' => $default,
				);
				if ( isset( $field_meta['description'] ) ) {
					$field['description'] = (string) $field_meta['description'];
				}
				if ( isset( $field_meta['options'] ) ) {
					$field['options'] = $field_meta['options'];
				}
				foreach ( array( 'min', 'max', 'step', 'pattern', 'maxLength' ) as $extra ) {
					if ( isset( $field_meta[ $extra ] ) ) {
						$field[ $extra ] = $field_meta[ $extra ];
					}
				}

				$panel['fields'][] = $field;
			}

			$panels[] = $panel;
		}

		$schema = array( 'panels' => $panels );

		/**
		 * PHP-side mutation of the settings schema. Rare — JS-side panel
		 * filter is the usual extension point. SPEC §11.2.
		 *
		 * @param array $schema { panels: [...] } payload.
		 */
		return apply_filters( 'blocksify_dashboard_settings_schema', $schema );
	}
}
