<?php
/**
 * Validates and normalizes supplier pricing rules.
 *
 * @package EmberPrice
 */

namespace EmberPrice\Pricing;

/**
 * Pure server-side validation for the rule domain. The React form's
 * min/max attributes improve UX, but this class is the authoritative
 * boundary before rule JSON is persisted or evaluated.
 */
final class RuleValidator {

	private const MARGIN_TYPES    = array( 'percentage', 'fixed' );
	private const ROUNDING_MODES  = array( 'none', 'decimals', 'psychological', 'multiple' );
	private const SALE_POLICIES   = array( 'keep', 'remove_if_exceeds', 'scale_proportionally' );
	private const GUARDRAIL_MODES = array( 'both', 'increase_only', 'decrease_only' );

	/**
	 * Validates a complete `{ rule, category_overrides }` structure.
	 *
	 * @param array $default_rule Raw rule structure.
	 *
	 * @throws \InvalidArgumentException When any value is outside its domain.
	 */
	public function validate_default_rule( array $default_rule ): array {
		$rule      = $this->validate_rule( (array) ( $default_rule['rule'] ?? array() ) );
		$overrides = (array) ( $default_rule['category_overrides'] ?? array() );
		$validated = array();

		if ( count( $overrides ) > 1000 ) {
			throw new \InvalidArgumentException( 'Too many category overrides.' );
		}

		foreach ( $overrides as $category_id => $override ) {
			if ( ! ctype_digit( (string) $category_id ) || (int) $category_id <= 0 ) {
				throw new \InvalidArgumentException( 'Category override ids must be positive integers.' );
			}

			$validated[ (string) (int) $category_id ] = $this->validate_rule( (array) $override );
		}

		return array(
			'rule'               => $rule,
			'category_overrides' => $validated,
		);
	}

	/**
	 * Validates and returns one normalized rule.
	 *
	 * @param array $rule Raw rule.
	 *
	 * @throws \InvalidArgumentException When any value is invalid.
	 */
	public function validate_rule( array $rule ): array {
		$margin_type = (string) ( $rule['margin_type'] ?? 'percentage' );
		$this->assert_enum( 'margin_type', $margin_type, self::MARGIN_TYPES );

		$margin_value = $this->finite_number( 'margin_value', $rule['margin_value'] ?? 0.0 );
		$margin_max   = 'percentage' === $margin_type ? 10.0 : 1000000000.0;
		$this->assert_range( 'margin_value', $margin_value, 0.0, $margin_max );

		$vat_rate = $this->finite_number( 'vat_rate', $rule['vat_rate'] ?? 0.0 );
		$this->assert_range( 'vat_rate', $vat_rate, 0.0, 1.0 );

		$rounding      = (array) ( $rule['rounding'] ?? array() );
		$rounding_mode = (string) ( $rounding['mode'] ?? 'none' );
		$this->assert_enum( 'rounding.mode', $rounding_mode, self::ROUNDING_MODES );

		$decimals_number = $this->finite_number( 'rounding.decimals', $rounding['decimals'] ?? 2 );

		if ( floor( $decimals_number ) !== $decimals_number ) {
			throw new \InvalidArgumentException( 'rounding.decimals must be an integer.' );
		}

		$decimals = (int) $decimals_number;
		$this->assert_range( 'rounding.decimals', $decimals, 0, 4 );

		$ending = $this->finite_number( 'rounding.ending', $rounding['ending'] ?? 0.99 );
		$this->assert_range( 'rounding.ending', $ending, 0.0, 0.99 );

		$multiple = $this->finite_number( 'rounding.multiple', $rounding['multiple'] ?? 1.0 );
		$this->assert_range( 'rounding.multiple', $multiple, 0.0001, 1000000000.0 );

		$sale_policy = (string) ( $rule['sale_price_policy'] ?? 'keep' );
		$this->assert_enum( 'sale_price_policy', $sale_policy, self::SALE_POLICIES );

		$guardrails = (array) ( $rule['guardrails'] ?? array() );
		$max_change = $this->finite_number( 'guardrails.max_change_percent', $guardrails['max_change_percent'] ?? 0.4 );
		$this->assert_range( 'guardrails.max_change_percent', $max_change, 0.0, 10.0 );

		$guardrail_mode = (string) ( $guardrails['mode'] ?? 'both' );
		$this->assert_enum( 'guardrails.mode', $guardrail_mode, self::GUARDRAIL_MODES );

		return array(
			'margin_type'        => $margin_type,
			'margin_value'       => $margin_value,
			'vat_rate'           => $vat_rate,
			'list_includes_vat'  => $this->boolean( 'list_includes_vat', $rule['list_includes_vat'] ?? false ),
			'store_includes_vat' => $this->boolean( 'store_includes_vat', $rule['store_includes_vat'] ?? false ),
			'rounding'           => array(
				'mode'     => $rounding_mode,
				'decimals' => $decimals,
				'ending'   => $ending,
				'multiple' => $multiple,
			),
			'sale_price_policy'  => $sale_policy,
			'guardrails'         => array(
				'max_change_percent' => $max_change,
				'mode'               => $guardrail_mode,
			),
		);
	}

	/**
	 * Converts a numeric rule value to a finite float.
	 *
	 * @param string $field Field name.
	 * @param mixed  $value Raw value.
	 *
	 * @throws \InvalidArgumentException When the value is not a finite number.
	 */
	private function finite_number( string $field, $value ): float {
		if ( ! is_numeric( $value ) ) {
			throw new \InvalidArgumentException( "{$field} must be numeric." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- internal validation message returned through WP_Error.
		}

		$number = (float) $value;

		if ( ! is_finite( $number ) ) {
			throw new \InvalidArgumentException( "{$field} must be finite." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- internal validation message returned through WP_Error.
		}

		return $number;
	}

	/**
	 * Asserts that a number is within an inclusive range.
	 *
	 * @param string    $field Field name.
	 * @param float|int $value Value.
	 * @param float|int $min   Minimum inclusive.
	 * @param float|int $max   Maximum inclusive.
	 *
	 * @throws \InvalidArgumentException When the value is out of range.
	 */
	private function assert_range( string $field, $value, $min, $max ): void {
		if ( $value < $min || $value > $max ) {
			throw new \InvalidArgumentException( "{$field} is outside the allowed range." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- internal validation message returned through WP_Error.
		}
	}

	/**
	 * Asserts that a string belongs to an allowed enumeration.
	 *
	 * @param string   $field   Field name.
	 * @param string   $value   Value.
	 * @param string[] $allowed Allowed values.
	 *
	 * @throws \InvalidArgumentException When the value is unsupported.
	 */
	private function assert_enum( string $field, string $value, array $allowed ): void {
		if ( ! in_array( $value, $allowed, true ) ) {
			throw new \InvalidArgumentException( "{$field} has an unsupported value." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- internal validation message returned through WP_Error.
		}
	}

	/**
	 * Normalizes a strict boolean or 0/1 equivalent.
	 *
	 * @param string $field Field name.
	 * @param mixed  $value Raw value.
	 *
	 * @throws \InvalidArgumentException When the value is not boolean-like.
	 */
	private function boolean( string $field, $value ): bool {
		if ( is_bool( $value ) ) {
			return $value;
		}

		if ( 0 === $value || 1 === $value || '0' === $value || '1' === $value ) {
			return (bool) $value;
		}

		throw new \InvalidArgumentException( "{$field} must be boolean." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- internal validation message returned through WP_Error.
	}
}
