<?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 ?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'] );
		$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' ) {
			?>
			<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 wp_kses_post( (string) ( $field['message_content'] ?? '' ) ); ?></div>
				<?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 ) : ?>
						<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'] ) ? ' required' : '';
		$min_attr    = $this->numericAttr( $field, 'min' );
		$max_attr    = $this->numericAttr( $field, 'max' );
		$step_attr   = $this->numericAttr( $field, 'step' );

		switch ( $type ) {
			case 'textarea':
				$rows = max( 2, min( 20, (int) ( $field['rows'] ?? 4 ) ) );
				printf(
					'<textarea id="%1$s" name="%2$s" rows="%6$d" class="widefat" placeholder="%3$s"%4$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 ),
					$rows
				);
				break;

			case 'wysiwyg':
				wp_editor(
					(string) $value,
					$id_attr,
					[
						'textarea_name' => $name_attr,
						'media_buttons' => true,
						'textarea_rows' => max( 2, min( 20, (int) ( $field['rows'] ?? 8 ) ) ),
					]
				);
				break;

			case 'number':
			case 'range':
				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) $value ),
					esc_attr( $placeholder ),
					$required, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static string.
					$min_attr . $max_attr . $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) $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', 'wp-powersuite' );
				}
				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>',
					esc_attr( $id_attr ),
					esc_attr( $name_attr ),
					esc_attr( $placeholder ),
					$required // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static string.
				);
				if ( $has_hash ) {
					echo '<p class="description cf-password-set">' . esc_html__( 'Password is set.', 'wp-powersuite' ) . '</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>',
					esc_attr( $id_attr ),
					esc_attr( $name_attr ),
					esc_attr( (string) $value ),
					esc_attr( $placeholder ),
					$required // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static string.
				);
				break;

			case 'email':
			case 'url':
				printf(
					'<input type="%6$s" id="%1$s" name="%2$s" value="%3$s" class="regular-text" placeholder="%4$s"%5$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.
					esc_attr( $type )
				);
				break;

			case 'oembed':
				printf(
					'<input type="url" id="%1$s" name="%2$s" value="%3$s" class="regular-text cf-oembed-url" placeholder="%4$s"%5$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.
				);
				echo '<div class="cf-oembed-preview" aria-live="polite"></div>';
				break;

			case 'date':
			case 'time':
			case 'datetime':
				$input_type = $type === 'datetime' ? 'datetime-local' : $type;
				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( $name_attr, $value, $id_attr );
				break;

			case 'color':
				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>',
					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.
				);
				break;

			case 'toggle':
				printf(
					'<label class="cf-toggle"><input type="checkbox" id="%1$s" name="%2$s" value="1"%3$s> <span>%4$s</span></label>',
					esc_attr( $id_attr ),
					esc_attr( $name_attr ),
					checked( (string) $value === '1' || $value === 1 || $value === true, true, false ),
					esc_html__( 'Yes', 'wp-powersuite' )
				);
				break;

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

			case 'radio':
				$this->renderRadio( $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, $name_attr, $value, $id_attr );
				break;

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

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

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

			case 'user':
				$this->renderUser( $field, $name_attr, $value, $id_attr );
				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>',
					esc_attr( $id_attr ),
					esc_attr( $name_attr ),
					esc_attr( (string) $value ),
					esc_attr( $placeholder ),
					$required // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static string.
				);
				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;
		?>
		<select id="<?php echo esc_attr( $id_attr ); ?>" name="<?php echo esc_attr( $name ); ?>"<?php echo $multiple ? ' multiple class="cf-multiselect"' : ''; ?>>
			<?php if ( ! $multiple ) : ?>
				<option value="">&mdash;</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;
		$i       = 0;
		echo '<div class="cf-choices">';
		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++;
		}
		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 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 ) ) );
		$i        = 0;
		echo '<div class="cf-choices">';
		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++;
		}
		echo '</div>';
	}

	/**
	 * @param string $type      image|file.
	 * @param string $name_attr Name attribute.
	 * @param mixed  $value     Attachment ID.
	 * @param string $id_attr   Element id.
	 */
	private function renderMedia( string $type, string $name_attr, mixed $value, string $id_attr ): void {
		$id      = (int) $value;
		$preview = '';
		if ( $id > 0 ) {
			if ( $type === 'image' ) {
				$img = wp_get_attachment_image( $id, 'thumbnail', 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 ); ?>">
			<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', 'wp-powersuite' ) : esc_html__( 'Select File', 'wp-powersuite' ); ?></button>
			<button type="button" class="button-link cf-media__clear"<?php echo $id > 0 ? '' : ' hidden'; ?>><?php esc_html_e( 'Remove', 'wp-powersuite' ); ?></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 ) );
	}

	/**
	 * @param string $name_attr Base name attribute.
	 * @param mixed  $value     Current link array.
	 * @param string $id_attr   Element id prefix.
	 */
	private function renderLink( 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';
		?>
		<div class="cf-link">
			<p>
				<label for="<?php echo esc_attr( $id_attr ); ?>-url"><?php esc_html_e( 'URL', 'wp-powersuite' ); ?></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">
			</p>
			<p>
				<label for="<?php echo esc_attr( $id_attr ); ?>-title"><?php esc_html_e( 'Link text', 'wp-powersuite' ); ?></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', 'wp-powersuite' ); ?>
				</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( string $name_attr, mixed $value, string $id_attr ): void {
		$ids = $this->normalizeIdList( $value );
		?>
		<div class="cf-gallery">
			<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, 'thumbnail', 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', 'wp-powersuite' ) . '">&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', 'wp-powersuite' ); ?></button>
			<button type="button" class="button-link cf-gallery__clear"<?php echo $ids === [] ? ' hidden' : ''; ?>><?php esc_html_e( 'Clear all', 'wp-powersuite' ); ?></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 ): void {
		$multiple   = ! empty( $field['multiple'] );
		$post_types = isset( $field['post_types'] ) && is_array( $field['post_types'] ) ? array_map( 'strval', $field['post_types'] ) : [];
		$selected   = $multiple ? $this->normalizeIdList( $value ) : [ (int) $value ];
		$posts      = $this->queryPosts( $post_types );
		$name       = $multiple ? $name_attr . '[]' : $name_attr;
		$type_attr  = $post_types !== [] ? implode( ',', $post_types ) : '';
		$include    = $multiple ? implode( ',', $selected ) : (string) (int) $value;
		?>
		<div class="cf-relational-search" data-cf-type="post_object" data-cf-post-types="<?php echo esc_attr( $type_attr ); ?>" data-cf-include="<?php echo esc_attr( $include ); ?>">
			<input type="search" class="cf-relational-search__input regular-text" placeholder="<?php esc_attr_e( 'Search posts…', 'wp-powersuite' ); ?>" autocomplete="off">
			<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 ) : ?>
				<option value="">&mdash;</option>
			<?php endif; ?>
			<?php foreach ( $posts as $post ) : ?>
				<option value="<?php echo esc_attr( (string) $post->ID ); ?>" <?php echo $multiple ? selected( in_array( $post->ID, $selected, true ), true, false ) : selected( (int) $value, $post->ID, false ); ?>>
					<?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 ): void {
		$taxonomy = sanitize_key( (string) ( $field['taxonomy'] ?? '' ) );
		if ( $taxonomy === '' || ! taxonomy_exists( $taxonomy ) ) {
			echo '<p class="description">' . esc_html__( 'Configure a taxonomy for this field.', 'wp-powersuite' ) . '</p>';
			return;
		}

		$multiple = ! empty( $field['multiple'] );
		$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;
		?>
		<div class="cf-relational-search" data-cf-type="term" 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…', 'wp-powersuite' ); ?>" autocomplete="off">
			<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 ) : ?>
				<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( $term->name ); ?>
					</option>
				<?php endforeach; ?>
			<?php endif; ?>
			</select>
		</div>
		<?php
	}

	/**
	 * @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 ): void {
		$multiple = ! empty( $field['multiple'] );
		$selected = $multiple ? $this->normalizeIdList( $value ) : [ (int) $value ];
		$users    = get_users( [ 'number' => 200, 'orderby' => 'display_name' ] );
		$name     = $multiple ? $name_attr . '[]' : $name_attr;
		$include  = $multiple ? implode( ',', $selected ) : (string) (int) $value;
		?>
		<div class="cf-relational-search" data-cf-type="user" 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…', 'wp-powersuite' ); ?>" autocomplete="off">
			<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 ) : ?>
				<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( $user->display_name . ' (' . $user->user_login . ')' ); ?>
				</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 ): array {
		$args = [
			'post_type'              => $post_types !== [] ? $post_types : array_values( get_post_types( [ 'public' => true ] ) ),
			'post_status'            => 'publish',
			'posts_per_page'         => 200,
			'orderby'                => 'date',
			'order'                  => 'DESC',
			'no_found_rows'          => true,
			'update_post_meta_cache' => false,
			'update_post_term_cache' => false,
		];

		$query = new \WP_Query( $args );

		return $query->posts;
	}

	/**
	 * 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_values( array_filter( array_map( 'absint', $parts ) ) );
	}

	/**
	 * 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 '';
				}
				return is_numeric( $raw ) ? ( $raw + 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':
				$date = trim( (string) $raw );
				return preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ? $date : '';

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

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

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

			case 'color':
				$color = sanitize_hex_color( (string) $raw );
				return is_string( $color ) ? $color : '';

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

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

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

			case 'post_object':
			case 'term':
			case 'user':
				if ( ! empty( $field['multiple'] ) ) {
					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_map( 'sanitize_text_field', array_map( 'strval', $values ) ) );
				}
				return sanitize_text_field( (string) $raw );

			case 'checkbox':
				$values = is_array( $raw ) ? $raw : [];
				return array_values( array_map( 'sanitize_text_field', array_map( 'strval', $values ) ) );

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

	/**
	 * 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'] ?? '' ) === '';
			}

			return $value === [];
		}

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