<?php
/**
 * Deterministic Conditional Logic evaluator shared by form entry points.
 *
 * @package SubmissionInbox
 */

namespace SUBIN\Forms;

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

/**
 * Sanitizes, validates, and evaluates Design Studio conditional-logic rules.
 *
 * Rules use OR between groups. A group uses its own `all` (AND) or `any` (OR)
 * matcher. When several rules target the same element, a matching hide rule
 * wins; otherwise, at least one enabled show rule must match when show rules
 * exist for that target.
 */
class ConditionalLogicEvaluator {
	const VERSION = 1;
	const MAX_RULES = 200;
	const MAX_GROUPS_PER_RULE = 20;
	const MAX_CONDITIONS_PER_GROUP = 50;
	const MAX_TOTAL_CONDITIONS = 1000;
	const MAX_VALUES = 100;
	const MAX_IDENTIFIER_LENGTH = 128;
	const MAX_VALUE_LENGTH = 2048;

	/**
	 * Supported rule actions.
	 *
	 * @var array<int,string>
	 */
	private $actions = array( 'show', 'hide' );

	/**
	 * Supported target types.
	 *
	 * @var array<int,string>
	 */
	private $target_types = array( 'field', 'row', 'block' );

	/**
	 * Supported comparison operators.
	 *
	 * @var array<int,string>
	 */
	private $operators = array(
		'is',
		'is_not',
		'contains',
		'not_contains',
		'empty',
		'not_empty',
		'before',
		'after',
	);

	/**
	 * Sanitize a complete logic document.
	 *
	 * @param array<string,mixed> $logic Raw logic document.
	 * @return array{version:int,rules:array<int,array<string,mixed>>}
	 */
	public function sanitize( array $logic ) {
		return $this->sanitize_logic( $logic );
	}

	/**
	 * Sanitize a complete logic document.
	 *
	 * @param array<string,mixed> $logic Raw logic document.
	 * @return array{version:int,rules:array<int,array<string,mixed>>}
	 */
	public function sanitize_logic( array $logic ) {
		$clean = array(
			'version' => self::VERSION,
			'rules'   => array(),
		);

		if ( ! isset( $logic['rules'] ) || ! is_array( $logic['rules'] ) ) {
			return $clean;
		}
		if ( $this->exceeds_total_condition_budget( $logic['rules'] ) ) {
			return $clean;
		}

		foreach ( array_slice( $logic['rules'], 0, self::MAX_RULES ) as $rule ) {
			if ( ! is_array( $rule ) ) {
				continue;
			}

			$sanitized = $this->sanitize_rule( $rule );
			if ( null !== $sanitized ) {
				$clean['rules'][] = $sanitized;
			}
		}

		return $clean;
	}

	/**
	 * Sanitize one rule. Invalid rule identity, action, or target returns null.
	 * Invalid groups and conditions are omitted, making the rule non-matching.
	 *
	 * @param array<string,mixed> $rule Raw rule.
	 * @return array<string,mixed>|null
	 */
	public function sanitize_rule( array $rule ) {
		if ( $this->exceeds_total_condition_budget( array( $rule ) ) ) {
			return null;
		}

		$id     = $this->sanitize_identifier( $rule['id'] ?? '' );
		$action = strtolower( trim( (string) ( $rule['action'] ?? '' ) ) );
		$target = is_array( $rule['target'] ?? null ) ? $rule['target'] : array();
		$type   = strtolower( trim( (string) ( $target['type'] ?? '' ) ) );
		$target_id = $this->sanitize_identifier( $target['id'] ?? '' );

		if (
			'' === $id ||
			! in_array( $action, $this->actions, true ) ||
			! in_array( $type, $this->target_types, true ) ||
			'' === $target_id
		) {
			return null;
		}

		$clean = array(
			'id'      => $id,
			'enabled' => $this->normalize_boolean( $rule['enabled'] ?? true ),
			'action'  => $action,
			'target'  => array(
				'type' => $type,
				'id'   => $target_id,
			),
			'groups'  => array(),
		);

		$groups = is_array( $rule['groups'] ?? null ) ? $rule['groups'] : array();
		foreach ( array_slice( $groups, 0, self::MAX_GROUPS_PER_RULE ) as $group ) {
			$sanitized = $this->sanitize_group( $group );
			if ( null !== $sanitized ) {
				$clean['groups'][] = $sanitized;
			}
		}

		return $clean;
	}

	/**
	 * Validate a complete logic document without mutating it.
	 *
	 * @param array<string,mixed>                  $logic Raw logic document.
	 * @param array<int,string|int>                $known_fields Optional field identifiers.
	 * @param array<string,array<int,string|int>>  $known_targets Optional target identifiers by type.
	 * @return array<int,array{path:string,code:string,message:string}>
	 */
	public function validate( array $logic, array $known_fields = array(), array $known_targets = array() ) {
		$errors = array();

		if ( self::VERSION !== (int) ( $logic['version'] ?? 0 ) ) {
			$this->add_error( $errors, 'version', 'unsupported_version', 'Conditional logic version must be 1.' );
		}

		if ( ! isset( $logic['rules'] ) || ! is_array( $logic['rules'] ) ) {
			$this->add_error( $errors, 'rules', 'invalid_rules', 'Rules must be an array.' );
			return $errors;
		}

		if ( count( $logic['rules'] ) > self::MAX_RULES ) {
			$this->add_error( $errors, 'rules', 'too_many_rules', 'Conditional logic contains too many rules.' );
		}
		if ( $this->exceeds_total_condition_budget( $logic['rules'] ) ) {
			$this->add_error( $errors, 'rules', 'too_many_total_conditions', 'Conditional logic contains too many conditions in total.' );
			return $errors;
		}

		$seen = array();
		foreach ( $logic['rules'] as $index => $rule ) {
			$path = 'rules.' . $index;
			if ( ! is_array( $rule ) ) {
				$this->add_error( $errors, $path, 'invalid_rule', 'Each rule must be an object.' );
				continue;
			}

			$errors = array_merge( $errors, $this->validate_rule( $rule, $known_fields, $known_targets, $path ) );
			$id = $this->sanitize_identifier( $rule['id'] ?? '' );
			if ( '' !== $id ) {
				if ( isset( $seen[ $id ] ) ) {
					$this->add_error( $errors, $path . '.id', 'duplicate_rule_id', 'Rule identifiers must be unique.' );
				}
				$seen[ $id ] = true;
			}
		}

		return $errors;
	}

	/**
	 * Validate one rule.
	 *
	 * @param array<string,mixed>                 $rule Raw rule.
	 * @param array<int,string|int>               $known_fields Optional field identifiers.
	 * @param array<string,array<int,string|int>> $known_targets Optional target identifiers by type.
	 * @param string                              $path Error path prefix.
	 * @return array<int,array{path:string,code:string,message:string}>
	 */
	public function validate_rule( array $rule, array $known_fields = array(), array $known_targets = array(), $path = 'rule' ) {
		$errors = array();
		$id     = $this->sanitize_identifier( $rule['id'] ?? '' );
		if ( '' === $id || $id !== trim( (string) ( $rule['id'] ?? '' ) ) ) {
			$this->add_error( $errors, $path . '.id', 'invalid_rule_id', 'Rule identifier is missing or invalid.' );
		}

		$action = strtolower( trim( (string) ( $rule['action'] ?? '' ) ) );
		if ( ! in_array( $action, $this->actions, true ) ) {
			$this->add_error( $errors, $path . '.action', 'invalid_action', 'Rule action must be show or hide.' );
		}

		$target = is_array( $rule['target'] ?? null ) ? $rule['target'] : array();
		$type   = strtolower( trim( (string) ( $target['type'] ?? '' ) ) );
		$target_id = $this->sanitize_identifier( $target['id'] ?? '' );
		if ( ! in_array( $type, $this->target_types, true ) ) {
			$this->add_error( $errors, $path . '.target.type', 'invalid_target_type', 'Target type must be field, row, or block.' );
		}
		if ( '' === $target_id || $target_id !== trim( (string) ( $target['id'] ?? '' ) ) ) {
			$this->add_error( $errors, $path . '.target.id', 'invalid_target_id', 'Target identifier is missing or invalid.' );
		}

		$known_target_set = $this->known_target_set( $known_targets, $type );
		if ( null !== $known_target_set && '' !== $target_id && ! isset( $known_target_set[ $target_id ] ) ) {
			$this->add_error( $errors, $path . '.target.id', 'unknown_target', 'Rule target does not exist.' );
		}

		if ( ! isset( $rule['groups'] ) || ! is_array( $rule['groups'] ) || empty( $rule['groups'] ) ) {
			$this->add_error( $errors, $path . '.groups', 'missing_groups', 'A rule must contain at least one condition group.' );
			return $errors;
		}
		if ( count( $rule['groups'] ) > self::MAX_GROUPS_PER_RULE ) {
			$this->add_error( $errors, $path . '.groups', 'too_many_groups', 'Rule contains too many groups.' );
		}

		$known_field_set = $this->known_identifier_set( $known_fields );
		foreach ( $rule['groups'] as $group_index => $group ) {
			$group_path = $path . '.groups.' . $group_index;
			if ( ! is_array( $group ) ) {
				$this->add_error( $errors, $group_path, 'invalid_group', 'Each condition group must be an object.' );
				continue;
			}

			$match = strtolower( trim( (string) ( $group['match'] ?? '' ) ) );
			if ( ! in_array( $match, array( 'all', 'any' ), true ) ) {
				$this->add_error( $errors, $group_path . '.match', 'invalid_group_match', 'Group match must be all or any.' );
			}

			if ( ! isset( $group['conditions'] ) || ! is_array( $group['conditions'] ) || empty( $group['conditions'] ) ) {
				$this->add_error( $errors, $group_path . '.conditions', 'missing_conditions', 'A group must contain at least one condition.' );
				continue;
			}
			if ( count( $group['conditions'] ) > self::MAX_CONDITIONS_PER_GROUP ) {
				$this->add_error( $errors, $group_path . '.conditions', 'too_many_conditions', 'Group contains too many conditions.' );
			}

			foreach ( $group['conditions'] as $condition_index => $condition ) {
				$condition_path = $group_path . '.conditions.' . $condition_index;
				if ( ! is_array( $condition ) ) {
					$this->add_error( $errors, $condition_path, 'invalid_condition', 'Each condition must be an object.' );
					continue;
				}

				$field = $this->sanitize_identifier( $condition['field'] ?? '' );
				if ( '' === $field || $field !== trim( (string) ( $condition['field'] ?? '' ) ) ) {
					$this->add_error( $errors, $condition_path . '.field', 'invalid_field', 'Condition field is missing or invalid.' );
				} elseif ( ! empty( $known_fields ) && ! isset( $known_field_set[ $field ] ) ) {
					$this->add_error( $errors, $condition_path . '.field', 'unknown_field', 'Condition field does not exist.' );
				}

				$operator = strtolower( trim( (string) ( $condition['operator'] ?? '' ) ) );
				if ( ! in_array( $operator, $this->operators, true ) ) {
					$this->add_error( $errors, $condition_path . '.operator', 'invalid_operator', 'Condition operator is not supported.' );
					continue;
				}

				if ( ! in_array( $operator, array( 'empty', 'not_empty' ), true ) ) {
					$expected = $this->normalize_value( $condition['value'] ?? null );
					if ( empty( $expected ) ) {
						$this->add_error( $errors, $condition_path . '.value', 'missing_value', 'Condition value is required for this operator.' );
					} elseif ( in_array( $operator, array( 'before', 'after' ), true ) ) {
						foreach ( $expected as $value ) {
							if ( null === $this->normalize_date( $value ) ) {
								$this->add_error( $errors, $condition_path . '.value', 'invalid_date', 'Date comparisons require a valid YYYY-MM-DD value.' );
								break;
							}
						}
					}
				}
			}
		}

		return $errors;
	}

	/**
	 * Evaluate whether one rule's condition groups match.
	 *
	 * @param array<string,mixed> $rule Raw or sanitized rule.
	 * @param array<string,mixed> $values Values keyed by field identifier.
	 * @return bool
	 */
	public function evaluate_rule( array $rule, array $values ) {
		$rule = $this->sanitize_rule( $rule );
		if ( null === $rule || ! $rule['enabled'] || empty( $rule['groups'] ) ) {
			return false;
		}

		foreach ( $rule['groups'] as $group ) {
			if ( $this->evaluate_group( $group, $values ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Calculate visibility for all targets referenced by enabled rules.
	 *
	 * @param array<string,mixed> $logic Logic document.
	 * @param array<string,mixed> $values Values keyed by field identifier.
	 * @return array<string,array<string,bool>> Visibility by target type and id.
	 */
	public function evaluate( array $logic, array $values ) {
		$logic  = $this->sanitize_logic( $logic );
		$states = array();

		foreach ( $logic['rules'] as $rule ) {
			if ( ! $rule['enabled'] ) {
				continue;
			}

			$type = $rule['target']['type'];
			$id   = $rule['target']['id'];
			if ( ! isset( $states[ $type ][ $id ] ) ) {
				$states[ $type ][ $id ] = array(
					'has_show'   => false,
					'show_match' => false,
					'hide_match' => false,
				);
			}

			$matched = $this->evaluate_rule( $rule, $values );
			if ( 'show' === $rule['action'] ) {
				$states[ $type ][ $id ]['has_show'] = true;
				if ( $matched ) {
					$states[ $type ][ $id ]['show_match'] = true;
				}
			} elseif ( $matched ) {
				$states[ $type ][ $id ]['hide_match'] = true;
			}
		}

		$visibility = array();
		foreach ( $states as $type => $targets ) {
			foreach ( $targets as $id => $state ) {
				$visibility[ $type ][ $id ] =
					! $state['hide_match'] && ( ! $state['has_show'] || $state['show_match'] );
			}
		}

		return $visibility;
	}

	/**
	 * Return whether a target is visible. Targets without rules are visible.
	 *
	 * @param string              $target_type Target type.
	 * @param string|int          $target_id Target identifier.
	 * @param array<string,mixed> $logic Logic document.
	 * @param array<string,mixed> $values Values keyed by field identifier.
	 * @return bool
	 */
	public function is_target_visible( $target_type, $target_id, array $logic, array $values ) {
		$type = strtolower( trim( (string) $target_type ) );
		$id   = $this->sanitize_identifier( $target_id );
		if ( ! in_array( $type, $this->target_types, true ) || '' === $id ) {
			return true;
		}

		$visibility = $this->evaluate( $logic, $values );
		return $visibility[ $type ][ $id ] ?? true;
	}

	/**
	 * Return whether a field target is visible.
	 *
	 * @param string|int          $field_id Field identifier.
	 * @param array<string,mixed> $logic Logic document.
	 * @param array<string,mixed> $values Values keyed by field identifier.
	 * @return bool
	 */
	public function is_field_visible( $field_id, array $logic, array $values ) {
		return $this->is_target_visible( 'field', $field_id, $logic, $values );
	}

	/**
	 * Filter a list of field identifiers to fields visible for current values.
	 * Original identifiers and ordering are preserved.
	 *
	 * @param array<int,string|int> $field_ids Field identifiers.
	 * @param array<string,mixed>   $logic Logic document.
	 * @param array<string,mixed>   $values Values keyed by field identifier.
	 * @return array<int,string|int>
	 */
	public function get_visible_field_ids( array $field_ids, array $logic, array $values ) {
		$visibility = $this->evaluate( $logic, $values );
		$visible    = array();

		foreach ( $field_ids as $field_id ) {
			$id = $this->sanitize_identifier( $field_id );
			if ( '' !== $id && ( $visibility['field'][ $id ] ?? true ) ) {
				$visible[] = $field_id;
			}
		}

		return $visible;
	}

	/**
	 * Compare an actual value using one supported condition operator.
	 *
	 * @param mixed  $actual Actual submitted value.
	 * @param string $operator Operator.
	 * @param mixed  $expected Expected condition value.
	 * @return bool
	 */
	public function compare_condition( $actual, $operator, $expected = '' ) {
		$operator = strtolower( trim( (string) $operator ) );
		$actual_values = $this->normalize_value( $actual );

		if ( 'empty' === $operator ) {
			return empty( $actual_values );
		}
		if ( 'not_empty' === $operator ) {
			return ! empty( $actual_values );
		}
		if ( ! in_array( $operator, $this->operators, true ) ) {
			return false;
		}

		$expected_values = $this->normalize_value( $expected );
		if ( 'is' === $operator || 'is_not' === $operator ) {
			$matched = $this->any_equal( $actual_values, $expected_values );
			return 'is' === $operator ? $matched : ! $matched;
		}

		if ( 'contains' === $operator || 'not_contains' === $operator ) {
			$matched = $this->any_contains( $actual_values, $expected_values );
			return 'contains' === $operator ? $matched : ! $matched;
		}

		foreach ( $actual_values as $actual_value ) {
			$actual_date = $this->normalize_date( $actual_value );
			if ( null === $actual_date ) {
				continue;
			}
			foreach ( $expected_values as $expected_value ) {
				$expected_date = $this->normalize_date( $expected_value );
				if ( null === $expected_date ) {
					continue;
				}
				if ( 'before' === $operator && $actual_date < $expected_date ) {
					return true;
				}
				if ( 'after' === $operator && $actual_date > $expected_date ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Normalize scalars and checkbox-style arrays into unique non-empty strings.
	 * Booleans follow HTML form semantics: true is `1`, false is empty.
	 *
	 * @param mixed $value Raw value.
	 * @return array<int,string>
	 */
	public function normalize_value( $value ) {
		$values = array();
		$this->append_normalized_values( $value, $values, 0 );

		$unique = array();
		$result = array();
		foreach ( $values as $item ) {
			if ( isset( $unique[ $item ] ) ) {
				continue;
			}
			$unique[ $item ] = true;
			$result[]        = $item;
		}

		return $result;
	}

	/**
	 * Sanitize one condition group.
	 *
	 * @param mixed $group Raw group.
	 * @return array<string,mixed>|null
	 */
	private function sanitize_group( $group ) {
		if ( ! is_array( $group ) ) {
			return null;
		}

		$match = strtolower( trim( (string) ( $group['match'] ?? '' ) ) );
		if ( ! in_array( $match, array( 'all', 'any' ), true ) ) {
			return null;
		}

		$clean = array(
			'match'      => $match,
			'conditions' => array(),
		);
		$conditions = is_array( $group['conditions'] ?? null ) ? $group['conditions'] : array();
		foreach ( array_slice( $conditions, 0, self::MAX_CONDITIONS_PER_GROUP ) as $condition ) {
			$sanitized = $this->sanitize_condition( $condition );
			if ( null !== $sanitized ) {
				$clean['conditions'][] = $sanitized;
			}
		}

		return $clean;
	}

	/**
	 * Sanitize one condition.
	 *
	 * @param mixed $condition Raw condition.
	 * @return array<string,mixed>|null
	 */
	private function sanitize_condition( $condition ) {
		if ( ! is_array( $condition ) ) {
			return null;
		}

		$field    = $this->sanitize_identifier( $condition['field'] ?? '' );
		$operator = strtolower( trim( (string) ( $condition['operator'] ?? '' ) ) );
		if ( '' === $field || ! in_array( $operator, $this->operators, true ) ) {
			return null;
		}

		$value = '';
		if ( ! in_array( $operator, array( 'empty', 'not_empty' ), true ) ) {
			$normalized = $this->normalize_value( $condition['value'] ?? null );
			$value = is_array( $condition['value'] ?? null ) ? $normalized : ( $normalized[0] ?? '' );
		}

		return array(
			'field'    => $field,
			'operator' => $operator,
			'value'    => $value,
		);
	}

	/**
	 * Evaluate one sanitized group.
	 *
	 * @param array<string,mixed> $group Group.
	 * @param array<string,mixed> $values Values.
	 * @return bool
	 */
	private function evaluate_group( array $group, array $values ) {
		if ( empty( $group['conditions'] ) ) {
			return false;
		}

		if ( 'any' === $group['match'] ) {
			foreach ( $group['conditions'] as $condition ) {
				if ( $this->evaluate_condition( $condition, $values ) ) {
					return true;
				}
			}
			return false;
		}

		foreach ( $group['conditions'] as $condition ) {
			if ( ! $this->evaluate_condition( $condition, $values ) ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Evaluate one sanitized condition.
	 *
	 * @param array<string,mixed> $condition Condition.
	 * @param array<string,mixed> $values Values.
	 * @return bool
	 */
	private function evaluate_condition( array $condition, array $values ) {
		$field  = $condition['field'];
		$actual = null;
		if ( array_key_exists( $field, $values ) ) {
			$actual = $values[ $field ];
		} elseif ( ctype_digit( $field ) && array_key_exists( (int) $field, $values ) ) {
			$actual = $values[ (int) $field ];
		}

		return $this->compare_condition( $actual, $condition['operator'], $condition['value'] );
	}

	/**
	 * Determine whether any values compare equally (ASCII case-insensitive).
	 *
	 * @param array<int,string> $actual Actual values.
	 * @param array<int,string> $expected Expected values.
	 * @return bool
	 */
	private function any_equal( array $actual, array $expected ) {
		foreach ( $actual as $actual_value ) {
			$actual_value = $this->ascii_lower( $actual_value );
			foreach ( $expected as $expected_value ) {
				if ( $actual_value === $this->ascii_lower( $expected_value ) ) {
					return true;
				}
			}
		}
		return false;
	}

	/**
	 * Determine whether any actual value contains any non-empty expected value.
	 *
	 * @param array<int,string> $actual Actual values.
	 * @param array<int,string> $expected Expected values.
	 * @return bool
	 */
	private function any_contains( array $actual, array $expected ) {
		foreach ( $actual as $actual_value ) {
			$actual_value = $this->ascii_lower( $actual_value );
			foreach ( $expected as $expected_value ) {
				$expected_value = $this->ascii_lower( $expected_value );
				if ( '' !== $expected_value && false !== strpos( $actual_value, $expected_value ) ) {
					return true;
				}
			}
		}
		return false;
	}

	/**
	 * Append normalized leaves recursively, with hard depth and count limits.
	 *
	 * @param mixed             $value Raw value.
	 * @param array<int,string> $output Output values.
	 * @param int               $depth Current nesting depth.
	 * @return void
	 */
	private function append_normalized_values( $value, array &$output, $depth ) {
		if ( count( $output ) >= self::MAX_VALUES || $depth > 10 ) {
			return;
		}

		if ( is_array( $value ) ) {
			foreach ( $value as $item ) {
				$this->append_normalized_values( $item, $output, $depth + 1 );
				if ( count( $output ) >= self::MAX_VALUES ) {
					break;
				}
			}
			return;
		}

		if ( null === $value || is_object( $value ) || is_resource( $value ) ) {
			return;
		}
		if ( is_float( $value ) && ( is_nan( $value ) || is_infinite( $value ) ) ) {
			return;
		}

		if ( is_bool( $value ) ) {
			$value = $value ? '1' : '';
		} elseif ( is_scalar( $value ) ) {
			$value = (string) $value;
		} else {
			return;
		}

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

		if ( function_exists( 'mb_substr' ) ) {
			$value = mb_substr( $value, 0, self::MAX_VALUE_LENGTH, 'UTF-8' );
		} else {
			$value = substr( $value, 0, self::MAX_VALUE_LENGTH );
		}
		$output[] = $value;
	}

	/**
	 * Check the document-wide condition budget without normalizing untrusted data.
	 *
	 * Returning an empty sanitized document when this budget is exceeded keeps
	 * every target visible instead of applying an arbitrary truncated rule set.
	 *
	 * @param array<int,mixed> $rules Raw rules.
	 * @return bool
	 */
	private function exceeds_total_condition_budget( array $rules ) {
		$total = 0;

		foreach ( $rules as $rule ) {
			if ( ! is_array( $rule ) || ! is_array( $rule['groups'] ?? null ) ) {
				continue;
			}
			foreach ( $rule['groups'] as $group ) {
				if ( ! is_array( $group ) || ! is_array( $group['conditions'] ?? null ) ) {
					continue;
				}
				$total += count( $group['conditions'] );
				if ( $total > self::MAX_TOTAL_CONDITIONS ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Normalize common serialized boolean values.
	 *
	 * @param mixed $value Raw boolean.
	 * @return bool
	 */
	private function normalize_boolean( $value ) {
		if ( true === $value || 1 === $value ) {
			return true;
		}
		if ( ! is_scalar( $value ) ) {
			return false;
		}

		return in_array( strtolower( trim( (string) $value ) ), array( '1', 'true', 'yes', 'on' ), true );
	}

	/**
	 * Sanitize an identifier without relying on WordPress runtime helpers.
	 *
	 * @param mixed $value Raw identifier.
	 * @return string
	 */
	private function sanitize_identifier( $value ) {
		if ( ! is_scalar( $value ) ) {
			return '';
		}

		$value = trim( (string) $value );
		$value = preg_replace( '/[^A-Za-z0-9_.:-]/', '', $value );
		return substr( (string) $value, 0, self::MAX_IDENTIFIER_LENGTH );
	}

	/**
	 * Parse the strict date-input format used by v1.
	 *
	 * @param string $value Date value.
	 * @return string|null
	 */
	private function normalize_date( $value ) {
		if ( ! preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', (string) $value, $matches ) ) {
			return null;
		}

		$year  = (int) $matches[1];
		$month = (int) $matches[2];
		$day   = (int) $matches[3];
		if ( ! checkdate( $month, $day, $year ) ) {
			return null;
		}

		return sprintf( '%04d-%02d-%02d', $year, $month, $day );
	}

	/**
	 * ASCII-only lowercasing is identical in PHP and browser runtimes.
	 *
	 * @param string $value Value.
	 * @return string
	 */
	private function ascii_lower( $value ) {
		return strtr( $value, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz' );
	}

	/**
	 * Build a normalized identifier set.
	 *
	 * @param array<int,string|int> $identifiers Identifiers.
	 * @return array<string,bool>
	 */
	private function known_identifier_set( array $identifiers ) {
		$set = array();
		foreach ( $identifiers as $identifier ) {
			$identifier = $this->sanitize_identifier( $identifier );
			if ( '' !== $identifier ) {
				$set[ $identifier ] = true;
			}
		}
		return $set;
	}

	/**
	 * Return a known-target set, or null when the caller did not provide one.
	 *
	 * @param array<string,array<int,string|int>> $known_targets Targets by type.
	 * @param string                              $type Target type.
	 * @return array<string,bool>|null
	 */
	private function known_target_set( array $known_targets, $type ) {
		if ( ! array_key_exists( $type, $known_targets ) || ! is_array( $known_targets[ $type ] ) ) {
			return null;
		}
		return $this->known_identifier_set( $known_targets[ $type ] );
	}

	/**
	 * Add a structured validation error.
	 *
	 * @param array<int,array<string,string>> $errors Errors.
	 * @param string                          $path Schema path.
	 * @param string                          $code Stable error code.
	 * @param string                          $message Human-readable message.
	 * @return void
	 */
	private function add_error( array &$errors, $path, $code, $message ) {
		$errors[] = array(
			'path'    => $path,
			'code'    => $code,
			'message' => $message,
		);
	}
}
