<?php
/**
 * Deterministic condition engine for auto-reply policies.
 *
 * @package SubmissionInbox
 */

namespace SUBIN\AutoReplies;

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

/**
 * Sanitizes, validates, and evaluates one flat all/any condition set.
 *
 * The engine deliberately has no WordPress runtime dependencies so the same
 * behavior can be exercised by package-free regression tests and safe
 * simulations. Invalid configuration and malformed submitted field data fail
 * closed. A valid rule with no conditions matches by design.
 */
class AutoReplyConditionEngine {
	const VERSION = 1;
	const MAX_CONDITIONS = 20;
	const MAX_FIELD_LENGTH = 190;
	const MAX_VALUE_LENGTH = 4096;
	const MAX_LIST_VALUES = 100;
	const MAX_DOCUMENT_BYTES = 65536;

	/**
	 * Supported field types.
	 *
	 * @var array<int,string>
	 */
	private $field_types = array(
		'text',
		'email',
		'url',
		'tel',
		'textarea',
		'choice',
		'select',
		'radio',
		'checkbox',
		'consent',
		'number',
		'date',
		'datetime',
		'time',
	);

	/**
	 * Operator aliases accepted at API boundaries.
	 *
	 * @var array<string,string>
	 */
	private $operator_aliases = array(
		'is'                    => 'equals',
		'equal'                 => 'equals',
		'equals'                => 'equals',
		'is_not'                => 'not_equals',
		'not_equal'             => 'not_equals',
		'not_equals'            => 'not_equals',
		'contains'              => 'contains',
		'does_not_contain'      => 'not_contains',
		'not_contains'          => 'not_contains',
		'starts_with'           => 'starts_with',
		'ends_with'             => 'ends_with',
		'empty'                 => 'is_empty',
		'is_empty'              => 'is_empty',
		'not_empty'             => 'is_not_empty',
		'is_not_empty'          => 'is_not_empty',
		'in'                    => 'in',
		'not_in'                => 'not_in',
		'greater'               => 'greater_than',
		'greater_than'          => 'greater_than',
		'greater_or_equal'      => 'greater_than_or_equal',
		'greater_than_or_equal' => 'greater_than_or_equal',
		'less'                  => 'less_than',
		'less_than'             => 'less_than',
		'less_or_equal'         => 'less_than_or_equal',
		'less_than_or_equal'    => 'less_than_or_equal',
		'before'                => 'before',
		'after'                 => 'after',
		'on_or_before'          => 'on_or_before',
		'on_or_after'           => 'on_or_after',
		'between'               => 'between',
		'not_between'           => 'not_between',
	);

	/**
	 * Sanitize a rule or a conditions document into the version-one shape.
	 *
	 * The returned `valid` flag is intentional. Sanitization never turns a
	 * malformed non-empty condition set into an unconditional match.
	 *
	 * @param mixed       $input Conditions list, conditions document, or rule.
	 * @param string|null $mode Optional all/any override.
	 * @return array{version:int,mode:string,conditions:array<int,array<string,mixed>>,valid:bool}
	 */
	public function sanitize_conditions( $input, $mode = null ) {
		$document = $this->extract_document( $input, $mode );
		$errors   = $this->validate_conditions( $input, $mode );
		$clean    = array(
			'version'    => self::VERSION,
			'mode'       => $this->normalize_mode( $document['mode'] ),
			'conditions' => array(),
			'valid'      => empty( $errors ),
		);

		if ( '' === $clean['mode'] ) {
			$clean['mode'] = 'all';
		}

		$conditions = is_array( $document['conditions'] ) ? $document['conditions'] : array();
		foreach ( array_slice( array_values( $conditions ), 0, self::MAX_CONDITIONS ) as $condition ) {
			$clean['conditions'][] = $this->sanitize_condition( $condition );
		}

		return $clean;
	}

	/**
	 * Validate a rule or condition document without mutating it.
	 *
	 * @param mixed       $input Conditions list, conditions document, or rule.
	 * @param string|null $mode Optional all/any override.
	 * @return array<int,array{path:string,code:string,message:string}>
	 */
	public function validate_conditions( $input, $mode = null ) {
		$errors   = array();
		$document = $this->extract_document( $input, $mode );

		if ( ! $document['shape_valid'] ) {
			$this->add_error( $errors, 'conditions', 'invalid_document', 'Conditions must be an array, a conditions document, or a rule.' );
			return $errors;
		}

		if ( ! $this->is_version_one( $document['version'] ) ) {
			$this->add_error( $errors, 'version', 'unsupported_version', 'Auto-reply condition version must be 1.' );
		}

		if ( '' === $this->normalize_mode( $document['mode'] ) ) {
			$this->add_error( $errors, 'mode', 'invalid_mode', 'Condition mode must be all or any.' );
		}

		if ( ! is_array( $document['conditions'] ) ) {
			$this->add_error( $errors, 'conditions', 'invalid_conditions', 'Conditions must be an array.' );
			return $errors;
		}

		$encoded_conditions = json_encode( $document['conditions'] );
		if ( ! is_string( $encoded_conditions ) || strlen( $encoded_conditions ) > self::MAX_DOCUMENT_BYTES ) {
			$this->add_error( $errors, 'conditions', 'conditions_too_large', 'The auto-reply condition document is too large.' );
		}

		if ( count( $document['conditions'] ) > self::MAX_CONDITIONS ) {
			$this->add_error( $errors, 'conditions', 'too_many_conditions', 'An auto-reply rule may contain at most 20 conditions.' );
		}

		if ( isset( $document['valid_marker'] ) && false === $document['valid_marker'] ) {
			$this->add_error( $errors, 'conditions', 'invalid_sanitized_document', 'The sanitized condition document contains invalid source data.' );
		}

		foreach ( array_slice( array_values( $document['conditions'] ), 0, self::MAX_CONDITIONS ) as $index => $condition ) {
			$path = 'conditions.' . $index;
			if ( ! is_array( $condition ) ) {
				$this->add_error( $errors, $path, 'invalid_condition', 'Each condition must be an object.' );
				continue;
			}

			$raw_field = $this->first_value( $condition, array( 'field', 'field_key', 'key' ), '' );
			$field     = $this->condition_field( $condition );
			if ( '' === $field || ! is_scalar( $raw_field ) || $field !== trim( (string) $raw_field ) ) {
				$this->add_error( $errors, $path . '.field', 'invalid_field', 'Condition field is missing or invalid.' );
			}

			$type = $this->normalize_type( $this->first_value( $condition, array( 'type', 'field_type' ), '' ) );
			if ( '' === $type ) {
				$this->add_error( $errors, $path . '.type', 'invalid_type', 'Condition field type is not supported.' );
			}

			$operator = $this->normalize_operator( $this->first_value( $condition, array( 'operator', 'comparison' ), '' ) );
			if ( '' === $operator ) {
				$this->add_error( $errors, $path . '.operator', 'invalid_operator', 'Condition operator is not supported.' );
				continue;
			}

			if ( '' !== $type && ! in_array( $operator, $this->operators_for_type( $type ), true ) ) {
				$this->add_error( $errors, $path . '.operator', 'operator_not_allowed', 'Condition operator is not allowed for this field type.' );
				continue;
			}

			if ( in_array( $operator, array( 'is_empty', 'is_not_empty' ), true ) ) {
				continue;
			}

			$value       = array_key_exists( 'value', $condition ) ? $condition['value'] : $this->first_value( $condition, array( 'expected', 'values' ), null );
			$value_valid = true;
			$this->normalize_expected( $value, $type, $operator, $value_valid );
			if ( ! $value_valid ) {
				$this->add_error( $errors, $path . '.value', 'invalid_value', $this->invalid_value_message( $type, $operator ) );
			}
		}

		return $errors;
	}

	/**
	 * Return whether a rule or condition document matches submitted fields.
	 *
	 * @param mixed       $input Conditions list, conditions document, or rule.
	 * @param mixed       $submitted_fields Associative values or submitted field rows.
	 * @param string|null $mode Optional all/any override.
	 * @return bool
	 */
	public function matches( $input, $submitted_fields, $mode = null ) {
		$result = $this->evaluate( $input, $submitted_fields, $mode );

		return ! empty( $result['matched'] );
	}

	/**
	 * Evaluate a rule and return simulation-friendly condition details.
	 *
	 * @param mixed       $input Conditions list, conditions document, or rule.
	 * @param mixed       $submitted_fields Associative values or submitted field rows.
	 * @param string|null $mode Optional all/any override.
	 * @return array<string,mixed>
	 */
	public function evaluate( $input, $submitted_fields, $mode = null ) {
		$document = $this->extract_document( $input, $mode );
		$errors   = $this->validate_conditions( $input, $mode );
		$clean    = $this->sanitize_conditions( $input, $mode );
		$result   = array(
			'version'         => self::VERSION,
			'mode'            => $clean['mode'],
			'matched'         => false,
			'valid'           => empty( $errors ),
			'condition_count' => count( $clean['conditions'] ),
			'conditions'      => array(),
			'errors'          => $errors,
		);

		if ( ! empty( $errors ) ) {
			foreach ( $clean['conditions'] as $index => $condition ) {
				$result['conditions'][] = $this->invalid_condition_detail( $index, $condition, $errors );
			}

			return $result;
		}

		if ( empty( $clean['conditions'] ) ) {
			$result['matched'] = true;
			return $result;
		}

		$field_map = $this->normalize_submitted_fields( $submitted_fields );
		if ( ! $field_map['valid'] ) {
			$result['valid'] = false;
			$this->add_error( $result['errors'], 'submitted_fields', 'invalid_submitted_fields', 'Submitted fields are malformed.' );
			foreach ( $clean['conditions'] as $index => $condition ) {
				$result['conditions'][] = $this->condition_detail( $index, $condition, null, false, 'invalid_submitted_fields' );
			}

			return $result;
		}

		$condition_matches = array();
		foreach ( $clean['conditions'] as $index => $condition ) {
			$field = $condition['field'];
			if ( ! isset( $field_map['fields'][ $field ] ) ) {
				// Capture adapters deliberately omit empty values. Treat an absent
				// field as empty only for the explicit empty-value operator; all
				// other operators continue to fail closed.
				$matched               = 'is_empty' === $condition['operator'];
				$condition_matches[]   = $matched;
				$result['conditions'][] = $this->condition_detail( $index, $condition, null, $matched, $matched ? 'matched_empty_not_submitted' : 'field_not_submitted' );
				continue;
			}

			$submitted = $field_map['fields'][ $field ];
			if ( '' !== $submitted['type'] && ! $this->types_are_compatible( $condition['type'], $submitted['type'] ) ) {
				$condition_matches[]   = false;
				$result['conditions'][] = $this->condition_detail( $index, $condition, $submitted, false, 'field_type_mismatch' );
				continue;
			}

			$matched               = $this->evaluate_condition( $condition, $submitted['value'] );
			$condition_matches[]   = $matched;
			$result['conditions'][] = $this->condition_detail( $index, $condition, $submitted, $matched, $matched ? 'matched' : 'not_matched' );
		}

		if ( 'any' === $clean['mode'] ) {
			$result['matched'] = in_array( true, $condition_matches, true );
		} else {
			$result['matched'] = ! in_array( false, $condition_matches, true );
		}

		return $result;
	}

	/**
	 * Return supported operators for one field type.
	 *
	 * @param string $type Field type.
	 * @return array<int,string>
	 */
	public function operators_for_type( $type ) {
		$type = $this->normalize_type( $type );
		$empty_operators = array( 'is_empty', 'is_not_empty' );

		if ( in_array( $type, array( 'text', 'email', 'url', 'tel', 'textarea' ), true ) ) {
			return array_merge( array( 'equals', 'not_equals', 'contains', 'not_contains', 'starts_with', 'ends_with' ), $empty_operators );
		}

		if ( in_array( $type, array( 'choice', 'select', 'radio', 'checkbox' ), true ) ) {
			return array_merge( array( 'equals', 'not_equals', 'contains', 'not_contains', 'in', 'not_in' ), $empty_operators );
		}

		if ( 'consent' === $type ) {
			return array_merge( array( 'equals', 'not_equals' ), $empty_operators );
		}

		if ( 'number' === $type ) {
			return array_merge( array( 'equals', 'not_equals', 'greater_than', 'greater_than_or_equal', 'less_than', 'less_than_or_equal', 'between', 'not_between' ), $empty_operators );
		}

		if ( in_array( $type, array( 'date', 'datetime', 'time' ), true ) ) {
			return array_merge( array( 'equals', 'not_equals', 'before', 'after', 'on_or_before', 'on_or_after', 'between', 'not_between' ), $empty_operators );
		}

		return array();
	}

	/**
	 * Extract a common document from accepted input shapes.
	 *
	 * @param mixed       $input Input.
	 * @param string|null $mode Mode override.
	 * @return array<string,mixed>
	 */
	private function extract_document( $input, $mode = null ) {
		$document = array(
			'version'      => self::VERSION,
			'mode'         => null === $mode ? 'all' : $mode,
			'conditions'   => array(),
			'shape_valid'  => true,
			'valid_marker' => null,
		);

		if ( ! is_array( $input ) ) {
			$document['shape_valid'] = false;
			$document['conditions']  = null;
			return $document;
		}

		if ( $this->looks_like_condition( $input ) ) {
			$document['conditions'] = array( $input );
			return $document;
		}

		if ( $this->is_list( $input ) || empty( $input ) ) {
			$document['conditions'] = $input;
			return $document;
		}

		$nested_document = null;
		if ( array_key_exists( 'conditions', $input ) ) {
			$document['conditions'] = $input['conditions'];

			// Repository/API rows may expose the canonical conditions document
			// under the rule's `conditions` key. Accept that boundary shape as
			// well as the canonical document passed directly.
			if (
				is_array( $input['conditions'] )
				&& ! $this->is_list( $input['conditions'] )
				&& array_key_exists( 'conditions', $input['conditions'] )
				&& ! $this->looks_like_condition( $input['conditions'] )
			) {
				$nested_document        = $input['conditions'];
				$document['conditions'] = $nested_document['conditions'];
			}
		} elseif ( $this->looks_like_rule( $input ) ) {
			$document['conditions'] = array();
		} else {
			$document['shape_valid'] = false;
			$document['conditions']  = null;
			return $document;
		}

		if ( array_key_exists( 'conditions_version', $input ) ) {
			$document['version'] = $input['conditions_version'];
		} elseif ( array_key_exists( 'version', $input ) ) {
			$document['version'] = $input['version'];
		} elseif ( is_array( $nested_document ) && array_key_exists( 'conditions_version', $nested_document ) ) {
			$document['version'] = $nested_document['conditions_version'];
		} elseif ( is_array( $nested_document ) && array_key_exists( 'version', $nested_document ) ) {
			$document['version'] = $nested_document['version'];
		}

		if ( null === $mode ) {
			$mode_found = false;
			foreach ( array( 'conditions_mode', 'condition_mode', 'conditions_match', 'condition_match', 'mode', 'match' ) as $mode_key ) {
				if ( array_key_exists( $mode_key, $input ) ) {
					$document['mode'] = $input[ $mode_key ];
					$mode_found       = true;
					break;
				}
			}
			if ( ! $mode_found && is_array( $nested_document ) ) {
				foreach ( array( 'conditions_mode', 'condition_mode', 'conditions_match', 'condition_match', 'mode', 'match' ) as $mode_key ) {
					if ( array_key_exists( $mode_key, $nested_document ) ) {
						$document['mode'] = $nested_document[ $mode_key ];
						break;
					}
				}
			}
		}

		if ( is_array( $nested_document ) && array_key_exists( 'valid', $nested_document ) && is_bool( $nested_document['valid'] ) ) {
			$document['valid_marker'] = $nested_document['valid'];
		}
		if ( array_key_exists( 'valid', $input ) && is_bool( $input['valid'] ) ) {
			$document['valid_marker'] = false === $document['valid_marker'] ? false : $input['valid'];
		}

		return $document;
	}

	/**
	 * Sanitize one condition without dropping its position.
	 *
	 * @param mixed $condition Raw condition.
	 * @return array<string,mixed>
	 */
	private function sanitize_condition( $condition ) {
		if ( ! is_array( $condition ) ) {
			return array(
				'field'    => '',
				'type'     => '',
				'operator' => '',
				'value'    => null,
			);
		}

		$type       = $this->normalize_type( $this->first_value( $condition, array( 'type', 'field_type' ), '' ) );
		$operator   = $this->normalize_operator( $this->first_value( $condition, array( 'operator', 'comparison' ), '' ) );
		$value       = array_key_exists( 'value', $condition ) ? $condition['value'] : $this->first_value( $condition, array( 'expected', 'values' ), null );
		$value_valid = true;
		$value       = $this->normalize_expected( $value, $type, $operator, $value_valid );

		return array(
			'field'    => $this->condition_field( $condition ),
			'type'     => $type,
			'operator' => $operator,
			'value'    => $value_valid ? $value : null,
		);
	}

	/**
	 * Evaluate one already-sanitized condition.
	 *
	 * @param array<string,mixed> $condition Condition.
	 * @param mixed               $actual Raw actual value.
	 * @return bool
	 */
	private function evaluate_condition( array $condition, $actual ) {
		$type     = $condition['type'];
		$operator = $condition['operator'];

		if ( 'is_empty' === $operator ) {
			return $this->is_empty_value( $actual, $type );
		}
		if ( 'is_not_empty' === $operator ) {
			return ! $this->is_empty_value( $actual, $type );
		}

		if ( $this->is_empty_value( $actual, $type ) ) {
			return false;
		}

		if ( in_array( $type, array( 'text', 'email', 'url', 'tel', 'textarea' ), true ) ) {
			$actual_valid = true;
			$normalized   = $this->normalize_textual_value( $actual, $type, $actual_valid );
			return $actual_valid && $this->compare_text( $normalized, $condition['value'], $operator, $type );
		}

		if ( in_array( $type, array( 'choice', 'select', 'radio', 'checkbox' ), true ) ) {
			$actual_valid = true;
			$normalized   = $this->normalize_choice_values( $actual, $actual_valid );
			return $actual_valid && $this->compare_choices( $normalized, $condition['value'], $operator );
		}

		if ( 'consent' === $type ) {
			$actual_valid = true;
			$normalized   = $this->normalize_consent( $actual, $actual_valid );
			if ( ! $actual_valid ) {
				return false;
			}
			return 'equals' === $operator ? $normalized === $condition['value'] : $normalized !== $condition['value'];
		}

		if ( 'number' === $type ) {
			$actual_valid = true;
			$normalized   = $this->normalize_number( $actual, $actual_valid );
			return $actual_valid && $this->compare_ordered( $normalized, $condition['value'], $operator, 'number' );
		}

		if ( in_array( $type, array( 'date', 'datetime', 'time' ), true ) ) {
			$actual_valid = true;
			$normalized   = $this->normalize_temporal( $actual, $type, $actual_valid );
			return $actual_valid && $this->compare_ordered( $normalized, $condition['value'], $operator, 'temporal' );
		}

		return false;
	}

	/**
	 * Compare textual values.
	 *
	 * @param string $actual Actual value.
	 * @param string $expected Expected value.
	 * @param string $operator Operator.
	 * @param string $type Field type.
	 * @return bool
	 */
	private function compare_text( $actual, $expected, $operator, $type ) {
		if ( in_array( $type, array( 'text', 'textarea', 'email' ), true ) ) {
			$actual   = strtolower( $actual );
			$expected = strtolower( $expected );
		}

		switch ( $operator ) {
			case 'equals':
				return $actual === $expected;
			case 'not_equals':
				return $actual !== $expected;
			case 'contains':
				return false !== strpos( $actual, $expected );
			case 'not_contains':
				return false === strpos( $actual, $expected );
			case 'starts_with':
				return 0 === strpos( $actual, $expected );
			case 'ends_with':
				return '' === $expected || substr( $actual, -strlen( $expected ) ) === $expected;
		}

		return false;
	}

	/**
	 * Compare choice value sets.
	 *
	 * @param array<int,string> $actual Actual values.
	 * @param array<int,string> $expected Expected values.
	 * @param string            $operator Operator.
	 * @return bool
	 */
	private function compare_choices( array $actual, array $expected, $operator ) {
		$actual_set   = $this->sorted_unique( $actual );
		$expected_set = $this->sorted_unique( $expected );
		$intersection = array_intersect( $actual_set, $expected_set );

		switch ( $operator ) {
			case 'equals':
				return $actual_set === $expected_set;
			case 'not_equals':
				return $actual_set !== $expected_set;
			case 'contains':
				return 1 === count( $expected_set ) && in_array( reset( $expected_set ), $actual_set, true );
			case 'not_contains':
				return 1 === count( $expected_set ) && ! in_array( reset( $expected_set ), $actual_set, true );
			case 'in':
				return ! empty( $intersection );
			case 'not_in':
				return empty( $intersection );
		}

		return false;
	}

	/**
	 * Compare numeric or canonical temporal values.
	 *
	 * @param float|string              $actual Actual value.
	 * @param float|string|array<mixed> $expected Expected value/range.
	 * @param string                    $operator Operator.
	 * @param string                    $family Value family.
	 * @return bool
	 */
	private function compare_ordered( $actual, $expected, $operator, $family ) {
		$comparison = function ( $left, $right ) use ( $family ) {
			if ( 'number' === $family ) {
				return $left <=> $right;
			}
			return strcmp( $left, $right );
		};

		if ( in_array( $operator, array( 'between', 'not_between' ), true ) ) {
			if ( ! is_array( $expected ) || 2 !== count( $expected ) ) {
				return false;
			}
			$inside = $comparison( $actual, $expected[0] ) >= 0 && $comparison( $actual, $expected[1] ) <= 0;
			return 'between' === $operator ? $inside : ! $inside;
		}

		$ordered = $comparison( $actual, $expected );
		switch ( $operator ) {
			case 'equals':
				return 0 === $ordered;
			case 'not_equals':
				return 0 !== $ordered;
			case 'greater_than':
			case 'after':
				return $ordered > 0;
			case 'greater_than_or_equal':
			case 'on_or_after':
				return $ordered >= 0;
			case 'less_than':
			case 'before':
				return $ordered < 0;
			case 'less_than_or_equal':
			case 'on_or_before':
				return $ordered <= 0;
		}

		return false;
	}

	/**
	 * Normalize expected condition values by field type and operator.
	 *
	 * @param mixed  $value Value.
	 * @param string $type Field type.
	 * @param string $operator Operator.
	 * @param bool   $valid Validity reference.
	 * @return mixed
	 */
	private function normalize_expected( $value, $type, $operator, &$valid ) {
		$valid = true;
		if ( in_array( $operator, array( 'is_empty', 'is_not_empty' ), true ) ) {
			return null;
		}

		if ( '' === $type || '' === $operator ) {
			$valid = false;
			return null;
		}

		if ( in_array( $type, array( 'text', 'email', 'url', 'tel', 'textarea' ), true ) ) {
			return $this->normalize_textual_expected( $value, $type, $operator, $valid );
		}

		if ( in_array( $type, array( 'choice', 'select', 'radio', 'checkbox' ), true ) ) {
			$values = $this->normalize_choice_values( $value, $valid );
			if ( ! $valid || empty( $values ) || ( in_array( $operator, array( 'contains', 'not_contains' ), true ) && 1 !== count( $values ) ) ) {
				$valid = false;
				return array();
			}
			return $values;
		}

		if ( 'consent' === $type ) {
			return $this->normalize_consent( $value, $valid );
		}

		if ( 'number' === $type ) {
			if ( in_array( $operator, array( 'between', 'not_between' ), true ) ) {
				return $this->normalize_range( $value, 'number', $valid );
			}
			return $this->normalize_number( $value, $valid );
		}

		if ( in_array( $type, array( 'date', 'datetime', 'time' ), true ) ) {
			if ( in_array( $operator, array( 'between', 'not_between' ), true ) ) {
				return $this->normalize_range( $value, $type, $valid );
			}
			return $this->normalize_temporal( $value, $type, $valid );
		}

		$valid = false;
		return null;
	}

	/**
	 * Normalize a two-value ordered range.
	 *
	 * @param mixed  $value Value.
	 * @param string $type Type/family.
	 * @param bool   $valid Validity reference.
	 * @return array<int,float|string>
	 */
	private function normalize_range( $value, $type, &$valid ) {
		if ( ! is_array( $value ) || 2 !== count( $value ) ) {
			$valid = false;
			return array();
		}

		$items = array_values( $value );
		if ( 'number' === $type ) {
			$first  = $this->normalize_number( $items[0], $first_valid );
			$second = $this->normalize_number( $items[1], $second_valid );
		} else {
			$first  = $this->normalize_temporal( $items[0], $type, $first_valid );
			$second = $this->normalize_temporal( $items[1], $type, $second_valid );
		}

		$valid = $first_valid && $second_valid && $first <= $second;
		return $valid ? array( $first, $second ) : array();
	}

	/**
	 * Normalize one textual value.
	 *
	 * @param mixed  $value Value.
	 * @param string $type Type.
	 * @param bool   $valid Validity reference.
	 * @return string
	 */
	private function normalize_textual_value( $value, $type, &$valid ) {
		if ( ! is_string( $value ) && ! is_int( $value ) && ! is_float( $value ) ) {
			$valid = false;
			return '';
		}

		$value = str_replace( array( "\r\n", "\r" ), "\n", (string) $value );
		$value = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value );
		$value = trim( null === $value ? '' : $value );
		if ( strlen( $value ) > self::MAX_VALUE_LENGTH ) {
			$valid = false;
			return '';
		}

		if ( 'email' === $type ) {
			$value = strtolower( $value );
			if ( false === filter_var( $value, FILTER_VALIDATE_EMAIL ) ) {
				$valid = false;
				return '';
			}
		} elseif ( 'url' === $type ) {
			if ( false === filter_var( $value, FILTER_VALIDATE_URL ) ) {
				$valid = false;
				return '';
			}
		} elseif ( 'tel' === $type ) {
			if ( ! preg_match( '/^\+?[0-9\s().-]{3,40}$/', $value ) ) {
				$valid = false;
				return '';
			}
			$prefix = 0 === strpos( $value, '+' ) ? '+' : '';
			$value  = $prefix . preg_replace( '/\D+/', '', $value );
			if ( strlen( ltrim( $value, '+' ) ) < 3 || strlen( ltrim( $value, '+' ) ) > 32 ) {
				$valid = false;
				return '';
			}
		}

		$valid = true;
		return $value;
	}

	/**
	 * Normalize a textual expected value. Partial-match operators intentionally
	 * accept fragments, while equality still validates the complete typed value.
	 *
	 * @param mixed  $value Value.
	 * @param string $type Type.
	 * @param string $operator Operator.
	 * @param bool   $valid Validity reference.
	 * @return string
	 */
	private function normalize_textual_expected( $value, $type, $operator, &$valid ) {
		if ( in_array( $operator, array( 'equals', 'not_equals' ), true ) ) {
			$normalized = $this->normalize_textual_value( $value, $type, $valid );
		} else {
			$normalized = $this->normalize_textual_value( $value, in_array( $type, array( 'text', 'textarea' ), true ) ? $type : 'text', $valid );
			if ( $valid && 'email' === $type ) {
				$normalized = strtolower( $normalized );
			} elseif ( $valid && 'tel' === $type ) {
				$prefix     = 0 === strpos( $normalized, '+' ) ? '+' : '';
				$normalized = $prefix . preg_replace( '/\D+/', '', $normalized );
				if ( '' === ltrim( $normalized, '+' ) ) {
					$valid = false;
				}
			}
		}

		if ( $valid && '' === $normalized ) {
			$valid = false;
		}

		return $valid ? $normalized : '';
	}

	/**
	 * Normalize scalar or array choice values.
	 *
	 * @param mixed $value Value.
	 * @param bool  $valid Validity reference.
	 * @return array<int,string>
	 */
	private function normalize_choice_values( $value, &$valid ) {
		$items = is_array( $value ) ? array_values( $value ) : array( $value );
		if ( count( $items ) > self::MAX_LIST_VALUES ) {
			$valid = false;
			return array();
		}

		$clean = array();
		foreach ( $items as $item ) {
			if ( ! is_string( $item ) && ! is_int( $item ) && ! is_float( $item ) ) {
				$valid = false;
				return array();
			}
			$item = trim( (string) $item );
			if ( '' === $item || strlen( $item ) > self::MAX_VALUE_LENGTH || preg_match( '/[\x00-\x1F\x7F]/', $item ) ) {
				$valid = false;
				return array();
			}
			$clean[] = $item;
		}

		$valid = true;
		return $this->sorted_unique( $clean );
	}

	/**
	 * Normalize consent to a strict boolean.
	 *
	 * @param mixed $value Value.
	 * @param bool  $valid Validity reference.
	 * @return bool
	 */
	private function normalize_consent( $value, &$valid ) {
		if ( is_bool( $value ) ) {
			$valid = true;
			return $value;
		}
		if ( is_int( $value ) && in_array( $value, array( 0, 1 ), true ) ) {
			$valid = true;
			return 1 === $value;
		}
		if ( ! is_string( $value ) ) {
			$valid = false;
			return false;
		}

		$value = strtolower( trim( $value ) );
		if ( in_array( $value, array( '1', 'true', 'yes', 'on', 'checked' ), true ) ) {
			$valid = true;
			return true;
		}
		if ( in_array( $value, array( '0', 'false', 'no', 'off', 'unchecked', '' ), true ) ) {
			$valid = true;
			return false;
		}

		$valid = false;
		return false;
	}

	/**
	 * Normalize a strict, finite decimal number.
	 *
	 * @param mixed $value Value.
	 * @param bool  $valid Validity reference.
	 * @return float
	 */
	private function normalize_number( $value, &$valid ) {
		if ( is_int( $value ) || is_float( $value ) ) {
			$number = (float) $value;
		} elseif ( is_string( $value ) && preg_match( '/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/', trim( $value ) ) ) {
			$number = (float) trim( $value );
		} else {
			$valid = false;
			return 0.0;
		}

		if ( is_nan( $number ) || is_infinite( $number ) ) {
			$valid = false;
			return 0.0;
		}

		$valid = true;
		return $number;
	}

	/**
	 * Normalize HTML-compatible local date/time values.
	 *
	 * @param mixed  $value Value.
	 * @param string $type Type.
	 * @param bool   $valid Validity reference.
	 * @return string
	 */
	private function normalize_temporal( $value, $type, &$valid ) {
		if ( ! is_string( $value ) ) {
			$valid = false;
			return '';
		}

		$value = trim( $value );
		if ( 'date' === $type && preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $parts ) ) {
			$valid = checkdate( (int) $parts[2], (int) $parts[3], (int) $parts[1] );
			return $valid ? $value : '';
		}

		if ( 'time' === $type && preg_match( '/^([01]\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?$/', $value, $parts ) ) {
			$valid = true;
			return $parts[1] . ':' . $parts[2] . ':' . ( isset( $parts[3] ) && '' !== $parts[3] ? $parts[3] : '00' );
		}

		if ( 'datetime' === $type && preg_match( '/^(\d{4})-(\d{2})-(\d{2})[T ]([01]\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?$/', $value, $parts ) ) {
			$valid = checkdate( (int) $parts[2], (int) $parts[3], (int) $parts[1] );
			return $valid ? sprintf( '%04d-%02d-%02d %s:%s:%s', $parts[1], $parts[2], $parts[3], $parts[4], $parts[5], isset( $parts[6] ) && '' !== $parts[6] ? $parts[6] : '00' ) : '';
		}

		$valid = false;
		return '';
	}

	/**
	 * Determine whether an explicitly submitted value is empty.
	 *
	 * @param mixed  $value Value.
	 * @param string $type Type.
	 * @return bool
	 */
	private function is_empty_value( $value, $type ) {
		if ( null === $value ) {
			return true;
		}
		if ( is_array( $value ) ) {
			if ( empty( $value ) ) {
				return true;
			}
			foreach ( $value as $item ) {
				if ( ! $this->is_empty_value( $item, $type ) ) {
					return false;
				}
			}
			return true;
		}
		if ( is_string( $value ) ) {
			if ( '' === trim( $value ) ) {
				return true;
			}
			if ( 'consent' === $type ) {
				$valid = true;
				return ! $this->normalize_consent( $value, $valid ) && $valid;
			}
		}
		if ( 'consent' === $type && ( false === $value || 0 === $value ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Normalize supported submitted-field shapes.
	 *
	 * @param mixed $submitted Submitted fields.
	 * @return array{valid:bool,fields:array<string,array{value:mixed,type:string}>}
	 */
	private function normalize_submitted_fields( $submitted ) {
		$result = array(
			'valid'  => is_array( $submitted ),
			'fields' => array(),
		);
		if ( ! is_array( $submitted ) ) {
			return $result;
		}

		if ( $this->is_list( $submitted ) ) {
			foreach ( $submitted as $record ) {
				if ( ! is_array( $record ) || ! $this->looks_like_submitted_record( $record ) ) {
					$result['valid'] = false;
					continue;
				}
				$this->add_submitted_record( $result, $record );
			}
			return $result;
		}

		foreach ( $submitted as $field => $value ) {
			$clean_field = $this->sanitize_field( $field );
			if ( '' === $clean_field || $clean_field !== trim( (string) $field ) ) {
				$result['valid'] = false;
				continue;
			}

			$type = '';
			if ( is_array( $value ) && array_key_exists( 'value', $value ) && ( array_key_exists( 'type', $value ) || array_key_exists( 'field_type', $value ) ) ) {
				$type  = $this->normalize_type( $this->first_value( $value, array( 'type', 'field_type' ), '' ) );
				$value = $value['value'];
				if ( '' === $type ) {
					$result['valid'] = false;
				}
			}

			$this->add_submitted_value( $result['fields'], $clean_field, $value, $type );
		}

		return $result;
	}

	/**
	 * Add one record-shaped submitted field.
	 *
	 * @param array<string,mixed> $result Result by reference.
	 * @param array<string,mixed> $record Record.
	 * @return void
	 */
	private function add_submitted_record( array &$result, array $record ) {
		$field = $this->sanitize_field( $this->first_value( $record, array( 'field', 'field_key', 'key', 'name', 'id' ), '' ) );
		if ( '' === $field ) {
			$result['valid'] = false;
			return;
		}

		$value_found = false;
		$value       = null;
		foreach ( array( 'value', 'field_value', 'values', 'raw_value' ) as $value_key ) {
			if ( array_key_exists( $value_key, $record ) ) {
				$value       = $record[ $value_key ];
				$value_found = true;
				break;
			}
		}
		if ( ! $value_found ) {
			$result['valid'] = false;
			return;
		}

		$raw_type      = $this->first_value( $record, array( 'type', 'field_type' ), '' );
		$has_raw_type  = is_scalar( $raw_type ) && '' !== trim( (string) $raw_type );
		$type          = $has_raw_type ? $this->normalize_type( $raw_type ) : '';
		if ( ( ! is_scalar( $raw_type ) && null !== $raw_type ) || ( $has_raw_type && '' === $type ) ) {
			$result['valid'] = false;
		}

		$this->add_submitted_value( $result['fields'], $field, $value, $type );
	}

	/**
	 * Add or merge a normalized submitted value.
	 *
	 * @param array<string,array{value:mixed,type:string}> $fields Field map.
	 * @param string                                       $field Field key.
	 * @param mixed                                        $value Value.
	 * @param string                                       $type Type.
	 * @return void
	 */
	private function add_submitted_value( array &$fields, $field, $value, $type ) {
		if ( ! isset( $fields[ $field ] ) ) {
			$fields[ $field ] = array(
				'value' => $value,
				'type'  => $type,
			);
			return;
		}

		$current = is_array( $fields[ $field ]['value'] ) ? $fields[ $field ]['value'] : array( $fields[ $field ]['value'] );
		$next    = is_array( $value ) ? $value : array( $value );
		$fields[ $field ]['value'] = array_merge( $current, $next );
		if ( '' === $fields[ $field ]['type'] ) {
			$fields[ $field ]['type'] = $type;
		} elseif ( '' !== $type && $fields[ $field ]['type'] !== $type ) {
			$fields[ $field ]['type'] = '__mismatch__';
		}
	}

	/**
	 * Build one successful or runtime-failed detail row.
	 *
	 * @param int                      $index Index.
	 * @param array<string,mixed>      $condition Condition.
	 * @param array<string,mixed>|null $submitted Submitted value wrapper.
	 * @param bool                     $matched Match state.
	 * @param string                   $reason Reason code.
	 * @return array<string,mixed>
	 */
	private function condition_detail( $index, array $condition, $submitted, $matched, $reason ) {
		return array(
			'index'       => $index,
			'field'       => $condition['field'],
			'type'        => $condition['type'],
			'operator'    => $condition['operator'],
			'expected'    => $condition['value'],
			'actual'      => is_array( $submitted ) ? $submitted['value'] : null,
			'field_found' => is_array( $submitted ),
			'matched'     => (bool) $matched,
			'valid'       => ! in_array( $reason, array( 'invalid_submitted_fields', 'field_type_mismatch' ), true ),
			'reason'      => $reason,
		);
	}

	/**
	 * Build an invalid-config detail row.
	 *
	 * @param int                         $index Index.
	 * @param array<string,mixed>         $condition Condition.
	 * @param array<int,array<string,mixed>> $errors Errors.
	 * @return array<string,mixed>
	 */
	private function invalid_condition_detail( $index, array $condition, array $errors ) {
		$prefix = 'conditions.' . $index;
		$reason = 'invalid_configuration';
		foreach ( $errors as $error ) {
			if ( 0 === strpos( $error['path'], $prefix ) ) {
				$reason = $error['code'];
				break;
			}
		}

		$detail          = $this->condition_detail( $index, $condition, null, false, $reason );
		$detail['valid'] = false;
		return $detail;
	}

	/**
	 * Normalize field type.
	 *
	 * @param mixed $type Type.
	 * @return string
	 */
	private function normalize_type( $type ) {
		if ( ! is_scalar( $type ) ) {
			return '';
		}
		$type = strtolower( trim( (string) $type ) );
		$aliases = array(
			'phone'          => 'tel',
			'phone_number'   => 'tel',
			'name'           => 'text',
			'address'        => 'text',
			'textbox'        => 'text',
			'input_text'     => 'text',
			'input_email'    => 'email',
			'input_number'   => 'number',
			'numbers'        => 'number',
			'numeric'        => 'number',
			'dropdown'       => 'select',
			'listselect'     => 'select',
			'input_radio'    => 'radio',
			'listradio'      => 'radio',
			'input_checkbox' => 'checkbox',
			'listcheckbox'   => 'checkbox',
			'acceptance'     => 'consent',
			'gdpr-checkbox'  => 'consent',
			'date-time'      => 'datetime',
			'date_time'      => 'datetime',
		);
		$type = $aliases[ $type ] ?? $type;
		return in_array( $type, $this->field_types, true ) ? $type : '';
	}

	/**
	 * Normalize operator alias.
	 *
	 * @param mixed $operator Operator.
	 * @return string
	 */
	private function normalize_operator( $operator ) {
		if ( ! is_scalar( $operator ) ) {
			return '';
		}
		$operator = strtolower( trim( (string) $operator ) );
		return $this->operator_aliases[ $operator ] ?? '';
	}

	/**
	 * Normalize all/any mode.
	 *
	 * @param mixed $mode Mode.
	 * @return string
	 */
	private function normalize_mode( $mode ) {
		if ( ! is_scalar( $mode ) ) {
			return '';
		}
		$mode = strtolower( trim( (string) $mode ) );
		return in_array( $mode, array( 'all', 'any' ), true ) ? $mode : '';
	}

	/**
	 * Read and strictly sanitize a condition field key.
	 *
	 * @param array<string,mixed> $condition Condition.
	 * @return string
	 */
	private function condition_field( array $condition ) {
		return $this->sanitize_field( $this->first_value( $condition, array( 'field', 'field_key', 'key' ), '' ) );
	}

	/**
	 * Strict field-key sanitizer.
	 *
	 * @param mixed $field Field key.
	 * @return string
	 */
	private function sanitize_field( $field ) {
		if ( ! is_string( $field ) && ! is_int( $field ) ) {
			return '';
		}
		$field = trim( (string) $field );
		if ( '' === $field || strlen( $field ) > self::MAX_FIELD_LENGTH || ! preg_match( '/^[A-Za-z0-9_.:-]+$/', $field ) ) {
			return '';
		}
		return $field;
	}

	/**
	 * Return whether two declared submitted/condition types are compatible.
	 *
	 * @param string $expected Expected type.
	 * @param string $actual Actual type.
	 * @return bool
	 */
	private function types_are_compatible( $expected, $actual ) {
		if ( $expected === $actual ) {
			return true;
		}
		$choice_types = array( 'choice', 'select', 'radio', 'checkbox' );
		return in_array( $expected, $choice_types, true )
			&& in_array( $actual, $choice_types, true )
			&& ( 'choice' === $expected || 'choice' === $actual );
	}

	/**
	 * Return sorted unique strings.
	 *
	 * @param array<int,string> $values Values.
	 * @return array<int,string>
	 */
	private function sorted_unique( array $values ) {
		$values = array_values( array_unique( $values, SORT_STRING ) );
		sort( $values, SORT_STRING );
		return $values;
	}

	/**
	 * First present value from aliases.
	 *
	 * @param array<string,mixed> $source Source.
	 * @param array<int,string>   $keys Keys.
	 * @param mixed               $default Default.
	 * @return mixed
	 */
	private function first_value( array $source, array $keys, $default = null ) {
		foreach ( $keys as $key ) {
			if ( array_key_exists( $key, $source ) ) {
				return $source[ $key ];
			}
		}
		return $default;
	}

	/**
	 * Whether input is a zero-based list (PHP 7.4 compatible).
	 *
	 * @param array<mixed> $value Value.
	 * @return bool
	 */
	private function is_list( array $value ) {
		if ( empty( $value ) ) {
			return true;
		}
		return array_keys( $value ) === range( 0, count( $value ) - 1 );
	}

	/**
	 * Whether an associative array looks like one condition.
	 *
	 * @param array<string,mixed> $value Value.
	 * @return bool
	 */
	private function looks_like_condition( array $value ) {
		return array_key_exists( 'field', $value )
			|| array_key_exists( 'field_key', $value )
			|| array_key_exists( 'operator', $value )
			|| array_key_exists( 'comparison', $value );
	}

	/**
	 * Whether an associative array looks like a rule with no conditions.
	 *
	 * @param array<string,mixed> $value Value.
	 * @return bool
	 */
	private function looks_like_rule( array $value ) {
		foreach ( array( 'id', 'name', 'form_id', 'provider', 'enabled', 'template_id', 'priority', 'conditions_mode', 'condition_mode' ) as $key ) {
			if ( array_key_exists( $key, $value ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Whether an array looks like one submitted field row.
	 *
	 * @param array<string,mixed> $value Value.
	 * @return bool
	 */
	private function looks_like_submitted_record( array $value ) {
		$has_field = array_key_exists( 'field', $value )
			|| array_key_exists( 'field_key', $value )
			|| array_key_exists( 'key', $value )
			|| array_key_exists( 'name', $value )
			|| array_key_exists( 'id', $value );
		$has_value = array_key_exists( 'value', $value )
			|| array_key_exists( 'field_value', $value )
			|| array_key_exists( 'values', $value )
			|| array_key_exists( 'raw_value', $value );
		return $has_field && $has_value;
	}

	/**
	 * Validate version without loose numeric coercion.
	 *
	 * @param mixed $version Version.
	 * @return bool
	 */
	private function is_version_one( $version ) {
		return 1 === $version || '1' === $version;
	}

	/**
	 * Error message for an invalid expected value.
	 *
	 * @param string $type Type.
	 * @param string $operator Operator.
	 * @return string
	 */
	private function invalid_value_message( $type, $operator ) {
		if ( in_array( $operator, array( 'between', 'not_between' ), true ) ) {
			return 'Between operators require an ordered pair of valid values.';
		}
		if ( 'number' === $type ) {
			return 'Number conditions require a finite decimal value.';
		}
		if ( in_array( $type, array( 'date', 'datetime', 'time' ), true ) ) {
			return 'Date and time conditions require a valid local HTML date/time value.';
		}
		if ( 'email' === $type ) {
			return 'Email conditions require a valid email address.';
		}
		if ( 'url' === $type ) {
			return 'URL conditions require an absolute valid URL.';
		}
		return 'Condition value is missing or invalid for this field type.';
	}

	/**
	 * Append a structured validation error.
	 *
	 * @param array<int,array<string,string>> $errors Errors.
	 * @param string                          $path Path.
	 * @param string                          $code Code.
	 * @param string                          $message Message.
	 * @return void
	 */
	private function add_error( array &$errors, $path, $code, $message ) {
		$errors[] = array(
			'path'    => $path,
			'code'    => $code,
			'message' => $message,
		);
	}
}
