<?php
/**
 * Custom Fields renderer and per-type value sanitizer.
 *
 * Renders each supported field type's control and sanitizes the submitted
 * value before it is written to post/term meta.
 *
 * @package CustomFields
 * @since 1.0.0
 */

namespace CustomFields;

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

/**
 * Stateless field input renderer + sanitizer.
 */
final class Fields {

	private const MAX_MULTI_VALUE_IDS = 500;

	private ?NestedFields $nested = null;

	private function nested(): NestedFields {
		if ( $this->nested === null ) {
			$this->nested = new NestedFields( $this );
		}

		return $this->nested;
	}

	/**
	 * Render one field's label, control, and instructions.
	 *
	 * @param array<string, mixed> $field      Field definition.
	 * @param string               $name_attr  HTML name attribute for the control.
	 * @param mixed                $value      Current value.
	 * @param string               $id_attr    HTML id for the control/label.
	 */
	public function render( array $field, string $name_attr, mixed $value, string $id_attr, array $group = [] ): void {
		$type         = (string) ( $field['type'] ?? 'text' );
		$label        = (string) ( $field['label'] ?? '' );
		$instructions = (string) ( $field['instructions'] ?? '' );
		$required     = ! empty( $field['required'] );
		$preserve_set = $type === 'password' && is_string( $value ) && $this->isPasswordHash( $value );
		$field_key    = (string) ( $field['key'] ?? '' );
		$conditional  = isset( $field['conditional'] ) && is_array( $field['conditional'] ) ? $field['conditional'] : [];
		$cond_attr    = '';
		if ( ! empty( $conditional['enabled'] ) ) {
			$cond_attr = ' data-cf-conditional="' . esc_attr( (string) wp_json_encode( $conditional ) ) . '"';
		}

		if ( $type === 'message' ) {
			$message = (string) ( $field['message_content'] ?? '' );
			if ( ! empty( $field['message_escape_html'] ) ) {
				$message = esc_html( $message );
			} else {
				$message = wp_kses_post( $message );
			}
			$message_new_lines = sanitize_key( (string) ( $field['message_new_lines'] ?? '' ) );
			if ( $message_new_lines === 'wpautop' ) {
				$message = wpautop( $message );
			} elseif ( $message_new_lines === 'br' ) {
				$message = nl2br( $message );
			}
			?>
			<div class="cf-field cf-field--message">
				<?php if ( $label !== '' ) : ?>
					<div class="cf-field__label"><strong><?php echo esc_html( $label ); ?></strong></div>
				<?php endif; ?>
				<div class="cf-field__message"><?php echo $message; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped or wp_kses_post sanitized above. ?></div>
				<?php if ( $instructions !== '' ) : ?>
					<p class="description"><?php echo esc_html( $instructions ); ?></p>
				<?php endif; ?>
			</div>
			<?php
			return;
		}

		if ( $type === 'separator' ) {
			?>
			<div class="cf-field cf-field--separator" aria-hidden="true">
				<hr>
			</div>
			<?php
			return;
		}

		if ( $type === 'output' ) {
			$output = wp_kses_post( (string) ( $field['output_content'] ?? '' ) );
			?>
			<div class="cf-field cf-field--output">
				<?php if ( $label !== '' ) : ?>
					<div class="cf-field__label"><strong><?php echo esc_html( $label ); ?></strong></div>
				<?php endif; ?>
				<div class="cf-field__output"><?php echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_kses_post sanitized above. ?></div>
				<?php if ( $instructions !== '' ) : ?>
					<p class="description"><?php echo esc_html( $instructions ); ?></p>
				<?php endif; ?>
			</div>
			<?php
			return;
		}

		if ( in_array( $type, [ 'tab', 'accordion' ], true ) ) {
			$layout_attrs = [
				'data-cf-layout-type' => $type,
			];

			if ( $type === 'tab' ) {
				$placement = sanitize_key( (string) ( $field['tab_placement'] ?? 'top' ) );
				if ( ! in_array( $placement, [ 'top', 'left' ], true ) ) {
					$placement = 'top';
				}

				$layout_attrs['data-placement'] = $placement;
				$layout_attrs['data-endpoint']  = ! empty( $field['tab_endpoint'] ) ? '1' : '0';
				$layout_attrs['data-selected']  = ! empty( $field['tab_selected'] ) ? '1' : '0';
			} else {
				$layout_attrs['data-open']         = ! empty( $field['accordion_open'] ) ? '1' : '0';
				$layout_attrs['data-multi-expand'] = ! empty( $field['accordion_multi_expand'] ) ? '1' : '0';
				$layout_attrs['data-endpoint']     = ! empty( $field['accordion_endpoint'] ) ? '1' : '0';
			}
			?>
			<div class="cf-field cf-field--layout cf-field--<?php echo esc_attr( $type ); ?>"<?php foreach ( $layout_attrs as $attr => $attr_value ) : ?> <?php echo esc_attr( $attr ); ?>="<?php echo esc_attr( $attr_value ); ?>"<?php endforeach; ?>>
				<?php if ( $label !== '' ) : ?>
					<div class="cf-layout-field__title"><?php echo esc_html( $label ); ?></div>
				<?php endif; ?>
				<?php if ( $instructions !== '' ) : ?>
					<p class="description"><?php echo esc_html( $instructions ); ?></p>
				<?php endif; ?>
			</div>
			<?php
			return;
		}
		?>
		<div class="cf-field cf-field--<?php echo esc_attr( $type ); ?>" data-cf-field-key="<?php echo esc_attr( $field_key ); ?>"<?php echo $cond_attr; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above. ?>>
			<div class="cf-field__label">
				<label for="<?php echo esc_attr( $id_attr ); ?>">
					<?php echo esc_html( $label ); ?>
					<?php if ( $required && ! $preserve_set ) : ?>
						<span class="cf-req">*</span>
					<?php endif; ?>
				</label>
			</div>
			<div class="cf-field__control">
				<?php
				$before = (string) ( $field['before'] ?? '' );
				$after  = (string) ( $field['after'] ?? '' );
				if ( $before !== '' ) {
					echo '<span class="cf-field__before">' . esc_html( $before ) . '</span> ';
				}
				$this->renderControl( $type, $field, $name_attr, $value, $id_attr, $group );
				if ( $after !== '' ) {
					echo ' <span class="cf-field__after">' . esc_html( $after ) . '</span>';
				}
				?>
				<?php if ( $instructions !== '' ) : ?>
					<p class="description"><?php echo esc_html( $instructions ); ?></p>
				<?php endif; ?>
			</div>
		</div>
		<?php
	}

	/**
	 * Render the control element for a field type.
	 *
	 * @param string               $type      Field type.
	 * @param array<string, mixed> $field     Field definition.
	 * @param string               $name_attr Name attribute.
	 * @param mixed                $value     Current value.
	 * @param string               $id_attr   Element id.
	 */
	private function renderControl( string $type, array $field, string $name_attr, mixed $value, string $id_attr, array $group = [] ): void {
		if ( NestedFields::isNestedType( $type ) ) {
			$this->nested()->render( $field, $name_attr, $value, $id_attr, $group );
			return;
		}

		$placeholder = (string) ( $field['placeholder'] ?? '' );
		$required    = ! empty( $field['required'] ) && ! ( $type === 'password' && is_string( $value ) && $this->isPasswordHash( $value ) ) ? ' required' : '';
		$min_attr    = $this->numericAttr( $field, 'min' );
		$max_attr    = $this->numericAttr( $field, 'max' );
		$step_attr   = in_array( $type, [ 'number', 'range' ], true ) ? $this->numericStepAttr( $field, $type ) : $this->numericAttr( $field, 'step' );
		$maxlength   = $this->maxlengthAttr( $field );

		switch ( $type ) {
			case 'textarea':
				$rows = max( 2, min( 20, (int) ( $field['rows'] ?? 8 ) ) );
				printf(
					'<textarea id="%1$s" name="%2$s" rows="%7$d" class="widefat" placeholder="%3$s"%4$s%6$s>%5$s</textarea>',
					esc_attr( $id_attr ),
					esc_attr( $name_attr ),
					esc_attr( $placeholder ),
					$required, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static string.
					esc_textarea( (string) $value ),
					$maxlength, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper.
					$rows
				);
				break;

			case 'wysiwyg':
				$tabs = sanitize_key( (string) ( $field['wysiwyg_tabs'] ?? 'all' ) );
				wp_editor(
					(string) $value,
					$id_attr,
					[
						'textarea_name' => $name_attr,
						'media_buttons' => ! empty( $field['wysiwyg_media_upload'] ),
						'textarea_rows' => 8,
						'teeny'         => (string) ( $field['wysiwyg_toolbar'] ?? 'full' ) === 'basic',
						'tinymce'       => $tabs !== 'text',
						'quicktags'     => $tabs !== 'visual',
					]
				);
				break;

			case 'number':
			case 'range':
				$numeric_value     = $value;
				$control_min_attr  = $min_attr;
				$control_max_attr  = $max_attr;
				$control_step_attr = $step_attr;
				if ( $type === 'range' ) {
					$range_min         = $this->numericValueOrDefault( $field['min'] ?? '', 0 );
					$range_max         = $this->numericValueOrDefault( $field['max'] ?? '', 100 );
					$range_step        = $this->numericValueOrDefault( $field['step'] ?? '', 1 );
					$numeric_value     = is_numeric( $value ) ? min( $range_max, max( $range_min, (float) $value ) ) : $range_min;
					$control_min_attr  = sprintf( ' min="%s"', esc_attr( (string) $range_min ) );
					$control_max_attr  = sprintf( ' max="%s"', esc_attr( (string) $range_max ) );
					$control_step_attr = sprintf( ' step="%s"', esc_attr( (string) $range_step ) );
				}
				printf(
					'<input type="%7$s" id="%1$s" name="%2$s" value="%3$s" class="regular-text%8$s" placeholder="%4$s"%5$s%6$s>',
					esc_attr( $id_attr ),
					esc_attr( $name_attr ),
					esc_attr( (string) $numeric_value ),
					esc_attr( $placeholder ),
					$required, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static string.
					$control_min_attr . $control_max_attr . $control_step_attr, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper.
					esc_attr( $type === 'range' ? 'range' : 'number' ),
					$type === 'range' ? ' cf-range-field' : ''
				);
				if ( $type === 'range' ) {
					printf( '<output class="cf-range-value" for="%s">%s</output>', esc_attr( $id_attr ), esc_html( (string) $numeric_value ) );
				}
				break;

			case 'password':
				$stored   = is_string( $value ) ? $value : '';
				$has_hash = $this->isPasswordHash( $stored );
				if ( $placeholder === '' && $has_hash ) {
					$placeholder = __( 'Leave blank to keep current password', 'wppowersuite-lite' );
				}
				printf(
					'<input type="password" id="%1$s" name="%2$s" value="" class="regular-text cf-password-field" placeholder="%3$s" autocomplete="new-password"%4$s%5$s>',
					esc_attr( $id_attr ),
					esc_attr( $name_attr ),
					esc_attr( $placeholder ),
					$required, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static string.
					$maxlength // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper.
				);
				if ( $has_hash ) {
					echo '<p class="description cf-password-set">' . esc_html__( 'Password is set.', 'wppowersuite-lite' ) . '</p>';
				}
				break;

			case 'phone':
				printf(
					'<input type="tel" id="%1$s" name="%2$s" value="%3$s" class="regular-text" placeholder="%4$s" autocomplete="tel"%5$s%6$s>',
					esc_attr( $id_attr ),
					esc_attr( $name_attr ),
					esc_attr( (string) $value ),
					esc_attr( $placeholder ),
					$required, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static string.
					$maxlength // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper.
				);
				break;

			case 'email':
			case 'url':
				printf(
					'<input type="%7$s" id="%1$s" name="%2$s" value="%3$s" class="regular-text" placeholder="%4$s"%5$s%6$s>',
					esc_attr( $id_attr ),
					esc_attr( $name_attr ),
					esc_attr( (string) $value ),
					esc_attr( $placeholder ),
					$required, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static string.
					$maxlength, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper.
					esc_attr( $type )
				);
				break;

			case 'oembed':
				$oembed_width  = absint( $field['oembed_width'] ?? 0 );
				$oembed_height = absint( $field['oembed_height'] ?? 0 );
				if ( $oembed_width <= 0 ) {
					$oembed_width = 640;
				}
				if ( $oembed_height <= 0 ) {
					$oembed_height = 390;
				}
				printf(
					'<input type="url" id="%1$s" name="%2$s" value="%3$s" class="regular-text cf-oembed-url" placeholder="%4$s"%5$s%6$s data-maxwidth="%7$d" data-maxheight="%8$d">',
					esc_attr( $id_attr ),
					esc_attr( $name_attr ),
					esc_attr( (string) $value ),
					esc_attr( $placeholder ),
					$required, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static string.
					$maxlength, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper.
					$oembed_width,
					$oembed_height
				);
				echo '<div class="cf-oembed-preview" aria-live="polite"';
				echo ' style="max-width:' . esc_attr( (string) $oembed_width ) . 'px"';
				echo '></div>';
				break;

			case 'date':
			case 'time':
			case 'datetime':
				if ( ( $value === '' || $value === null ) && ! empty( $field['default_current'] ) ) {
					$value = $type === 'date' ? current_time( 'Y-m-d' ) : ( $type === 'time' ? current_time( 'H:i' ) : current_time( 'Y-m-d\TH:i' ) );
				}
				$input_type = $type === 'datetime' ? 'datetime-local' : $type;
				if ( $type === 'date' ) {
					$value = $this->dateInputValue( $field, $value );
				}
				printf(
					'<input type="%6$s" id="%1$s" name="%2$s" value="%3$s"%4$s%5$s>',
					esc_attr( $id_attr ),
					esc_attr( $name_attr ),
					esc_attr( (string) $value ),
					$required, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static string.
					$this->dateBoundAttr( $field, 'min' ) . $this->dateBoundAttr( $field, 'max' ) . $step_attr, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper.
					esc_attr( $input_type )
				);
				break;

			case 'link':
				$this->renderLink( $field, $name_attr, $value, $id_attr );
				break;

			case 'color':
				$palette = $this->colorPalette( $field );
				printf(
					'<input type="text" id="%1$s" name="%2$s" value="%3$s" class="cf-color-field" data-default-color="%4$s" placeholder="%5$s"%6$s data-alpha-enabled="%7$s" data-palettes="%8$s" data-show-color-wheel="%9$s">',
					esc_attr( $id_attr ),
					esc_attr( $name_attr ),
					esc_attr( (string) $value ),
					esc_attr( (string) ( $field['default'] ?? '' ) ),
					esc_attr( $placeholder !== '' ? $placeholder : '#000000' ),
					$required, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static string.
					! empty( $field['enable_opacity'] ) ? '1' : '0',
					esc_attr( $palette ),
					! isset( $field['show_color_wheel'] ) || ! empty( $field['show_color_wheel'] ) ? '1' : '0'
				);
				break;

			case 'icon_picker':
				$this->renderIconPicker( $field, $name_attr, $value, $id_attr );
				break;

			case 'google_map':
				$this->renderGoogleMap( $field, $name_attr, $value, $id_attr );
				break;

			case 'toggle':
				$on_text  = (string) ( $field['toggle_on_text'] ?? '' );
				$off_text = (string) ( $field['toggle_off_text'] ?? '' );
				$on_text  = $on_text !== '' ? $on_text : __( 'Yes', 'wppowersuite-lite' );
				$off_text = $off_text !== '' ? $off_text : __( 'No', 'wppowersuite-lite' );
				$checked  = (string) $value === '1' || $value === 1 || $value === true;
				$stylized = ! empty( $field['toggle_ui'] );
				if ( $stylized ) {
					printf(
						'<label class="cf-toggle"><input type="checkbox" id="%1$s" name="%2$s" value="1"%3$s data-on-text="%4$s" data-off-text="%5$s"> <span>%6$s</span></label>',
						esc_attr( $id_attr ),
						esc_attr( $name_attr ),
						checked( $checked, true, false ),
						esc_attr( $on_text ),
						esc_attr( $off_text ),
						esc_html( $checked ? $on_text : $off_text )
					);
				} else {
					printf(
						'<label class="cf-check-label"><input type="checkbox" id="%1$s" name="%2$s" value="1"%3$s> %4$s</label>',
						esc_attr( $id_attr ),
						esc_attr( $name_attr ),
						checked( $checked, true, false ),
						esc_html( (string) ( $field['toggle_message'] ?? '' ) !== '' ? (string) $field['toggle_message'] : (string) ( $field['label'] ?? __( 'Enabled', 'wppowersuite-lite' ) ) )
					);
				}
				if ( $stylized && (string) ( $field['toggle_message'] ?? '' ) !== '' ) {
					echo '<p class="description">' . esc_html( (string) $field['toggle_message'] ) . '</p>';
				}
				break;

			case 'select':
				$this->renderSelect( $field, $name_attr, $value, $id_attr );
				break;

			case 'nav_menu':
				$this->renderNavMenu( $field, $name_attr, $value, $id_attr );
				break;

			case 'radio':
				$this->renderRadio( $field, $name_attr, $value, $id_attr );
				break;

			case 'button_group':
				$this->renderButtonGroup( $field, $name_attr, $value, $id_attr );
				break;

			case 'checkbox':
				$this->renderCheckboxes( $field, $name_attr, $value, $id_attr );
				break;

			case 'image':
			case 'file':
				$this->renderMedia( $type, $field, $name_attr, $value, $id_attr );
				break;

			case 'gallery':
				$this->renderGallery( $field, $name_attr, $value, $id_attr );
				break;

			case 'post_object':
				$this->renderPostObject( $field, $name_attr, $value, $id_attr, $group );
				break;

			case 'relationship':
				$field['multiple'] = true;
				$this->renderPostObject( $field, $name_attr, $value, $id_attr, $group, 'relationship' );
				break;

			case 'page_link':
				$this->renderPostObject( $field, $name_attr, $value, $id_attr, $group, 'page_link' );
				break;

			case 'term':
				$this->renderTerm( $field, $name_attr, $value, $id_attr, $group );
				break;

			case 'user':
				$this->renderUser( $field, $name_attr, $value, $id_attr, $group );
				break;

			case 'text':
			default:
				printf(
					'<input type="text" id="%1$s" name="%2$s" value="%3$s" class="regular-text" placeholder="%4$s"%5$s%6$s>',
					esc_attr( $id_attr ),
					esc_attr( $name_attr ),
					esc_attr( (string) $value ),
					esc_attr( $placeholder ),
					$required, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static string.
					$maxlength // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper.
				);
				break;
		}
	}

	/**
	 * @param array<string, mixed> $field     Field definition.
	 * @param string               $name_attr Name attribute.
	 * @param mixed                $value     Current value.
	 * @param string               $id_attr   Element id.
	 */
	private function renderSelect( array $field, string $name_attr, mixed $value, string $id_attr ): void {
		$choices  = Validator::parseChoices( (string) ( $field['choices'] ?? '' ) );
		$multiple = ! empty( $field['multiple'] );
		$selected = is_array( $value ) ? array_map( 'strval', $value ) : [ (string) $value ];
		$name     = $multiple ? $name_attr . '[]' : $name_attr;
		$allow_null = ! empty( $field['allow_null'] );
		$placeholder = (string) ( $field['placeholder'] ?? '' );
		$placeholder = $placeholder !== '' ? $placeholder : _x( 'Select', 'verb', 'wppowersuite-lite' );
		?>
		<?php if ( $multiple || ! empty( $field['select_ui'] ) ) : ?>
			<input type="hidden" name="<?php echo esc_attr( $name_attr ); ?>" value="">
		<?php endif; ?>
		<select id="<?php echo esc_attr( $id_attr ); ?>" name="<?php echo esc_attr( $name ); ?>" class="<?php echo esc_attr( $multiple ? 'cf-select-field cf-multiselect' : 'cf-select-field' ); ?>"<?php echo $multiple ? ' multiple' : ''; ?> data-ui="<?php echo esc_attr( ! empty( $field['select_ui'] ) ? '1' : '0' ); ?>" data-ajax="<?php echo esc_attr( ! empty( $field['select_ajax'] ) ? '1' : '0' ); ?>" data-create-options="<?php echo esc_attr( ! empty( $field['select_create_options'] ) ? '1' : '0' ); ?>" data-placeholder="<?php echo esc_attr( $placeholder ); ?>">
			<?php if ( ! $multiple && $allow_null ) : ?>
				<option value=""><?php echo esc_html( '- ' . $placeholder . ' -' ); ?></option>
			<?php endif; ?>
			<?php foreach ( $choices as $val => $label ) : ?>
				<option value="<?php echo esc_attr( (string) $val ); ?>" <?php selected( in_array( (string) $val, $selected, true ) ); ?>>
					<?php echo esc_html( $label ); ?>
				</option>
			<?php endforeach; ?>
		</select>
		<?php
	}

	/**
	 * @param array<string, mixed> $field     Field definition.
	 * @param string               $name_attr Name attribute.
	 * @param mixed                $value     Current value.
	 * @param string               $id_attr   Element id.
	 */
	private function renderRadio( array $field, string $name_attr, mixed $value, string $id_attr ): void {
		$choices      = Validator::parseChoices( (string) ( $field['choices'] ?? '' ) );
		$current      = (string) $value;
		$allow_custom = ! empty( $field['allow_custom'] );
		$is_custom    = $allow_custom && $current !== '' && ! array_key_exists( $current, $choices );
		if ( $current === '' && empty( $field['allow_null'] ) && $choices !== [] ) {
			$current = (string) array_key_first( $choices );
		}
		$i       = 0;
		$layout_class = (string) ( $field['choice_layout'] ?? 'vertical' ) === 'horizontal' ? ' cf-choices--horizontal' : '';
		echo '<div class="cf-choices' . esc_attr( $layout_class ) . '">';
		printf( '<input type="hidden" name="%s" value="">', esc_attr( $name_attr ) );
		if ( ! empty( $field['allow_null'] ) ) {
			printf(
				'<label class="cf-choice"><input type="radio" name="%1$s" value=""%2$s> <span>%3$s</span></label>',
				esc_attr( $name_attr ),
				checked( $current === '', true, false ),
				esc_html__( 'None', 'wppowersuite-lite' )
			);
		}
		foreach ( $choices as $val => $label ) {
			$rid = $id_attr . '-' . $i;
			printf(
				'<label class="cf-choice"><input type="radio" id="%1$s" name="%2$s" value="%3$s"%4$s> <span>%5$s</span></label>',
				esc_attr( $rid ),
				esc_attr( $name_attr ),
				esc_attr( (string) $val ),
				checked( $current === (string) $val, true, false ),
				esc_html( $label )
			);
			$i++;
		}
		if ( $allow_custom ) {
			$custom_id = $id_attr . '-other';
			printf(
				'<label class="cf-choice cf-choice--custom"><input type="radio" class="cf-radio-other-toggle"%1$s data-target="%2$s"> <span>%3$s</span> <input type="text" id="%2$s" name="%4$s" value="%5$s" class="regular-text cf-radio-other-value"%6$s></label>',
				checked( $is_custom, true, false ),
				esc_attr( $custom_id ),
				esc_html__( 'Other', 'wppowersuite-lite' ),
				esc_attr( $name_attr ),
				esc_attr( $is_custom ? $current : '' ),
				$is_custom ? '' : ' disabled'
			);
		}
		echo '</div>';
	}

	/**
	 * @param array<string, mixed> $field     Field definition.
	 * @param string               $name_attr Name attribute.
	 * @param mixed                $value     Current value.
	 * @param string               $id_attr   Element id.
	 */
	private function renderButtonGroup( array $field, string $name_attr, mixed $value, string $id_attr ): void {
		$choices = Validator::parseChoices( (string) ( $field['choices'] ?? '' ) );
		$current = (string) $value;
		if ( $current === '' && empty( $field['allow_null'] ) && $choices !== [] ) {
			$current = (string) array_key_first( $choices );
		}
		$i       = 0;
		$layout_class = (string) ( $field['choice_layout'] ?? 'horizontal' ) === 'vertical' ? ' cf-button-group--vertical' : '';

		echo '<div class="cf-button-group' . esc_attr( $layout_class ) . '" role="radiogroup">';
		printf( '<input type="hidden" name="%s" value="">', esc_attr( $name_attr ) );
		if ( ! empty( $field['allow_null'] ) ) {
			printf(
				'<label class="cf-button-choice"><input type="radio" name="%1$s" value=""%2$s> <span>%3$s</span></label>',
				esc_attr( $name_attr ),
				checked( $current === '', true, false ),
				esc_html__( 'None', 'wppowersuite-lite' )
			);
		}
		foreach ( $choices as $val => $label ) {
			$rid = $id_attr . '-' . $i;
			printf(
				'<label class="cf-button-choice"><input type="radio" id="%1$s" name="%2$s" value="%3$s"%4$s> <span>%5$s</span></label>',
				esc_attr( $rid ),
				esc_attr( $name_attr ),
				esc_attr( (string) $val ),
				checked( $current === (string) $val, true, false ),
				esc_html( $label )
			);
			$i++;
		}
		echo '</div>';
	}

	/**
	 * @param string $name_attr Name attribute.
	 * @param mixed  $value     Current icon key.
	 * @param string $id_attr   Element id.
	 */
	private function renderIconPicker( array $field, string $name_attr, mixed $value, string $id_attr ): void {
		$current = is_string( $value ) ? trim( $value ) : '';
		$current_dashicon = sanitize_key( $current );
		$current_is_url   = $current !== '' && esc_url_raw( $current ) === $current;
		$tabs             = isset( $field['icon_tabs'] ) && is_array( $field['icon_tabs'] ) ? array_values( array_intersect( [ 'dashicons', 'media_library', 'url' ], $field['icon_tabs'] ) ) : [ 'dashicons', 'media_library', 'url' ];
		if ( $tabs === [] ) {
			$tabs = [ 'dashicons' ];
		}
		?>
		<div class="cf-icon-picker" data-current="<?php echo esc_attr( $current ); ?>">
			<input type="hidden" id="<?php echo esc_attr( $id_attr ); ?>" name="<?php echo esc_attr( $name_attr ); ?>" value="<?php echo esc_attr( $current ); ?>" class="cf-icon-picker__value">
			<?php if ( count( $tabs ) > 1 ) : ?>
				<div class="cf-icon-picker__tabs" role="tablist">
					<?php foreach ( $tabs as $tab ) : ?>
						<button type="button" class="button cf-icon-picker__tab" data-icon-tab="<?php echo esc_attr( $tab ); ?>"><?php echo esc_html( $this->iconTabLabel( $tab ) ); ?></button>
					<?php endforeach; ?>
				</div>
			<?php endif; ?>
			<?php if ( in_array( 'dashicons', $tabs, true ) ) : ?>
				<div class="cf-icon-picker__panel" data-icon-panel="dashicons">
					<select class="cf-icon-picker__select">
						<option value=""><?php esc_html_e( 'No icon', 'wppowersuite-lite' ); ?></option>
						<?php foreach ( $this->iconChoices() as $icon => $label ) : ?>
							<option value="<?php echo esc_attr( $icon ); ?>" <?php selected( ! $current_is_url ? $current_dashicon : '', $icon ); ?>><?php echo esc_html( $label ); ?></option>
						<?php endforeach; ?>
					</select>
				</div>
			<?php endif; ?>
			<?php if ( in_array( 'media_library', $tabs, true ) ) : ?>
				<div class="cf-icon-picker__panel" data-icon-panel="media_library" hidden>
					<button type="button" class="button cf-icon-picker__media"><?php esc_html_e( 'Select Icon', 'wppowersuite-lite' ); ?></button>
				</div>
			<?php endif; ?>
			<?php if ( in_array( 'url', $tabs, true ) ) : ?>
				<div class="cf-icon-picker__panel" data-icon-panel="url" hidden>
					<input type="url" class="regular-text cf-icon-picker__url" value="<?php echo esc_attr( $current_is_url ? $current : '' ); ?>" placeholder="https://example.com/icon.svg">
				</div>
			<?php endif; ?>
			<span class="cf-icon-picker__preview<?php echo $current_is_url ? ' cf-icon-picker__preview--image' : ' dashicons ' . esc_attr( $current_dashicon !== '' ? $current_dashicon : 'dashicons-marker' ); ?>" aria-hidden="true">
				<?php if ( $current_is_url ) : ?>
					<img src="<?php echo esc_url( $current ); ?>" alt="">
				<?php endif; ?>
			</span>
		</div>
		<?php
	}

	/**
	 * @param array<string, mixed> $field     Field definition.
	 * @param string               $name_attr Name attribute.
	 * @param mixed                $value     Current menu term ID.
	 * @param string               $id_attr   Element id.
	 */
	private function renderNavMenu( array $field, string $name_attr, mixed $value, string $id_attr ): void {
		$menus      = wp_get_nav_menus();
		$current    = absint( $value );
		$allow_null = ! empty( $field['allow_null'] );
		?>
		<select id="<?php echo esc_attr( $id_attr ); ?>" name="<?php echo esc_attr( $name_attr ); ?>" class="cf-select-field cf-nav-menu-field">
			<?php if ( $allow_null ) : ?>
				<option value=""><?php echo esc_html__( '- Select menu -', 'wppowersuite-lite' ); ?></option>
			<?php endif; ?>
			<?php foreach ( $menus as $menu ) : ?>
				<?php if ( ! $menu instanceof \WP_Term ) { continue; } ?>
				<option value="<?php echo esc_attr( (string) $menu->term_id ); ?>" <?php selected( $current, (int) $menu->term_id ); ?>>
					<?php echo esc_html( $menu->name ); ?>
				</option>
			<?php endforeach; ?>
		</select>
		<?php if ( $menus === [] ) : ?>
			<p class="description"><?php esc_html_e( 'No navigation menus exist yet.', 'wppowersuite-lite' ); ?></p>
		<?php endif; ?>
		<?php
	}

	/**
	 * Label for an icon picker source tab.
	 */
	private function iconTabLabel( string $tab ): string {
		return match ( $tab ) {
			'media_library' => __( 'Media', 'wppowersuite-lite' ),
			'url'           => __( 'URL', 'wppowersuite-lite' ),
			default         => __( 'Dashicons', 'wppowersuite-lite' ),
		};
	}

	/**
	 * Dashicons offered by the lightweight icon picker.
	 *
	 * @return array<string, string>
	 */
	private function iconChoices(): array {
		return [
			'dashicons-admin-post'     => __( 'Post', 'wppowersuite-lite' ),
			'dashicons-admin-page'     => __( 'Page', 'wppowersuite-lite' ),
			'dashicons-admin-media'    => __( 'Media', 'wppowersuite-lite' ),
			'dashicons-admin-links'    => __( 'Link', 'wppowersuite-lite' ),
			'dashicons-admin-comments' => __( 'Comments', 'wppowersuite-lite' ),
			'dashicons-admin-users'    => __( 'Users', 'wppowersuite-lite' ),
			'dashicons-admin-tools'    => __( 'Tools', 'wppowersuite-lite' ),
			'dashicons-admin-settings' => __( 'Settings', 'wppowersuite-lite' ),
			'dashicons-art'            => __( 'Art', 'wppowersuite-lite' ),
			'dashicons-awards'         => __( 'Award', 'wppowersuite-lite' ),
			'dashicons-book'           => __( 'Book', 'wppowersuite-lite' ),
			'dashicons-calendar'       => __( 'Calendar', 'wppowersuite-lite' ),
			'dashicons-camera'         => __( 'Camera', 'wppowersuite-lite' ),
			'dashicons-cart'           => __( 'Cart', 'wppowersuite-lite' ),
			'dashicons-chart-bar'      => __( 'Chart', 'wppowersuite-lite' ),
			'dashicons-cloud'          => __( 'Cloud', 'wppowersuite-lite' ),
			'dashicons-email-alt'      => __( 'Email', 'wppowersuite-lite' ),
			'dashicons-format-image'   => __( 'Image', 'wppowersuite-lite' ),
			'dashicons-heart'          => __( 'Heart', 'wppowersuite-lite' ),
			'dashicons-location'       => __( 'Location', 'wppowersuite-lite' ),
			'dashicons-marker'         => __( 'Marker', 'wppowersuite-lite' ),
			'dashicons-money-alt'      => __( 'Money', 'wppowersuite-lite' ),
			'dashicons-phone'          => __( 'Phone', 'wppowersuite-lite' ),
			'dashicons-products'       => __( 'Products', 'wppowersuite-lite' ),
			'dashicons-star-filled'    => __( 'Star', 'wppowersuite-lite' ),
			'dashicons-store'          => __( 'Store', 'wppowersuite-lite' ),
			'dashicons-tag'            => __( 'Tag', 'wppowersuite-lite' ),
			'dashicons-tickets-alt'    => __( 'Ticket', 'wppowersuite-lite' ),
			'dashicons-video-alt3'     => __( 'Video', 'wppowersuite-lite' ),
			'dashicons-visibility'     => __( 'Visibility', 'wppowersuite-lite' ),
		];
	}

	/**
	 * @param string $name_attr Name attribute.
	 * @param mixed  $value     Current map value.
	 * @param string $id_attr   Element id prefix.
	 */
	private function renderGoogleMap( array $field, string $name_attr, mixed $value, string $id_attr ): void {
		$map     = is_array( $value ) ? $value : [];
		$address = (string) ( $map['address'] ?? '' );
		$lat     = (string) ( $map['lat'] ?? ( $field['map_center_lat'] ?? '-37.81411' ) );
		$lng     = (string) ( $map['lng'] ?? ( $field['map_center_lng'] ?? '144.96328' ) );
		$zoom    = (string) ( $map['zoom'] ?? ( $field['map_zoom'] ?? '14' ) );
		$height  = max( Config::MIN_MAP_HEIGHT, min( Config::MAX_MAP_HEIGHT, absint( $field['map_height'] ?? 400 ) ) );
		$query   = $lat !== '' && $lng !== '' ? $lat . ',' . $lng : $address;
		$embed   = $query !== '' ? 'https://maps.google.com/maps?q=' . rawurlencode( $query ) . '&z=' . rawurlencode( $zoom ) . '&output=embed' : '';
		$link    = $query !== '' ? 'https://www.google.com/maps/search/?api=1&query=' . rawurlencode( $query ) : '';
		?>
		<div class="cf-google-map-field" style="--cf-map-height: <?php echo esc_attr( (string) $height ); ?>px;">
			<p class="cf-google-map-field__address">
				<label for="<?php echo esc_attr( $id_attr ); ?>-address"><?php esc_html_e( 'Address', 'wppowersuite-lite' ); ?></label>
				<input type="text" id="<?php echo esc_attr( $id_attr ); ?>-address" name="<?php echo esc_attr( $name_attr ); ?>[address]" value="<?php echo esc_attr( $address ); ?>" class="regular-text">
			</p>
			<div class="cf-google-map-field__coords">
				<p>
					<label for="<?php echo esc_attr( $id_attr ); ?>-lat"><?php esc_html_e( 'Latitude', 'wppowersuite-lite' ); ?></label>
					<input type="number" id="<?php echo esc_attr( $id_attr ); ?>-lat" name="<?php echo esc_attr( $name_attr ); ?>[lat]" value="<?php echo esc_attr( $lat ); ?>" step="any" min="-90" max="90">
				</p>
				<p>
					<label for="<?php echo esc_attr( $id_attr ); ?>-lng"><?php esc_html_e( 'Longitude', 'wppowersuite-lite' ); ?></label>
					<input type="number" id="<?php echo esc_attr( $id_attr ); ?>-lng" name="<?php echo esc_attr( $name_attr ); ?>[lng]" value="<?php echo esc_attr( $lng ); ?>" step="any" min="-180" max="180">
				</p>
				<p>
					<label for="<?php echo esc_attr( $id_attr ); ?>-zoom"><?php esc_html_e( 'Zoom', 'wppowersuite-lite' ); ?></label>
					<input type="number" id="<?php echo esc_attr( $id_attr ); ?>-zoom" name="<?php echo esc_attr( $name_attr ); ?>[zoom]" value="<?php echo esc_attr( $zoom ); ?>" min="1" max="22">
				</p>
			</div>
			<div class="cf-google-map-field__preview" data-empty-text="<?php esc_attr_e( 'Enter an address or coordinates to preview the map.', 'wppowersuite-lite' ); ?>">
				<div class="cf-google-map-field__frame">
					<?php if ( $embed !== '' ) : ?>
						<iframe title="<?php esc_attr_e( 'Map preview', 'wppowersuite-lite' ); ?>" src="<?php echo esc_url( $embed ); ?>" loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe>
					<?php else : ?>
						<p><?php esc_html_e( 'Enter an address or coordinates to preview the map.', 'wppowersuite-lite' ); ?></p>
					<?php endif; ?>
				</div>
				<div class="cf-google-map-field__actions">
					<button type="button" class="button cf-google-map-field__refresh"><?php esc_html_e( 'Update preview', 'wppowersuite-lite' ); ?></button>
					<a class="button cf-google-map-field__open<?php echo $link === '' ? ' is-disabled' : ''; ?>" href="<?php echo esc_url( $link ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Open in Google Maps', 'wppowersuite-lite' ); ?></a>
				</div>
			</div>
		</div>
		<?php
	}

	/**
	 * @param array<string, mixed> $field     Field definition.
	 * @param string               $name_attr Name attribute.
	 * @param mixed                $value     Current value.
	 * @param string               $id_attr   Element id.
	 */
	private function renderCheckboxes( array $field, string $name_attr, mixed $value, string $id_attr ): void {
		$choices        = Validator::parseChoices( (string) ( $field['choices'] ?? '' ) );
		$selected       = is_array( $value ) ? array_map( 'strval', $value ) : array_filter( array_map( 'trim', explode( ',', (string) $value ) ) );
		$allow_custom   = ! empty( $field['allow_custom'] );
		$custom_values  = $allow_custom ? array_values( array_diff( $selected, array_map( 'strval', array_keys( $choices ) ) ) ) : [];
		$i              = 0;
		$layout_class = (string) ( $field['choice_layout'] ?? 'vertical' ) === 'horizontal' ? ' cf-choices--horizontal' : '';
		echo '<div class="cf-choices' . esc_attr( $layout_class ) . '">';
		printf( '<input type="hidden" name="%s" value="">', esc_attr( $name_attr ) );
		if ( ! empty( $field['checkbox_toggle'] ) && $choices !== [] ) {
			printf(
				'<label class="cf-choice cf-choice--toggle-all"><input type="checkbox" class="cf-checkbox-toggle-all" data-target="%1$s"> <span>%2$s</span></label>',
				esc_attr( $name_attr . '[]' ),
				esc_html__( 'Toggle all', 'wppowersuite-lite' )
			);
		}
		foreach ( $choices as $val => $label ) {
			$cid = $id_attr . '-' . $i;
			printf(
				'<label class="cf-choice"><input type="checkbox" id="%1$s" name="%2$s[]" value="%3$s"%4$s> <span>%5$s</span></label>',
				esc_attr( $cid ),
				esc_attr( $name_attr ),
				esc_attr( (string) $val ),
				checked( in_array( (string) $val, $selected, true ), true, false ),
				esc_html( $label )
			);
			$i++;
		}
		if ( $allow_custom ) {
			foreach ( $custom_values as $custom_value ) {
				printf(
					'<label class="cf-choice cf-choice--custom"><span>%1$s</span> <input type="text" name="%2$s[]" value="%3$s" class="cf-checkbox-custom-value"></label>',
					esc_html__( 'Custom', 'wppowersuite-lite' ),
					esc_attr( $name_attr ),
					esc_attr( $custom_value )
				);
			}
			printf(
				'<button type="button" class="button cf-checkbox-custom-add" data-name="%1$s[]">%2$s</button>',
				esc_attr( $name_attr ),
				esc_html__( 'Add custom value', 'wppowersuite-lite' )
			);
		}
		echo '</div>';
	}

	/**
	 * @param string               $type      image|file.
	 * @param array<string, mixed> $field     Field definition.
	 * @param string               $name_attr Name attribute.
	 * @param mixed                $value     Attachment ID.
	 * @param string               $id_attr   Element id.
	 */
	private function renderMedia( string $type, array $field, string $name_attr, mixed $value, string $id_attr ): void {
		$id           = (int) $value;
		$preview      = '';
		$preview_size = sanitize_key( (string) ( $field['image_preview_size'] ?? 'medium' ) );
		if ( $preview_size === '' ) {
			$preview_size = 'medium';
		}
		if ( $id > 0 ) {
			if ( $type === 'image' ) {
				$img = wp_get_attachment_image( $id, $preview_size, false, [ 'class' => 'cf-media-preview__img' ] );
				$preview = is_string( $img ) ? $img : '';
			} else {
				$url     = wp_get_attachment_url( $id );
				$preview = is_string( $url ) ? '<span class="cf-media-preview__file">' . esc_html( basename( $url ) ) . '</span>' : '';
			}
		}
		?>
		<div class="cf-media" data-type="<?php echo esc_attr( $type ); ?>" data-cf-library="<?php echo esc_attr( (string) ( $field['media_library'] ?? 'all' ) ); ?>" data-cf-preview-size="<?php echo esc_attr( $preview_size ); ?>" data-cf-mime-types="<?php echo esc_attr( (string) ( $field['mime_types'] ?? '' ) ); ?>">
			<input type="hidden" id="<?php echo esc_attr( $id_attr ); ?>" name="<?php echo esc_attr( $name_attr ); ?>" value="<?php echo esc_attr( (string) $id ); ?>" class="cf-media__input">
			<div class="cf-media__preview"><?php echo $preview; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts. ?></div>
			<button type="button" class="button cf-media__select"><?php echo $type === 'image' ? esc_html__( 'Select Image', 'wppowersuite-lite' ) : esc_html__( 'Select File', 'wppowersuite-lite' ); ?></button>
			<button type="button" class="button-link cf-media__clear"<?php echo $id > 0 ? '' : ' hidden'; ?>><?php esc_html_e( 'Remove', 'wppowersuite-lite' ); ?></button>
		</div>
		<?php
	}

	/**
	 * Build a min/max HTML attribute for date, time, and datetime fields.
	 *
	 * Accepts the native input formats (2026-01-31, 13:30, 2026-01-31T13:30).
	 *
	 * @param array<string, mixed> $field Field definition.
	 * @param string               $key   min|max.
	 */
	private function dateBoundAttr( array $field, string $key ): string {
		$value = trim( (string) ( $field[ $key ] ?? '' ) );
		if ( $value === '' || ! preg_match( '/^[0-9][0-9:T\\-]*$/', $value ) ) {
			return '';
		}

		return sprintf( ' %s="%s"', esc_attr( $key ), esc_attr( $value ) );
	}

	/**
	 * Build a min/max/step HTML attribute when the field defines a numeric bound.
	 *
	 * @param array<string, mixed> $field Field definition.
	 * @param string               $key   min|max|step.
	 */
	private function numericAttr( array $field, string $key ): string {
		$value = (string) ( $field[ $key ] ?? '' );
		if ( $value === '' || ! is_numeric( $value ) ) {
			return '';
		}

		return sprintf( ' %s="%s"', esc_attr( $key ), esc_attr( $value ) );
	}

	/**
	 * Build a compatible numeric step attribute.
	 *
	 * Number inputs use "any" when the field setting is blank so decimal values
	 * are accepted by browser validation. Range sliders mirror the broad fallback
	 * of one whole-number step.
	 *
	 * @param array<string, mixed> $field Field definition.
	 * @param string               $type  Field type.
	 */
	private function numericStepAttr( array $field, string $type ): string {
		$value = (string) ( $field['step'] ?? '' );
		if ( $value !== '' && is_numeric( $value ) ) {
			return sprintf( ' step="%s"', esc_attr( $value ) );
		}

		return $type === 'range' ? ' step="1"' : ' step="any"';
	}

	/**
	 * Convert a numeric setting to a float fallback.
	 */
	private function numericValueOrDefault( mixed $value, float $default ): float {
		return is_numeric( $value ) ? (float) $value : $default;
	}

	/**
	 * Build a maxlength HTML attribute.
	 *
	 * @param array<string, mixed> $field Field definition.
	 */
	private function maxlengthAttr( array $field ): string {
		$value = absint( $field['maxlength'] ?? 0 );
		if ( $value <= 0 ) {
			return '';
		}

		return sprintf( ' maxlength="%s"', esc_attr( (string) $value ) );
	}

	/**
	 * @param array<string, mixed> $field     Field definition.
	 * @param string               $name_attr Base name attribute.
	 * @param mixed                $value     Current link array.
	 * @param string               $id_attr   Element id prefix.
	 */
	private function renderLink( array $field, string $name_attr, mixed $value, string $id_attr ): void {
		$link          = is_array( $value ) ? $value : [];
		$url           = (string) ( $link['url'] ?? '' );
		$title         = (string) ( $link['title'] ?? '' );
		$target        = (string) ( $link['target'] ?? '' );
		$new_tab       = $target === '_blank';
		$required_attr = ! empty( $field['required'] ) ? ' required' : '';
		?>
		<div class="cf-link">
			<p>
				<label for="<?php echo esc_attr( $id_attr ); ?>-url"><?php esc_html_e( 'URL', 'wppowersuite-lite' ); ?></label>
				<input type="url" id="<?php echo esc_attr( $id_attr ); ?>-url" name="<?php echo esc_attr( $name_attr ); ?>[url]" value="<?php echo esc_attr( $url ); ?>" class="widefat"<?php echo $required_attr; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static attribute. ?>>
			</p>
			<p>
				<label for="<?php echo esc_attr( $id_attr ); ?>-title"><?php esc_html_e( 'Link text', 'wppowersuite-lite' ); ?></label>
				<input type="text" id="<?php echo esc_attr( $id_attr ); ?>-title" name="<?php echo esc_attr( $name_attr ); ?>[title]" value="<?php echo esc_attr( $title ); ?>" class="widefat">
			</p>
			<p>
				<label>
					<input type="checkbox" name="<?php echo esc_attr( $name_attr ); ?>[target]" value="_blank" <?php checked( $new_tab ); ?>>
					<?php esc_html_e( 'Open in new tab', 'wppowersuite-lite' ); ?>
				</label>
			</p>
		</div>
		<?php
	}

	/**
	 * @param string $name_attr Name attribute.
	 * @param mixed  $value     Comma-separated IDs or array.
	 * @param string $id_attr   Element id.
	 */
	private function renderGallery( array $field, string $name_attr, mixed $value, string $id_attr ): void {
		$ids          = $this->normalizeIdList( $value );
		$preview_size = sanitize_key( (string) ( $field['gallery_preview_size'] ?? 'medium' ) );
		$insert       = sanitize_key( (string) ( $field['gallery_insert'] ?? 'append' ) );
		if ( $preview_size === '' ) {
			$preview_size = 'medium';
		}
		if ( ! in_array( $insert, [ 'append', 'prepend' ], true ) ) {
			$insert = 'append';
		}
		?>
		<div class="cf-gallery" data-cf-library="<?php echo esc_attr( (string) ( $field['media_library'] ?? 'all' ) ); ?>" data-cf-preview-size="<?php echo esc_attr( $preview_size ); ?>" data-cf-insert="<?php echo esc_attr( $insert ); ?>" data-cf-mime-types="<?php echo esc_attr( (string) ( $field['mime_types'] ?? '' ) ); ?>">
			<input type="hidden" id="<?php echo esc_attr( $id_attr ); ?>" name="<?php echo esc_attr( $name_attr ); ?>" value="<?php echo esc_attr( implode( ',', $ids ) ); ?>" class="cf-gallery__input">
			<div class="cf-gallery__preview">
				<?php foreach ( $ids as $id ) : ?>
					<?php
					$img = wp_get_attachment_image( $id, $preview_size, false, [ 'class' => 'cf-gallery-preview__img', 'data-id' => (string) $id ] );
					if ( is_string( $img ) && $img !== '' ) {
						echo '<span class="cf-gallery-preview__item" data-id="' . esc_attr( (string) $id ) . '">' . $img . '<button type="button" class="cf-gallery-preview__remove" aria-label="' . esc_attr__( 'Remove', 'wppowersuite-lite' ) . '">&times;</button></span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- attachment image HTML.
					}
					?>
				<?php endforeach; ?>
			</div>
			<button type="button" class="button cf-gallery__add"><?php esc_html_e( 'Add Images', 'wppowersuite-lite' ); ?></button>
			<button type="button" class="button-link cf-gallery__clear"<?php echo $ids === [] ? ' hidden' : ''; ?>><?php esc_html_e( 'Clear all', 'wppowersuite-lite' ); ?></button>
		</div>
		<?php
	}

	/**
	 * @param array<string, mixed> $field     Field definition.
	 * @param string               $name_attr Name attribute.
	 * @param mixed                $value     Selected post ID(s).
	 * @param string               $id_attr   Element id.
	 */
	private function renderPostObject( array $field, string $name_attr, mixed $value, string $id_attr, array $group = [], string $rel_type = 'post_object' ): void {
		$multiple   = $rel_type === 'relationship' || ! empty( $field['multiple'] );
		$post_types = isset( $field['post_types'] ) && is_array( $field['post_types'] ) ? array_map( 'strval', $field['post_types'] ) : [];
		$post_statuses = isset( $field['post_statuses'] ) && is_array( $field['post_statuses'] ) ? array_map( 'strval', $field['post_statuses'] ) : [];
		$taxonomy   = sanitize_key( (string) ( $field['taxonomy'] ?? '' ) );
		$taxonomy_terms = isset( $field['taxonomy_terms'] ) && is_array( $field['taxonomy_terms'] ) ? array_values( array_filter( array_map( 'absint', $field['taxonomy_terms'] ) ) ) : [];
		$selected   = $rel_type === 'page_link' ? $this->normalizePageLinkList( $value ) : ( $multiple ? $this->normalizeIdList( $value ) : [ (int) $value ] );
		$posts      = $this->queryPosts( $post_types, $taxonomy, $post_statuses, $taxonomy_terms );
		$name       = $multiple ? $name_attr . '[]' : $name_attr;
		$type_attr  = $post_types !== [] ? implode( ',', $post_types ) : '';
		$status_attr = $post_statuses !== [] ? implode( ',', $post_statuses ) : '';
		$taxonomy_terms_attr = $taxonomy_terms !== [] ? implode( ',', $taxonomy_terms ) : '';
		$include    = implode( ',', array_filter( array_map( 'absint', $selected ) ) );
		$group_key  = (string) ( $group['key'] ?? '' );
		$field_key  = (string) ( $field['key'] ?? '' );
		$archives   = $rel_type === 'page_link' && ! empty( $field['allow_archives'] ) ? $this->pageLinkArchiveOptions( $post_types ) : [];
		$relationship_filters = isset( $field['relationship_filters'] ) && is_array( $field['relationship_filters'] )
			? array_values( array_filter( array_map( 'sanitize_key', $field['relationship_filters'] ) ) )
			: [ 'search', 'post_type', 'taxonomy' ];
		$relationship_elements = isset( $field['relationship_elements'] ) && is_array( $field['relationship_elements'] )
			? array_values( array_filter( array_map( 'sanitize_key', $field['relationship_elements'] ) ) )
			: [];
		$show_relationship_images = $rel_type === 'relationship' && in_array( 'featured_image', $relationship_elements, true );
		$relationship_filter_post_types = [];
		if ( $rel_type === 'relationship' && in_array( 'post_type', $relationship_filters, true ) ) {
			$source_post_types = $post_types !== [] ? $post_types : array_values( get_post_types( [ 'public' => true ], 'names' ) );
			foreach ( $source_post_types as $post_type ) {
				$object = get_post_type_object( $post_type );
				if ( $object ) {
					$relationship_filter_post_types[ $post_type ] = (string) ( $object->labels->singular_name ?? $post_type );
				}
			}
		}
		$relationship_filter_taxonomy_terms = [];
		if ( $rel_type === 'relationship' && in_array( 'taxonomy', $relationship_filters, true ) ) {
			$relationship_filter_taxonomy_terms = $this->relationshipFilterTermChoices( $taxonomy_terms, $taxonomy );
		}
		?>
		<div class="cf-relational-search" data-cf-type="<?php echo esc_attr( $rel_type ); ?>" data-cf-group-key="<?php echo esc_attr( $group_key ); ?>" data-cf-field-key="<?php echo esc_attr( $field_key ); ?>" data-cf-post-types="<?php echo esc_attr( $type_attr ); ?>" data-cf-post-statuses="<?php echo esc_attr( $status_attr ); ?>" data-cf-taxonomy-terms="<?php echo esc_attr( $taxonomy_terms_attr ); ?>" data-cf-include="<?php echo esc_attr( $include ); ?>" data-cf-relationship-filters="<?php echo esc_attr( implode( ',', $relationship_filters ) ); ?>" data-cf-relationship-elements="<?php echo esc_attr( implode( ',', $relationship_elements ) ); ?>">
			<input type="search" class="cf-relational-search__input regular-text" placeholder="<?php esc_attr_e( 'Search posts…', 'wppowersuite-lite' ); ?>" autocomplete="off">
			<span class="cf-relational-search__status" aria-live="polite"></span>
			<?php if ( $rel_type === 'relationship' && ( $relationship_filter_post_types !== [] || $relationship_filter_taxonomy_terms !== [] ) ) : ?>
				<div class="cf-relationship-picker__filters">
					<?php if ( $relationship_filter_post_types !== [] ) : ?>
						<label>
							<span><?php esc_html_e( 'Post type', 'wppowersuite-lite' ); ?></span>
							<select class="cf-relationship-filter cf-relationship-filter--post-type">
								<option value=""><?php esc_html_e( 'All post types', 'wppowersuite-lite' ); ?></option>
								<?php foreach ( $relationship_filter_post_types as $filter_post_type => $filter_post_type_label ) : ?>
									<option value="<?php echo esc_attr( $filter_post_type ); ?>"><?php echo esc_html( $filter_post_type_label ); ?></option>
								<?php endforeach; ?>
							</select>
						</label>
					<?php endif; ?>
					<?php if ( $relationship_filter_taxonomy_terms !== [] ) : ?>
						<label>
							<span><?php esc_html_e( 'Taxonomy', 'wppowersuite-lite' ); ?></span>
							<select class="cf-relationship-filter cf-relationship-filter--taxonomy">
								<option value=""><?php esc_html_e( 'All terms', 'wppowersuite-lite' ); ?></option>
								<?php foreach ( $relationship_filter_taxonomy_terms as $filter_term_id => $filter_term_label ) : ?>
									<option value="<?php echo esc_attr( (string) $filter_term_id ); ?>"><?php echo esc_html( $filter_term_label ); ?></option>
								<?php endforeach; ?>
							</select>
						</label>
					<?php endif; ?>
				</div>
			<?php endif; ?>
			<select id="<?php echo esc_attr( $id_attr ); ?>" name="<?php echo esc_attr( $name ); ?>" class="cf-relational cf-relational__select"<?php echo $multiple ? ' multiple' : ''; ?>>
			<?php if ( ! $multiple && ! empty( $field['allow_null'] ) ) : ?>
				<option value="">&mdash;</option>
			<?php endif; ?>
			<?php foreach ( $archives as $archive_value => $archive_label ) : ?>
				<option value="<?php echo esc_attr( $archive_value ); ?>" <?php selected( in_array( $archive_value, array_map( 'strval', $selected ), true ) ); ?>>
					<?php echo esc_html( $archive_label ); ?>
				</option>
			<?php endforeach; ?>
			<?php foreach ( $posts as $post ) : ?>
				<?php $thumb = $show_relationship_images ? (string) get_the_post_thumbnail_url( $post, 'thumbnail' ) : ''; ?>
				<option value="<?php echo esc_attr( (string) $post->ID ); ?>"<?php echo $thumb !== '' ? ' data-image="' . esc_url( $thumb ) . '"' : ''; ?> <?php selected( in_array( (string) $post->ID, array_map( 'strval', $selected ), true ) ); ?>>
					<?php echo esc_html( $post->post_title . ' (' . $post->post_type . ')' ); ?>
				</option>
			<?php endforeach; ?>
			</select>
		</div>
		<?php
	}

	/**
	 * @param array<string, mixed> $field     Field definition.
	 * @param string               $name_attr Name attribute.
	 * @param mixed                $value     Selected term ID(s).
	 * @param string               $id_attr   Element id.
	 */
	private function renderTerm( array $field, string $name_attr, mixed $value, string $id_attr, array $group = [] ): void {
		$taxonomy = sanitize_key( (string) ( $field['taxonomy'] ?? '' ) );
		if ( $taxonomy === '' || ! taxonomy_exists( $taxonomy ) ) {
			echo '<p class="description">' . esc_html__( 'Configure a taxonomy for this field.', 'wppowersuite-lite' ) . '</p>';
			return;
		}

		$field_type = $this->taxonomyFieldType( $field );
		$multiple   = $this->taxonomyAllowsMultiple( $field );
		$selected   = $multiple ? $this->normalizeIdList( $value ) : [ (int) $value ];
		$terms    = get_terms(
			[
				'taxonomy'   => $taxonomy,
				'hide_empty' => false,
				'number'     => 200,
			]
		);
		$name     = $multiple ? $name_attr . '[]' : $name_attr;
		$include  = $multiple ? implode( ',', $selected ) : (string) (int) $value;
		$group_key = (string) ( $group['key'] ?? '' );
		$field_key = (string) ( $field['key'] ?? '' );
		$tax_object = get_taxonomy( $taxonomy );
		$can_create = ! empty( $field['taxonomy_add_terms'] )
			&& $tax_object instanceof \WP_Taxonomy
			&& current_user_can( $tax_object->cap->manage_terms ?? 'manage_categories' );
		if ( in_array( $field_type, [ 'radio', 'checkbox' ], true ) ) {
			$layout_class = (string) ( $field['choice_layout'] ?? 'vertical' ) === 'horizontal' ? ' cf-choices--horizontal' : '';
			echo '<div class="cf-term-choices" data-cf-group-key="' . esc_attr( $group_key ) . '" data-cf-field-key="' . esc_attr( $field_key ) . '" data-cf-taxonomy="' . esc_attr( $taxonomy ) . '" data-cf-input-type="' . esc_attr( $field_type === 'checkbox' ? 'checkbox' : 'radio' ) . '" data-cf-input-name="' . esc_attr( $field_type === 'checkbox' ? $name_attr . '[]' : $name_attr ) . '">';
			echo '<div class="cf-choices' . esc_attr( $layout_class ) . '">';
			if ( $field_type === 'radio' && ! empty( $field['allow_null'] ) ) {
				printf(
					'<label class="cf-choice"><input type="radio" name="%1$s" value=""%2$s> <span>%3$s</span></label>',
					esc_attr( $name_attr ),
					checked( (int) $value <= 0, true, false ),
					esc_html__( 'None', 'wppowersuite-lite' )
				);
			}
			if ( is_array( $terms ) ) {
				$i = 0;
				foreach ( $terms as $term ) {
					if ( ! $term instanceof \WP_Term ) {
						continue;
					}
					$input_type = $field_type === 'checkbox' ? 'checkbox' : 'radio';
					$input_name = $field_type === 'checkbox' ? $name_attr . '[]' : $name_attr;
					printf(
						'<label class="cf-choice"><input type="%1$s" id="%2$s" name="%3$s" value="%4$s"%5$s> <span>%6$s</span></label>',
						esc_attr( $input_type ),
						esc_attr( $id_attr . '-' . $i ),
						esc_attr( $input_name ),
						esc_attr( (string) $term->term_id ),
						checked( in_array( (int) $term->term_id, $selected, true ), true, false ),
						esc_html( $this->termChoiceLabel( $term ) )
					);
					$i++;
				}
			}
			echo '</div>';
			if ( $can_create ) {
				$this->renderTermCreator( $taxonomy, is_array( $terms ) ? $terms : [] );
			}
			echo '</div>';
			return;
		}
		?>
		<div class="cf-relational-search" data-cf-type="term" data-cf-group-key="<?php echo esc_attr( $group_key ); ?>" data-cf-field-key="<?php echo esc_attr( $field_key ); ?>" data-cf-taxonomy="<?php echo esc_attr( $taxonomy ); ?>" data-cf-include="<?php echo esc_attr( $include ); ?>">
			<input type="search" class="cf-relational-search__input regular-text" placeholder="<?php esc_attr_e( 'Search terms…', 'wppowersuite-lite' ); ?>" autocomplete="off">
			<span class="cf-relational-search__status" aria-live="polite"></span>
			<select id="<?php echo esc_attr( $id_attr ); ?>" name="<?php echo esc_attr( $name ); ?>" class="cf-relational cf-relational__select"<?php echo $multiple ? ' multiple' : ''; ?>>
			<?php if ( ! $multiple && ! empty( $field['allow_null'] ) ) : ?>
				<option value="">&mdash;</option>
			<?php endif; ?>
			<?php if ( is_array( $terms ) ) : ?>
				<?php foreach ( $terms as $term ) : ?>
					<?php if ( ! $term instanceof \WP_Term ) {
						continue;
					} ?>
					<option value="<?php echo esc_attr( (string) $term->term_id ); ?>" <?php echo $multiple ? selected( in_array( $term->term_id, $selected, true ), true, false ) : selected( (int) $value, $term->term_id, false ); ?>>
						<?php echo esc_html( $this->termChoiceLabel( $term ) ); ?>
					</option>
				<?php endforeach; ?>
			<?php endif; ?>
			</select>
			<?php if ( $can_create ) : ?>
				<?php $this->renderTermCreator( $taxonomy, is_array( $terms ) ? $terms : [] ); ?>
			<?php endif; ?>
		</div>
		<?php
	}

	/**
	 * Render the inline term creator shared by select, radio, and checkbox term fields.
	 *
	 * @param string               $taxonomy Taxonomy key.
	 * @param array<int, \WP_Term> $terms    Available terms for the parent dropdown.
	 */
	private function renderTermCreator( string $taxonomy, array $terms ): void {
		$hierarchical = is_taxonomy_hierarchical( $taxonomy );
		?>
		<div class="cf-term-create">
			<input type="text" class="cf-term-create__input regular-text" maxlength="200" placeholder="<?php esc_attr_e( 'New term name', 'wppowersuite-lite' ); ?>">
			<?php if ( $hierarchical ) : ?>
				<select class="cf-term-create__parent">
					<option value="0"><?php esc_html_e( 'No parent', 'wppowersuite-lite' ); ?></option>
					<?php foreach ( $terms as $term ) : ?>
						<?php if ( ! $term instanceof \WP_Term ) {
							continue;
						} ?>
						<option value="<?php echo esc_attr( (string) $term->term_id ); ?>"><?php echo esc_html( $this->termChoiceLabel( $term ) ); ?></option>
					<?php endforeach; ?>
				</select>
			<?php endif; ?>
			<button type="button" class="button cf-term-create__button"><?php esc_html_e( 'Add term', 'wppowersuite-lite' ); ?></button>
			<span class="cf-term-create__status" aria-live="polite"></span>
		</div>
		<?php
	}

	private function termChoiceLabel( \WP_Term $term ): string {
		$ancestors = get_ancestors( $term->term_id, $term->taxonomy, 'taxonomy' );
		$prefix    = ! empty( $ancestors ) ? str_repeat( '- ', count( $ancestors ) ) : '';

		return $prefix . $term->name;
	}

	/**
	 * @param array<string, mixed> $field     Field definition.
	 * @param string               $name_attr Name attribute.
	 * @param mixed                $value     Selected user ID(s).
	 * @param string               $id_attr   Element id.
	 */
	private function renderUser( array $field, string $name_attr, mixed $value, string $id_attr, array $group = [] ): void {
		$multiple = ! empty( $field['multiple'] );
		$selected = $multiple ? $this->normalizeIdList( $value ) : [ (int) $value ];
		$can_list = RelationalSearch::canListUsers();
		$roles    = isset( $field['user_roles'] ) && is_array( $field['user_roles'] ) ? array_values( array_filter( array_map( 'sanitize_key', $field['user_roles'] ) ) ) : [];
		$args     = [ 'number' => 200, 'orderby' => 'display_name' ];
		if ( $roles !== [] ) {
			$args['role__in'] = $roles;
		}
		$users    = $can_list
			? get_users( $args )
			: $this->usersForSelectedIds( $selected );
		$name     = $multiple ? $name_attr . '[]' : $name_attr;
		$include  = $multiple ? implode( ',', $selected ) : (string) (int) $value;
		$group_key = (string) ( $group['key'] ?? '' );
		$field_key = (string) ( $field['key'] ?? '' );
		?>
		<div class="cf-relational-search" data-cf-type="user" data-cf-group-key="<?php echo esc_attr( $group_key ); ?>" data-cf-field-key="<?php echo esc_attr( $field_key ); ?>" data-cf-include="<?php echo esc_attr( $include ); ?>">
			<input type="search" class="cf-relational-search__input regular-text" placeholder="<?php esc_attr_e( 'Search users…', 'wppowersuite-lite' ); ?>" autocomplete="off">
			<span class="cf-relational-search__status" aria-live="polite"></span>
			<select id="<?php echo esc_attr( $id_attr ); ?>" name="<?php echo esc_attr( $name ); ?>" class="cf-relational cf-relational__select"<?php echo $multiple ? ' multiple' : ''; ?>>
			<?php if ( ! $multiple && ! empty( $field['allow_null'] ) ) : ?>
				<option value="">&mdash;</option>
			<?php endif; ?>
			<?php foreach ( $users as $user ) : ?>
				<option value="<?php echo esc_attr( (string) $user->ID ); ?>" <?php echo $multiple ? selected( in_array( $user->ID, $selected, true ), true, false ) : selected( (int) $value, $user->ID, false ); ?>>
					<?php echo esc_html( $can_list ? $user->display_name . ' (' . $user->user_login . ')' : $user->display_name ); ?>
				</option>
			<?php endforeach; ?>
			</select>
		</div>
		<?php
	}

	/**
	 * @param array<int, string> $post_types Post type keys to include.
	 * @return array<int, \WP_Post>
	 */
	private function queryPosts( array $post_types, string $taxonomy = '', array $post_statuses = [], array $taxonomy_terms = [] ): array {
		$allowed_statuses = array_keys( Config::relationalPostStatuses() );
		$post_statuses   = array_values(
			array_intersect(
				array_map( 'sanitize_key', $post_statuses ),
				$allowed_statuses
			)
		);
		$args = [
			'post_type'              => $post_types !== [] ? $post_types : array_values( get_post_types( [ 'public' => true ] ) ),
			'post_status'            => $post_statuses !== [] ? $post_statuses : 'any',
			'posts_per_page'         => 200,
			'orderby'                => 'date',
			'order'                  => 'DESC',
			'no_found_rows'          => true,
			'update_post_meta_cache' => false,
			'update_post_term_cache' => false,
		];

		if ( $taxonomy !== '' && taxonomy_exists( $taxonomy ) ) {
			$args['tax_query'] = [
				[
					'taxonomy' => $taxonomy,
					'operator' => 'EXISTS',
				],
			];
		}

		if ( $taxonomy_terms !== [] ) {
			$tax_query = [ 'relation' => 'OR' ];
			foreach ( $this->groupTermIdsByTaxonomy( $taxonomy_terms ) as $term_taxonomy => $term_ids ) {
				$tax_query[] = [
					'taxonomy' => $term_taxonomy,
					'field'    => 'term_id',
					'terms'    => $term_ids,
					'operator' => 'IN',
				];
			}
			if ( count( $tax_query ) > 1 ) {
				$args['tax_query'] = $tax_query;
			}
		}

		$query = new \WP_Query( $args );

		return array_values(
			array_filter(
				$query->posts,
				static fn( mixed $post ): bool => $post instanceof \WP_Post && current_user_can( 'read_post', $post->ID )
			)
		);
	}

	/**
	 * @param array<int, int> $configured_term_ids Saved relationship term filters.
	 * @return array<int, string>
	 */
	private function relationshipFilterTermChoices( array $configured_term_ids, string $legacy_taxonomy = '' ): array {
		$out = [];

		if ( $configured_term_ids !== [] ) {
			foreach ( $this->groupTermIdsByTaxonomy( $configured_term_ids ) as $term_ids ) {
				foreach ( $term_ids as $term_id ) {
					$term = get_term( $term_id );
					if ( $term instanceof \WP_Term ) {
						$out[ (int) $term->term_id ] = $this->termChoiceLabel( $term );
					}
				}
			}

			return $out;
		}

		$args = [
			'hide_empty' => false,
			'number'     => 200,
		];
		if ( $legacy_taxonomy !== '' && taxonomy_exists( $legacy_taxonomy ) ) {
			$args['taxonomy'] = $legacy_taxonomy;
		} else {
			$args['taxonomy'] = array_values( get_taxonomies( [ 'public' => true ], 'names' ) );
		}

		$terms = get_terms( $args );
		if ( is_array( $terms ) ) {
			foreach ( $terms as $term ) {
				if ( $term instanceof \WP_Term ) {
					$out[ (int) $term->term_id ] = $this->termChoiceLabel( $term );
				}
			}
		}

		return $out;
	}

	/**
	 * @param array<int, int> $term_ids Term IDs.
	 * @return array<string, array<int, int>>
	 */
	private function groupTermIdsByTaxonomy( array $term_ids ): array {
		$out = [];
		foreach ( array_unique( array_filter( array_map( 'absint', $term_ids ) ) ) as $term_id ) {
			$term = get_term( $term_id );
			if ( ! $term instanceof \WP_Term ) {
				continue;
			}
			$out[ $term->taxonomy ][] = (int) $term->term_id;
		}

		return $out;
	}

	/**
	 * Normalize a stored value to a list of positive integers.
	 *
	 * @param mixed $value Raw value.
	 * @return array<int, int>
	 */
	private function normalizeIdList( mixed $value ): array {
		if ( is_array( $value ) ) {
			$parts = $value;
		} else {
			$parts = array_filter( array_map( 'trim', explode( ',', (string) $value ) ) );
		}

		return array_slice(
			array_values(
				array_unique(
					array_filter( array_map( 'absint', $parts ) )
				)
			),
			0,
			self::MAX_MULTI_VALUE_IDS
		);
	}

	/**
	 * Normalize Page Link values, allowing post IDs plus strict archive tokens.
	 *
	 * @param mixed $value Raw value.
	 * @return array<int, string>
	 */
	private function normalizePageLinkList( mixed $value ): array {
		$parts = is_array( $value ) ? $value : array_filter( array_map( 'trim', explode( ',', (string) $value ) ) );
		$out   = [];
		foreach ( $parts as $part ) {
			$token = $this->sanitizePageLinkToken( $part );
			if ( $token !== '' ) {
				$out[] = $token;
			}
		}

		return array_slice( array_values( array_unique( $out ) ), 0, self::MAX_MULTI_VALUE_IDS );
	}

	private function sanitizePageLinkToken( mixed $value ): string {
		$raw = trim( (string) $value );
		if ( $raw === '' ) {
			return '';
		}

		$id = absint( $raw );
		if ( (string) $id === $raw && $id > 0 ) {
			return (string) $id;
		}

		if ( $raw === 'archive:home' ) {
			return $raw;
		}

		if ( preg_match( '/^archive:post_type:([a-z0-9_-]+)$/', $raw, $matches ) && post_type_exists( $matches[1] ) ) {
			return $raw;
		}

		return '';
	}

	/**
	 * @param array<int, string> $post_types Configured post type filter.
	 * @return array<string, string>
	 */
	private function pageLinkArchiveOptions( array $post_types ): array {
		$options = [
			'archive:home' => __( 'Home', 'wppowersuite-lite' ),
		];
		$types = $post_types !== [] ? $post_types : array_values( get_post_types( [ 'public' => true ], 'names' ) );
		foreach ( $types as $type ) {
			$type = sanitize_key( (string) $type );
			if ( $type === '' || ! post_type_exists( $type ) || ! get_post_type_archive_link( $type ) ) {
				continue;
			}
			$object = get_post_type_object( $type );
			$options[ 'archive:post_type:' . $type ] = sprintf(
				/* translators: %s: post type label. */
				__( '%s archive', 'wppowersuite-lite' ),
				$object ? (string) $object->labels->name : $type
			);
		}

		return $options;
	}

	/**
	 * Resolve only already-selected users — not the site directory.
	 *
	 * @param array<int, int> $ids User IDs.
	 * @return array<int, \WP_User>
	 */
	private function usersForSelectedIds( array $ids ): array {
		$users = [];
		foreach ( $ids as $id ) {
			if ( $id <= 0 ) {
				continue;
			}
			$user = get_userdata( $id );
			if ( $user instanceof \WP_User ) {
				$users[] = $user;
			}
		}

		return $users;
	}

	/**
	 * Sanitize a submitted value for a field, ready for meta storage.
	 *
	 * @param array<string, mixed> $field Field definition.
	 * @param mixed                $raw   Raw submitted value (already unslashed).
	 * @return mixed Sanitized value (string, int, or array).
	 */
	public function sanitizeValue( array $field, mixed $raw, array $group = [] ): mixed {
		$type = (string) ( $field['type'] ?? 'text' );

		if ( NestedFields::isNestedType( $type ) ) {
			return $this->nested()->sanitize( $field, $raw, $group );
		}

		switch ( $type ) {
			case 'textarea':
				return sanitize_textarea_field( (string) $raw );

			case 'wysiwyg':
				return wp_kses_post( (string) $raw );

			case 'number':
			case 'range':
				if ( $raw === '' || $raw === null ) {
					return '';
				}
				$numeric = str_replace( ',', '', trim( (string) $raw ) );
				return is_numeric( $numeric ) ? ( $numeric + 0 ) : '';

			case 'password':
				$plain = sanitize_text_field( (string) $raw );
				if ( $plain === '' ) {
					return '';
				}
				if ( $this->isPasswordHash( $plain ) ) {
					return $plain;
				}

				return wp_hash_password( $plain );

			case 'phone':
				return sanitize_text_field( (string) $raw );

			case 'email':
				return sanitize_email( (string) $raw );

			case 'url':
			case 'oembed':
				return esc_url_raw( trim( (string) $raw ) );

			case 'date':
				return $this->sanitizeDateValue( $field, $raw );

			case 'time':
				$time = trim( (string) $raw );
				return preg_match( '/^\d{2}:\d{2}(:\d{2})?$/', $time ) ? $time : '';

			case 'datetime':
				$datetime = trim( (string) $raw );
				return $this->parseDateTimeValue( $datetime ) instanceof \DateTimeImmutable ? $datetime : '';

			case 'link':
				if ( ! is_array( $raw ) ) {
					return '';
				}
				$url = esc_url_raw( trim( (string) ( $raw['url'] ?? '' ) ) );
				if ( $url === '' ) {
					return '';
				}
				return [
					'url'    => $url,
					'title'  => sanitize_text_field( (string) ( $raw['title'] ?? '' ) ),
					'target' => (string) ( $raw['target'] ?? '' ) === '_blank' ? '_blank' : '',
				];

			case 'color':
				return $this->sanitizeColorValue( $raw, ! empty( $field['enable_opacity'] ) );

			case 'icon_picker':
				return $this->sanitizeIconValue( $raw );

			case 'google_map':
				if ( ! is_array( $raw ) ) {
					return [ 'address' => '', 'lat' => '', 'lng' => '', 'zoom' => 14 ];
				}
				return [
					'address' => sanitize_text_field( (string) ( $raw['address'] ?? '' ) ),
					'lat'     => $this->sanitizeCoordinate( $raw['lat'] ?? '', -90, 90 ),
					'lng'     => $this->sanitizeCoordinate( $raw['lng'] ?? '', -180, 180 ),
					'zoom'    => max( 1, min( 22, absint( $raw['zoom'] ?? 14 ) ) ),
				];

			case 'toggle':
				return ! empty( $raw ) ? '1' : '0';

			case 'nav_menu':
				$menu_id = absint( $raw );
				return $menu_id > 0 && is_nav_menu( $menu_id ) ? $menu_id : '';

			case 'image':
			case 'file':
				return (int) $raw > 0 ? (int) $raw : '';

			case 'gallery':
				return $this->normalizeIdList( $raw );

			case 'post_object':
			case 'relationship':
			case 'user':
				if ( $type === 'relationship' || ! empty( $field['multiple'] ) ) {
					return $this->normalizeIdList( $raw );
				}
				$id = absint( $raw );
				return $id > 0 ? $id : '';

			case 'page_link':
				$values = $this->normalizePageLinkList( $raw );
				if ( ! empty( $field['multiple'] ) ) {
					return $values;
				}
				return $values[0] ?? '';

			case 'term':
				if ( $this->taxonomyAllowsMultiple( $field ) ) {
					return $this->normalizeIdList( $raw );
				}
				$id = absint( $raw );
				return $id > 0 ? $id : '';

			case 'select':
				if ( ! empty( $field['multiple'] ) ) {
					$values = is_array( $raw ) ? $raw : [];
					return array_values(
						array_filter(
							array_map( 'sanitize_text_field', array_map( 'strval', $values ) ),
							static fn( string $item ): bool => trim( $item ) !== ''
						)
					);
				}
				return sanitize_text_field( (string) $raw );

			case 'checkbox':
				$values = is_array( $raw ) ? $raw : [];
				return array_values(
					array_filter(
						array_map( 'sanitize_text_field', array_map( 'strval', $values ) ),
						static fn( string $item ): bool => trim( $item ) !== ''
					)
				);

			case 'radio':
			case 'text':
			default:
				return sanitize_text_field( (string) $raw );
		}
	}

	/**
	 * Validate a sanitized value against the field definition.
	 *
	 * @param array<string, mixed> $field Field definition.
	 * @param mixed                $value Sanitized value.
	 * @param array<string, mixed> $group Field group definition.
	 * @return array<int, string>
	 */
	public function validateValue( array $field, mixed $value, array $group = [] ): array {
		$type = (string) ( $field['type'] ?? 'text' );
		$min  = absint( $field['min'] ?? 0 );
		$needs_empty_validation = ( $min > 0 && in_array( $type, [ 'gallery', 'relationship', 'repeater', 'flexible_content' ], true ) )
			|| ( $type === 'flexible_content' && $this->flexibleHasLayoutMinimum( $field ) );
		if ( $this->isEmpty( $value ) && ! $needs_empty_validation ) {
			return [];
		}

		$label  = (string) ( $field['label'] ?? $field['name'] ?? __( 'Field', 'wppowersuite-lite' ) );
		$errors = [];

		if ( Config::typeSupports( $type, 'maxlength' ) ) {
			$errors = array_merge( $errors, $this->validateMaxlength( $field, $value, $label ) );
		}

		if ( in_array( $type, Config::choiceTypes(), true ) ) {
			$errors = array_merge( $errors, $this->validateChoiceValue( $field, $value, $label ) );
		}

		if ( in_array( $type, [ 'number', 'range' ], true ) ) {
			$errors = array_merge( $errors, $this->validateNumericBounds( $field, $value, $label ) );
		}

		if ( in_array( $type, [ 'date', 'time', 'datetime' ], true ) ) {
			$errors = array_merge( $errors, $this->validateTemporalValue( $field, $value, $label ) );
		}

		if ( $type === 'email' ) {
			$errors = array_merge( $errors, $this->validateEmailValue( $value, $label ) );
		}

		if ( in_array( $type, [ 'url', 'oembed' ], true ) ) {
			$errors = array_merge( $errors, $this->validateUrlValue( $value, $label ) );
		}

		if ( $type === 'link' ) {
			$errors = array_merge( $errors, $this->validateLinkValue( $value, $label ) );
		}

		if ( $type === 'icon_picker' ) {
			$errors = array_merge( $errors, $this->validateIconValue( $value, $label ) );
		}

		if ( $type === 'google_map' ) {
			$errors = array_merge( $errors, $this->validateGoogleMapValue( $value, $label ) );
		}

		if ( in_array( $type, [ 'image', 'file', 'gallery' ], true ) ) {
			$errors = array_merge( $errors, $this->validateAttachmentValue( $field, $type, $value, $label ) );
		}

		if ( $type === 'gallery' ) {
			$errors = array_merge( $errors, $this->validateGalleryCount( $field, $value, $label ) );
		}

		if ( in_array( $type, [ 'relationship', 'repeater', 'flexible_content' ], true ) ) {
			$errors = array_merge( $errors, $this->validateItemCount( $field, $value, $label ) );
		}

		if ( in_array( $type, [ 'group', 'repeater', 'flexible_content' ], true ) ) {
			$errors = array_merge( $errors, $this->validateNestedValue( $field, $value, $group, $label ) );
		}

		if ( in_array( $type, [ 'post_object', 'relationship', 'page_link' ], true ) ) {
			$errors = array_merge( $errors, $this->validatePostObjectValue( $field, $value, $label ) );
		}

		if ( $type === 'term' ) {
			$errors = array_merge( $errors, $this->validateTermValue( $field, $value, $label ) );
		}

		if ( $type === 'user' ) {
			$errors = array_merge( $errors, $this->validateUserValue( $field, $value, $label ) );
		}

		return $errors;
	}

	/**
	 * Validate subfield values stored inside group, repeater, and flexible content fields.
	 *
	 * @param array<string, mixed> $field Parent field definition.
	 * @param mixed                $value Sanitized parent value.
	 * @param array<string, mixed> $group Field group definition.
	 * @return array<int, string>
	 */
	private function validateNestedValue( array $field, mixed $value, array $group, string $label ): array {
		$type = (string) ( $field['type'] ?? '' );
		if ( $type === 'group' ) {
			return is_array( $value ) ? $this->validateSubFieldValues( $this->normalizedSubFields( $field ), $value, $group, $label ) : [];
		}

		if ( $type === 'repeater' ) {
			if ( ! is_array( $value ) ) {
				return [];
			}

			$errors = [];
			foreach ( array_values( $value ) as $index => $row ) {
				if ( ! is_array( $row ) ) {
					continue;
				}
				$errors = array_merge(
					$errors,
					$this->validateSubFieldValues(
						$this->normalizedSubFields( $field ),
						$row,
						$group,
						sprintf(
							/* translators: 1: field label, 2: row number. */
							__( '%1$s row %2$s', 'wppowersuite-lite' ),
							$label,
							(string) ( $index + 1 )
						)
					)
				);
			}

			return $errors;
		}

		if ( $type === 'clone' ) {
			return $this->validateCloneValue( $field, $value, $group, $label );
		}

		if ( $type !== 'flexible_content' || ! is_array( $value ) ) {
			return [];
		}

		$errors  = [];
		$layouts = $this->normalizedLayouts( $field );
		$layout_counts = array_fill_keys( array_keys( $layouts ), 0 );
		foreach ( array_values( $value ) as $index => $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$layout_key = (string) ( $row['_layout'] ?? '' );
			if ( $layout_key === '' || ! isset( $layouts[ $layout_key ] ) ) {
				continue;
			}
			++$layout_counts[ $layout_key ];
			$layout_label = (string) ( $layouts[ $layout_key ]['label'] ?? $layouts[ $layout_key ]['name'] ?? $layout_key );
			$errors       = array_merge(
				$errors,
				$this->validateSubFieldValues(
					$this->normalizedSubFields( $layouts[ $layout_key ] ),
					$row,
					$group,
					sprintf(
						/* translators: 1: field label, 2: layout label, 3: row number. */
						__( '%1$s %2$s row %3$s', 'wppowersuite-lite' ),
						$label,
						$layout_label,
						(string) ( $index + 1 )
					)
				)
			);
		}

		$errors = array_merge( $errors, $this->validateFlexibleLayoutCounts( $layouts, $layout_counts, $label ) );

		return $errors;
	}

	/**
	 * Validate clone values against their resolved source fields.
	 *
	 * @param array<string, mixed> $field Clone field definition.
	 * @param mixed                $value Sanitized clone value.
	 * @param array<string, mixed> $group Field group definition.
	 * @return array<int, string>
	 */
	private function validateCloneValue( array $field, mixed $value, array $group, string $label ): array {
		$source = $this->nested()->cloneValidationFields( $field, $group );
		if ( $source === null || $source['fields'] === [] ) {
			return [];
		}

		if ( ! $source['multiple'] ) {
			$source_field = $source['fields'][0];
			$source_label = (string) ( $source_field['label'] ?? $label );
			$errors       = $this->validateValue( $source_field, $value, $group );

			return array_map(
				static fn( string $error ): string => sprintf(
					/* translators: 1: clone field label, 2: source validation error. */
					__( '“%1$s” clone source error: %2$s', 'wppowersuite-lite' ),
					$source_label,
					$error
				),
				$errors
			);
		}

		return is_array( $value )
			? $this->validateSubFieldValues( $source['fields'], $value, $group, $label )
			: [];
	}

	/**
	 * @param array<string, array<string, mixed>> $layouts       Layout definitions keyed by layout key.
	 * @param array<string, int>                  $layout_counts Saved row counts keyed by layout key.
	 * @return array<int, string>
	 */
	private function validateFlexibleLayoutCounts( array $layouts, array $layout_counts, string $label ): array {
		$errors = [];

		foreach ( $layouts as $layout_key => $layout ) {
			$count        = $layout_counts[ $layout_key ] ?? 0;
			$layout_label = (string) ( $layout['label'] ?? $layout['name'] ?? $layout_key );
			$min          = absint( $layout['min'] ?? 0 );
			$max          = absint( $layout['max'] ?? 0 );

			if ( $min > 0 && $count < $min ) {
				$errors[] = sprintf(
					/* translators: 1: field label, 2: layout label, 3: minimum layout count. */
					__( '“%1$s” requires at least %3$s “%2$s” layouts.', 'wppowersuite-lite' ),
					$label,
					$layout_label,
					(string) $min
				);
			}

			if ( $max > 0 && $count > $max ) {
				$errors[] = sprintf(
					/* translators: 1: field label, 2: layout label, 3: maximum layout count. */
					__( '“%1$s” allows no more than %3$s “%2$s” layouts.', 'wppowersuite-lite' ),
					$label,
					$layout_label,
					(string) $max
				);
			}
		}

		return $errors;
	}

	/**
	 * Whether a flexible content field has a layout-level minimum.
	 *
	 * @param array<string, mixed> $field Field definition.
	 */
	private function flexibleHasLayoutMinimum( array $field ): bool {
		foreach ( $this->normalizedLayouts( $field ) as $layout ) {
			if ( absint( $layout['min'] ?? 0 ) > 0 ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * @param array<int, array<string, mixed>> $sub_fields Subfield definitions.
	 * @param array<string, mixed>             $row        Sanitized row value.
	 * @param array<string, mixed>             $group      Field group definition.
	 * @return array<int, string>
	 */
	private function validateSubFieldValues( array $sub_fields, array $row, array $group, string $parent_label ): array {
		$errors = [];
		$values = $this->conditionalValueMap( $sub_fields, $row );

		foreach ( $sub_fields as $sub ) {
			$type = (string) ( $sub['type'] ?? 'text' );
			if ( ! Config::storesValue( $type ) ) {
				continue;
			}

			$sub_name = (string) ( $sub['name'] ?? $sub['key'] ?? '' );
			if ( $sub_name === '' ) {
				continue;
			}

			$conditional = isset( $sub['conditional'] ) && is_array( $sub['conditional'] ) ? $sub['conditional'] : [];
			if ( ! RuleEngine::fieldVisible( $conditional, $values ) ) {
				continue;
			}

			$sub_value = $row[ $sub_name ] ?? null;
			$sub_label = $parent_label . ' > ' . (string) ( $sub['label'] ?? $sub['name'] ?? $sub['key'] ?? __( 'Field', 'wppowersuite-lite' ) );

			if ( ! empty( $sub['required'] ) && $this->isEmpty( $sub_value ) ) {
				$errors[] = sprintf(
					/* translators: %s: field label. */
					__( '“%s” is required.', 'wppowersuite-lite' ),
					$sub_label
				);
				continue;
			}

			if ( ! $this->isEmpty( $sub_value ) && ! $this->matchesPattern( $sub, $sub_value ) ) {
				$errors[] = sprintf(
					/* translators: %s: field label. */
					__( '“%s” has an invalid format.', 'wppowersuite-lite' ),
					$sub_label
				);
				continue;
			}

			$value_errors = $this->validateValue( $sub, $sub_value, $group );
			if ( $value_errors !== [] ) {
				array_push( $errors, ...$value_errors );
			}
		}

		return $errors;
	}

	/**
	 * Build a field-key value map for nested conditional rules.
	 *
	 * @param array<int, array<string, mixed>> $sub_fields Subfield definitions.
	 * @param array<string, mixed>             $row        Sanitized row value.
	 * @return array<string, mixed>
	 */
	private function conditionalValueMap( array $sub_fields, array $row ): array {
		$values = [];

		foreach ( $sub_fields as $sub ) {
			$key  = (string) ( $sub['key'] ?? '' );
			$name = (string) ( $sub['name'] ?? $key );
			if ( $name === '' ) {
				continue;
			}
			$value = $row[ $name ] ?? null;
			if ( $key !== '' ) {
				$values[ $key ] = $value;
			}
			$values[ $name ] = $value;
		}

		return $values;
	}

	/**
	 * Return submitted custom choice values that should be persisted to the field definition.
	 *
	 * @param array<string, mixed> $field Field definition.
	 * @param mixed                $value Sanitized value.
	 * @return array<int, string>
	 */
	public function customChoiceValuesToSave( array $field, mixed $value ): array {
		$type = (string) ( $field['type'] ?? 'text' );
		$can_save = ( $type === 'select' && ! empty( $field['select_create_options'] ) && ! empty( $field['select_save_options'] ) )
			|| ( in_array( $type, [ 'radio', 'checkbox' ], true ) && ! empty( $field['allow_custom'] ) && ! empty( $field['save_custom'] ) );

		if ( ! $can_save ) {
			return [];
		}

		$existing = array_map( 'strval', array_keys( Validator::parseChoices( (string) ( $field['choices'] ?? '' ) ) ) );
		$values   = is_array( $value ) ? $value : [ $value ];
		$custom   = [];

		foreach ( $values as $item ) {
			$item = trim( sanitize_text_field( (string) $item ) );
			if ( $item === '' || in_array( $item, $existing, true ) || in_array( $item, $custom, true ) ) {
				continue;
			}
			$custom[] = $item;
		}

		return $custom;
	}

	/**
	 * Return saved-option updates for a field and any nested choice fields it owns.
	 *
	 * @param array<string, mixed> $field Field definition.
	 * @param mixed                $value Sanitized field value.
	 * @return array<string, array<int, string>>
	 */
	public function customChoiceUpdatesToSave( array $field, mixed $value ): array {
		$updates = [];
		$type    = (string) ( $field['type'] ?? 'text' );
		$key     = (string) ( $field['key'] ?? '' );
		$custom  = $this->customChoiceValuesToSave( $field, $value );

		if ( $key !== '' && $custom !== [] ) {
			$updates[ $key ] = $custom;
		}

		if ( $type === 'group' && is_array( $value ) ) {
			foreach ( $this->normalizedSubFields( $field ) as $sub ) {
				$sub_name = (string) ( $sub['name'] ?? $sub['key'] ?? '' );
				if ( $sub_name === '' ) {
					continue;
				}
				$this->mergeCustomChoiceUpdates( $updates, $this->customChoiceUpdatesToSave( $sub, $value[ $sub_name ] ?? null ) );
			}
		}

		if ( $type === 'repeater' && is_array( $value ) ) {
			foreach ( array_values( $value ) as $row ) {
				if ( ! is_array( $row ) ) {
					continue;
				}
				foreach ( $this->normalizedSubFields( $field ) as $sub ) {
					$sub_name = (string) ( $sub['name'] ?? $sub['key'] ?? '' );
					if ( $sub_name === '' ) {
						continue;
					}
					$this->mergeCustomChoiceUpdates( $updates, $this->customChoiceUpdatesToSave( $sub, $row[ $sub_name ] ?? null ) );
				}
			}
		}

		if ( $type === 'flexible_content' && is_array( $value ) ) {
			$layouts = $this->normalizedLayouts( $field );
			foreach ( array_values( $value ) as $row ) {
				if ( ! is_array( $row ) ) {
					continue;
				}
				$layout_key = (string) ( $row['_layout'] ?? '' );
				if ( $layout_key === '' || ! isset( $layouts[ $layout_key ] ) ) {
					continue;
				}
				foreach ( $this->normalizedSubFields( $layouts[ $layout_key ] ) as $sub ) {
					$sub_name = (string) ( $sub['name'] ?? $sub['key'] ?? '' );
					if ( $sub_name === '' ) {
						continue;
					}
					$this->mergeCustomChoiceUpdates( $updates, $this->customChoiceUpdatesToSave( $sub, $row[ $sub_name ] ?? null ) );
				}
			}
		}

		return $updates;
	}

	/**
	 * Append custom saved-option values to matching field definitions.
	 *
	 * @param array<int, array<string, mixed>>    $fields  Field definitions.
	 * @param array<string, array<int, string>>   $updates Custom values keyed by field key.
	 * @param bool                               &$changed Whether any definition changed.
	 * @return array<int, array<string, mixed>>
	 */
	public function fieldsWithCustomChoiceValues( array $fields, array $updates, bool &$changed = false ): array {
		foreach ( $fields as &$field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}

			$field_key = (string) ( $field['key'] ?? '' );
			if ( $field_key !== '' && ! empty( $updates[ $field_key ] ) ) {
				$next_choices = $this->choicesWithCustomValues( (string) ( $field['choices'] ?? '' ), $updates[ $field_key ] );
				if ( $next_choices !== (string) ( $field['choices'] ?? '' ) ) {
					$field['choices'] = $next_choices;
					$changed          = true;
				}
			}

			if ( isset( $field['sub_fields'] ) && is_array( $field['sub_fields'] ) ) {
				$field['sub_fields'] = $this->fieldsWithCustomChoiceValues( $field['sub_fields'], $updates, $changed );
			}

			if ( isset( $field['layouts'] ) && is_array( $field['layouts'] ) ) {
				foreach ( $field['layouts'] as &$layout ) {
					if ( ! is_array( $layout ) ) {
						continue;
					}
					if ( isset( $layout['sub_fields'] ) && is_array( $layout['sub_fields'] ) ) {
						$layout['sub_fields'] = $this->fieldsWithCustomChoiceValues( $layout['sub_fields'], $updates, $changed );
					}
				}
				unset( $layout );
			}
		}
		unset( $field );

		return $fields;
	}

	/**
	 * @param array<string, array<int, string>> $target Existing updates.
	 * @param array<string, array<int, string>> $source Additional updates.
	 */
	private function mergeCustomChoiceUpdates( array &$target, array $source ): void {
		foreach ( $source as $field_key => $values ) {
			$target[ $field_key ] = array_values(
				array_unique(
					array_merge( $target[ $field_key ] ?? [], $values )
				)
			);
		}
	}

	/**
	 * @param array<string, mixed> $field Field or layout definition.
	 * @return array<int, array<string, mixed>>
	 */
	private function normalizedSubFields( array $field ): array {
		$raw = isset( $field['sub_fields'] ) && is_array( $field['sub_fields'] ) ? $field['sub_fields'] : [];

		return array_values( array_filter( $raw, 'is_array' ) );
	}

	/**
	 * @param array<string, mixed> $field Flexible content field definition.
	 * @return array<string, array<string, mixed>>
	 */
	private function normalizedLayouts( array $field ): array {
		$layouts = [];
		$raw     = isset( $field['layouts'] ) && is_array( $field['layouts'] ) ? $field['layouts'] : [];

		foreach ( $raw as $layout ) {
			if ( ! is_array( $layout ) ) {
				continue;
			}
			$key = (string) ( $layout['key'] ?? '' );
			if ( $key !== '' ) {
				$layouts[ $key ] = $layout;
			}
		}

		return $layouts;
	}

	/**
	 * @param array<int, string> $values Custom choice values.
	 */
	private function choicesWithCustomValues( string $choices, array $values ): string {
		$existing = Validator::parseChoices( $choices );
		$lines    = trim( $choices );

		foreach ( $values as $value ) {
			if ( count( $existing ) >= Config::MAX_CHOICES ) {
				break;
			}
			$value = trim( sanitize_text_field( (string) $value ) );
			if ( $value === '' || array_key_exists( $value, $existing ) ) {
				continue;
			}
			$lines .= ( $lines === '' ? '' : "\n" ) . $value . ' : ' . $value;
			$existing[ $value ] = $value;
		}

		return $lines;
	}

	/**
	 * Normalize imported taxonomy display types for WP PowerSuite term fields.
	 *
	 * @param array<string, mixed> $field Field definition.
	 */
	private function taxonomyFieldType( array $field ): string {
		$type = sanitize_key( (string) ( $field['taxonomy_field_type'] ?? 'checkbox' ) );

		return in_array( $type, [ 'select', 'multi_select', 'radio', 'checkbox' ], true ) ? $type : 'checkbox';
	}

	/**
	 * Whether the term field stores multiple term IDs.
	 *
	 * @param array<string, mixed> $field Field definition.
	 */
	private function taxonomyAllowsMultiple( array $field ): bool {
		return ! empty( $field['multiple'] ) || in_array( $this->taxonomyFieldType( $field ), [ 'multi_select', 'checkbox' ], true );
	}

	/**
	 * @param array<string, mixed> $field Field definition.
	 * @param mixed                $value Sanitized value.
	 * @return array<int, string>
	 */
	private function validateGalleryCount( array $field, mixed $value, string $label ): array {
		$count = is_array( $value ) ? count( array_filter( array_map( 'absint', $value ) ) ) : 0;
		$min   = (string) ( $field['min'] ?? '' );
		$max   = (string) ( $field['max'] ?? '' );

		if ( $min !== '' && is_numeric( $min ) && $count < (int) $min ) {
			return [
				sprintf(
					/* translators: 1: field label, 2: minimum image count. */
					__( '“%1$s” requires at least %2$s images.', 'wppowersuite-lite' ),
					$label,
					$min
				),
			];
		}

		if ( $max !== '' && is_numeric( $max ) && (int) $max > 0 && $count > (int) $max ) {
			return [
				sprintf(
					/* translators: 1: field label, 2: maximum image count. */
					__( '“%1$s” allows no more than %2$s images.', 'wppowersuite-lite' ),
					$label,
					$max
				),
			];
		}

		return [];
	}

	/**
	 * Validate count limits for multi-item fields such as relationship rows,
	 * repeaters, and flexible content layouts.
	 *
	 * @param array<string, mixed> $field Field definition.
	 * @param mixed                $value Sanitized value.
	 * @return array<int, string>
	 */
	private function validateItemCount( array $field, mixed $value, string $label ): array {
		$count = is_array( $value ) ? count( $value ) : 0;
		$min   = absint( $field['min'] ?? 0 );
		$max   = absint( $field['max'] ?? 0 );

		if ( $min > 0 && $count < $min ) {
			return [
				sprintf(
					/* translators: 1: field label, 2: minimum item count. */
					__( '“%1$s” requires at least %2$s items.', 'wppowersuite-lite' ),
					$label,
					(string) $min
				),
			];
		}

		if ( $max > 0 && $count > $max ) {
			return [
				sprintf(
					/* translators: 1: field label, 2: maximum item count. */
					__( '“%1$s” allows no more than %2$s items.', 'wppowersuite-lite' ),
					$label,
					(string) $max
				),
			];
		}

		return [];
	}

	/**
	 * @param array<string, mixed> $field Field definition.
	 * @param mixed                $value Sanitized value.
	 * @return array<int, string>
	 */
	private function validateMaxlength( array $field, mixed $value, string $label ): array {
		$maxlength = absint( $field['maxlength'] ?? 0 );
		if ( $maxlength <= 0 ) {
			return [];
		}

		$text   = (string) $value;
		$length = function_exists( 'mb_strlen' ) ? mb_strlen( $text ) : strlen( $text );
		if ( $length <= $maxlength ) {
			return [];
		}

		return [
			sprintf(
				/* translators: 1: field label, 2: maximum characters. */
				__( '“%1$s” must be %2$s characters or fewer.', 'wppowersuite-lite' ),
				$label,
				(string) $maxlength
			),
		];
	}

	/**
	 * @param array<string, mixed> $field Field definition.
	 * @param mixed                $value Sanitized value.
	 * @return array<int, string>
	 */
	private function validateChoiceValue( array $field, mixed $value, string $label ): array {
		$choices = Validator::parseChoices( (string) ( $field['choices'] ?? '' ) );
		if ( $choices === [] ) {
			return [];
		}

		if ( ! empty( $field['allow_custom'] ) && in_array( (string) ( $field['type'] ?? '' ), [ 'radio', 'checkbox' ], true ) ) {
			return [];
		}

		if ( ! empty( $field['select_create_options'] ) && (string) ( $field['type'] ?? '' ) === 'select' ) {
			return [];
		}

		$allowed = array_map( 'strval', array_keys( $choices ) );
		$values  = is_array( $value ) ? array_map( 'strval', $value ) : [ (string) $value ];

		foreach ( $values as $submitted ) {
			if ( ! in_array( $submitted, $allowed, true ) ) {
				return [
					sprintf(
						/* translators: %s: field label. */
						__( '“%s” contains a value that is not one of its configured choices.', 'wppowersuite-lite' ),
						$label
					),
				];
			}
		}

		return [];
	}

	/**
	 * @param mixed $value Sanitized value.
	 * @return array<int, string>
	 */
	private function validateEmailValue( mixed $value, string $label ): array {
		$email = trim( (string) $value );
		if ( $email === '' || is_email( $email ) ) {
			return [];
		}

		return [
			sprintf(
				/* translators: %s: field label. */
				__( '“%s” must be a valid email address.', 'wppowersuite-lite' ),
				$label
			),
		];
	}

	/**
	 * @param mixed $value Sanitized value.
	 * @return array<int, string>
	 */
	private function validateUrlValue( mixed $value, string $label ): array {
		$url = trim( (string) $value );
		if ( $url === '' || $this->isHttpUrl( $url ) ) {
			return [];
		}

		return [
			sprintf(
				/* translators: %s: field label. */
				__( '“%s” must be a valid URL.', 'wppowersuite-lite' ),
				$label
			),
		];
	}

	/**
	 * @param mixed $value Sanitized value.
	 * @return array<int, string>
	 */
	private function validateLinkValue( mixed $value, string $label ): array {
		if ( ! is_array( $value ) ) {
			return [
				sprintf(
					/* translators: %s: field label. */
					__( '“%s” must be a valid link.', 'wppowersuite-lite' ),
					$label
				),
			];
		}

		$url = trim( (string) ( $value['url'] ?? '' ) );
		if ( $url === '' || $this->isHttpUrl( $url ) ) {
			return [];
		}

		return [
			sprintf(
				/* translators: %s: field label. */
				__( '“%s” must use a valid link URL.', 'wppowersuite-lite' ),
				$label
			),
		];
	}

	/**
	 * @param mixed $value Sanitized value.
	 * @return array<int, string>
	 */
	private function validateIconValue( mixed $value, string $label ): array {
		$icon = is_string( $value ) ? trim( $value ) : '';
		if ( $icon === '' || isset( $this->iconChoices()[ sanitize_key( $icon ) ] ) || $this->isIconUrl( $icon ) ) {
			return [];
		}

		return [
			sprintf(
				/* translators: %s: field label. */
				__( '“%s” contains an icon that is not available.', 'wppowersuite-lite' ),
				$label
			),
		];
	}

	/**
	 * Sanitize an icon picker value.
	 */
	private function sanitizeIconValue( mixed $raw ): string {
		$value = trim( (string) $raw );
		if ( $value === '' ) {
			return '';
		}

		$url = esc_url_raw( $value );
		if ( $this->isIconUrl( $value ) ) {
			return $url;
		}

		$dashicon = sanitize_key( $value );

		return isset( $this->iconChoices()[ $dashicon ] ) ? $dashicon : '';
	}

	/**
	 * Whether a string is a safe icon URL.
	 */
	private function isIconUrl( string $value ): bool {
		return preg_match( '#^https?://#i', $value ) === 1 && esc_url_raw( $value ) !== '';
	}

	/**
	 * Whether a value is an HTTP(S) URL suitable for URL, oEmbed, and link fields.
	 *
	 * Protocol-relative URLs can be valid user-entered links. Keep the
	 * same validation shape while still rejecting unsupported protocols.
	 */
	private function isHttpUrl( string $url ): bool {
		if ( str_starts_with( $url, '//' ) ) {
			$url = 'https:' . $url;
		} elseif ( ! preg_match( '#^https?://#i', $url ) ) {
			return false;
		}

		return filter_var( $url, FILTER_VALIDATE_URL ) !== false && esc_url_raw( $url ) !== '';
	}

	/**
	 * @param mixed $raw Raw coordinate value.
	 */
	private function sanitizeCoordinate( 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 color input, optionally allowing rgba() values from alpha pickers.
	 */
	private function sanitizeColorValue( mixed $raw, bool $allow_alpha ): string {
		$value = trim( (string) $raw );
		$hex   = sanitize_hex_color( $value );
		if ( is_string( $hex ) ) {
			return $hex;
		}

		if ( ! $allow_alpha ) {
			return '';
		}

		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*\)$/', $value, $matches ) ) {
			return '';
		}

		$red   = max( 0, min( 255, (int) $matches[1] ) );
		$green = max( 0, min( 255, (int) $matches[2] ) );
		$blue  = max( 0, min( 255, (int) $matches[3] ) );
		$alpha = max( 0, min( 1, (float) $matches[4] ) );

		return sprintf( 'rgba(%d,%d,%d,%s)', $red, $green, $blue, rtrim( rtrim( sprintf( '%.3F', $alpha ), '0' ), '.' ) );
	}

	/**
	 * Resolve configured color palette presets.
	 *
	 * @param array<string, mixed> $field Field definition.
	 */
	private function colorPalette( array $field ): string {
		$source = sanitize_key( (string) ( $field['custom_palette_source'] ?? '' ) );
		if ( $source !== 'themejson' ) {
			return (string) ( $field['palette_colors'] ?? '' );
		}
		if ( ! function_exists( 'wp_get_global_settings' ) ) {
			return '';
		}

		$settings = wp_get_global_settings( [ 'color', 'palette' ] );
		$colors   = [];
		foreach ( is_array( $settings ) ? $settings : [] as $palette ) {
			if ( ! is_array( $palette ) ) {
				continue;
			}
			foreach ( $palette as $entry ) {
				$color = is_array( $entry ) ? sanitize_hex_color( (string) ( $entry['color'] ?? '' ) ) : '';
				if ( is_string( $color ) ) {
					$colors[] = $color;
				}
			}
		}

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

	/**
	 * @param mixed $value Sanitized map value.
	 * @return array<int, string>
	 */
	private function validateGoogleMapValue( mixed $value, string $label ): array {
		if ( ! is_array( $value ) ) {
			return [
				sprintf(
					/* translators: %s: field label. */
					__( '“%s” has an invalid map value.', 'wppowersuite-lite' ),
					$label
				),
			];
		}

		$lat = (string) ( $value['lat'] ?? '' );
		$lng = (string) ( $value['lng'] ?? '' );
		if ( ( $lat === '' && $lng !== '' ) || ( $lat !== '' && $lng === '' ) ) {
			return [
				sprintf(
					/* translators: %s: field label. */
					__( '“%s” needs both latitude and longitude.', 'wppowersuite-lite' ),
					$label
				),
			];
		}

		return [];
	}

	/**
	 * @param array<string, mixed> $field Field definition.
	 * @param mixed                $value Sanitized value.
	 * @return array<int, string>
	 */
	private function validateNumericBounds( array $field, mixed $value, string $label ): array {
		if ( is_string( $value ) ) {
			$value = str_replace( ',', '', trim( $value ) );
		}

		if ( ! is_numeric( $value ) ) {
			return [
				sprintf(
					/* translators: %s: field label. */
					__( '“%s” must be a number.', 'wppowersuite-lite' ),
					$label
				),
			];
		}

		$number = (float) $value;
		$min    = (string) ( $field['min'] ?? '' );
		$max    = (string) ( $field['max'] ?? '' );

		if ( $min !== '' && is_numeric( $min ) && $number < (float) $min ) {
			return [
				sprintf(
					/* translators: 1: field label, 2: minimum value. */
					__( '“%1$s” must be at least %2$s.', 'wppowersuite-lite' ),
					$label,
					$min
				),
			];
		}

		if ( $max !== '' && is_numeric( $max ) && $number > (float) $max ) {
			return [
				sprintf(
					/* translators: 1: field label, 2: maximum value. */
					__( '“%1$s” must be no more than %2$s.', 'wppowersuite-lite' ),
					$label,
					$max
				),
			];
		}

		return [];
	}

	/**
	 * @param array<string, mixed> $field Field definition.
	 * @param mixed                $value Sanitized value.
	 * @return array<int, string>
	 */
	private function validateTemporalValue( array $field, mixed $value, string $label ): array {
		$type = (string) ( $field['type'] ?? 'text' );
		$text = (string) $value;
		$ok   = false;

		if ( $type === 'date' ) {
			$ok = $this->parseDateValue( $field, $text ) instanceof \DateTimeImmutable;
		} elseif ( $type === 'time' ) {
			$normalized = strlen( $text ) === 5 ? $text . ':00' : $text;
			$date       = \DateTimeImmutable::createFromFormat( '!H:i:s', $normalized );
			$parse      = \DateTimeImmutable::getLastErrors();
			$ok         = $date instanceof \DateTimeImmutable
				&& $date->format( 'H:i:s' ) === $normalized
				&& ( $parse === false || ( $parse['warning_count'] === 0 && $parse['error_count'] === 0 ) );
		} elseif ( $type === 'datetime' ) {
			$ok = $this->parseDateTimeValue( $text ) instanceof \DateTimeImmutable;
		}

		if ( ! $ok ) {
			return [
				sprintf(
					/* translators: %s: field label. */
					__( '“%s” has an invalid date or time value.', 'wppowersuite-lite' ),
					$label
				),
			];
		}

		return [];
	}

	private function dateInputValue( array $field, mixed $value ): string {
		$date = $this->parseDateValue( $field, $value );

		return $date instanceof \DateTimeImmutable ? $date->format( 'Y-m-d' ) : '';
	}

	private function sanitizeDateValue( array $field, mixed $raw ): string {
		$text = trim( (string) $raw );
		if ( $text === '' ) {
			return '';
		}

		$date = $this->parseDateValue( $field, $text );
		if ( ! $date instanceof \DateTimeImmutable ) {
			return '';
		}

		$save_format = trim( (string) ( $field['save_format'] ?? '' ) );

		return $save_format !== '' ? $date->format( $save_format ) : $date->format( 'Y-m-d' );
	}

	private function parseDateTimeValue( string $text ): ?\DateTimeImmutable {
		if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/', $text ) ) {
			return null;
		}

		$format = strlen( $text ) === 16 ? 'Y-m-d\TH:i' : 'Y-m-d\TH:i:s';
		$date   = \DateTimeImmutable::createFromFormat( '!' . $format, $text );
		$parse  = \DateTimeImmutable::getLastErrors();
		if (
			$date instanceof \DateTimeImmutable
			&& $date->format( $format ) === $text
			&& ( $parse === false || ( $parse['warning_count'] === 0 && $parse['error_count'] === 0 ) )
		) {
			return $date;
		}

		return null;
	}

	private function parseDateValue( array $field, mixed $value ): ?\DateTimeImmutable {
		$text = trim( (string) $value );
		if ( $text === '' ) {
			return null;
		}

		$formats = [];
		$save_format = trim( (string) ( $field['save_format'] ?? '' ) );
		if ( $save_format !== '' ) {
			$formats[] = $save_format;
		}
		$formats[] = 'Y-m-d';

		foreach ( array_values( array_unique( $formats ) ) as $format ) {
			$date  = \DateTimeImmutable::createFromFormat( '!' . $format, $text );
			$parse = \DateTimeImmutable::getLastErrors();
			if (
				$date instanceof \DateTimeImmutable
				&& $date->format( $format ) === $text
				&& ( $parse === false || ( $parse['warning_count'] === 0 && $parse['error_count'] === 0 ) )
			) {
				return $date;
			}
		}

		return null;
	}

	/**
	 * @param mixed $value Sanitized value.
	 * @return array<int, string>
	 */
	private function validateAttachmentValue( array $field, string $type, mixed $value, string $label ): array {
		$ids = $type === 'gallery' && is_array( $value ) ? $value : [ $value ];
		foreach ( $ids as $id ) {
			$id = absint( $id );
			if ( $id <= 0 ) {
				continue;
			}
			if ( get_post_type( $id ) !== 'attachment' ) {
				return [
					sprintf(
						/* translators: %s: field label. */
						__( '“%s” must reference a valid media attachment.', 'wppowersuite-lite' ),
						$label
					),
				];
			}

			if ( in_array( $type, [ 'image', 'gallery' ], true ) && ! $this->isImageAttachment( $id ) ) {
				return [
					sprintf(
						/* translators: %s: field label. */
						__( '“%s” must reference a valid image attachment.', 'wppowersuite-lite' ),
						$label
					),
				];
			}

			$mime_error = $this->validateAttachmentMime( $field, $id, $label );
			if ( $mime_error !== null ) {
				return [ $mime_error ];
			}

			$size_error = $this->validateAttachmentSize( $field, $id, $label );
			if ( $size_error !== null ) {
				return [ $size_error ];
			}

			if ( in_array( $type, [ 'image', 'gallery' ], true ) ) {
				$dimension_error = $this->validateAttachmentDimensions( $field, $id, $label );
				if ( $dimension_error !== null ) {
					return [ $dimension_error ];
				}
			}
		}

		return [];
	}

	private function isImageAttachment( int $attachment_id ): bool {
		if ( function_exists( 'wp_attachment_is_image' ) && wp_attachment_is_image( $attachment_id ) ) {
			return true;
		}

		$mime = strtolower( (string) get_post_mime_type( $attachment_id ) );

		return str_starts_with( $mime, 'image/' );
	}

	/**
	 * Validate an attachment against configured MIME/extensions.
	 *
	 * @param array<string, mixed> $field Field definition.
	 */
	private function validateAttachmentMime( array $field, int $attachment_id, string $label ): ?string {
		$allowed = $this->parseAllowedMimeTypes( (string) ( $field['mime_types'] ?? '' ) );
		if ( $allowed === [] ) {
			return null;
		}

		$file = get_attached_file( $attachment_id );
		$type = wp_check_filetype( is_string( $file ) ? $file : '' );
		$mime = (string) ( get_post_mime_type( $attachment_id ) ?: ( $type['type'] ?? '' ) );
		$ext  = strtolower( (string) ( $type['ext'] ?? pathinfo( is_string( $file ) ? $file : '', PATHINFO_EXTENSION ) ) );

		if ( in_array( $mime, $allowed, true ) || ( $ext !== '' && in_array( $ext, $allowed, true ) ) ) {
			return null;
		}

		return sprintf(
			/* translators: %s: field label. */
			__( '“%s” references a file type that is not allowed.', 'wppowersuite-lite' ),
			$label
		);
	}

	/**
	 * Validate an attachment against configured file-size limits.
	 *
	 * @param array<string, mixed> $field Field definition.
	 */
	private function validateAttachmentSize( array $field, int $attachment_id, string $label ): ?string {
		$file = get_attached_file( $attachment_id );
		if ( ! is_string( $file ) || ! file_exists( $file ) ) {
			return null;
		}

		$size_bytes = filesize( $file );
		if ( $size_bytes === false ) {
			return null;
		}

		$size_mb = $size_bytes / 1048576;
		$min     = (string) ( $field['min_size'] ?? '' );
		$max     = (string) ( $field['max_size'] ?? '' );
		if ( $min !== '' && is_numeric( $min ) && $size_mb < (float) $min ) {
			return sprintf(
				/* translators: 1: field label, 2: minimum size in MB. */
				__( '“%1$s” requires a file at least %2$s MB.', 'wppowersuite-lite' ),
				$label,
				$min
			);
		}
		if ( $max !== '' && is_numeric( $max ) && $size_mb > (float) $max ) {
			return sprintf(
				/* translators: 1: field label, 2: maximum size in MB. */
				__( '“%1$s” requires a file no larger than %2$s MB.', 'wppowersuite-lite' ),
				$label,
				$max
			);
		}

		return null;
	}

	/**
	 * Validate an image attachment against configured dimensions.
	 *
	 * @param array<string, mixed> $field Field definition.
	 */
	private function validateAttachmentDimensions( array $field, int $attachment_id, string $label ): ?string {
		$meta   = wp_get_attachment_metadata( $attachment_id );
		$width  = is_array( $meta ) ? absint( $meta['width'] ?? 0 ) : 0;
		$height = is_array( $meta ) ? absint( $meta['height'] ?? 0 ) : 0;
		if ( $width <= 0 || $height <= 0 ) {
			return null;
		}

		$min_width  = absint( $field['min_width'] ?? 0 );
		$min_height = absint( $field['min_height'] ?? 0 );
		$max_width  = absint( $field['max_width'] ?? 0 );
		$max_height = absint( $field['max_height'] ?? 0 );
		if ( ( $min_width > 0 && $width < $min_width ) || ( $min_height > 0 && $height < $min_height ) ) {
			return sprintf(
				/* translators: %s: field label. */
				__( '“%s” references an image that is smaller than the configured dimensions.', 'wppowersuite-lite' ),
				$label
			);
		}
		if ( ( $max_width > 0 && $width > $max_width ) || ( $max_height > 0 && $height > $max_height ) ) {
			return sprintf(
				/* translators: %s: field label. */
				__( '“%s” references an image that is larger than the configured dimensions.', 'wppowersuite-lite' ),
				$label
			);
		}

		return null;
	}

	/**
	 * @return array<int, string>
	 */
	private function parseAllowedMimeTypes( string $raw ): array {
		if ( trim( $raw ) === '' ) {
			return [];
		}

		return array_values(
			array_unique(
				array_filter(
					array_map(
						static fn( string $item ): string => strtolower( trim( $item ) ),
						preg_split( '/[\s,]+/', $raw ) ?: []
					)
				)
			)
		);
	}

	/**
	 * @param array<string, mixed> $field Field definition.
	 * @param mixed                $value Sanitized value.
	 * @return array<int, string>
	 */
	private function validatePostObjectValue( array $field, mixed $value, string $label ): array {
		$ids        = is_array( $value ) ? $value : [ $value ];
		$post_types = isset( $field['post_types'] ) && is_array( $field['post_types'] ) ? array_map( 'strval', $field['post_types'] ) : [];
		$post_statuses = isset( $field['post_statuses'] ) && is_array( $field['post_statuses'] ) ? array_values( array_filter( array_map( 'sanitize_key', $field['post_statuses'] ) ) ) : [];
		$taxonomy   = sanitize_key( (string) ( $field['taxonomy'] ?? '' ) );
		$taxonomy_terms = isset( $field['taxonomy_terms'] ) && is_array( $field['taxonomy_terms'] ) ? array_values( array_filter( array_map( 'absint', $field['taxonomy_terms'] ) ) ) : [];
		$is_page_link = (string) ( $field['type'] ?? '' ) === 'page_link';

		foreach ( $ids as $id ) {
			$raw_id = (string) $id;
			if ( $is_page_link && str_starts_with( $raw_id, 'archive:' ) ) {
				if ( empty( $field['allow_archives'] ) || $this->pageLinkArchiveUrl( $raw_id, $post_types ) === '' ) {
					return [
						sprintf(
							/* translators: %s: field label. */
							__( '“%s” references an archive outside its configured post types.', 'wppowersuite-lite' ),
							$label
						),
					];
				}
				continue;
			}

			$id = absint( $id );
			if ( $id <= 0 ) {
				continue;
			}
			$post = get_post( $id );
			if ( ! $post instanceof \WP_Post || ( $post_types !== [] && ! in_array( $post->post_type, $post_types, true ) ) ) {
				return [
					sprintf(
						/* translators: %s: field label. */
						__( '“%s” references a post outside its configured post types.', 'wppowersuite-lite' ),
						$label
					),
				];
			}
			if ( $post_statuses !== [] && ! in_array( (string) $post->post_status, $post_statuses, true ) ) {
				return [
					sprintf(
						/* translators: %s: field label. */
						__( '“%s” references a post outside its configured post statuses.', 'wppowersuite-lite' ),
						$label
					),
				];
			}
			if ( $taxonomy !== '' && taxonomy_exists( $taxonomy ) && ! has_term( '', $taxonomy, $id ) ) {
				return [
					sprintf(
						/* translators: %s: field label. */
						__( '“%s” references a post outside its configured taxonomy filter.', 'wppowersuite-lite' ),
						$label
					),
				];
			}
			if ( $taxonomy_terms !== [] && ! $this->postMatchesTermFilter( $id, $taxonomy_terms ) ) {
				return [
					sprintf(
						/* translators: %s: field label. */
						__( '“%s” references a post outside its configured taxonomy term filter.', 'wppowersuite-lite' ),
						$label
					),
				];
			}
		}

		return [];
	}

	/**
	 * @param int              $post_id Post ID.
	 * @param array<int, int>  $term_ids Allowed term IDs.
	 */
	private function postMatchesTermFilter( int $post_id, array $term_ids ): bool {
		foreach ( $this->groupTermIdsByTaxonomy( $term_ids ) as $taxonomy => $ids ) {
			if ( has_term( $ids, $taxonomy, $post_id ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Resolve a strict Page Link archive token to a URL.
	 *
	 * @param array<int, string> $post_types Configured post type filter.
	 */
	private function pageLinkArchiveUrl( string $token, array $post_types = [] ): string {
		if ( $token === 'archive:home' ) {
			return home_url( '/' );
		}
		if ( ! preg_match( '/^archive:post_type:([a-z0-9_-]+)$/', $token, $matches ) ) {
			return '';
		}
		$type = $matches[1];
		if ( $post_types !== [] && ! in_array( $type, $post_types, true ) ) {
			return '';
		}
		$url = get_post_type_archive_link( $type );

		return is_string( $url ) ? $url : '';
	}

	/**
	 * @param array<string, mixed> $field Field definition.
	 * @param mixed                $value Sanitized value.
	 * @return array<int, string>
	 */
	private function validateTermValue( array $field, mixed $value, string $label ): array {
		$taxonomy = sanitize_key( (string) ( $field['taxonomy'] ?? '' ) );
		if ( $taxonomy === '' || ! taxonomy_exists( $taxonomy ) ) {
			return [];
		}

		$ids = is_array( $value ) ? $value : [ $value ];
		foreach ( $ids as $id ) {
			$id = absint( $id );
			if ( $id <= 0 ) {
				continue;
			}
			$term = get_term( $id, $taxonomy );
			if ( ! $term instanceof \WP_Term || is_wp_error( $term ) ) {
				return [
					sprintf(
						/* translators: %s: field label. */
						__( '“%s” references a term outside its configured taxonomy.', 'wppowersuite-lite' ),
						$label
					),
				];
			}
		}

		return [];
	}

	/**
	 * @param mixed $value Sanitized value.
	 * @return array<int, string>
	 */
	private function validateUserValue( array $field, mixed $value, string $label ): array {
		$ids   = is_array( $value ) ? $value : [ $value ];
		$roles = isset( $field['user_roles'] ) && is_array( $field['user_roles'] ) ? array_values( array_filter( array_map( 'sanitize_key', $field['user_roles'] ) ) ) : [];
		foreach ( $ids as $id ) {
			$id = absint( $id );
			if ( $id <= 0 ) {
				continue;
			}
			$user = get_userdata( $id );
			if ( ! $user instanceof \WP_User ) {
				return [
					sprintf(
						/* translators: %s: field label. */
						__( '“%s” references a user that does not exist.', 'wppowersuite-lite' ),
						$label
					),
				];
			}
			if ( $roles !== [] && array_intersect( $roles, (array) $user->roles ) === [] ) {
				return [
					sprintf(
						/* translators: %s: field label. */
						__( '“%s” references a user outside its configured roles.', 'wppowersuite-lite' ),
						$label
					),
				];
			}
		}

		return [];
	}

	/**
	 * Whether a sanitized value matches an optional regex pattern.
	 *
	 * @param array<string, mixed> $field Field definition.
	 * @param mixed                $value Sanitized value.
	 */
	public function matchesPattern( array $field, mixed $value ): bool {
		$pattern = trim( (string) ( $field['pattern'] ?? '' ) );
		if ( $pattern === '' || $this->isEmpty( $value ) || ! is_scalar( $value ) ) {
			return true;
		}

		$subject = (string) $value;
		if ( @preg_match( $pattern, '' ) !== false ) { // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
			return (bool) preg_match( $pattern, $subject );
		}

		return (bool) preg_match( '#^' . $pattern . '$#', $subject );
	}

	/**
	 * Whether an empty submission should preserve the stored value.
	 *
	 * @param array<string, mixed> $field Field definition.
	 */
	public function preservesValueWhenEmpty( array $field ): bool {
		return (string) ( $field['type'] ?? '' ) === 'password';
	}

	/**
	 * Whether a stored password value is a WordPress password hash.
	 */
	public function isPasswordHash( string $value ): bool {
		if ( $value === '' ) {
			return false;
		}

		return str_starts_with( $value, '$P$' )
			|| str_starts_with( $value, '$wp$' )
			|| str_starts_with( $value, '$2y$' );
	}

	/**
	 * Whether a submitted value should be treated as empty (and meta deleted).
	 *
	 * @param mixed $value Sanitized value.
	 */
	public function isEmpty( mixed $value ): bool {
		if ( is_array( $value ) ) {
			if ( isset( $value['url'] ) || isset( $value['title'] ) || isset( $value['target'] ) ) {
				return (string) ( $value['url'] ?? '' ) === ''
					&& (string) ( $value['title'] ?? '' ) === ''
					&& (string) ( $value['target'] ?? '' ) === '';
			}
			if ( isset( $value['address'] ) || isset( $value['lat'] ) || isset( $value['lng'] ) ) {
				return (string) ( $value['address'] ?? '' ) === ''
					&& (string) ( $value['lat'] ?? '' ) === ''
					&& (string) ( $value['lng'] ?? '' ) === '';
			}

			return $value === [];
		}

		return $value === '' || $value === null;
	}
}
