<?php
/**
 * Custom Fields validation and sanitization.
 *
 * @package CustomFields
 * @since 1.0.0
 */

namespace CustomFields;

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

/**
 * Validation and sanitization helper for field-group definitions.
 */
final class Validator {

	private Storage $storage;

	public function __construct( Storage $storage ) {
		$this->storage = $storage;
	}

	/**
	 * Normalize a raw key to allowed characters.
	 *
	 * @param string $key Raw key.
	 */
	public function sanitizeKey( string $key ): string {
		$key = strtolower( trim( $key ) );
		$key = preg_replace( '/[^a-z0-9_]/', '', $key );

		return substr( (string) $key, 0, Config::MAX_KEY_LENGTH );
	}

	/**
	 * Normalize a field key (allows hyphens for type-random format).
	 *
	 * @param string $key Raw field key.
	 */
	public function sanitizeFieldKey( string $key ): string {
		$key = strtolower( trim( $key ) );
		$key = preg_replace( '/[^a-z0-9_-]/', '', $key );

		return substr( (string) $key, 0, Config::MAX_KEY_LENGTH );
	}

	/**
	 * Validate a group key.
	 *
	 * @param string      $key          Sanitized key.
	 * @param bool        $is_edit      Whether editing.
	 * @param string|null $original_key Original key when editing.
	 * @return string|null Error message, or null when valid.
	 */
	public function validateKey( string $key, bool $is_edit, ?string $original_key = null ): ?string {
		if ( $key === '' ) {
			return __( 'The group key is required.', 'wp-powersuite' );
		}

		if ( ! preg_match( '/^[a-z][a-z0-9_]*$/', $key ) ) {
			return __( 'Use lowercase letters, numbers, and underscores only. The key must start with a letter.', 'wp-powersuite' );
		}

		if ( $is_edit && $original_key !== null && $key !== $original_key ) {
			return __( 'The group key cannot be changed after creation.', 'wp-powersuite' );
		}

		if ( ! $is_edit && $this->storage->exists( $key ) ) {
			return __( 'A field group with this key already exists.', 'wp-powersuite' );
		}

		return null;
	}

	/**
	 * Build a complete, sanitized field-group definition from raw input.
	 *
	 * @param array<string, mixed>      $input    Raw (already unslashed) input.
	 * @param array<string, mixed>|null $existing Existing definition when editing.
	 * @return array<string, mixed>
	 */
	public function buildDefinition( array $input, ?array $existing = null ): array {
		$now        = time();
		$definition = Config::defaultGroup();

		if ( is_array( $existing ) ) {
			$definition = array_merge( $definition, $existing );
		}

		$definition['key']         = $this->sanitizeKey( (string) ( $input['key'] ?? ( $existing['key'] ?? '' ) ) );
		if ( $definition['key'] === '' && ! is_array( $existing ) ) {
			$title = sanitize_text_field( (string) ( $input['title'] ?? '' ) );
			$definition['key'] = KeyGenerator::generateGroupKey( $title, array_keys( $this->storage->all() ) );
		}
		$definition['title']       = sanitize_text_field( (string) ( $input['title'] ?? '' ) );
		$definition['enabled']     = ! empty( $input['enabled'] );
		$definition['position']    = $this->sanitizePosition( (string) ( $input['position'] ?? 'normal' ) );
		$definition['priority']    = $this->sanitizePriority( (string) ( $input['priority'] ?? 'default' ) );
		$definition['style']       = $this->sanitizeStyle( (string) ( $input['style'] ?? 'default' ) );
		$definition['location']       = $this->sanitizeLocation( isset( $input['location'] ) && is_array( $input['location'] ) ? $input['location'] : [] );
		$definition['location_rules'] = $this->sanitizeLocationRules(
			isset( $input['location_rules'] ) && is_array( $input['location_rules'] ) ? $input['location_rules'] : [],
			$definition['location']
		);
		$definition['fields']         = $this->sanitizeFields( isset( $input['fields'] ) && is_array( $input['fields'] ) ? $input['fields'] : [] );
		$definition['schema']     = Config::SCHEMA_VERSION;
		$definition['created_at']  = (int) ( $existing['created_at'] ?? 0 ) ?: $now;
		$definition['updated_at']  = $now;

		unset( $definition['prefix_meta'] );

		return $definition;
	}

	/**
	 * Sanitize the meta-box position.
	 *
	 * @param string $position Raw position.
	 */
	public function sanitizePosition( string $position ): string {
		$position = sanitize_key( $position );

		if ( $position === 'advanced' ) {
			return 'normal';
		}

		return array_key_exists( $position, Config::positions() ) ? $position : 'normal';
	}

	/**
	 * Sanitize the meta-box priority.
	 *
	 * @param string $priority Raw priority.
	 */
	public function sanitizePriority( string $priority ): string {
		$priority = sanitize_key( $priority );

		return array_key_exists( $priority, Config::priorities() ) ? $priority : 'default';
	}

	/**
	 * Sanitize the meta-box presentation style.
	 *
	 * @param string $style Raw style.
	 */
	public function sanitizeStyle( string $style ): string {
		$style = sanitize_key( $style );

		return array_key_exists( $style, Config::metaBoxStyles() ) ? $style : 'default';
	}

	/**
	 * Sanitize a location rule.
	 *
	 * @param array<string, mixed> $location Raw location.
	 * @return array{type: string, values: array<int, string>}
	 */
	public function sanitizeLocation( array $location ): array {
		$type = sanitize_key( (string) ( $location['type'] ?? 'post_type' ) );
		if ( ! array_key_exists( $type, Config::locationTypes() ) ) {
			$type = 'post_type';
		}

		$values = [];
		if ( isset( $location['values'] ) && is_array( $location['values'] ) ) {
			foreach ( $location['values'] as $value ) {
				$value = sanitize_key( (string) $value );
				if ( $value !== '' ) {
					$values[] = $value;
				}
			}
		}

		$page_templates = [];
		if ( isset( $location['page_templates'] ) && is_array( $location['page_templates'] ) ) {
			foreach ( $location['page_templates'] as $template ) {
				$template = sanitize_file_name( (string) $template );
				if ( $template !== '' ) {
					$page_templates[] = $template;
				}
			}
		}

		return [
			'type'             => $type,
			'values'           => array_values( array_unique( $values ) ),
			'page_templates'   => array_values( array_unique( $page_templates ) ),
		];
	}

	/**
	 * Sanitize location rule group (AND/OR builder).
	 *
	 * @param array<string, mixed> $raw      Raw location_rules input.
	 * @param array<string, mixed> $location Legacy location fallback.
	 * @return array{screen: string, match: string, rules: array<int, array<string, string>>}
	 */
	public function sanitizeLocationRules( array $raw, array $location = [] ): array {
		$screen = sanitize_key( (string) ( $raw['screen'] ?? '' ) );
		if ( ! in_array( $screen, [ 'post', 'taxonomy' ], true ) ) {
			$legacy_type = sanitize_key( (string) ( $location['type'] ?? 'post_type' ) );
			$screen      = $legacy_type === 'taxonomy' ? 'taxonomy' : 'post';
		}

		$match = (string) ( $raw['match'] ?? 'all' );
		$rules = LocationRules::sanitizeRulesList( $raw['rules'] ?? [] );

		if ( $rules === [] && self::locationHasLegacyValues( $location ) ) {
			$migrated = LocationRules::fromLegacyLocation( $location );
			$screen   = $migrated['screen'];
			$match    = $migrated['match'];
			$rules    = $migrated['rules'];
		}

		$normalized = [
			'screen' => $screen,
			'match'  => $match === 'any' ? 'any' : 'all',
			'rules'  => $rules,
		];

		return LocationRules::normalizeScreen( $normalized );
	}

	/**
	 * Whether legacy location data contains explicit targets worth migrating.
	 *
	 * @param array<string, mixed> $location Legacy location block.
	 */
	private static function locationHasLegacyValues( array $location ): bool {
		if ( $location === [] ) {
			return false;
		}

		foreach ( (array) ( $location['values'] ?? [] ) as $value ) {
			if ( (string) $value !== '' ) {
				return true;
			}
		}

		foreach ( (array) ( $location['page_templates'] ?? [] ) as $template ) {
			if ( (string) $template !== '' ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Sanitize conditional logic block.
	 *
	 * @param array<string, mixed> $raw Raw conditional input.
	 * @return array{enabled: bool, match: string, rules: array<int, array<string, string>>}
	 */
	public function sanitizeConditional( array $raw ): array {
		$rules = [];
		if ( isset( $raw['rules'] ) && is_array( $raw['rules'] ) ) {
			foreach ( $raw['rules'] as $rule ) {
				if ( ! is_array( $rule ) ) {
					continue;
				}
				$field = $this->sanitizeKey( (string) ( $rule['field'] ?? '' ) );
				if ( $field === '' ) {
					continue;
				}
				$operator = (string) ( $rule['operator'] ?? '==' );
				if ( ! array_key_exists( $operator, Config::ruleOperators() ) ) {
					$operator = '==';
				}
				$rules[] = [
					'field'    => $field,
					'operator' => $operator,
					'value'    => sanitize_text_field( (string) ( $rule['value'] ?? '' ) ),
				];
			}
		}

		return [
			'enabled' => ! empty( $raw['enabled'] ),
			'match'   => (string) ( $raw['match'] ?? 'all' ) === 'any' ? 'any' : 'all',
			'rules'   => $rules,
		];
	}

	/**
	 * Sanitize the ordered list of fields.
	 *
	 * @param array<int|string, mixed> $fields Raw fields.
	 * @return array<int, array<string, mixed>>
	 */
	public function sanitizeFields( array $fields ): array {
		$clean    = [];
		$used_keys = [];

		foreach ( $fields as $raw ) {
			if ( ! is_array( $raw ) ) {
				continue;
			}

			$field = $this->sanitizeField( $raw );
			$type  = (string) ( $field['type'] ?? 'text' );

			if ( $type === 'message' ) {
				if ( $field['label'] === '' && $field['message_content'] === '' ) {
					continue;
				}
			} elseif ( $field['label'] === '' && $field['name'] === '' ) {
				continue;
			}

			// Guarantee a unique key within the group.
			$key = $field['key'];
			if ( $key === '' || isset( $used_keys[ $key ] ) ) {
				$key = KeyGenerator::generateFieldKey(
					(string) ( $field['label'] ?? '' ),
					$used_keys,
					(string) ( $field['type'] ?? 'field' )
				);
			}
			$field['key']      = $key;
			$used_keys[ $key ] = true;

			// Display-only fields do not use meta storage.
			if ( Config::storesValue( $type ) ) {
				$posted_key  = $this->sanitizeFieldKey( (string) ( $raw['key'] ?? '' ) );
				$posted_name = $this->sanitizeMetaName( (string) ( $raw['name'] ?? '' ) );
				if ( $posted_key !== '' && $posted_key === $key && $posted_name !== '' ) {
					$field['name'] = $posted_name;
				} else {
					$field['name'] = $key;
				}
			} else {
				$field['name'] = '';
			}

			$clean[] = $field;
		}

		return $clean;
	}

	/**
	 * Sanitize a single field definition.
	 *
	 * @param array<string, mixed> $raw Raw field.
	 * @return array<string, mixed>
	 */
	public function sanitizeField( array $raw ): array {
		$field = Config::defaultField();

		$field['key']          = $this->sanitizeFieldKey( (string) ( $raw['key'] ?? '' ) );
		$field['label']        = sanitize_text_field( (string) ( $raw['label'] ?? '' ) );
		$field['name']         = $this->sanitizeMetaName( (string) ( $raw['name'] ?? '' ) );
		$field['type']         = Config::isValidType( (string) ( $raw['type'] ?? '' ) ) ? (string) $raw['type'] : 'text';
		$field['instructions'] = sanitize_textarea_field( (string) ( $raw['instructions'] ?? '' ) );
		$field['required']     = ! empty( $raw['required'] );
		$field['placeholder']  = sanitize_text_field( (string) ( $raw['placeholder'] ?? '' ) );
		$field['choices']      = $this->sanitizeChoices( (string) ( $raw['choices'] ?? '' ) );
		$field['multiple']     = ! empty( $raw['multiple'] );
		$return                = (string) ( $raw['return'] ?? 'id' );
		$field['return']       = in_array( $return, [ 'id', 'url' ], true ) ? $return : 'id';
		$field['message_content'] = wp_kses_post( (string) ( $raw['message_content'] ?? '' ) );
		$field['min']          = sanitize_text_field( (string) ( $raw['min'] ?? '' ) );
		$field['max']          = sanitize_text_field( (string) ( $raw['max'] ?? '' ) );
		$field['step']         = sanitize_text_field( (string) ( $raw['step'] ?? '' ) );
		$field['rows']         = max( 2, min( 20, absint( $raw['rows'] ?? 4 ) ) );
		$field['post_types']   = $this->sanitizePostTypes( isset( $raw['post_types'] ) && is_array( $raw['post_types'] ) ? $raw['post_types'] : [] );
		$field['taxonomy']      = sanitize_key( (string) ( $raw['taxonomy'] ?? '' ) );
		$field['before']        = sanitize_text_field( (string) ( $raw['before'] ?? '' ) );
		$field['after']         = sanitize_text_field( (string) ( $raw['after'] ?? '' ) );
		$field['pattern']       = sanitize_text_field( (string) ( $raw['pattern'] ?? '' ) );
		$field['show_in_list']  = ! empty( $raw['show_in_list'] );
		$width                  = (string) ( $raw['width'] ?? '100' );
		$field['width']         = array_key_exists( $width, Config::fieldWidths() ) ? $width : '100';

		$field['button_label']         = sanitize_text_field( (string) ( $raw['button_label'] ?? '' ) );
		$field['clone_source']         = sanitize_text_field( (string) ( $raw['clone_source'] ?? '' ) );
		$field['sub_fields']           = $this->sanitizeSubFields( $raw['sub_fields'] ?? [] );
		$field['layouts']              = $this->sanitizeLayouts( $raw['layouts'] ?? [] );
		$field['conditional']          = $this->sanitizeConditional( isset( $raw['conditional'] ) && is_array( $raw['conditional'] ) ? $raw['conditional'] : [] );
		$field['bidirectional']        = ! empty( $raw['bidirectional'] );
		$field['bidirectional_target'] = sanitize_text_field( (string) ( $raw['bidirectional_target'] ?? '' ) );

		// Default value: keep as text; arrays not supported for defaults.
		$field['default'] = sanitize_text_field( (string) ( $raw['default'] ?? '' ) );

		return $field;
	}

	/**
	 * @param mixed $raw Raw sub fields.
	 * @return array<int, array<string, mixed>>
	 */
	public function sanitizeSubFields( mixed $raw ): array {
		if ( ! is_array( $raw ) ) {
			return [];
		}

		$clean     = [];
		$used_keys = [];
		foreach ( $raw as $sub_raw ) {
			if ( ! is_array( $sub_raw ) ) {
				continue;
			}
			$sub = $this->sanitizeField( $sub_raw );
			$type = (string) ( $sub['type'] ?? 'text' );
			if ( in_array( $type, Config::nestedTypes(), true ) ) {
				continue;
			}
			$key = $sub['key'];
			if ( $key === '' || isset( $used_keys[ $key ] ) ) {
				$key = KeyGenerator::generateFieldKey(
					(string) ( $sub['label'] ?? '' ),
					$used_keys,
					(string) ( $sub['type'] ?? 'field' )
				);
			}
			$sub['key'] = $key;
			$used_keys[ $key ] = true;
			$sub['name'] = $key;
			if ( $sub['label'] !== '' || $sub['name'] !== '' ) {
				$clean[] = $sub;
			}
		}

		return $clean;
	}

	/**
	 * @param mixed $raw Raw layouts.
	 * @return array<int, array<string, mixed>>
	 */
	public function sanitizeLayouts( mixed $raw ): array {
		if ( ! is_array( $raw ) ) {
			return [];
		}

		$clean           = [];
		$used_layout_keys = [];
		foreach ( $raw as $layout_raw ) {
			if ( ! is_array( $layout_raw ) ) {
				continue;
			}
			$label = sanitize_text_field( (string) ( $layout_raw['label'] ?? '' ) );
			$key   = $this->sanitizeFieldKey( (string) ( $layout_raw['key'] ?? '' ) );
			if ( $key === '' || isset( $used_layout_keys[ $key ] ) ) {
				$key = KeyGenerator::generateFieldKey( $label, $used_layout_keys, 'layout' );
			}
			if ( $key === '' ) {
				continue;
			}
			$used_layout_keys[ $key ] = true;
			$sub_raw = $layout_raw['sub_fields'] ?? [];
			if ( isset( $layout_raw['sub_fields_json'] ) && is_string( $layout_raw['sub_fields_json'] ) ) {
				$decoded = json_decode( $layout_raw['sub_fields_json'], true );
				if ( is_array( $decoded ) ) {
					$sub_raw = $decoded;
				}
			}

			$clean[] = [
				'key'        => $key,
				'label'      => $label !== '' ? $label : $key,
				'sub_fields' => $this->sanitizeSubFields( $sub_raw ),
			];
		}

		return $clean;
	}

	/**
	 * Sanitize post type keys for relational fields.
	 *
	 * @param array<int, mixed> $post_types Raw list.
	 * @return array<int, string>
	 */
	public function sanitizePostTypes( array $post_types ): array {
		$clean = [];
		foreach ( $post_types as $post_type ) {
			$post_type = sanitize_key( (string) $post_type );
			if ( $post_type !== '' ) {
				$clean[] = $post_type;
			}
		}

		return array_values( array_unique( $clean ) );
	}

	/**
	 * Sanitize a meta key (allows leading underscore for hidden meta).
	 *
	 * @param string $name Raw meta name.
	 */
	public function sanitizeMetaName( string $name ): string {
		$name = strtolower( trim( $name ) );
		$name = preg_replace( '/[^a-z0-9_]/', '', (string) $name );
		$name = substr( (string) $name, 0, 255 );

		if ( $name !== '' && ! preg_match( '/^[a-z]/', $name ) ) {
			$name = 'f_' . $name;
		}

		return substr( $name, 0, 255 );
	}

	/**
	 * Sanitize the choices definition (one "value|Label" per line).
	 *
	 * @param string $choices Raw choices block.
	 */
	public function sanitizeChoices( string $choices ): string {
		$lines = preg_split( '/\r\n|\r|\n/', $choices );
		$out   = [];
		foreach ( (array) $lines as $line ) {
			$line = sanitize_text_field( (string) $line );
			if ( $line !== '' ) {
				$out[] = $line;
			}
		}

		return implode( "\n", $out );
	}

	/**
	 * Parse a choices block into a value => label map.
	 *
	 * @param string $choices Raw choices block.
	 * @return array<string, string>
	 */
	public static function parseChoices( string $choices ): array {
		$out = [];
		foreach ( preg_split( '/\r\n|\r|\n/', $choices ) ?: [] as $line ) {
			$line = trim( (string) $line );
			if ( $line === '' ) {
				continue;
			}
			if ( strpos( $line, '|' ) !== false ) {
				[ $value, $label ] = array_pad( explode( '|', $line, 2 ), 2, '' );
				$value             = trim( $value );
				$out[ $value ]     = trim( $label ) !== '' ? trim( $label ) : $value;
			} else {
				$out[ $line ] = $line;
			}
		}

		return $out;
	}


	/**
	 * Validate a fully built field-group definition.
	 *
	 * @param array<string, mixed> $definition   Built definition.
	 * @param bool                 $is_edit      Whether editing.
	 * @param string|null          $original_key Original key when editing.
	 * @return array<int, string>
	 */
	public function validateDefinition( array $definition, bool $is_edit, ?string $original_key = null ): array {
		$errors = [];

		$key_error = $this->validateKey( (string) ( $definition['key'] ?? '' ), $is_edit, $original_key );
		if ( $key_error !== null ) {
			$errors[] = $key_error;
		}

		if ( (string) ( $definition['title'] ?? '' ) === '' ) {
			$errors[] = __( 'The group title is required.', 'wp-powersuite' );
		}

		$location_rules = LocationRules::get( $definition );
		if ( ( $location_rules['rules'] ?? [] ) === [] ) {
			$errors[] = __( 'Add at least one location rule for this group.', 'wp-powersuite' );
		}

		$fields = isset( $definition['fields'] ) && is_array( $definition['fields'] ) ? $definition['fields'] : [];
		if ( $fields === [] ) {
			$errors[] = __( 'Add at least one field to this group.', 'wp-powersuite' );
		}

		$group_key = (string) ( $definition['key'] ?? '' );
		$used_meta   = [];
		$global_meta = MetaKeys::registeredKeys( $this->storage, $is_edit ? $original_key : null );

		foreach ( $fields as $field ) {
			$type  = (string) ( $field['type'] ?? '' );
			$label = (string) ( $field['label'] ?? $field['key'] ?? '' );

			if ( Config::storesValue( $type ) ) {
				$meta_name = (string) ( $field['name'] ?? '' );
				if ( $meta_name === '' ) {
					$errors[] = sprintf(
						/* translators: %s: field label. */
						__( 'The field "%s" needs a name.', 'wp-powersuite' ),
						$label
					);
				} else {
					$resolved = MetaKeys::resolve( $group_key, $field );
					if ( isset( $used_meta[ $resolved ] ) ) {
						$errors[] = sprintf(
							/* translators: %s: meta key. */
							__( 'The name "%s" is used more than once in this group.', 'wp-powersuite' ),
							$resolved
						);
					} else {
						$used_meta[ $resolved ] = true;
					}

					if ( isset( $global_meta[ $resolved ] ) ) {
						$errors[] = sprintf(
							/* translators: 1: meta key, 2: other group key. */
							__( 'The name "%1$s" is already used by the field group "%2$s".', 'wp-powersuite' ),
							$resolved,
							$global_meta[ $resolved ]
						);
					}
				}
			}

			if ( in_array( $type, Config::choiceTypes(), true ) && self::parseChoices( (string) ( $field['choices'] ?? '' ) ) === [] ) {
				$errors[] = sprintf(
					/* translators: %s: field label. */
					__( 'The field "%s" needs at least one choice.', 'wp-powersuite' ),
					$label
				);
			}

			if ( $type === 'term' && (string) ( $field['taxonomy'] ?? '' ) === '' ) {
				$errors[] = sprintf(
					/* translators: %s: field label. */
					__( 'The field "%s" requires a taxonomy.', 'wp-powersuite' ),
					$label
				);
			}

			if ( $type === 'repeater' && ( $field['sub_fields'] ?? [] ) === [] ) {
				$errors[] = sprintf(
					/* translators: %s: field label. */
					__( 'The repeater "%s" needs at least one sub field.', 'wp-powersuite' ),
					$label
				);
			}

			if ( $type === 'flexible_content' && ( $field['layouts'] ?? [] ) === [] ) {
				$errors[] = sprintf(
					/* translators: %s: field label. */
					__( 'The flexible content field "%s" needs at least one layout.', 'wp-powersuite' ),
					$label
				);
			}

			if ( $type === 'clone' && (string) ( $field['clone_source'] ?? '' ) === '' ) {
				$errors[] = sprintf(
					/* translators: %s: field label. */
					__( 'The clone field "%s" requires a source field.', 'wp-powersuite' ),
					$label
				);
			}

			if ( ! empty( $field['bidirectional'] ) && (string) ( $field['bidirectional_target'] ?? '' ) === '' ) {
				$errors[] = sprintf(
					/* translators: %s: field label. */
					__( 'The field "%s" needs a linked field to sync with.', 'wp-powersuite' ),
					$label
				);
			}
		}

		return $errors;
	}
}
