<?php
/**
 * AJAX search for relational field types (posts, terms, users).
 *
 * @package CustomFields
 * @since 1.9.0
 */

namespace CustomFields;

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

/**
 * Server-side search helpers for relational fields.
 */
final class RelationalSearch {

	private const MAX_INCLUDE_IDS = 50;
	private const MAX_SEARCH_LENGTH = 100;

	/**
	 * Register REST search routes.
	 */
	public static function registerRoutes(): void {
		$permission = static function (): bool {
			return current_user_can( 'edit_posts' );
		};

		$users_permission = static function (): bool {
			return self::canListUsers();
		};

		register_rest_route(
			'wp-powersuite/v1',
			'/custom-fields/search/posts',
			[
				'methods'             => 'GET',
				'callback'            => [ self::class, 'restPosts' ],
				'permission_callback' => $permission,
				'args'                => [
					's'          => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'post_types' => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'post_statuses' => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'taxonomy_terms' => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'filter_post_type' => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => 'sanitize_key',
					],
					'filter_taxonomy'  => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => 'sanitize_key',
					],
					'group_key'  => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => [ self::class, 'sanitizeLookupKey' ],
					],
					'field_key'  => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => [ self::class, 'sanitizeLookupKey' ],
					],
					'include'    => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => 'sanitize_text_field',
					],
				],
			]
		);

		register_rest_route(
			'wp-powersuite/v1',
			'/custom-fields/search/terms',
			[
				'methods'             => 'GET',
				'callback'            => [ self::class, 'restTerms' ],
				'permission_callback' => $permission,
				'args'                => [
					's'        => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'taxonomy' => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => 'sanitize_key',
					],
					'group_key' => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => [ self::class, 'sanitizeLookupKey' ],
					],
					'field_key' => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => [ self::class, 'sanitizeLookupKey' ],
					],
					'include'  => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => 'sanitize_text_field',
					],
				],
			]
		);

		register_rest_route(
			'wp-powersuite/v1',
			'/custom-fields/terms',
			[
				'methods'             => 'POST',
				'callback'            => [ self::class, 'restCreateTerm' ],
				'permission_callback' => $permission,
				'args'                => [
					'group_key' => [
						'type'              => 'string',
						'required'          => true,
						'sanitize_callback' => [ self::class, 'sanitizeLookupKey' ],
					],
					'field_key' => [
						'type'              => 'string',
						'required'          => true,
						'sanitize_callback' => [ self::class, 'sanitizeLookupKey' ],
					],
					'term_name' => [
						'type'              => 'string',
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
					],
					'term_parent' => [
						'type'              => 'integer',
						'default'           => 0,
						'sanitize_callback' => 'absint',
					],
				],
			]
		);

		register_rest_route(
			'wp-powersuite/v1',
			'/custom-fields/search/users',
			[
				'methods'             => 'GET',
				'callback'            => [ self::class, 'restUsers' ],
				'permission_callback' => $users_permission,
				'args'                => [
					's'         => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'group_key' => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => [ self::class, 'sanitizeLookupKey' ],
					],
					'field_key' => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => [ self::class, 'sanitizeLookupKey' ],
					],
					'include'   => [
						'type'              => 'string',
						'default'           => '',
						'sanitize_callback' => 'sanitize_text_field',
					],
				],
			]
		);
	}

	/**
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public static function restPosts( \WP_REST_Request $request ): \WP_REST_Response {
		$field      = self::fieldFromRequest( $request, [ 'post_object', 'relationship', 'page_link' ] );
		if ( self::hasFieldLookup( $request ) && $field === null ) {
			return new \WP_REST_Response( [ 'results' => [] ], 400 );
		}

		$post_types = $field !== null && isset( $field['post_types'] ) && is_array( $field['post_types'] )
			? array_values( array_filter( array_map( 'sanitize_key', $field['post_types'] ) ) )
			: self::parseList( (string) $request->get_param( 'post_types' ) );
		$post_statuses = $field !== null && isset( $field['post_statuses'] ) && is_array( $field['post_statuses'] )
			? array_values( array_filter( array_map( 'sanitize_key', $field['post_statuses'] ) ) )
			: self::sanitizePostStatuses( self::parseList( (string) $request->get_param( 'post_statuses' ) ) );
		$taxonomy_terms = $field !== null && isset( $field['taxonomy_terms'] ) && is_array( $field['taxonomy_terms'] )
			? self::limitIds( array_map( 'absint', $field['taxonomy_terms'] ) )
			: self::parseIds( (string) $request->get_param( 'taxonomy_terms' ) );
		$taxonomy   = $field !== null ? sanitize_key( (string) ( $field['taxonomy'] ?? '' ) ) : '';
		$requested_post_type = sanitize_key( (string) $request->get_param( 'filter_post_type' ) );
		if ( $requested_post_type !== '' ) {
			if ( $post_types === [] || in_array( $requested_post_type, $post_types, true ) ) {
				$post_types = [ $requested_post_type ];
			} else {
				return new \WP_REST_Response(
					[
						'results' => [],
					]
				);
			}
		}
		$requested_taxonomy = sanitize_text_field( (string) $request->get_param( 'filter_taxonomy' ) );
		if ( $requested_taxonomy !== '' && is_numeric( $requested_taxonomy ) ) {
			$requested_term_id = absint( $requested_taxonomy );
			if ( $requested_term_id <= 0 || ( $taxonomy_terms !== [] && ! in_array( $requested_term_id, $taxonomy_terms, true ) ) ) {
				return new \WP_REST_Response(
					[
						'results' => [],
					]
				);
			}
			$taxonomy_terms = [ $requested_term_id ];
		} elseif ( $requested_taxonomy !== '' ) {
			$requested_taxonomy_key = sanitize_key( $requested_taxonomy );
			if ( $taxonomy === '' || $taxonomy === $requested_taxonomy_key ) {
				$taxonomy = $requested_taxonomy_key;
			} else {
				return new \WP_REST_Response(
					[
						'results' => [],
					]
				);
			}
		}
		$include    = self::parseIds( (string) $request->get_param( 'include' ) );
		$is_page_link = $field !== null && (string) ( $field['type'] ?? '' ) === 'page_link';
		$allow_archives = $is_page_link && ! empty( $field['allow_archives'] );
		$results    = self::searchPosts(
			self::normalizeSearch( (string) $request->get_param( 's' ) ),
			$post_types,
			$include,
			$taxonomy,
			$post_statuses,
			$taxonomy_terms
		);
		if ( $allow_archives ) {
			$results = array_merge( self::searchPageLinkArchives( (string) $request->get_param( 's' ), $post_types ), $results );
		}

		return new \WP_REST_Response(
			[
				'results' => $results,
			]
		);
	}

	/**
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public static function restTerms( \WP_REST_Request $request ): \WP_REST_Response {
		$field    = self::fieldFromRequest( $request, 'term' );
		if ( self::hasFieldLookup( $request ) && $field === null ) {
			return new \WP_REST_Response( [ 'results' => [] ], 400 );
		}

		$taxonomy = $field !== null
			? sanitize_key( (string) ( $field['taxonomy'] ?? '' ) )
			: sanitize_key( (string) $request->get_param( 'taxonomy' ) );

		if ( $taxonomy === '' || ! taxonomy_exists( $taxonomy ) ) {
			return new \WP_REST_Response( [ 'results' => [] ], 400 );
		}

		return new \WP_REST_Response(
			[
				'results' => self::searchTerms(
					self::normalizeSearch( (string) $request->get_param( 's' ) ),
					$taxonomy,
					self::parseIds( (string) $request->get_param( 'include' ) )
				),
			]
		);
	}

	/**
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public static function restUsers( \WP_REST_Request $request ): \WP_REST_Response {
		$field = self::fieldFromRequest( $request, 'user' );
		if ( self::hasFieldLookup( $request ) && $field === null ) {
			return new \WP_REST_Response( [ 'results' => [] ], 400 );
		}

		$roles = $field !== null && isset( $field['user_roles'] ) && is_array( $field['user_roles'] )
			? array_values( array_filter( array_map( 'sanitize_key', $field['user_roles'] ) ) )
			: [];

		return new \WP_REST_Response(
			[
				'results' => self::searchUsers(
					self::normalizeSearch( (string) $request->get_param( 's' ) ),
					self::parseIds( (string) $request->get_param( 'include' ) ),
					$roles
				),
			]
		);
	}

	/**
	 * Create a taxonomy term for a saved term field that explicitly allows it.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function restCreateTerm( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$field = self::fieldFromRequest( $request, 'term' );
		if ( $field === null || empty( $field['taxonomy_add_terms'] ) ) {
			return new \WP_Error( 'wpps_cf_term_create_forbidden', __( 'This field does not allow new terms.', 'wppowersuite-lite' ), [ 'status' => 403 ] );
		}

		$taxonomy = sanitize_key( (string) ( $field['taxonomy'] ?? '' ) );
		$tax_object = $taxonomy !== '' ? get_taxonomy( $taxonomy ) : false;
		if ( ! $tax_object instanceof \WP_Taxonomy ) {
			return new \WP_Error( 'wpps_cf_term_create_bad_taxonomy', __( 'The configured taxonomy is not available.', 'wppowersuite-lite' ), [ 'status' => 400 ] );
		}

		$capability = $tax_object->cap->manage_terms ?? 'manage_categories';
		if ( ! current_user_can( $capability ) ) {
			return new \WP_Error( 'wpps_cf_term_create_forbidden', __( 'You are not allowed to create terms for this taxonomy.', 'wppowersuite-lite' ), [ 'status' => 403 ] );
		}

		$term_name = trim( sanitize_text_field( (string) $request->get_param( 'term_name' ) ) );
		if ( $term_name === '' || strlen( $term_name ) > 200 ) {
			return new \WP_Error( 'wpps_cf_term_create_bad_name', __( 'Enter a valid term name.', 'wppowersuite-lite' ), [ 'status' => 400 ] );
		}

		$parent = absint( $request->get_param( 'term_parent' ) );
		$args   = [];
		if ( $parent > 0 && is_taxonomy_hierarchical( $taxonomy ) ) {
			$parent_term = get_term( $parent, $taxonomy );
			if ( ! $parent_term instanceof \WP_Term || is_wp_error( $parent_term ) ) {
				return new \WP_Error( 'wpps_cf_term_create_bad_parent', __( 'The selected parent term is not available.', 'wppowersuite-lite' ), [ 'status' => 400 ] );
			}
			$args['parent'] = $parent;
		}

		if ( term_exists( $term_name, $taxonomy, $parent ) ) {
			return new \WP_Error( 'wpps_cf_term_create_exists', __( 'That term already exists.', 'wppowersuite-lite' ), [ 'status' => 409 ] );
		}

		$created = wp_insert_term( $term_name, $taxonomy, $args );
		if ( is_wp_error( $created ) ) {
			return new \WP_Error( 'wpps_cf_term_create_failed', $created->get_error_message(), [ 'status' => 400 ] );
		}

		$term = get_term( absint( $created['term_id'] ?? 0 ), $taxonomy );
		if ( ! $term instanceof \WP_Term || is_wp_error( $term ) ) {
			return new \WP_Error( 'wpps_cf_term_create_failed', __( 'The term was created but could not be loaded.', 'wppowersuite-lite' ), [ 'status' => 500 ] );
		}

		return new \WP_REST_Response(
			[
				'term' => self::formatTerm( $term ),
			],
			201
		);
	}

	/**
	 * @param array<int, string> $post_types Post type keys.
	 * @param array<int, int>    $include    Selected IDs to always include.
	 * @return array<int, array{value: string, label: string}>
	 */
	public static function searchPosts( string $search, array $post_types, array $include = [], string $taxonomy = '', array $post_statuses = [], array $taxonomy_terms = [] ): array {
		$out     = [];
		$seen    = [];
		$search  = self::normalizeSearch( $search );
		$include = self::limitIds( $include );
		$taxonomy_terms = self::limitIds( $taxonomy_terms );
		$post_statuses = self::sanitizePostStatuses( $post_statuses );

		foreach ( $include as $post_id ) {
			$post = get_post( $post_id );
			if ( ! $post instanceof \WP_Post ) {
				continue;
			}
			if ( $post_types !== [] && ! in_array( $post->post_type, $post_types, true ) ) {
				continue;
			}
			if ( $post_statuses !== [] && ! in_array( $post->post_status, $post_statuses, true ) ) {
				continue;
			}
			if ( $taxonomy !== '' && taxonomy_exists( $taxonomy ) && ! has_term( '', $taxonomy, $post_id ) ) {
				continue;
			}
			if ( $taxonomy_terms !== [] && ! self::postMatchesTermFilter( $post_id, $taxonomy_terms ) ) {
				continue;
			}
			if ( ! current_user_can( 'read_post', $post_id ) ) {
				continue;
			}
			$out[]            = self::formatPost( $post );
			$seen[ $post_id ] = true;
		}

		$args = [
			'post_type'              => $post_types !== [] ? $post_types : 'any',
			'post_status'            => $post_statuses !== [] ? $post_statuses : 'any',
			'posts_per_page'         => 20,
			'orderby'                => 'title',
			'order'                  => 'ASC',
			's'                      => $search,
			'no_found_rows'          => true,
			'update_post_meta_cache' => false,
			'update_post_term_cache' => false,
			'post__not_in'           => array_keys( $seen ),
		];

		if ( $search === '' ) {
			unset( $args['s'] );
		}

		if ( $taxonomy !== '' && taxonomy_exists( $taxonomy ) ) {
			$args['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 ) {
				$args['tax_query'] = $tax_query;
			}
		}

		$query = new \WP_Query( $args );
		foreach ( $query->posts as $post ) {
			if ( ! $post instanceof \WP_Post ) {
				continue;
			}
			if ( ! current_user_can( 'read_post', $post->ID ) ) {
				continue;
			}
			$out[] = self::formatPost( $post );
		}

		return $out;
	}

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

		return false;
	}

	/**
	 * @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;
	}

	/**
	 * Page Link archive choices for imported allow_archives settings.
	 *
	 * @param array<int, string> $post_types Configured post type filter.
	 * @return array<int, array{value: string, label: string}>
	 */
	private static function searchPageLinkArchives( string $search, array $post_types ): array {
		$needle = strtolower( self::normalizeSearch( $search ) );
		$out    = [];
		$add    = static function ( string $value, string $label ) use ( &$out, $needle ): void {
			if ( $needle !== '' && ! str_contains( strtolower( $label ), $needle ) && ! str_contains( strtolower( $value ), $needle ) ) {
				return;
			}
			$out[] = [
				'value' => $value,
				'label' => $label,
			];
		};

		$add( '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 );
			$add(
				'archive:post_type:' . $type,
				sprintf(
					/* translators: %s: post type label. */
					__( '%s archive', 'wppowersuite-lite' ),
					$object ? (string) $object->labels->name : $type
				)
			);
		}

		return $out;
	}

	/**
	 * @param array<int, int> $include Selected term IDs to always include.
	 * @return array<int, array{value: string, label: string}>
	 */
	public static function searchTerms( string $search, string $taxonomy, array $include = [] ): array {
		$out     = [];
		$seen    = [];
		$search  = self::normalizeSearch( $search );
		$include = self::limitIds( $include );

		foreach ( $include as $term_id ) {
			$term = get_term( $term_id, $taxonomy );
			if ( ! $term instanceof \WP_Term || is_wp_error( $term ) ) {
				continue;
			}
			$out[]            = self::formatTerm( $term );
			$seen[ $term_id ] = true;
		}

		$args = [
			'taxonomy'   => $taxonomy,
			'hide_empty' => false,
			'number'     => 20,
			'orderby'    => 'name',
			'order'      => 'ASC',
			'exclude'    => array_keys( $seen ),
		];

		if ( $search !== '' ) {
			$args['search'] = $search;
		}

		$terms = get_terms( $args );
		if ( ! is_array( $terms ) ) {
			return $out;
		}

		foreach ( $terms as $term ) {
			if ( ! $term instanceof \WP_Term ) {
				continue;
			}
			$out[] = self::formatTerm( $term );
		}

		return $out;
	}

	/**
	 * User directory listing is list_users, not edit_posts.
	 */
	public static function canListUsers(): bool {
		return current_user_can( 'list_users' );
	}

	/**
	 * @param array<int, int> $include Selected user IDs to always include.
	 * @return array<int, array{value: string, label: string}>
	 */
	public static function searchUsers( string $search, array $include = [], array $roles = [] ): array {
		if ( ! self::canListUsers() ) {
			return [];
		}

		$out     = [];
		$seen    = [];
		$search  = self::normalizeSearch( $search );
		$include = self::limitIds( $include );
		$roles   = array_values( array_filter( array_map( 'sanitize_key', $roles ) ) );

		foreach ( $include as $user_id ) {
			$user = get_user_by( 'id', $user_id );
			if ( ! $user instanceof \WP_User ) {
				continue;
			}
			if ( $roles !== [] && array_intersect( $roles, (array) $user->roles ) === [] ) {
				continue;
			}
			$out[]             = self::formatUser( $user );
			$seen[ $user_id ]  = true;
		}

		$args = [
			'number'  => 20,
			'orderby' => 'display_name',
			'order'   => 'ASC',
			'exclude' => array_keys( $seen ),
		];
		if ( $roles !== [] ) {
			$args['role__in'] = $roles;
		}

		if ( $search !== '' ) {
			$args['search']         = '*' . $search . '*';
			$args['search_columns'] = [ 'user_login', 'user_nicename', 'display_name' ];
		}

		foreach ( get_users( $args ) as $user ) {
			if ( ! $user instanceof \WP_User ) {
				continue;
			}
			$out[] = self::formatUser( $user );
		}

		return $out;
	}

	/**
	 * @return array{value: string, label: string, image?: string}
	 */
	private static function formatPost( \WP_Post $post ): array {
		$item = [
			'value' => (string) $post->ID,
			'label' => $post->post_title . ' (' . $post->post_type . ')',
		];
		$thumb = (string) get_the_post_thumbnail_url( $post, 'thumbnail' );
		if ( $thumb !== '' ) {
			$item['image'] = esc_url_raw( $thumb );
		}

		return $item;
	}

	/**
	 * @return array{value: string, label: string}
	 */
	private static function formatTerm( \WP_Term $term ): array {
		$prefix    = '';
		$ancestors = get_ancestors( $term->term_id, $term->taxonomy, 'taxonomy' );
		if ( ! empty( $ancestors ) ) {
			$prefix = str_repeat( '- ', count( $ancestors ) );
		}

		return [
			'value' => (string) $term->term_id,
			'label' => $prefix . $term->name,
		];
	}

	/**
	 * @return array{value: string, label: string}
	 */
	private static function formatUser( \WP_User $user ): array {
		return [
			'value' => (string) $user->ID,
			'label' => $user->display_name . ' (' . $user->user_login . ')',
		];
	}

	/**
	 * Sanitizes field and group lookup keys without stripping hyphens.
	 */
	public static function sanitizeLookupKey( mixed $value ): string {
		$value = strtolower( (string) $value );

		return preg_replace( '/[^a-z0-9_-]/', '', $value ) ?: '';
	}

	/**
	 * Resolve a requested field definition so search restrictions come from storage.
	 *
	 * @return array<string, mixed>|null
	 */
	private static function fieldFromRequest( \WP_REST_Request $request, string|array $expected_type ): ?array {
		$group_key = self::sanitizeLookupKey( $request->get_param( 'group_key' ) );
		$field_key = self::sanitizeLookupKey( $request->get_param( 'field_key' ) );
		if ( $group_key === '' || $field_key === '' ) {
			return null;
		}

		$group = ( new Storage() )->get( $group_key );
		if ( ! is_array( $group ) || empty( $group['enabled'] ) ) {
			return null;
		}

		$expected_types = is_array( $expected_type ) ? array_map( 'strval', $expected_type ) : [ $expected_type ];
		$fields         = isset( $group['fields'] ) && is_array( $group['fields'] ) ? $group['fields'] : [];
		return self::findFieldInDefinitions( $fields, $field_key, $expected_types );
	}

	/**
	 * Resolve a field by key/name from top-level, group/repeater sub-fields, or flexible layouts.
	 *
	 * @param array<int, mixed>    $fields         Field definitions.
	 * @param string               $field_key      Requested key/name.
	 * @param array<int, string>   $expected_types Allowed field types.
	 * @return array<string, mixed>|null
	 */
	private static function findFieldInDefinitions( array $fields, string $field_key, array $expected_types ): ?array {
		foreach ( $fields as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}

			$key  = self::sanitizeLookupKey( $field['key'] ?? '' );
			$name = self::sanitizeLookupKey( $field['name'] ?? '' );
			$type = (string) ( $field['type'] ?? '' );
			if ( in_array( $type, $expected_types, true ) && ( $key === $field_key || $name === $field_key ) ) {
				return $field;
			}

			if ( isset( $field['sub_fields'] ) && is_array( $field['sub_fields'] ) ) {
				$nested = self::findFieldInDefinitions( $field['sub_fields'], $field_key, $expected_types );
				if ( $nested !== null ) {
					return $nested;
				}
			}

			if ( isset( $field['layouts'] ) && is_array( $field['layouts'] ) ) {
				foreach ( $field['layouts'] as $layout ) {
					if ( ! is_array( $layout ) || ! isset( $layout['sub_fields'] ) || ! is_array( $layout['sub_fields'] ) ) {
						continue;
					}
					$nested = self::findFieldInDefinitions( $layout['sub_fields'], $field_key, $expected_types );
					if ( $nested !== null ) {
						return $nested;
					}
				}
			}
		}

		return null;
	}

	/**
	 * Whether the client asked to bind a lookup to a saved field definition.
	 */
	private static function hasFieldLookup( \WP_REST_Request $request ): bool {
		return self::sanitizeLookupKey( $request->get_param( 'group_key' ) ) !== ''
			|| self::sanitizeLookupKey( $request->get_param( 'field_key' ) ) !== '';
	}

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

		return array_values(
			array_filter(
				array_map( 'sanitize_key', array_map( 'trim', explode( ',', $raw ) ) )
			)
		);
	}

	/**
	 * Keep post-status filters constrained to registered WordPress statuses.
	 *
	 * @param array<int, string> $statuses Status keys.
	 * @return array<int, string>
	 */
	private static function sanitizePostStatuses( array $statuses ): array {
		$allowed = array_keys( Config::relationalPostStatuses() );
		$clean   = [];
		foreach ( $statuses as $status ) {
			$status = sanitize_key( (string) $status );
			if ( $status !== '' && in_array( $status, $allowed, true ) ) {
				$clean[] = $status;
			}
		}

		return array_values( array_unique( $clean ) );
	}

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

		return self::limitIds( array_map( 'trim', explode( ',', $raw ) ) );
	}

	private static function normalizeSearch( string $search ): string {
		$search = trim( sanitize_text_field( $search ) );
		if ( $search === '' ) {
			return '';
		}

		return function_exists( 'mb_substr' )
			? mb_substr( $search, 0, self::MAX_SEARCH_LENGTH )
			: substr( $search, 0, self::MAX_SEARCH_LENGTH );
	}

	/**
	 * @param array<int, mixed> $ids Raw IDs.
	 * @return array<int, int>
	 */
	private static function limitIds( array $ids ): array {
		return array_slice(
			array_values(
				array_unique(
					array_filter(
						array_map( 'absint', $ids )
					)
				)
			),
			0,
			self::MAX_INCLUDE_IDS
		);
	}
}
