<?php

namespace SugarCalendarBookings\Support\DataProcessing;

use Exception;
use SugarCalendarBookings\Support\Helpers\Data;

/**
 * Class Sanitizer.
 *
 * Sanitizes data fields according to provided rules.
 *
 * IMPORTANT: The sanitizer never returns the raw value.
 *  - If the value cannot be sanitized, an empty value is returned.
 *  - If no sanitization rule is defined for some of the input fields,
 *    those fields will be excluded from the sanitized result.
 *  - If the rule is set, but not include sanitization rule,
 *    that field will be excluded from the sanitized result.
 *
 * @since 1.0.0
 */
class Sanitizer {

	use RulesTrait;

	/**
	 * Map of rule names to sanitization methods.
	 *
	 * @since 1.0.0
	 *
	 * @var array
	 */
	private static $rule_map = [
		'string'       => 'sanitize_string',
		'textarea'     => 'sanitize_textarea',
		'numeric'      => 'sanitize_numeric',
		'boolean'      => 'sanitize_boolean',
		'form_boolean' => 'sanitize_boolean',
		'url'          => 'sanitize_url',
		'email'        => 'sanitize_email',
		'id'           => 'sanitize_id',
		'html'         => 'sanitize_html',
		'slug'         => 'sanitize_slug',
	];

	/**
	 * Sanitize as string (sanitize_text_field).
	 *
	 * @since 1.0.0
	 *
	 * @param mixed $value Value to sanitize.
	 *
	 * @return string
	 */
	private static function sanitize_string( $value ) {

		return is_string( $value ) ? sanitize_text_field( $value ) : '';
	}

	/**
	 * Sanitize as textarea (sanitize_textarea_field).
	 *
	 * Preserves newlines, unlike sanitize_text_field().
	 *
	 * @since 1.4.0
	 *
	 * @param mixed $value Value to sanitize.
	 *
	 * @return string
	 */
	private static function sanitize_textarea( $value ) {

		return is_string( $value ) ? sanitize_textarea_field( $value ) : '';
	}

	/**
	 * Sanitize as numeric.
	 *
	 * @since 1.0.0
	 *
	 * @param mixed $value Value to sanitize.
	 *
	 * @return float|int
	 */
	private static function sanitize_numeric( $value ) {

		return is_numeric( $value ) ? $value + 0 : 0;
	}

	/**
	 * Sanitize as boolean.
	 *
	 * @since 1.0.0
	 *
	 * @param mixed $value Value to sanitize.
	 *
	 * @return bool
	 */
	private static function sanitize_boolean( $value ) {

		return filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
	}

	/**
	 * Sanitize as URL.
	 *
	 * @since 1.0.0
	 *
	 * @param mixed $value Value to sanitize.
	 *
	 * @return string
	 */
	private static function sanitize_url( $value ) {

		return is_string( $value ) ? sanitize_url( $value ) : '';
	}

	/**
	 * Sanitize as email.
	 *
	 * @since 1.0.0
	 *
	 * @param mixed $value Value to sanitize.
	 *
	 * @return string
	 */
	private static function sanitize_email( $value ) {

		return is_string( $value ) ? sanitize_email( $value ) : '';
	}

	/**
	 * Sanitize as ID.
	 *
	 * @since 1.0.0
	 *
	 * @param mixed $value Value to sanitize.
	 *
	 * @return int
	 */
	private static function sanitize_id( $value ) {

		return is_numeric( $value ) ? intval( $value ) : 0;
	}

	/**
	 * Sanitize as HTML (restricted allowed tags).
	 *
	 * @since 1.0.0
	 *
	 * @param mixed $value Value to sanitize.
	 *
	 * @return string
	 */
	private static function sanitize_html( $value ) {

		if ( ! is_string( $value ) ) {
			return '';
		}

		return wp_kses_post( $value );
	}

	/**
	 * Sanitize as URL slug.
	 *
	 * @since 1.5.0
	 *
	 * @param mixed $value Value to sanitize.
	 *
	 * @return string
	 */
	private static function sanitize_slug( $value ) {

		if ( ! is_string( $value ) ) {
			return '';
		}

		return sanitize_title( $value );
	}

	/**
	 * Sanitize data according to rules.
	 *
	 * @since 1.0.0
	 *
	 * @param array $data  The data to sanitize.
	 * @param array $rules The sanitization rules.
	 *
	 * @return array Sanitized data.
	 */
	public static function sanitize( $data, $rules ) {

		$sanitized = [];

		foreach ( $rules as $field => $field_rules ) {
			$field_rules = self::parse_rules( $field_rules );

			if ( self::is_wildcard( $field ) ) {
				self::process_wildcard_rule( $sanitized, $data, $field, $field_rules );
				continue;
			}

			self::sanitize_field( $sanitized, $data, $field, $field_rules );
		}

		return $sanitized;
	}

	/**
	 * Sanitize a single field (dot notation or flat).
	 *
	 * @since 1.0.0
	 *
	 * @param array  $sanitized   The current sanitized array.
	 * @param array  $data        The original data array.
	 * @param string $field       The field name.
	 * @param array  $field_rules The sanitization rules for the field.
	 *
	 * phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag.Missing
	 */
	private static function sanitize_field( &$sanitized, $data, $field, $field_rules ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$value = Data::get( $data, $field, '__not_exists__' );

		// If field from rules does not exist in data, skip.
		if ( $value === '__not_exists__' ) {
			return;
		}

		// Each array field should be sanitized separately via wildcard rules.
		// Empty arrays are preserved so consumers can distinguish
		// "field was sent but empty" from "field was not sent".
		if ( is_array( $value ) ) {
			if ( empty( $value ) ) {
				Data::set( $sanitized, $field, [] );
			}

			return;
		}

		// If nullable and value is null, set to null and return.
		if (
			in_array( 'nullable', $field_rules, true ) &&
			is_null( $value )
		) {
			Data::set( $sanitized, $field, null );

			return;
		}

		$is_sanitized = false;

		foreach ( $field_rules as $rule ) {
			$method = self::$rule_map[ $rule ] ?? null;

			if ( $method ) {
				$value        = self::$method( $value );
				$is_sanitized = true;
			}
		}

		/*
		 * Set value only if it was sanitized. All not sanitized values are skipped.
		 * This is important from security perspective. It prevents not sanitized values
		 * from being set in the sanitized array.
		 */
		if ( $is_sanitized ) {
			Data::set( $sanitized, $field, $value );
		} elseif ( defined( 'SCBOOKINGS_DEV_MODE' ) && SCBOOKINGS_DEV_MODE ) {
			throw new Exception( 'Sanitization rule is missing for field: ' . esc_html( $field ) );
		}
	}

	/**
	 * Process wildcard rule.
	 *
	 * @since 1.0.0
	 *
	 * @param array  $sanitized   The current sanitized array.
	 * @param array  $data        The original data array.
	 * @param string $field       The field name (with wildcard).
	 * @param array  $field_rules The sanitization rules for the field.
	 */
	private static function process_wildcard_rule( &$sanitized, $data, $field, $field_rules ) {

		$segments       = explode( '.', $field );
		$wildcard_index = array_search( '*', $segments, true );

		// Build prefix and suffix.
		$prefix = array_slice( $segments, 0, $wildcard_index );
		$suffix = array_slice( $segments, $wildcard_index + 1 );

		// Get the array to iterate.
		$array_path       = implode( '.', $prefix );
		$array_to_iterate = Data::get( $data, $array_path, [] );

		if ( is_array( $array_to_iterate ) ) {
			foreach ( $array_to_iterate as $i => $item ) {
				// Build the full key for this item.
				$item_key = implode( '.', array_merge( $prefix, [ $i ], $suffix ) );

				$sub_rules = [ $item_key => $field_rules ];
				$sanitized = array_replace_recursive(
					$sanitized,
					self::sanitize( $data, $sub_rules )
				);
			}
		}
	}
}
