<?php
/**
 * Schema driven sanitization.
 *
 * @package MenuVerse
 */

namespace WPXero\MenuVerse\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * Coerces untrusted input into the shape declared by a settings schema.
 *
 * Unknown keys are dropped rather than stored: `additionalProperties` is
 * always treated as false so a compromised or buggy client cannot grow the
 * options row.
 */
final class Schema_Sanitizer {

	/**
	 * Sanitizes a value against a schema.
	 *
	 * @param mixed $value  Raw value.
	 * @param array $schema Schema definition.
	 * @return mixed
	 */
	public static function sanitize( $value, array $schema ) {
		$type = isset( $schema['type'] ) ? $schema['type'] : 'string';

		switch ( $type ) {
			case 'object':
				return self::sanitize_object( $value, $schema );
			case 'array':
				return self::sanitize_array( $value, $schema );
			case 'boolean':
				return (bool) rest_sanitize_boolean( $value );
			case 'integer':
				return self::clamp( (int) $value, $schema );
			case 'number':
				return self::clamp( (float) $value, $schema );
			default:
				return self::sanitize_string( $value, $schema );
		}
	}

	/**
	 * Sanitizes an object value.
	 *
	 * @param mixed $value  Raw value.
	 * @param array $schema Schema definition.
	 * @return array
	 */
	private static function sanitize_object( $value, array $schema ) {
		$value  = is_array( $value ) ? $value : array();
		$result = array();

		if ( empty( $schema['properties'] ) || ! is_array( $schema['properties'] ) ) {
			return $result;
		}

		foreach ( $schema['properties'] as $key => $property ) {
			$property = (array) $property;

			if ( array_key_exists( $key, $value ) ) {
				$result[ $key ] = self::sanitize( $value[ $key ], $property );
			} else {
				$result[ $key ] = Schema::defaults( $property );
			}
		}

		return $result;
	}

	/**
	 * Sanitizes a list value.
	 *
	 * @param mixed $value  Raw value.
	 * @param array $schema Schema definition.
	 * @return array
	 */
	private static function sanitize_array( $value, array $schema ) {
		if ( ! is_array( $value ) ) {
			return array();
		}

		$items  = isset( $schema['items'] ) ? (array) $schema['items'] : array( 'type' => 'string' );
		$result = array();

		foreach ( array_values( $value ) as $item ) {
			$result[] = self::sanitize( $item, $items );
		}

		if ( isset( $schema['maxItems'] ) ) {
			$result = array_slice( $result, 0, (int) $schema['maxItems'] );
		}

		return $result;
	}

	/**
	 * Sanitizes a scalar string value, honouring the declared format.
	 *
	 * @param mixed $value  Raw value.
	 * @param array $schema Schema definition.
	 * @return string
	 */
	private static function sanitize_string( $value, array $schema ) {
		if ( is_array( $value ) || is_object( $value ) ) {
			return '';
		}

		$value  = (string) $value;
		$format = isset( $schema['format'] ) ? $schema['format'] : '';

		switch ( $format ) {
			case 'color':
				$value = self::sanitize_color( $value );
				break;
			case 'color-alpha':
				$value = self::sanitize_color( $value, true );
				break;
			case 'css-size':
				$value = self::sanitize_css_size( $value );
				break;
			case 'url':
				$value = esc_url_raw( $value, array( 'http', 'https', 'mailto', 'tel' ) );
				break;
			case 'slug':
			case 'icon':
				$value = sanitize_key( $value );
				break;
			case 'html':
				$value = wp_kses_post( $value );
				break;
			default:
				$value = sanitize_text_field( $value );
				break;
		}

		if ( '' !== $format ) {
			/**
			 * Filters a sanitized value for a schema format.
			 *
			 * Lets extensions register additional formats without patching core.
			 *
			 * @since 1.0.0
			 *
			 * @param string $value  Sanitized value.
			 * @param array  $schema Schema definition for the value.
			 */
			$value = apply_filters( "menuverse_sanitize_format_{$format}", $value, $schema );
		}

		if ( isset( $schema['enum'] ) && is_array( $schema['enum'] ) && ! in_array( $value, $schema['enum'], true ) ) {
			$value = array_key_exists( 'default', $schema ) ? (string) $schema['default'] : (string) reset( $schema['enum'] );
		}

		if ( isset( $schema['maxLength'] ) ) {
			$value = mb_substr( $value, 0, (int) $schema['maxLength'] );
		}

		return $value;
	}

	/**
	 * Sanitizes a hex, rgb or rgba colour string.
	 *
	 * @param string $value       Raw colour.
	 * @param bool   $allow_alpha Whether to accept 4 and 8 digit hex with an
	 *                            alpha channel. Core's sanitize_hex_color()
	 *                            handles 3 and 6 digit forms only.
	 * @return string
	 */
	private static function sanitize_color( $value, $allow_alpha = false ) {
		$value = trim( $value );

		if ( '' === $value ) {
			return '';
		}

		if ( $allow_alpha && preg_match( '/^#(?:[A-Fa-f0-9]{4}|[A-Fa-f0-9]{8})$/', $value ) ) {
			return strtolower( $value );
		}

		$hex = sanitize_hex_color( $value );

		if ( $hex ) {
			return $hex;
		}

		if ( preg_match( '/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d+)\s*)?\)$/', $value ) ) {
			return $value;
		}

		if ( preg_match( '/^var\(--[a-zA-Z0-9_-]+\)$/', $value ) ) {
			return $value;
		}

		return '';
	}

	/**
	 * Sanitizes a CSS length against a unit allow list.
	 *
	 * @param string $value Raw length.
	 * @return string
	 */
	private static function sanitize_css_size( $value ) {
		$value = trim( $value );

		if ( preg_match( '/^(-?\d*\.?\d+)(px|em|rem|%|vw|vh|ch)?$/', $value, $matches ) ) {
			$unit = isset( $matches[2] ) && '' !== $matches[2] ? $matches[2] : 'px';

			return $matches[1] . $unit;
		}

		return '';
	}

	/**
	 * Constrains a numeric value to the schema range.
	 *
	 * @param int|float $value  Numeric value.
	 * @param array     $schema Schema definition.
	 * @return int|float
	 */
	private static function clamp( $value, array $schema ) {
		if ( isset( $schema['minimum'] ) && $value < $schema['minimum'] ) {
			$value = $schema['minimum'];
		}

		if ( isset( $schema['maximum'] ) && $value > $schema['maximum'] ) {
			$value = $schema['maximum'];
		}

		return $value;
	}
}
