<?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.', 'wppowersuite-lite' );
		}

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

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

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

		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['show_in_rest'] = ! empty( $input['show_in_rest'] );
		$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 ( count( $rules ) >= Config::MAX_CONDITIONAL_RULES ) {
					break;
				}
				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 ( count( $clean ) >= Config::MAX_FIELDS ) {
				break;
			}
			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';
		$type                  = (string) $field['type'];
		$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( $raw['choices'] ?? '' );
		$field['multiple']     = $type === 'relationship' || ! empty( $raw['multiple'] );
		$return_options        = Config::returnFormatOptions()[ $type ] ?? [];
		$return_default        = (string) array_key_first( $return_options );
		$return                = sanitize_text_field( (string) ( $raw['return'] ?? $return_default ) );
		$field['return']       = $return_options !== [] && array_key_exists( $return, $return_options ) ? $return : $return_default;
		$field['message_content'] = wp_kses_post( (string) ( $raw['message_content'] ?? '' ) );
		$field['output_content']  = wp_kses_post( (string) ( $raw['output_content'] ?? '' ) );
		if ( Config::typeSupports( $type, 'bounds' ) ) {
			$field['min']  = $this->sanitizeOptionalSignedNumber( $raw['min'] ?? '', -999999999999, 999999999999 );
			$field['max']  = $this->sanitizeOptionalSignedNumber( $raw['max'] ?? '', -999999999999, 999999999999 );
			$field['step'] = $this->sanitizeOptionalNumber( $raw['step'] ?? '' );
		} elseif ( Config::typeSupports( $type, 'count' ) || Config::typeSupports( $type, 'gallery_count' ) ) {
			$field['min']  = $this->sanitizeOptionalAbsint( $raw['min'] ?? '' );
			$field['max']  = $this->sanitizeOptionalAbsint( $raw['max'] ?? '' );
			$field['step'] = '';
		} else {
			$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'] ?? 8 ) ) );
		$new_lines             = sanitize_key( (string) ( $raw['new_lines'] ?? '' ) );
		$field['new_lines']    = in_array( $new_lines, [ '', 'br', 'wpautop' ], true ) ? $new_lines : '';
		$field['post_types']   = $this->sanitizePostTypes( isset( $raw['post_types'] ) && is_array( $raw['post_types'] ) ? $raw['post_types'] : [] );
		$field['post_statuses'] = $this->sanitizePostStatuses( isset( $raw['post_statuses'] ) && is_array( $raw['post_statuses'] ) ? $raw['post_statuses'] : [] );
		$field['relationship_filters']  = $this->sanitizeRelationshipFilters( $raw['relationship_filters'] ?? ( $type === 'relationship' ? [ 'search', 'post_type', 'taxonomy' ] : [] ) );
		$field['relationship_elements'] = $this->sanitizeRelationshipElements( $raw['relationship_elements'] ?? [] );
		$field['allow_archives'] = ! isset( $raw['allow_archives'] ) || ! empty( $raw['allow_archives'] );
		$field['taxonomy']      = sanitize_key( (string) ( $raw['taxonomy'] ?? ( $type === 'term' ? 'category' : '' ) ) );
		$field['taxonomy_terms'] = $this->sanitizeTaxonomyTerms( isset( $raw['taxonomy_terms'] ) && is_array( $raw['taxonomy_terms'] ) ? $raw['taxonomy_terms'] : [] );
		$taxonomy_field_type    = sanitize_key( (string) ( $raw['taxonomy_field_type'] ?? $field['taxonomy_field_type'] ) );
		$field['taxonomy_field_type'] = in_array( $taxonomy_field_type, [ 'select', 'multi_select', 'radio', 'checkbox' ], true ) ? $taxonomy_field_type : 'checkbox';
		$field['taxonomy_save_terms'] = ! empty( $raw['taxonomy_save_terms'] );
		$field['taxonomy_load_terms'] = ! empty( $raw['taxonomy_load_terms'] );
		$field['taxonomy_add_terms']  = array_key_exists( 'taxonomy_add_terms', $raw ) ? ! empty( $raw['taxonomy_add_terms'] ) : $type === 'term';
		$field['user_roles']    = $this->sanitizeUserRoles( isset( $raw['user_roles'] ) && is_array( $raw['user_roles'] ) ? $raw['user_roles'] : [] );
		$media_library          = sanitize_key( (string) ( $raw['media_library'] ?? 'all' ) );
		$field['media_library'] = in_array( $media_library, [ 'all', 'uploaded_to' ], true ) ? $media_library : 'all';
		$field['mime_types']    = $this->sanitizeMimeTypes( (string) ( $raw['mime_types'] ?? '' ) );
		$field['min_size']      = $this->sanitizeOptionalNumber( $raw['min_size'] ?? '' );
		$field['max_size']      = $this->sanitizeOptionalNumber( $raw['max_size'] ?? '' );
		$field['min_width']     = $this->sanitizeOptionalAbsint( $raw['min_width'] ?? '' );
		$field['min_height']    = $this->sanitizeOptionalAbsint( $raw['min_height'] ?? '' );
		$field['max_width']     = $this->sanitizeOptionalAbsint( $raw['max_width'] ?? '' );
		$field['max_height']    = $this->sanitizeOptionalAbsint( $raw['max_height'] ?? '' );
		$field['image_preview_size'] = $this->sanitizeImageSize( (string) ( $raw['image_preview_size'] ?? $field['image_preview_size'] ) );
		$field['gallery_preview_size'] = $this->sanitizeImageSize( (string) ( $raw['gallery_preview_size'] ?? $field['gallery_preview_size'] ) );
		$gallery_insert         = sanitize_key( (string) ( $raw['gallery_insert'] ?? 'append' ) );
		$field['gallery_insert'] = in_array( $gallery_insert, [ 'append', 'prepend' ], true ) ? $gallery_insert : 'append';
		$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['maxlength']     = absint( $raw['maxlength'] ?? 0 ) > 0 ? (string) absint( $raw['maxlength'] ) : '';
		$field['allow_null']    = ! empty( $raw['allow_null'] );
		$choice_layout_default  = $type === 'button_group' ? 'horizontal' : 'vertical';
		$choice_layout          = sanitize_key( (string) ( $raw['choice_layout'] ?? $choice_layout_default ) );
		$field['choice_layout'] = in_array( $choice_layout, [ 'vertical', 'horizontal' ], true ) ? $choice_layout : $choice_layout_default;
		$field['checkbox_toggle'] = ! empty( $raw['checkbox_toggle'] );
		$field['allow_custom']    = ! empty( $raw['allow_custom'] );
		$field['save_custom']     = $field['allow_custom'] && ! empty( $raw['save_custom'] );
		$field['select_ui']       = ! empty( $raw['select_ui'] );
		$field['select_ajax']     = $field['select_ui'] && ! empty( $raw['select_ajax'] );
		$field['select_create_options'] = $field['select_ui'] && ! empty( $raw['select_create_options'] );
		$field['select_save_options']   = $field['select_create_options'] && ! empty( $raw['select_save_options'] );
		$nav_menu_container             = sanitize_key( (string) ( $raw['nav_menu_container'] ?? 'div' ) );
		$nav_menu_container             = $nav_menu_container === 'none' ? '0' : $nav_menu_container;
		$field['nav_menu_container']    = in_array( $nav_menu_container, [ 'div', 'nav', '0' ], true ) ? $nav_menu_container : 'div';
		$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['display_format']       = $this->sanitizeDateFormat( (string) ( $raw['display_format'] ?? $this->defaultDisplayFormat( $type ) ) );
		$field['save_format']          = $this->sanitizeDateFormat( (string) ( $raw['save_format'] ?? '' ) );
		$field['first_day']            = max( 0, min( 6, absint( $raw['first_day'] ?? 1 ) ) );
		$field['default_current']      = ! empty( $raw['default_current'] );
		$field['enable_opacity']       = ! empty( $raw['enable_opacity'] );
		$palette_source                = sanitize_key( (string) ( $raw['custom_palette_source'] ?? '' ) );
		$field['custom_palette_source'] = in_array( $palette_source, [ '', 'custom', 'themejson' ], true ) ? $palette_source : '';
		$field['palette_colors']       = $this->sanitizePaletteColors( (string) ( $raw['palette_colors'] ?? '' ) );
		$field['show_color_wheel']     = ! isset( $raw['show_color_wheel'] ) || ! empty( $raw['show_color_wheel'] );
		$field['toggle_message']       = sanitize_text_field( (string) ( $raw['toggle_message'] ?? '' ) );
		$field['toggle_ui']            = ! empty( $raw['toggle_ui'] );
		$field['toggle_on_text']       = sanitize_text_field( (string) ( $raw['toggle_on_text'] ?? 'Yes' ) );
		$field['toggle_off_text']      = sanitize_text_field( (string) ( $raw['toggle_off_text'] ?? 'No' ) );
		$field['icon_tabs']            = $this->sanitizeIconTabs( $raw['icon_tabs'] ?? [] );
		$field['oembed_width']         = $this->sanitizeOptionalAbsint( $raw['oembed_width'] ?? '' );
		$field['oembed_height']        = $this->sanitizeOptionalAbsint( $raw['oembed_height'] ?? '' );
		$field['map_center_lat']       = $this->sanitizeOptionalSignedNumber( $raw['map_center_lat'] ?? $field['map_center_lat'], -90, 90 );
		$field['map_center_lng']       = $this->sanitizeOptionalSignedNumber( $raw['map_center_lng'] ?? $field['map_center_lng'], -180, 180 );
		$field['map_zoom']             = max( 1, min( 22, absint( $raw['map_zoom'] ?? 14 ) ) );
		$field['map_height']           = max( Config::MIN_MAP_HEIGHT, min( Config::MAX_MAP_HEIGHT, absint( $raw['map_height'] ?? 400 ) ) );
		$message_new_lines             = sanitize_key( (string) ( $raw['message_new_lines'] ?? $field['message_new_lines'] ) );
		$field['message_new_lines']    = in_array( $message_new_lines, [ '', 'br', 'wpautop' ], true ) ? $message_new_lines : '';
		$field['message_escape_html']  = ! empty( $raw['message_escape_html'] );
		$wysiwyg_tabs                  = sanitize_key( (string) ( $raw['wysiwyg_tabs'] ?? 'all' ) );
		$field['wysiwyg_tabs']         = in_array( $wysiwyg_tabs, [ 'all', 'visual', 'text' ], true ) ? $wysiwyg_tabs : 'all';
		$wysiwyg_toolbar               = sanitize_key( (string) ( $raw['wysiwyg_toolbar'] ?? 'full' ) );
		$field['wysiwyg_toolbar']      = in_array( $wysiwyg_toolbar, [ 'full', 'basic' ], true ) ? $wysiwyg_toolbar : 'full';
		$field['wysiwyg_media_upload'] = ! isset( $raw['wysiwyg_media_upload'] ) || ! empty( $raw['wysiwyg_media_upload'] );
		$field['wysiwyg_delay']        = ! empty( $raw['wysiwyg_delay'] );
		$tab_placement                 = sanitize_key( (string) ( $raw['tab_placement'] ?? 'top' ) );
		$field['tab_placement']        = in_array( $tab_placement, [ 'top', 'left' ], true ) ? $tab_placement : 'top';
		$field['tab_endpoint']         = ! empty( $raw['tab_endpoint'] );
		$field['tab_selected']         = ! empty( $raw['tab_selected'] );
		$field['accordion_open']       = ! empty( $raw['accordion_open'] );
		$field['accordion_multi_expand'] = ! empty( $raw['accordion_multi_expand'] );
		$field['accordion_endpoint']   = ! empty( $raw['accordion_endpoint'] );

		$field['button_label']         = sanitize_text_field( (string) ( $raw['button_label'] ?? '' ) );
		$nested_layout_default         = (string) ( $field['type'] ?? 'text' ) === 'repeater' ? 'table' : 'block';
		$nested_layout                 = sanitize_key( (string) ( $raw['nested_layout'] ?? $nested_layout_default ) );
		$field['nested_layout']        = in_array( $nested_layout, [ 'block', 'table', 'row' ], true ) ? $nested_layout : $nested_layout_default;
		$field['collapsed']            = sanitize_key( (string) ( $raw['collapsed'] ?? '' ) );
		$field['rows_per_page']        = max( 1, min( Config::MAX_REPEATER_ROWS_PER_PAGE, absint( $raw['rows_per_page'] ?? 20 ) ) );
		$clone_source_raw              = $raw['clone_source'] ?? '';
		if ( is_array( $clone_source_raw ) ) {
			$clone_source_raw = implode( ',', array_filter( array_map( static fn( $value ): string => trim( sanitize_text_field( (string) $value ) ), $clone_source_raw ) ) );
		}
		$field['clone_source']         = sanitize_text_field( (string) $clone_source_raw );
		$clone_display                 = sanitize_key( (string) ( $raw['clone_display'] ?? 'group' ) );
		$field['clone_display']        = in_array( $clone_display, [ 'group', 'seamless' ], true ) ? $clone_display : 'group';
		$clone_layout                  = sanitize_key( (string) ( $raw['clone_layout'] ?? 'block' ) );
		$field['clone_layout']         = in_array( $clone_layout, [ 'block', 'table', 'row' ], true ) ? $clone_layout : 'block';
		$field['clone_prefix_label']   = ! empty( $raw['clone_prefix_label'] );
		$field['clone_prefix_name']    = ! empty( $raw['clone_prefix_name'] );
		$field['sub_fields']           = $this->sanitizeSubFields( $raw['sub_fields'] ?? [] );
		$field['collapsed']            = $this->sanitizeCollapsedPreviewField( $field['collapsed'], $field['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'] ?? '' ) );

		$field['default'] = $this->sanitizeDefaultValue( $raw['default'] ?? '', $type, ! empty( $field['multiple'] ) );

		$this->pruneUnsupportedFieldSettings( $field );

		return $field;
	}

	/**
	 * Remove stale settings that are not valid for the selected field type.
	 *
	 * Hidden builder inputs can still be submitted after a type change. Keeping
	 * this cleanup server-side makes the saved definition match the field type
	 * support matrix instead of preserving unrelated legacy values.
	 *
	 * @param array<string, mixed> $field Sanitized field definition.
	 */
	private function pruneUnsupportedFieldSettings( array &$field ): void {
		$defaults       = Config::defaultField();
		$type           = (string) ( $field['type'] ?? 'text' );
		$is_display     = in_array( $type, Config::displayOnlyTypes(), true );
		$is_nested      = in_array( $type, Config::nestedTypes(), true );
		$is_relational  = in_array( $type, Config::relationalTypes(), true );
		$is_choice      = in_array( $type, Config::choiceTypes(), true );
		$is_media       = in_array( $type, Config::mediaTypes(), true );
		$supports_count = Config::typeSupports( $type, 'count' ) || Config::typeSupports( $type, 'gallery_count' );

		foreach ( [ 'placeholder', 'default', 'pattern', 'maxlength', 'allow_null', 'choice_layout', 'checkbox_toggle', 'rows', 'new_lines', 'button_label', 'before_after' ] as $setting ) {
			if ( Config::typeSupports( $type, $setting ) ) {
				continue;
			}
			if ( $setting === 'placeholder' && Config::typeSupports( $type, 'select_options' ) ) {
				continue;
			}

			if ( $setting === 'before_after' ) {
				$field['before'] = $defaults['before'];
				$field['after']  = $defaults['after'];
				continue;
			}

			$key           = $setting === 'button_label' ? 'button_label' : $setting;
			$field[ $key ] = $defaults[ $key ] ?? '';
		}

		foreach (
			[
				'date_display'         => [ 'display_format' ],
				'date_save_format'     => [ 'save_format' ],
				'date_first_day'       => [ 'first_day' ],
				'relationship_filters' => [ 'relationship_filters' ],
				'relationship_elements' => [ 'relationship_elements' ],
				'taxonomy_term_sync'   => [ 'taxonomy_save_terms', 'taxonomy_load_terms' ],
				'taxonomy_term_create' => [ 'taxonomy_add_terms' ],
				'default_current'      => [ 'default_current' ],
				'color_options'        => [ 'enable_opacity', 'custom_palette_source', 'palette_colors', 'show_color_wheel' ],
				'toggle_options'       => [ 'toggle_message', 'toggle_ui', 'toggle_on_text', 'toggle_off_text' ],
				'icon_tabs'            => [ 'icon_tabs' ],
				'oembed_dimensions'    => [ 'oembed_width', 'oembed_height' ],
				'google_map_defaults'  => [ 'map_center_lat', 'map_center_lng', 'map_zoom', 'map_height' ],
				'message_options'      => [ 'message_new_lines', 'message_escape_html' ],
				'output_options'       => [ 'output_content' ],
				'wysiwyg_options'      => [ 'wysiwyg_tabs', 'wysiwyg_toolbar', 'wysiwyg_media_upload', 'wysiwyg_delay' ],
				'tab_options'          => [ 'tab_placement', 'tab_endpoint' ],
				'accordion_options'    => [ 'accordion_open', 'accordion_multi_expand', 'accordion_endpoint' ],
				'nested_layout'        => [ 'nested_layout' ],
				'repeater_collapsed'   => [ 'collapsed' ],
				'repeater_pagination'  => [ 'rows_per_page' ],
				'choice_custom'        => [ 'allow_custom', 'save_custom' ],
				'select_options'       => [ 'select_ui', 'select_ajax', 'select_create_options', 'select_save_options' ],
				'nav_menu_container'   => [ 'nav_menu_container' ],
				'image_preview_size'   => [ 'image_preview_size' ],
				'gallery_display'      => [ 'gallery_preview_size', 'gallery_insert' ],
			] as $setting => $keys
		) {
			if ( Config::typeSupports( $type, $setting ) ) {
				continue;
			}
			foreach ( $keys as $key ) {
				$field[ $key ] = $defaults[ $key ];
			}
		}

		if ( ! $is_choice ) {
			$field['choices'] = $defaults['choices'];
		}

		if ( $type !== 'message' ) {
			$field['message_content'] = $defaults['message_content'];
		}

		if ( ! Config::typeSupports( $type, 'bounds' ) && ! $supports_count ) {
			$field['min'] = $defaults['min'];
			$field['max'] = $defaults['max'];
		}

		if ( ! Config::typeSupports( $type, 'bounds' ) ) {
			$field['step'] = $defaults['step'];
		}

		if ( $type === 'relationship' ) {
			$field['multiple'] = true;
		} elseif ( ! in_array( $type, Config::multipleTypes(), true ) ) {
			$field['multiple'] = $defaults['multiple'];
		}

		if ( ! in_array( $type, [ 'post_object', 'relationship', 'page_link' ], true ) ) {
			$field['post_types'] = $defaults['post_types'];
		}

		if ( ! Config::typeSupports( $type, 'post_status_filter' ) ) {
			$field['post_statuses'] = $defaults['post_statuses'];
		}
		if ( ! Config::typeSupports( $type, 'allow_archives' ) ) {
			$field['allow_archives'] = $defaults['allow_archives'];
		}

		if ( ! in_array( $type, [ 'term', 'post_object', 'relationship', 'page_link' ], true ) ) {
			$field['taxonomy'] = $defaults['taxonomy'];
		}
		if ( ! in_array( $type, [ 'post_object', 'relationship', 'page_link' ], true ) ) {
			$field['taxonomy_terms'] = $defaults['taxonomy_terms'];
		}

		if ( ! Config::typeSupports( $type, 'taxonomy_field_type' ) ) {
			$field['taxonomy_field_type'] = $defaults['taxonomy_field_type'];
		}

		if ( $type === 'term' && $field['taxonomy_field_type'] === 'checkbox' ) {
			$field['allow_null'] = false;
		}

		if ( ! Config::typeSupports( $type, 'user_role' ) ) {
			$field['user_roles'] = $defaults['user_roles'];
		}

		if ( ! $is_media ) {
			foreach ( [ 'media_library', 'mime_types', 'min_size', 'max_size' ] as $key ) {
				$field[ $key ] = $defaults[ $key ];
			}
		}

		if ( ! Config::typeSupports( $type, 'image_dimensions' ) ) {
			foreach ( [ 'min_width', 'min_height', 'max_width', 'max_height' ] as $key ) {
				$field[ $key ] = $defaults[ $key ];
			}
		}

		if ( $is_display || $is_nested ) {
			$field['show_in_list'] = $defaults['show_in_list'];
		}

		if ( $is_display ) {
			$field['required']    = $defaults['required'];
			$field['conditional'] = $defaults['conditional'];
		}

		$field['tab_selected'] = $defaults['tab_selected'];

		if ( ! in_array( $type, [ 'group', 'repeater' ], true ) ) {
			$field['sub_fields'] = $defaults['sub_fields'];
		}

		if ( $type !== 'flexible_content' ) {
			$field['layouts'] = $defaults['layouts'];
		}

		if ( $type !== 'clone' ) {
			foreach ( [ 'clone_source', 'clone_display', 'clone_layout', 'clone_prefix_label', 'clone_prefix_name' ] as $key ) {
				$field[ $key ] = $defaults[ $key ];
			}
		}

		if ( ! $is_relational ) {
			$field['bidirectional']        = $defaults['bidirectional'];
			$field['bidirectional_target'] = $defaults['bidirectional_target'];
		}
	}

	/**
	 * @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 ( count( $clean ) >= Config::MAX_SUB_FIELDS ) {
				break;
			}
			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 string                          $collapsed Collapsed preview field key/name.
	 * @param array<int, array<string, mixed>> $sub_fields Sanitized sub fields.
	 */
	private function sanitizeCollapsedPreviewField( string $collapsed, array $sub_fields ): string {
		if ( $collapsed === '' ) {
			return '';
		}

		foreach ( $sub_fields as $sub ) {
			$key  = sanitize_key( (string) ( $sub['key'] ?? '' ) );
			$name = sanitize_key( (string) ( $sub['name'] ?? '' ) );
			if ( $collapsed === $key || $collapsed === $name ) {
				return $name !== '' ? $name : $key;
			}
		}

		return '';
	}

	/**
	 * @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 ( count( $clean ) >= Config::MAX_LAYOUTS ) {
				break;
			}
			if ( ! is_array( $layout_raw ) ) {
				continue;
			}
			$label = sanitize_text_field( (string) ( $layout_raw['label'] ?? '' ) );
			$key   = $this->sanitizeFieldKey( (string) ( $layout_raw['key'] ?? '' ) );
			$display = sanitize_key( (string) ( $layout_raw['display'] ?? 'block' ) );
			$display = in_array( $display, [ 'block', 'table', 'row' ], true ) ? $display : 'block';
			$min     = max( 0, absint( $layout_raw['min'] ?? 0 ) );
			$max     = max( 0, absint( $layout_raw['max'] ?? 0 ) );
			if ( $max > 0 && $min > $max ) {
				$min = $max;
			}
			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,
				'display'    => $display,
				'min'        => $min,
				'max'        => $max,
				'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 user role keys for user fields.
	 *
	 * @param array<int, mixed> $roles Raw list.
	 * @return array<int, string>
	 */
	public function sanitizeUserRoles( array $roles ): array {
		$editable = array_keys( get_editable_roles() );
		$clean    = [];
		foreach ( $roles as $role ) {
			$role = sanitize_key( (string) $role );
			if ( $role !== '' && in_array( $role, $editable, true ) ) {
				$clean[] = $role;
			}
		}

		return array_values( array_unique( $clean ) );
	}

	/**
	 * Sanitize post status keys for relational fields.
	 *
	 * @param array<int, mixed> $statuses Raw list.
	 * @return array<int, string>
	 */
	public function sanitizePostStatuses( array $statuses ): array {
		$allowed = array_keys( Config::relationalPostStatuses() );
		$clean   = [];
		foreach ( $statuses as $status ) {
			$status = sanitize_key( (string) $status );
			if ( $status !== '' && in_array( $status, $allowed, true ) ) {
				$clean[] = $status;
			}
		}

		return array_values( array_unique( $clean ) );
	}

	/**
	 * Sanitize relationship search/filter UI tokens.
	 *
	 * @param mixed $raw Raw list.
	 * @return array<int, string>
	 */
	private function sanitizeRelationshipFilters( mixed $raw ): array {
		$allowed = [ 'search', 'post_type', 'taxonomy' ];
		$filters = is_array( $raw ) ? $raw : [];
		$clean   = [];

		foreach ( $filters as $filter ) {
			$filter = sanitize_key( (string) $filter );
			if ( in_array( $filter, $allowed, true ) ) {
				$clean[] = $filter;
			}
		}

		return array_values( array_unique( $clean ) );
	}

	/**
	 * Sanitize extra relationship display element tokens.
	 *
	 * @param mixed $raw Raw list.
	 * @return array<int, string>
	 */
	private function sanitizeRelationshipElements( mixed $raw ): array {
		$allowed  = [ 'featured_image' ];
		$elements = is_array( $raw ) ? $raw : [];
		$clean    = [];

		foreach ( $elements as $element ) {
			$element = sanitize_key( (string) $element );
			if ( in_array( $element, $allowed, true ) ) {
				$clean[] = $element;
			}
		}

		return array_values( array_unique( $clean ) );
	}

	/**
	 * Sanitize relational taxonomy term filter IDs.
	 *
	 * @param array<int|string, mixed> $terms Raw term IDs.
	 * @return array<int, int>
	 */
	private function sanitizeTaxonomyTerms( array $terms ): array {
		$clean = [];
		foreach ( $terms as $term_id ) {
			$term_id = absint( $term_id );
			if ( $term_id > 0 ) {
				$clean[] = $term_id;
			}
		}

		return array_values( array_unique( $clean ) );
	}

	/**
	 * Sanitize comma-separated MIME/extensions.
	 */
	private function sanitizeMimeTypes( string $raw ): string {
		$items = preg_split( '/[\s,]+/', strtolower( $raw ) ) ?: [];
		$out   = [];
		foreach ( $items as $item ) {
			$item = trim( (string) $item );
			if ( $item === '' || ! preg_match( '#^[a-z0-9.+_-]+(?:/[a-z0-9.+_-]+)?$#', $item ) ) {
				continue;
			}
			$out[] = $item;
		}

		return implode( ', ', array_values( array_unique( $out ) ) );
	}

	/**
	 * Sanitize a PHP date format string for display-only formatting.
	 */
	private function sanitizeDateFormat( string $raw ): string {
		$format = sanitize_text_field( $raw );
		$format = substr( $format, 0, 80 );

		return preg_match( '/^[A-Za-z0-9\\s:\\/\\.,\\-_\\\\]+$/', $format ) ? $format : '';
	}

	/**
	 * Compatible default display formats for temporal fields.
	 */
	private function defaultDisplayFormat( string $type ): string {
		return match ( $type ) {
			'date'     => 'd/m/Y',
			'time'     => 'g:i a',
			'datetime' => 'd/m/Y g:i a',
			default    => '',
		};
	}

	/**
	 * Sanitize comma-separated color presets.
	 */
	private function sanitizePaletteColors( string $raw ): string {
		if ( trim( $raw ) === '' ) {
			return '';
		}

		preg_match_all( '/#[0-9a-fA-F]{3,8}\b|rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(?:0|1|0?\.\d+)\s*\)/', $raw, $matches );
		$out = [];
		foreach ( $matches[0] as $item ) {
			$hex = sanitize_hex_color( $item );
			if ( is_string( $hex ) ) {
				$out[] = $hex;
				continue;
			}

			if ( preg_match( '/^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(0|1|0?\.\d+)\s*\)$/', $item, $rgba ) ) {
				$out[] = sprintf(
					'rgba(%d,%d,%d,%s)',
					max( 0, min( 255, (int) $rgba[1] ) ),
					max( 0, min( 255, (int) $rgba[2] ) ),
					max( 0, min( 255, (int) $rgba[3] ) ),
					rtrim( rtrim( sprintf( '%.3F', max( 0, min( 1, (float) $rgba[4] ) ) ), '0' ), '.' )
				);
			}
		}

		return implode( ', ', array_values( array_unique( $out ) ) );
	}

	/**
	 * Sanitize enabled icon picker source tabs.
	 *
	 * @param mixed $raw Raw tab list.
	 * @return array<int, string>
	 */
	private function sanitizeIconTabs( mixed $raw ): array {
		$allowed = [ 'dashicons', 'media_library', 'url' ];
		$tabs    = is_array( $raw ) ? $raw : [];
		$out     = [];

		foreach ( $tabs as $tab ) {
			$tab = sanitize_key( (string) $tab );
			if ( in_array( $tab, $allowed, true ) ) {
				$out[] = $tab;
			}
		}

		$out = array_values( array_unique( $out ) );

		return $out !== [] ? $out : [ 'dashicons' ];
	}

	/**
	 * Sanitize an optional numeric string.
	 */
	private function sanitizeOptionalNumber( mixed $raw ): string {
		$value = trim( (string) $raw );
		if ( $value === '' || ! is_numeric( $value ) ) {
			return '';
		}

		return (string) max( 0, (float) $value );
	}

	/**
	 * Sanitize an optional signed number within a closed range.
	 */
	private function sanitizeOptionalSignedNumber( mixed $raw, float $min, float $max ): string {
		$value = trim( (string) $raw );
		if ( $value === '' || ! is_numeric( $value ) ) {
			return '';
		}

		$number = (float) $value;
		if ( $number < $min || $number > $max ) {
			return '';
		}

		return (string) $number;
	}

	/**
	 * Sanitize an optional positive integer string.
	 */
	private function sanitizeOptionalAbsint( mixed $raw ): string {
		$value = absint( $raw );

		return $value > 0 ? (string) $value : '';
	}

	/**
	 * Sanitize a WordPress image size slug.
	 */
	private function sanitizeImageSize( string $raw ): string {
		$value = sanitize_key( $raw );
		$sizes = array_unique(
			array_merge(
				[ 'thumbnail', 'medium', 'medium_large', 'large', 'full' ],
				array_map( 'sanitize_key', get_intermediate_image_sizes() )
			)
		);

		return in_array( $value, $sizes, true ) ? $value : 'medium';
	}

	/**
	 * 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" or "value : Label" per line).
	 *
	 * @param mixed $choices Raw choices block.
	 */
	public function sanitizeChoices( mixed $choices ): string {
		if ( is_array( $choices ) ) {
			$lines = [];
			foreach ( $choices as $value => $label ) {
				if ( count( $lines ) >= Config::MAX_CHOICES ) {
					break;
				}
				if ( is_array( $label ) || is_object( $label ) ) {
					continue;
				}

				$value = sanitize_text_field( (string) $value );
				$label = sanitize_text_field( (string) $label );
				if ( $value === '' && $label === '' ) {
					continue;
				}

				$lines[] = $value !== $label ? $value . '|' . $label : $value;
			}

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

		$lines = preg_split( '/\r\n|\r|\n/', (string) $choices );
		$out   = [];
		foreach ( (array) $lines as $line ) {
			if ( count( $out ) >= Config::MAX_CHOICES ) {
				break;
			}
			$line = sanitize_text_field( (string) $line );
			if ( $line !== '' ) {
				$out[] = $line;
			}
		}

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

	/**
	 * Sanitize a field's default value.
	 *
	 * Checkbox and multi-select defaults accept one value per
	 * line. Other supported defaults are scalar text values.
	 */
	private function sanitizeDefaultValue( mixed $raw, string $type, bool $is_multiple = false ): string {
		if ( $type === 'toggle' ) {
			return ! empty( $raw ) ? '1' : '0';
		}

		if ( $type !== 'checkbox' && ! ( $type === 'select' && $is_multiple ) ) {
			return sanitize_text_field( (string) $raw );
		}

		$values = is_array( $raw ) ? $raw : preg_split( '/\r\n|\r|\n|,/', (string) $raw );
		$out    = [];
		foreach ( (array) $values as $value ) {
			if ( count( $out ) >= Config::MAX_CHOICES ) {
				break;
			}
			$value = sanitize_text_field( (string) $value );
			if ( $value !== '' ) {
				$out[] = $value;
			}
		}

		return implode( "\n", array_values( array_unique( $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;
			} elseif ( preg_match( '/^([^:]+)\s:\s(.+)$/', $line, $matches ) ) {
				$value         = trim( (string) $matches[1] );
				$out[ $value ] = trim( (string) $matches[2] ) !== '' ? trim( (string) $matches[2] ) : $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.', 'wppowersuite-lite' );
		}

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

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

		$group_key = (string) ( $definition['key'] ?? '' );
		$used_meta   = [];
		$field_keys  = [];

		foreach ( $fields as $field ) {
			$field_key = (string) ( $field['key'] ?? '' );
			if ( $field_key !== '' ) {
				$field_keys[ $field_key ] = true;
			}
		}

		foreach ( $fields as $field ) {
			$type  = (string) ( $field['type'] ?? '' );
			$label = (string) ( $field['label'] ?? $field['key'] ?? '' );
			$key   = (string) ( $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.', 'wppowersuite-lite' ),
						$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.', 'wppowersuite-lite' ),
							$resolved
						);
					} else {
						$used_meta[ $resolved ] = true;
					}

				}
			}

			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.', 'wppowersuite-lite' ),
					$label
				);
			}

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

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

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

			if ( $type === 'clone' && (string) ( $field['clone_source'] ?? '' ) === '' ) {
				$errors[] = sprintf(
					/* translators: %s: field label. */
					__( 'The clone field "%s" requires a source field.', 'wppowersuite-lite' ),
					$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.', 'wppowersuite-lite' ),
					$label
				);
			}

			$conditional = isset( $field['conditional'] ) && is_array( $field['conditional'] ) ? $field['conditional'] : [];
			if ( ! empty( $conditional['enabled'] ) ) {
				$rules = isset( $conditional['rules'] ) && is_array( $conditional['rules'] ) ? $conditional['rules'] : [];
				if ( $rules === [] ) {
					$errors[] = sprintf(
						/* translators: %s: field label. */
						__( 'The field "%s" has conditional logic enabled but no conditions.', 'wppowersuite-lite' ),
						$label
					);
				}

				foreach ( $rules as $rule ) {
					if ( ! is_array( $rule ) ) {
						continue;
					}
					$source_key = (string) ( $rule['field'] ?? '' );
					if ( $source_key === '' ) {
						continue;
					}
					if ( $source_key === $key ) {
						$errors[] = sprintf(
							/* translators: %s: field label. */
							__( 'The field "%s" cannot use itself as a conditional logic source.', 'wppowersuite-lite' ),
							$label
						);
						continue;
					}
					if ( ! isset( $field_keys[ $source_key ] ) ) {
						$errors[] = sprintf(
							/* translators: 1: field label, 2: missing field key. */
							__( 'The field "%1$s" uses a missing conditional logic source "%2$s".', 'wppowersuite-lite' ),
							$label,
							$source_key
						);
					}
				}
			}
		}

		return $errors;
	}
}
