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

namespace WPXero\MenuVerse\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * Validates a settings payload against a module schema.
 *
 * Sanitization already coerces values; validation exists so the REST layer can
 * tell a client precisely which key was wrong instead of silently rewriting it.
 */
final class Schema_Validator {

	/**
	 * Validates a value against a schema.
	 *
	 * @param mixed  $value  Value to check.
	 * @param array  $schema Schema definition.
	 * @param string $path   Dotted path used in error messages.
	 * @return true|\WP_Error
	 */
	public static function validate( $value, array $schema, $path = '' ) {
		$type = isset( $schema['type'] ) ? $schema['type'] : 'string';

		switch ( $type ) {
			case 'object':
				return self::validate_object( $value, $schema, $path );
			case 'array':
				return self::validate_array( $value, $schema, $path );
			case 'boolean':
				return is_bool( $value ) ? true : self::error( $path, __( 'Expected a boolean value.', 'menuverse' ) );
			case 'integer':
				return is_int( $value ) ? self::validate_range( $value, $schema, $path ) : self::error( $path, __( 'Expected an integer value.', 'menuverse' ) );
			case 'number':
				return is_numeric( $value ) ? self::validate_range( $value, $schema, $path ) : self::error( $path, __( 'Expected a numeric value.', 'menuverse' ) );
			default:
				return self::validate_string( $value, $schema, $path );
		}
	}

	/**
	 * Validates an object value.
	 *
	 * @param mixed  $value  Value to check.
	 * @param array  $schema Schema definition.
	 * @param string $path   Dotted path used in error messages.
	 * @return true|\WP_Error
	 */
	private static function validate_object( $value, array $schema, $path ) {
		if ( ! is_array( $value ) ) {
			return self::error( $path, __( 'Expected an object.', 'menuverse' ) );
		}

		if ( ! empty( $schema['required'] ) && is_array( $schema['required'] ) ) {
			foreach ( $schema['required'] as $required ) {
				if ( ! array_key_exists( $required, $value ) ) {
					return self::error( self::join( $path, $required ), __( 'This setting is required.', 'menuverse' ) );
				}
			}
		}

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

		foreach ( $schema['properties'] as $key => $property ) {
			if ( ! array_key_exists( $key, $value ) ) {
				continue;
			}

			$result = self::validate( $value[ $key ], (array) $property, self::join( $path, $key ) );

			if ( is_wp_error( $result ) ) {
				return $result;
			}
		}

		return true;
	}

	/**
	 * Validates a list value.
	 *
	 * @param mixed  $value  Value to check.
	 * @param array  $schema Schema definition.
	 * @param string $path   Dotted path used in error messages.
	 * @return true|\WP_Error
	 */
	private static function validate_array( $value, array $schema, $path ) {
		if ( ! is_array( $value ) ) {
			return self::error( $path, __( 'Expected a list.', 'menuverse' ) );
		}

		if ( isset( $schema['maxItems'] ) && count( $value ) > (int) $schema['maxItems'] ) {
			return self::error(
				$path,
				sprintf(
					/* translators: %d: maximum number of allowed items. */
					__( 'No more than %d items are allowed.', 'menuverse' ),
					(int) $schema['maxItems']
				)
			);
		}

		if ( isset( $schema['minItems'] ) && count( $value ) < (int) $schema['minItems'] ) {
			return self::error(
				$path,
				sprintf(
					/* translators: %d: minimum number of required items. */
					__( 'At least %d items are required.', 'menuverse' ),
					(int) $schema['minItems']
				)
			);
		}

		if ( empty( $schema['items'] ) ) {
			return true;
		}

		foreach ( array_values( $value ) as $index => $item ) {
			$result = self::validate( $item, (array) $schema['items'], self::join( $path, (string) $index ) );

			if ( is_wp_error( $result ) ) {
				return $result;
			}
		}

		return true;
	}

	/**
	 * Validates a string value.
	 *
	 * @param mixed  $value  Value to check.
	 * @param array  $schema Schema definition.
	 * @param string $path   Dotted path used in error messages.
	 * @return true|\WP_Error
	 */
	private static function validate_string( $value, array $schema, $path ) {
		if ( ! is_string( $value ) ) {
			return self::error( $path, __( 'Expected a string value.', 'menuverse' ) );
		}

		if ( isset( $schema['enum'] ) && is_array( $schema['enum'] ) && ! in_array( $value, $schema['enum'], true ) ) {
			return self::error(
				$path,
				sprintf(
					/* translators: %s: comma separated list of allowed values. */
					__( 'Value must be one of: %s.', 'menuverse' ),
					implode( ', ', array_map( 'strval', $schema['enum'] ) )
				)
			);
		}

		if ( isset( $schema['maxLength'] ) && mb_strlen( $value ) > (int) $schema['maxLength'] ) {
			return self::error( $path, __( 'Value is too long.', 'menuverse' ) );
		}

		if ( isset( $schema['minLength'] ) && mb_strlen( $value ) < (int) $schema['minLength'] ) {
			return self::error( $path, __( 'Value is too short.', 'menuverse' ) );
		}

		if ( isset( $schema['pattern'] ) && ! preg_match( '#' . $schema['pattern'] . '#', $value ) ) {
			return self::error( $path, __( 'Value has an unexpected format.', 'menuverse' ) );
		}

		return true;
	}

	/**
	 * Validates that a number sits inside the schema range.
	 *
	 * @param int|float $value  Numeric value.
	 * @param array     $schema Schema definition.
	 * @param string    $path   Dotted path used in error messages.
	 * @return true|\WP_Error
	 */
	private static function validate_range( $value, array $schema, $path ) {
		if ( isset( $schema['minimum'] ) && $value < $schema['minimum'] ) {
			return self::error(
				$path,
				sprintf(
					/* translators: %s: minimum allowed value. */
					__( 'Value must be %s or greater.', 'menuverse' ),
					(string) $schema['minimum']
				)
			);
		}

		if ( isset( $schema['maximum'] ) && $value > $schema['maximum'] ) {
			return self::error(
				$path,
				sprintf(
					/* translators: %s: maximum allowed value. */
					__( 'Value must be %s or less.', 'menuverse' ),
					(string) $schema['maximum']
				)
			);
		}

		return true;
	}

	/**
	 * Joins two path segments.
	 *
	 * @param string $path    Existing path.
	 * @param string $segment Segment to append.
	 * @return string
	 */
	private static function join( $path, $segment ) {
		return '' === $path ? $segment : $path . '.' . $segment;
	}

	/**
	 * Builds a validation error.
	 *
	 * @param string $path    Dotted setting path.
	 * @param string $message Human readable message.
	 * @return \WP_Error
	 */
	private static function error( $path, $message ) {
		return new \WP_Error(
			'menuverse_invalid_setting',
			$message,
			array(
				'status' => 400,
				'param'  => $path,
			)
		);
	}
}
