<?php
/**
 * Custom Fields — block editor field schema and placement rules.
 *
 * @package CustomFields
 * @since 1.4.0
 */

namespace CustomFields;

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

/**
 * Builds editor-ready field group schemas and resolves where fields render.
 */
final class EditorSchema {

	/**
	 * Field types rendered in the block editor document sidebar.
	 *
	 * @return array<int, string>
	 */
	public static function sidebarTypes(): array {
		return [
			'text',
			'textarea',
			'number',
			'range',
			'email',
			'phone',
			'url',
			'date',
			'time',
			'datetime',
			'color',
			'toggle',
			'select',
			'radio',
			'checkbox',
			'button_group',
			'nav_menu',
			'message',
			'separator',
			'output',
			'image',
			'file',
			'link',
			'gallery',
			'google_map',
			'oembed',
			'icon_picker',
			'post_object',
			'page_link',
			'term',
			'user',
		];
	}

	/**
	 * Nested field types that always require a meta box surface.
	 *
	 * @return array<int, string>
	 */
	public static function metaBoxTypes(): array {
		return Config::nestedTypes();
	}

	/**
	 * Stored presentation position for a group.
	 *
	 * @param array<string, mixed> $group Group definition.
	 */
	public static function groupPosition( array $group ): string {
		$position = sanitize_key( (string) ( $group['position'] ?? 'normal' ) );

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

	/**
	 * Whether a group uses the block editor document sidebar (Side position only).
	 *
	 * @param array<string, mixed> $group Group definition.
	 */
	public static function groupUsesGutenbergSidebar( array $group ): bool {
		return self::groupPosition( $group ) === 'side';
	}

	/**
	 * Whether the block editor is active for a post type.
	 *
	 * @param string $post_type Post type key.
	 */
	public static function usesBlockEditor( string $post_type ): bool {
		return function_exists( 'use_block_editor_for_post_type' ) && use_block_editor_for_post_type( $post_type );
	}

	/**
	 * Whether a field must use the classic meta box (nested or multi relational).
	 *
	 * @param array<string, mixed> $field Field definition.
	 */
	public static function fieldUsesMetaBox( array $field ): bool {
		$type = (string) ( $field['type'] ?? 'text' );
		if ( in_array( $type, self::metaBoxTypes(), true ) ) {
			return true;
		}

		if ( $type === 'wysiwyg' || $type === 'password' ) {
			return true;
		}

		if ( ! in_array( $type, self::sidebarTypes(), true ) ) {
			return true;
		}

		if ( $type === 'relationship' ) {
			return true;
		}

		return in_array( $type, [ 'post_object', 'page_link', 'term', 'user' ], true ) && ( ! empty( $field['multiple'] ) || self::termAllowsMultiple( $field ) );
	}

	/**
	 * Whether a side-position group can be fully handled in the document sidebar.
	 *
	 * @param array<string, mixed> $group Group definition.
	 */
	public static function groupIsSidebarOnly( array $group ): bool {
		if ( ! self::groupUsesGutenbergSidebar( $group ) ) {
			return false;
		}

		foreach ( (array) ( $group['fields'] ?? [] ) as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}
			if ( self::fieldUsesMetaBox( $field ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Whether a group contains fields that must use the classic meta box.
	 *
	 * @param array<string, mixed> $group Group definition.
	 */
	public static function groupHasComplexFields( array $group ): bool {
		foreach ( (array) ( $group['fields'] ?? [] ) as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}
			if ( self::fieldUsesMetaBox( $field ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Fields that should render in classic meta boxes for a group.
	 *
	 * @param array<string, mixed> $group     Group definition.
	 * @param string               $post_type Post type key.
	 * @return array<int, array<string, mixed>>
	 */
	public static function fieldsForMetaBox( array $group, string $post_type ): array {
		$fields = isset( $group['fields'] ) && is_array( $group['fields'] ) ? $group['fields'] : [];
		if ( $fields === [] ) {
			return [];
		}

		if ( ! self::usesBlockEditor( $post_type ) ) {
			return $fields;
		}

		// Normal, after_title, and advanced always use meta boxes in the block editor.
		if ( ! self::groupUsesGutenbergSidebar( $group ) ) {
			return $fields;
		}

		// Side position: simple fields use the document sidebar via REST.
		if ( self::groupIsSidebarOnly( $group ) ) {
			return [];
		}

		$meta_box_fields = [];
		foreach ( $fields as $field ) {
			if ( is_array( $field ) && self::fieldUsesMetaBox( $field ) ) {
				$meta_box_fields[] = $field;
			}
		}

		return $meta_box_fields;
	}

	/**
	 * Stored values belong on the post the caller can edit, of the requested type.
	 */
	private static function canIncludeStoredValues( int $post_id, string $post_type ): bool {
		if ( $post_id <= 0 || $post_type === '' ) {
			return false;
		}

		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post || $post->post_type !== $post_type ) {
			return false;
		}

		return current_user_can( 'edit_post', $post_id );
	}

	/**
	 * Build schema for matching groups that use the block editor sidebar.
	 *
	 * @param int    $post_id   Post ID (0 for new posts).
	 * @param string $post_type Post type key.
	 * @return array{postType: string, groups: array<int, array<string, mixed>>}
	 */
	public static function forPost( int $post_id, string $post_type ): array {
		if ( ! self::usesBlockEditor( $post_type ) ) {
			return [
				'postType' => $post_type,
				'groups'   => [],
			];
		}

		if ( $post_id > 0 && ! self::canIncludeStoredValues( $post_id, $post_type ) ) {
			return [
				'postType' => $post_type,
				'groups'   => [],
			];
		}

		$locations = new Locations( new Storage() );
		$groups    = $locations->groupsForPost( $post_id, $post_type );
		$payload   = [];

		foreach ( $groups as $group_key => $group ) {
			if ( ! is_array( $group ) ) {
				continue;
			}

			$serialized = self::serializeGroup( (string) $group_key, $group, $post_id );
			if ( $serialized !== null ) {
				$payload[] = $serialized;
			}
		}

		return [
			'postType' => $post_type,
			'groups'   => $payload,
		];
	}

	/**
	 * @param array<string, mixed> $group Group definition.
	 * @return array<string, mixed>|null
	 */
	private static function serializeGroup( string $group_key, array $group, int $post_id ): ?array {
		if ( ! self::groupUsesGutenbergSidebar( $group ) ) {
			return null;
		}

		$fields = [];

		foreach ( (array) ( $group['fields'] ?? [] ) as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}

			$type = (string) ( $field['type'] ?? 'text' );
			if ( self::fieldUsesMetaBox( $field ) ) {
				continue;
			}

			if ( ! in_array( $type, self::sidebarTypes(), true ) && ! Config::storesValue( $type ) ) {
				if ( $type !== 'message' ) {
					continue;
				}
			}

			$meta_key = MetaKeys::resolve( $group_key, $field );
			if ( Config::storesValue( $type ) && $meta_key === '' ) {
				continue;
			}

			$serialized_field             = self::serializeField( $field, $meta_key, $post_id );
			$serialized_field['groupKey'] = $group_key;
			$fields[]                     = $serialized_field;
		}

		if ( $fields === [] ) {
			return null;
		}

		$field_key_map = [];
		foreach ( $fields as $field ) {
			if ( (string) ( $field['key'] ?? '' ) !== '' ) {
				$field_key_map[ (string) $field['key'] ] = (string) ( $field['metaKey'] ?? '' );
			}
		}

		return [
			'key'         => $group_key,
			'title'       => (string) ( $group['title'] ?? $group_key ),
			'position'    => 'side',
			'sidebarOnly' => self::groupIsSidebarOnly( $group ),
			'fieldKeyMap' => $field_key_map,
			'fields'      => $fields,
		];
	}

	/**
	 * Whether any matching group renders fields in meta boxes.
	 */
	public static function postTypeUsesMetaBoxFields( int $post_id, string $post_type ): bool {
		$locations = new Locations( new Storage() );
		foreach ( $locations->groupsForPost( $post_id, $post_type ) as $group ) {
			if ( is_array( $group ) && self::fieldsForMetaBox( $group, $post_type ) !== [] ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Whether any matching group uses the block editor document sidebar.
	 */
	public static function postTypeUsesGutenbergSidebar( int $post_id, string $post_type ): bool {
		if ( ! self::usesBlockEditor( $post_type ) ) {
			return false;
		}

		$locations = new Locations( new Storage() );
		foreach ( $locations->groupsForPost( $post_id, $post_type ) as $group ) {
			if ( ! is_array( $group ) || ! self::groupUsesGutenbergSidebar( $group ) ) {
				continue;
			}

			foreach ( (array) ( $group['fields'] ?? [] ) as $field ) {
				if ( is_array( $field ) && ! self::fieldUsesMetaBox( $field ) ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * @deprecated 1.10.2 Use postTypeUsesMetaBoxFields().
	 */
	public static function postTypeHasComplexFields( int $post_id, string $post_type ): bool {
		return self::postTypeUsesMetaBoxFields( $post_id, $post_type );
	}

	/**
	 * @param array<string, mixed> $field     Field definition.
	 * @return array<string, mixed>
	 */
	private static function serializeField( array $field, string $meta_key, int $post_id ): array {
		$type = (string) ( $field['type'] ?? 'text' );

		$item = [
			'key'          => (string) ( $field['key'] ?? '' ),
			'label'        => (string) ( $field['label'] ?? '' ),
			'name'         => (string) ( $field['name'] ?? '' ),
			'type'         => $type,
			'metaKey'      => $meta_key,
			'required'     => ! empty( $field['required'] ),
			'instructions' => (string) ( $field['instructions'] ?? '' ),
			'placeholder'  => (string) ( $field['placeholder'] ?? '' ),
			'default'      => $field['default'] ?? '',
			'prepend'      => (string) ( $field['before'] ?? '' ),
			'append'       => (string) ( $field['after'] ?? '' ),
			'multiple'     => $type === 'relationship' || ! empty( $field['multiple'] ) || self::termAllowsMultiple( $field ),
			'allowNull'    => ! empty( $field['allow_null'] ),
			'maxlength'    => absint( $field['maxlength'] ?? 0 ),
			'choices'      => self::serializeChoices( (string) ( $field['choices'] ?? '' ) ),
			'min'          => (string) ( $field['min'] ?? '' ),
			'max'          => (string) ( $field['max'] ?? '' ),
			'step'         => (string) ( $field['step'] ?? '' ),
			'rows'         => max( 2, min( 20, (int) ( $field['rows'] ?? 4 ) ) ),
			'message'      => wp_kses_post( (string) ( $field['message_content'] ?? '' ) ),
			'output'       => wp_kses_post( (string) ( $field['output_content'] ?? '' ) ),
			'messageNewLines' => (string) ( $field['message_new_lines'] ?? '' ),
			'messageEscapeHtml' => ! empty( $field['message_escape_html'] ),
			'defaultCurrent' => ! empty( $field['default_current'] ),
			'currentDefault' => self::currentDefaultValue( $type ),
			'saveFormat'   => $type === 'date' ? (string) ( $field['save_format'] ?? '' ) : '',
			'toggleMessage' => (string) ( $field['toggle_message'] ?? '' ),
			'toggleUi'     => ! array_key_exists( 'toggle_ui', $field ) || ! empty( $field['toggle_ui'] ),
			'toggleOnText' => (string) ( $field['toggle_on_text'] ?? 'Yes' ),
			'toggleOffText' => (string) ( $field['toggle_off_text'] ?? 'No' ),
			'iconTabs'     => isset( $field['icon_tabs'] ) && is_array( $field['icon_tabs'] ) ? array_values( array_filter( array_map( 'sanitize_key', $field['icon_tabs'] ) ) ) : [ 'dashicons', 'media_library', 'url' ],
			'oembedWidth'  => absint( $field['oembed_width'] ?? 0 ),
			'oembedHeight' => absint( $field['oembed_height'] ?? 0 ),
			'mapCenterLat' => (string) ( $field['map_center_lat'] ?? '' ),
			'mapCenterLng' => (string) ( $field['map_center_lng'] ?? '' ),
			'mapZoom'      => absint( $field['map_zoom'] ?? 14 ),
			'mapHeight'    => self::mapHeight( $field ),
			'mediaLibrary' => (string) ( $field['media_library'] ?? 'all' ),
			'mimeTypes'    => (string) ( $field['mime_types'] ?? '' ),
			'colorPalettes' => self::colorPalette( $field ),
			'colorAlpha'   => ! empty( $field['enable_opacity'] ),
			'colorShowWheel' => ! isset( $field['show_color_wheel'] ) || ! empty( $field['show_color_wheel'] ),
			'conditional'  => ! empty( $field['conditional']['enabled'] ) ? ( $field['conditional'] ?? [] ) : [],
			'options'      => self::serializeRelationalOptions( $field ),
		];

		if ( in_array( $type, [ 'post_object', 'relationship', 'page_link' ], true ) ) {
			$post_types = isset( $field['post_types'] ) && is_array( $field['post_types'] )
				? array_values( array_map( 'strval', $field['post_types'] ) )
				: [];
			$item['postTypes'] = $post_types;
			$item['postStatuses'] = isset( $field['post_statuses'] ) && is_array( $field['post_statuses'] )
				? array_values( array_map( 'strval', $field['post_statuses'] ) )
				: [];
		}

		if ( $type === 'term' ) {
			$taxonomy = sanitize_key( (string) ( $field['taxonomy'] ?? '' ) );
			$tax_object = $taxonomy !== '' ? get_taxonomy( $taxonomy ) : false;
			$item['taxonomy'] = $taxonomy;
			$item['addTerms'] = ! empty( $field['taxonomy_add_terms'] )
				&& $tax_object instanceof \WP_Taxonomy
				&& current_user_can( $tax_object->cap->manage_terms ?? 'manage_categories' );
			$item['hierarchical'] = $taxonomy !== '' && is_taxonomy_hierarchical( $taxonomy );
			$item['parentTerms']  = $item['hierarchical'] ? self::serializeTermParentOptions( $taxonomy ) : [];
		}

		if ( $type === 'user' ) {
			$item['userRoles'] = isset( $field['user_roles'] ) && is_array( $field['user_roles'] )
				? array_values( array_filter( array_map( 'sanitize_key', $field['user_roles'] ) ) )
				: [];
		}

		if ( $type === 'relationship' ) {
			$item['relationshipFilters'] = isset( $field['relationship_filters'] ) && is_array( $field['relationship_filters'] )
				? array_values( array_filter( array_map( 'sanitize_key', $field['relationship_filters'] ) ) )
				: [ 'search', 'post_type', 'taxonomy' ];
			$item['relationshipElements'] = isset( $field['relationship_elements'] ) && is_array( $field['relationship_elements'] )
				? array_values( array_filter( array_map( 'sanitize_key', $field['relationship_elements'] ) ) )
				: [];
			$item['relationshipPostTypeOptions'] = self::serializeRelationshipPostTypeOptions( $field );
			$item['relationshipTaxonomyOptions'] = self::serializeRelationshipTaxonomyOptions( $field );
		}

		if ( Config::storesValue( $type ) && $meta_key !== '' && $post_id > 0 && current_user_can( 'edit_post', $post_id ) ) {
			$item['value'] = get_post_meta( $post_id, $meta_key, true );
		} elseif ( $type === 'link' ) {
			$item['value'] = [ 'url' => '', 'title' => '', 'target' => '' ];
		} elseif ( $type === 'toggle' ) {
			$item['value'] = '';
		} elseif ( in_array( $type, [ 'checkbox', 'gallery', 'relationship' ], true ) || ( ! empty( $field['multiple'] ) && in_array( $type, [ 'select', 'page_link' ], true ) ) || self::termAllowsMultiple( $field ) ) {
			$item['value'] = self::multiDefaultValue( $field );
		} else {
			$item['value'] = $field['default'] ?? '';
		}

		return $item;
	}

	/**
	 * @param array<string, mixed> $field Field definition.
	 * @return array<int, string>
	 */
	private static function multiDefaultValue( array $field ): array {
		$default = $field['default'] ?? '';
		$values  = is_array( $default ) ? $default : preg_split( '/\r\n|\r|\n|,/', (string) $default );
		$out     = [];
		foreach ( (array) $values as $value ) {
			$value = trim( (string) $value );
			if ( $value !== '' ) {
				$out[] = $value;
			}
		}

		return array_values( array_unique( $out ) );
	}

	/**
	 * Google Map height exposed to the block editor sidebar.
	 *
	 * @param array<string, mixed> $field Field definition.
	 */
	private static function mapHeight( array $field ): int {
		$height = absint( $field['map_height'] ?? 320 );

		if ( $height <= 0 ) {
			$height = 320;
		}

		return max( Config::MIN_MAP_HEIGHT, min( Config::MAX_MAP_HEIGHT, $height ) );
	}

	/**
	 * Whether a term field stores multiple term IDs.
	 *
	 * @param array<string, mixed> $field Field definition.
	 */
	private static function termAllowsMultiple( array $field ): bool {
		if ( (string) ( $field['type'] ?? '' ) !== 'term' ) {
			return false;
		}

		$field_type = sanitize_key( (string) ( $field['taxonomy_field_type'] ?? 'checkbox' ) );

		return in_array( $field_type, [ 'multi_select', 'checkbox' ], true );
	}

	/**
	 * @return array<int, array{value: string, label: string}>
	 */
	private static function serializeChoices( string $choices ): array {
		$map = Validator::parseChoices( $choices );
		$out = [];
		foreach ( $map as $value => $label ) {
			$out[] = [
				'value' => (string) $value,
				'label' => (string) $label,
			];
		}

		return $out;
	}

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

		if ( in_array( $type, [ 'post_object', 'relationship', 'page_link' ], true ) ) {
			$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_intersect( array_map( 'sanitize_key', $field['post_statuses'] ), array_keys( Config::relationalPostStatuses() ) ) )
				: [];
			$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'] ) ) )
				: [];
			$query      = [
				'post_type'              => $post_types !== [] ? $post_types : 'any',
				'post_status'            => $post_statuses !== [] ? $post_statuses : 'any',
				'posts_per_page'         => 100,
				'orderby'                => 'title',
				'order'                  => 'ASC',
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
			];
			if ( $taxonomy !== '' && taxonomy_exists( $taxonomy ) ) {
				$query['tax_query'] = [
					[
						'taxonomy' => $taxonomy,
						'operator' => 'EXISTS',
					],
				];
			}
			if ( $taxonomy_terms !== [] ) {
				$tax_query = [ 'relation' => 'OR' ];
				foreach ( self::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 ) {
					$query['tax_query'] = $tax_query;
				}
			}
			$posts = get_posts( $query );
			foreach ( $posts as $post ) {
				if ( ! $post instanceof \WP_Post ) {
					continue;
				}
				if ( ! current_user_can( 'read_post', $post->ID ) ) {
					continue;
				}
				$out[] = [
					'value' => (string) $post->ID,
					'label' => $post->post_title . ' (' . $post->post_type . ')',
				];
			}

			if ( $type === 'page_link' && ! empty( $field['allow_archives'] ) ) {
				$out = array_merge( self::serializePageLinkArchiveOptions( $post_types ), $out );
			}

			return $out;
		}

		if ( $type === 'term' ) {
			$taxonomy = sanitize_key( (string) ( $field['taxonomy'] ?? '' ) );
			if ( $taxonomy === '' || ! taxonomy_exists( $taxonomy ) ) {
				return [];
			}
			$terms = get_terms(
				[
					'taxonomy'   => $taxonomy,
					'hide_empty' => false,
					'number'     => 200,
				]
			);
			if ( ! is_array( $terms ) ) {
				return [];
			}
			foreach ( $terms as $term ) {
				if ( ! $term instanceof \WP_Term ) {
					continue;
				}
				$out[] = [
					'value' => (string) $term->term_id,
					'label' => self::termChoiceLabel( $term ),
				];
			}

			return $out;
		}

		if ( $type === 'user' ) {
			if ( ! RelationalSearch::canListUsers() ) {
				return [];
			}

			$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;
			}

			foreach ( get_users( $args ) as $user ) {
				if ( ! $user instanceof \WP_User ) {
					continue;
				}
				$out[] = [
					'value' => (string) $user->ID,
					'label' => $user->display_name . ' (' . $user->user_login . ')',
				];
			}
		}

		if ( $type === 'nav_menu' ) {
			foreach ( wp_get_nav_menus() as $menu ) {
				if ( ! $menu instanceof \WP_Term ) {
					continue;
				}
				$out[] = [
					'value' => (string) $menu->term_id,
					'label' => (string) $menu->name,
				];
			}
		}

		return $out;
	}

	/**
	 * Palette colors exposed to the block editor sidebar color picker.
	 *
	 * @param array<string, mixed> $field Field definition.
	 */
	private static function colorPalette( array $field ): string {
		if ( (string) ( $field['custom_palette_source'] ?? '' ) !== 'themejson' ) {
			return (string) ( $field['palette_colors'] ?? '' );
		}

		if ( ! function_exists( 'wp_get_global_settings' ) ) {
			return '';
		}

		$palette = wp_get_global_settings( [ 'color', 'palette' ] );
		if ( ! is_array( $palette ) ) {
			return '';
		}

		$out = [];
		foreach ( $palette as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}
			$color = sanitize_hex_color( (string) ( $entry['color'] ?? '' ) );
			if ( is_string( $color ) ) {
				$out[] = $color;
			}
		}

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

	private static function currentDefaultValue( string $type ): string {
		if ( $type === 'date' ) {
			return current_time( 'Y-m-d' );
		}

		if ( $type === 'time' ) {
			return current_time( 'H:i' );
		}

		if ( $type === 'datetime' ) {
			return current_time( 'Y-m-d\TH:i' );
		}

		return '';
	}

	/**
	 * @return array<int, array{value: string, label: string}>
	 */
	private static function serializeTermParentOptions( string $taxonomy ): array {
		$terms = get_terms(
			[
				'taxonomy'   => $taxonomy,
				'hide_empty' => false,
				'number'     => 200,
			]
		);
		if ( ! is_array( $terms ) ) {
			return [];
		}

		$out = [];
		foreach ( $terms as $term ) {
			if ( ! $term instanceof \WP_Term ) {
				continue;
			}
			$out[] = [
				'value' => (string) $term->term_id,
				'label' => self::termChoiceLabel( $term ),
			];
		}

		return $out;
	}

	/**
	 * @param array<string, mixed> $field Field definition.
	 * @return array<int, array{value: string, label: string}>
	 */
	private static function serializeRelationshipPostTypeOptions( array $field ): array {
		$post_types = isset( $field['post_types'] ) && is_array( $field['post_types'] )
			? array_values( array_filter( array_map( 'sanitize_key', $field['post_types'] ) ) )
			: [];
		$types      = $post_types !== [] ? $post_types : array_values( get_post_types( [ 'public' => true ], 'names' ) );
		$out        = [];

		foreach ( $types as $post_type ) {
			$object = get_post_type_object( $post_type );
			if ( ! $object ) {
				continue;
			}
			$out[] = [
				'value' => $post_type,
				'label' => (string) $object->labels->singular_name,
			];
		}

		return $out;
	}

	/**
	 * @param array<string, mixed> $field Field definition.
	 * @return array<int, array{value: string, label: string}>
	 */
	private static function serializeRelationshipTaxonomyOptions( array $field ): array {
		$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'] ) ) )
			: [];
		$out            = [];

		if ( $taxonomy_terms !== [] ) {
			foreach ( self::groupTermIdsByTaxonomy( $taxonomy_terms ) as $term_ids ) {
				foreach ( $term_ids as $term_id ) {
					$term = get_term( $term_id );
					if ( $term instanceof \WP_Term ) {
						$out[] = [
							'value' => (string) $term->term_id,
							'label' => self::termChoiceLabel( $term ),
						];
					}
				}
			}

			return $out;
		}

		$args = [
			'hide_empty' => false,
			'number'     => 200,
		];
		if ( $taxonomy !== '' && taxonomy_exists( $taxonomy ) ) {
			$args['taxonomy'] = $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[] = [
						'value' => (string) $term->term_id,
						'label' => self::termChoiceLabel( $term ),
					];
				}
			}
		}

		return $out;
	}

	/**
	 * @param array<int, int> $term_ids Term IDs.
	 * @return array<string, array<int, int>>
	 */
	private static 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;
	}

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

		return $out;
	}

	private static 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;
	}
}
