<?php
/**
 * Term indexable
 *
 * @since  3.1
 * @package  elasticpress
 */

namespace ElasticPress\Indexable\Term;

use WP_Term_Query;
use ElasticPress\Elasticsearch;
use ElasticPress\Indexable;

if ( ! defined( 'ABSPATH' ) ) {
	// @codeCoverageIgnoreStart
	exit; // Exit if accessed directly.
	// @codeCoverageIgnoreEnd
}

/**
 * Term indexable class
 */
class Term extends Indexable {

	/**
	 * Indexable slug
	 *
	 * @var   string
	 * @since 3.1
	 */
	public $slug = 'term';

	/**
	 * Flag to indicate if the indexable has support for
	 * `id_range` pagination method during a sync.
	 *
	 * @var boolean
	 * @since 5.2.0
	 */
	public $support_indexing_advanced_pagination = true;

	/**
	 * Instantiate the indexable SyncManager and QueryIntegration, the main responsibles for the WP integration.
	 *
	 * @since 4.5.0
	 * @return void
	 */
	public function setup() {
		$this->labels = [
			'plural'   => esc_html__( 'Terms', 'elasticpress' ),
			'singular' => esc_html__( 'Term', 'elasticpress' ),
		];

		$this->sync_manager      = new SyncManager( $this->slug );
		$this->query_integration = new QueryIntegration( $this->slug );
	}

	/**
	 * Format query vars into ES query
	 *
	 * @param  array $query_vars WP_Term_Query args.
	 * @since  3.1
	 * @return array
	 */
	public function format_args( $query_vars ) {
		$query_vars = $this->sanitize_query_vars( $query_vars );

		$formatted_args = [
			'from' => $this->parse_from( $query_vars ),
			'size' => $this->parse_size( $query_vars ),
		];

		$formatted_args = $this->maybe_orderby( $formatted_args, $query_vars );

		$filters = $this->parse_filters( $query_vars );
		if ( ! empty( $filters ) ) {
			$formatted_args['post_filter'] = $filters;
		}

		$formatted_args = $this->maybe_set_search_fields( $formatted_args, $query_vars );
		$formatted_args = $this->maybe_set_fields( $formatted_args, $query_vars );

		/**
		 * Filter full Elasticsearch query for Terms indexable
		 *
		 * @hook ep_term_formatted_args
		 * @param  {array} $query Elasticsearch query
		 * @param  {array} $query_vars Query variables
		 * @since  3.4
		 * @return {array} New query
		 */
		return apply_filters( 'ep_term_formatted_args', $formatted_args, $query_vars );
	}

	/**
	 * Generate the mapping array
	 *
	 * @since  3.6.0
	 * @return array
	 */
	public function generate_mapping() {
		$es_version = Elasticsearch::factory()->get_elasticsearch_version();

		if ( empty( $es_version ) ) {
			$es_version = apply_filters( 'ep_fallback_elasticsearch_version', '2.0' );
		}
		$es_version = (string) $es_version;

		$mapping_file = '7-0.php';

		if ( version_compare( $es_version, '7.0', '<' ) ) {
			$mapping_file = 'initial.php';
		}

		/**
		 * Filter mapping file for Terms indexable
		 *
		 * @hook ep_term_mapping_file
		 * @param  {string} $file File name
		 * @since  3.4
		 * @return {string} New file name
		 */
		$mapping = require apply_filters( 'ep_term_mapping_file', __DIR__ . '/../../../mappings/term/' . $mapping_file );

		/**
		 * Filter full Elasticsearch query for Terms indexable
		 *
		 * @hook ep_term_mapping
		 * @param  {array} $mapping Elasticsearch mapping
		 * @since  3.4
		 * @return {array} New mapping
		 */
		$mapping = apply_filters( 'ep_term_mapping', $mapping );

		return $mapping;
	}

	/**
	 * Prepare a term document for indexing
	 *
	 * @param  int $term_id Term ID
	 * @since  3.1
	 * @return bool|array
	 */
	public function prepare_document( $term_id ) {
		$term = get_term( $term_id );

		if ( ! $term || ! is_a( $term, 'WP_Term' ) ) {
			return false;
		}

		$term_args = [
			'term_id'          => $term->term_id,
			'ID'               => $term->term_id,
			'name'             => $term->name,
			'slug'             => $term->slug,
			'term_group'       => $term->group,
			'term_taxonomy_id' => $term->term_taxonomy_id,
			'taxonomy'         => $term->taxonomy,
			'description'      => $term->description,
			'parent'           => $term->parent,
			'count'            => $term->count,
			'meta'             => $this->prepare_meta_types( $this->prepare_meta( $term->term_id ) ),
			'hierarchy'        => $this->prepare_term_hierarchy( $term->term_id, $term->taxonomy ),
			'object_ids'       => $this->prepare_object_ids( $term->term_id, $term->taxonomy ),
		];

		/**
		 * Filter term fields pre-sync
		 *
		 * @hook ep_term_sync_args
		 * @param  {array} $term_args Current term fields
		 * @param  {int} $term_id Term ID
		 * @since  3.4
		 * @return {array} New fields
		 */
		$term_args = apply_filters( 'ep_term_sync_args', $term_args, $term_id );

		return $term_args;
	}

	/**
	 * Query DB for terms
	 *
	 * @param  array $args Query arguments
	 * @since  3.1
	 * @return array
	 */
	public function query_db( $args ) {
		$defaults = [
			'number'                          => $this->get_bulk_items_per_page(),
			'offset'                          => 0,
			'orderby'                         => 'id',
			'order'                           => 'desc',
			'taxonomy'                        => $this->get_indexable_taxonomies(),
			'hide_empty'                      => false,
			'hierarchical'                    => false,
			'update_term_meta_cache'          => false,
			'cache_results'                   => false,
			'ep_indexing_advanced_pagination' => true,
		];

		if ( isset( $args['per_page'] ) ) {
			$args['number'] = $args['per_page'];
		}

		if ( isset( $args['include'] ) ) {
			$args['include'] = $args['include'];
		}

		if ( isset( $args['exclude'] ) ) {
			$args['exclude'] = $args['exclude'];
		}

		/**
		 * Filter database arguments for term query
		 *
		 * @hook ep_term_query_db_args
		 * @param  {array} $args Query arguments based to WP_Term_Query
		 * @since  3.4
		 * @return {array} New arguments
		 */
		$args = apply_filters( 'ep_term_query_db_args', wp_parse_args( $args, $defaults ) );

		$all_query_args = $args;

		unset( $all_query_args['number'] );
		unset( $all_query_args['offset'] );
		unset( $all_query_args['fields'] );

		if ( isset( $args['include'] ) || 0 < $args['offset'] ) {
			// Disable advanced pagination. Not useful if only indexing specific IDs.
			$args['ep_indexing_advanced_pagination'] = false;
		}

		// Explicitly set the orderby to ID to prevent accidental modifications by other code.
		add_filter( 'terms_clauses', [ $this, 'set_orderby' ], 9999, 3 );

		// Enforce the following query args during advanced pagination to ensure things work correctly.
		if ( $args['ep_indexing_advanced_pagination'] ) {
			$args = array_merge(
				$args,
				[
					'suppress_filters' => false,
					'orderby'          => 'ID',
					'order'            => 'DESC',
					'paged'            => 1,
					'offset'           => 0,
				]
			);
			add_filter( 'terms_clauses', [ $this, 'bulk_indexing_filter_terms_where' ], 9999, 3 );

			$query         = new WP_Term_Query( $args );
			$total_objects = $this->get_total_objects_for_query( $args );

			remove_filter( 'terms_clauses', [ $this, 'bulk_indexing_filter_terms_where' ], 9999, 3 );
		} else {

			/**
			 * Filter database arguments for term count query
			 *
			 * @hook ep_term_all_query_db_args
			 * @param  {array} $args Query arguments based to `wp_count_terms()`
			 * @since  3.4
			 * @return {array} New arguments
			 */
			$total_objects = wp_count_terms( apply_filters( 'ep_term_all_query_db_args', $all_query_args, $args ) );
			$total_objects = ! is_wp_error( $total_objects ) ? (int) $total_objects : 0;

			if ( ! empty( $args['offset'] ) ) {
				if ( (int) $args['offset'] >= $total_objects ) {
					$total_objects = 0;
				}
			}

			$query = new WP_Term_Query( $args );
		}

		remove_filter( 'terms_clauses', [ $this, 'set_orderby' ], 9999, 3 );

		if ( is_array( $query->terms ) ) {
			array_walk( $query->terms, array( $this, 'remap_terms' ) );
		}

		return [
			'objects'       => $query->terms,
			'total_objects' => $total_objects,
		];
	}

	/**
	 * Filters the WHERE clause of the SQL query used for bulk indexing terms by modifying it to include a range of
	 * comment IDs based on advanced pagination parameters.
	 *
	 * @param array $clauses    Associative array of the clauses for the query.
	 * @param array $taxonomies An array of taxonomy names.
	 * @param array $args       An array of term query arguments.
	 *
	 * @return array The modified SQL WHERE clauses.
	 */
	public function bulk_indexing_filter_terms_where( $clauses, $taxonomies, $args ) {
		$using_advanced_pagination = $args['ep_indexing_advanced_pagination'] ?? false;

		if ( $using_advanced_pagination ) {
			$requested_upper_limit_id        = $args['ep_indexing_upper_limit_object_id'] ?? PHP_INT_MAX;
			$requested_lower_limit_object_id = $args['ep_indexing_lower_limit_object_id'] ?? 0;
			$last_processed_id               = $args['ep_indexing_last_processed_object_id'] ?? null;

			// On the first loopthrough we begin with the requested upper limit ID. Afterwards, use the last processed ID to paginate.
			$upper_limit_range_object_id = $requested_upper_limit_id;
			if ( is_numeric( $last_processed_id ) ) {
				$upper_limit_range_object_id = $last_processed_id - 1;
			}

			// Sanitize. Abort if unexpected data at this point.
			if ( ! is_numeric( $upper_limit_range_object_id ) || ! is_numeric( $requested_lower_limit_object_id ) ) {
				return $clauses;
			}

			$range = [
				'upper_limit' => "t.term_id <= {$upper_limit_range_object_id}",
				'lower_limit' => "t.term_id >= {$requested_lower_limit_object_id}",
			];

			// Skip the end range if it's unnecessary.
			$skip_ending_range = 0 === $requested_lower_limit_object_id;
			$where             = $clauses['where'];
			$where             = $skip_ending_range ? " {$range['upper_limit']} AND {$where}" : " {$range['upper_limit']} AND {$range['lower_limit']} AND {$where}";

			$clauses['where'] = $where;
		}

		return $clauses;
	}

	/**
	 * Get the total number of terms for a given query.
	 *
	 * @param array $query_args The query args.
	 * @return int The total number of terms.
	 */
	protected function get_total_objects_for_query( $query_args ) {
		static $object_counts = [];

		// Reset the pagination-related args for optimal caching.
		$normalized_query_args = array_merge(
			$query_args,
			[
				'offset'                               => 0,
				'paged'                                => 1,
				'posts_per_page'                       => 1,
				'no_found_rows'                        => false,
				'ep_indexing_last_processed_object_id' => null,
			]
		);

		$cache_key = md5( get_current_blog_id() . wp_json_encode( $normalized_query_args ) );
		if ( ! isset( $object_counts[ $cache_key ] ) ) {
			$object_counts[ $cache_key ] = wp_count_terms( $normalized_query_args );
		}

		return $object_counts[ $cache_key ];
	}

	/**
	 * Returns indexable taxonomies for the current site
	 *
	 * @since  3.1
	 * @return mixed|void
	 */
	public function get_indexable_taxonomies() {
		$taxonomies        = get_taxonomies( [], 'objects' );
		$public_taxonomies = [];

		foreach ( $taxonomies as $taxonomy ) {
			if ( $taxonomy->public || $taxonomy->publicly_queryable ) {
				$public_taxonomies[] = $taxonomy->name;
			}
		}

		/**
		 * Filter indexable taxonomies for Terms indexable
		 *
		 * @hook ep_indexable_taxonomies
		 * @param  {array} $public_taxonomies Taxonomies
		 * @since  3.4
		 * @return {array} New taxonomies array
		 */
		return apply_filters( 'ep_indexable_taxonomies', $public_taxonomies );
	}

	/**
	 * Rebuild our term object to match the fields we need.
	 *
	 * In particular, result of WP_Term_Query does not
	 * include an "id" field, which our index command
	 * expects.
	 *
	 * @param  object $value Term object
	 * @since  3.1
	 * @return void Returns by reference
	 */
	public function remap_terms( &$value ) {
		$value = (object) array(
			'ID'               => $value->term_id,
			'term_id'          => $value->term_id,
			'name'             => $value->name,
			'slug'             => $value->slug,
			'term_group'       => $value->term_group,
			'term_taxonomy_id' => $value->term_taxonomy_id,
			'taxonomy'         => $value->taxonomy,
			'description'      => $value->description,
			'parent'           => $value->parent,
			'count'            => $value->count,
		);
	}

	/**
	 * Prepare meta to send to ES
	 *
	 * @param  int $term_id Term ID
	 * @since  3.1
	 * @return array
	 */
	public function prepare_meta( $term_id ) {
		$meta = (array) get_term_meta( $term_id );

		if ( empty( $meta ) ) {
			return [];
		}

		$prepared_meta = [];

		/**
		 * Filter index-able private meta
		 *
		 * Allows for specifying private meta keys that may be indexed in the same manner as public meta keys.
		 *
		 * @since 3.4
		 * @hook ep_prepare_term_meta_allowed_protected_keys
		 * @param {array} $allowed_protected_keys Array of index-able private meta keys.
		 * @param {int} $term_id Term ID.
		 * @return {array} New meta keys
		 */
		$allowed_protected_keys = apply_filters( 'ep_prepare_term_meta_allowed_protected_keys', [], $term_id );

		/**
		 * Filter non-indexed public meta
		 *
		 * Allows for specifying public meta keys that should be excluded from the ElasticPress index.
		 *
		 * @since 3.4
		 * @hook ep_prepare_term_meta_excluded_public_keys
		 * @param {array} $public_keys  Array of public meta keys to exclude from index.
		 * @param {int} $term_id Term ID.
		 * @return {array} New keys
		 */
		$excluded_public_keys = apply_filters(
			'ep_prepare_term_meta_excluded_public_keys',
			[
				'session_tokens',
			],
			$term_id
		);

		foreach ( $meta as $key => $value ) {

			$allow_index = false;

			if ( is_protected_meta( $key ) ) {

				if ( true === $allowed_protected_keys || in_array( $key, $allowed_protected_keys, true ) ) {
					$allow_index = true;
				}
			} elseif ( true !== $excluded_public_keys && ! in_array( $key, $excluded_public_keys, true ) ) {

				$allow_index = true;
			}

			/**
			 * Filter kill switch for any term meta
			 *
			 * @since 3.4
			 * @hook ep_prepare_term_meta_whitelist_key
			 * @param  {boolean} $index_key Whether to index key or not
			 * @param {string} $key Key name
			 * @param {int} $term_id Term ID.
			 * @return {boolean} New index value
			 */
			if ( true === $allow_index || apply_filters( 'ep_prepare_term_meta_whitelist_key', false, $key, $term_id ) ) {
				$prepared_meta[ $key ] = maybe_unserialize( $value );
			}
		}

		return $prepared_meta;
	}

	/**
	 * Prepare term hierarchy to send to ES
	 *
	 * @param  int    $term_id Term ID.
	 * @param  string $taxonomy Term taxonomy.
	 * @since  3.1
	 * @return array
	 */
	public function prepare_term_hierarchy( $term_id, $taxonomy ) {
		$hierarchy = [];
		$children  = get_term_children( $term_id, $taxonomy );
		$ancestors = get_ancestors( $term_id, $taxonomy, 'taxonomy' );

		if ( ! empty( $children ) && ! is_wp_error( $children ) ) {
			$hierarchy['children']['terms'] = $children;
			$children_count                 = 0;

			foreach ( $children as $child_term_id ) {
				$child_term      = get_term( $child_term_id );
				$children_count += (int) $child_term->count;
			}

			$hierarchy['children']['count'] = $children_count;
		} else {
			$hierarchy['children']['terms'] = 0;
			$hierarchy['children']['count'] = 0;
		}

		if ( ! empty( $ancestors ) ) {
			$hierarchy['ancestors']['terms'] = $ancestors;
		} else {
			$hierarchy['ancestors']['terms'] = 0;
		}

		return $hierarchy;
	}

	/**
	 * Prepare object IDs to send to ES
	 *
	 * @param  int    $term_id Term ID.
	 * @param  string $taxonomy Term taxonomy.
	 * @since  3.1
	 * @return array
	 */
	public function prepare_object_ids( $term_id, $taxonomy ) {
		$ids        = [];
		$object_ids = get_objects_in_term( [ $term_id ], [ $taxonomy ] );

		if ( ! empty( $object_ids ) && ! is_wp_error( $object_ids ) ) {
			$ids['value'] = array_map( 'absint', array_values( $object_ids ) );
		} else {
			$ids['value'] = 0;
		}

		return $ids;
	}

	/**
	 * Parse an 'order' query variable and cast it to ASC or DESC as necessary.
	 *
	 * @access protected
	 *
	 * @param  string $order The 'order' query variable.
	 * @since  3.1
	 * @return string The sanitized 'order' query variable.
	 */
	protected function parse_order( $order ) {
		if ( ! is_string( $order ) || empty( $order ) ) {
			return 'desc';
		}

		if ( 'ASC' === strtoupper( $order ) ) {
			return 'asc';
		} else {
			return 'desc';
		}
	}

	/**
	 * Convert the alias to a properly-prefixed sort value.
	 *
	 * @access protected
	 *
	 * @param  string $orderby Alias or path for the field to order by.
	 * @param  string $order Order direction
	 * @param  array  $args Query args
	 * @since  3.1
	 * @return array
	 */
	protected function parse_orderby( $orderby, $order, $args ) {
		$sort = [];

		if ( empty( $orderby ) ) {
			return $sort;
		}

		$from_to = [
			'slug'        => 'slug.raw',
			'id'          => 'term_id',
			'description' => 'description.sortable',
		];

		if ( in_array( $orderby, [ 'meta_value', 'meta_value_num' ], true ) ) {
			if ( empty( $args['meta_key'] ) ) {
				return $sort;
			} else {
				$from_to['meta_value']     = 'meta.' . $args['meta_key'] . '.value';
				$from_to['meta_value_num'] = 'meta.' . $args['meta_key'] . '.long';
			}
		}

		if ( 'name' === $orderby ) {
			$es_version      = Elasticsearch::factory()->get_elasticsearch_version();
			$from_to['name'] = version_compare( (string) $es_version, '7.0', '<' ) ? 'name.raw' : 'name.sortable';
		}

		$orderby = $from_to[ $orderby ] ?? $orderby;

		$sort[] = array(
			$orderby => array(
				'order' => $order,
			),
		);

		return $sort;
	}

	/**
	 * Sanitize WP_Term_Query arguments to be used to create the ES query.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return array
	 */
	protected function sanitize_query_vars( $query_vars ) {
		if ( ! empty( $query_vars['get'] ) && 'all' === $query_vars['get'] ) {
			$query_vars['childless']    = false;
			$query_vars['child_of']     = 0;
			$query_vars['hide_empty']   = false;
			$query_vars['hierarchical'] = false;
			$query_vars['pad_counts']   = false;
		}

		$query_vars['taxonomy'] = ( ! empty( $query_vars['taxonomy'] ) ) ?
			(array) $query_vars['taxonomy'] :
			[];

		return $query_vars;
	}

	/**
	 * Parse the `from` clause of the ES Query.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return int
	 */
	protected function parse_from( $query_vars ) {
		return ( isset( $query_vars['offset'] ) ) ? (int) $query_vars['offset'] : 0;
	}

	/**
	 * Parse the `size` clause of the ES Query.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return int
	 */
	protected function parse_size( $query_vars ) {
		if ( ! empty( $query_vars['number'] ) ) {
			$number = (int) $query_vars['number'];
		} else {
			/**
			 * Set the maximum results window size.
			 *
			 * The request will return a HTTP 500 Internal Error if the size of the
			 * request is larger than the [index.max_result_window] parameter in ES.
			 * See the scroll api for a more efficient way to request large data sets.
			 *
			 * @return int The max results window size.
			 *
			 * @since 2.3.0
			 */
			$number = apply_filters( 'ep_max_results_window', 10000 );
		}

		return $number;
	}

	/**
	 * Parse the order of results in the ES query.
	 *
	 * @since 5.1.0
	 * @param array $formatted_args Formatted Elasticsearch query
	 * @param array $query_vars     WP_Term_Query arguments
	 * @return array
	 */
	protected function maybe_orderby( $formatted_args, $query_vars ) {
		// Set sort order, default is 'ASC'.
		if ( ! empty( $query_vars['order'] ) ) {
			$order = $this->parse_order( $query_vars['order'] );
		} else {
			$order = 'desc';
		}

		// Set orderby, default is 'name'.
		if ( empty( $query_vars['orderby'] ) ) {
			$query_vars['orderby'] = 'name';
		}

		// Set sort type.
		$formatted_args['sort'] = $this->parse_orderby( $query_vars['orderby'], $order, $query_vars );

		return $formatted_args;
	}

	/**
	 * Based on WP_Term_Query arguments, parses the various filters that could be applied into the ES query.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return array
	 */
	protected function parse_filters( $query_vars ) {
		$filters = [
			'taxonomy'                => $this->parse_taxonomy( $query_vars ),
			'object_ids'              => $this->parse_object_ids( $query_vars ),
			'include'                 => $this->parse_include( $query_vars ),
			'exclude'                 => $this->parse_exclude( $query_vars ),
			'exclude_tree'            => $this->parse_exclude_tree( $query_vars ),
			'name'                    => $this->parse_name( $query_vars ),
			'slug'                    => $this->parse_slug( $query_vars ),
			'term_taxonomy_id'        => $this->parse_term_taxonomy_id( $query_vars ),
			'hierarchical_hide_empty' => $this->parse_hierarchical_hide_empty( $query_vars ),
			'child_of'                => $this->parse_child_of( $query_vars ),
			'parent'                  => $this->parse_parent( $query_vars ),
			'childless'               => $this->parse_childless( $query_vars ),
			'meta_query'              => $this->parse_meta_queries( $query_vars ),
		];

		$filters = array_values( array_filter( $filters ) );

		if ( ! empty( $filters ) ) {
			$filters = [
				'bool' => [
					'must' => $filters,
				],
			];
		}

		return $filters;
	}

	/**
	 * Parse the `taxonomy` WP Term Query arg and transform it into an ES query clause.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return array
	 */
	protected function parse_taxonomy( $query_vars ) {
		if ( empty( $query_vars['taxonomy'] ) ) {
			return [];
		}

		if ( count( $query_vars['taxonomy'] ) < 2 ) {
			return [
				'term' => [
					'taxonomy.raw' => $query_vars['taxonomy'][0],
				],
			];
		}

		return [
			'terms' => [
				'taxonomy.raw' => $query_vars['taxonomy'],
			],
		];
	}

	/**
	 * Parse the `object_ids` WP Term Query arg and transform it into an ES query clause.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return array
	 */
	protected function parse_object_ids( $query_vars ) {
		if ( empty( $query_vars['object_ids'] ) ) {
			return [];
		}

		return [
			'bool' => [
				'must' => [
					'terms' => [
						'object_ids.value' => (array) $query_vars['object_ids'],
					],
				],
			],
		];
	}

	/**
	 * Parse the `include` WP Term Query arg and transform it into an ES query clause.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return array
	 */
	protected function parse_include( $query_vars ) {
		if ( empty( $query_vars['include'] ) ) {
			return [];
		}

		return [
			'bool' => [
				'must' => [
					'terms' => [
						'term_id' => array_values( (array) $query_vars['include'] ),
					],
				],
			],
		];
	}

	/**
	 * Parse the `exclude` WP Term Query arg and transform it into an ES query clause.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return array
	 */
	protected function parse_exclude( $query_vars ) {
		if ( ! empty( $query_vars['include'] ) || empty( $query_vars['exclude'] ) ) {
			return [];
		}

		return [
			'bool' => [
				'must_not' => [
					'terms' => [
						'term_id' => array_values( (array) $query_vars['exclude'] ),
					],
				],
			],
		];
	}

	/**
	 * Parse the `exclude_tree` WP Term Query arg and transform it into an ES query clause.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return array
	 */
	protected function parse_exclude_tree( $query_vars ) {
		if ( ! empty( $query_vars['include'] ) || empty( $query_vars['exclude_tree'] ) ) {
			return [];
		}

		return [
			'bool' => [
				'must_not' => [
					[
						'terms' => [
							'term_id' => array_values( (array) $query_vars['exclude_tree'] ),
						],
					],
					[
						'terms' => [
							'parent' => array_values( (array) $query_vars['exclude_tree'] ),
						],
					],
				],
			],
		];
	}

	/**
	 * Parse the `name` WP Term Query arg and transform it into an ES query clause.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return array
	 */
	protected function parse_name( $query_vars ) {
		if ( empty( $query_vars['name'] ) ) {
			return [];
		}

		return [
			'terms' => [
				'name.raw' => (array) $query_vars['name'],
			],
		];
	}

	/**
	 * Parse the `slug` WP Term Query arg and transform it into an ES query clause.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return array
	 */
	protected function parse_slug( $query_vars ) {
		if ( empty( $query_vars['slug'] ) ) {
			return [];
		}

		$query_vars['slug'] = (array) $query_vars['slug'];
		$query_vars['slug'] = array_map( 'sanitize_title', $query_vars['slug'] );

		return [
			'terms' => [
				'slug.raw' => (array) $query_vars['slug'],
			],
		];
	}

	/**
	 * Parse the `term_taxonomy_id` WP Term Query arg and transform it into an ES query clause.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return array
	 */
	protected function parse_term_taxonomy_id( $query_vars ) {
		if ( empty( $query_vars['term_taxonomy_id'] ) ) {
			return [];
		}

		return [
			'bool' => [
				'must' => [
					'terms' => [
						'term_taxonomy_id' => array_values( (array) $query_vars['term_taxonomy_id'] ),
					],
				],
			],
		];
	}

	/**
	 * Parse the `hide_empty` and `hierarchical` WP Term Query args and transform them into ES query clauses.
	 *
	 * `hierarchical` needs to work in conjunction with `hide_empty`, as per WP docs:
	 * > `hierarchical`: Whether to include terms that have non-empty descendants (even if $hide_empty is set to true).
	 *
	 * In summary:
	 * - hide_empty AND hierarchical: count > 1 OR hierarchy.children > 1
	 * - hide_empty AND NOT hierarchical: count > 1 (ignore hierarchy.children)
	 * - NOT hide_empty (AND hierarchical): there is no need to limit the query
	 *
	 * @see https://developer.wordpress.org/reference/classes/WP_Term_Query/__construct/
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return array
	 */
	protected function parse_hierarchical_hide_empty( $query_vars ) {
		$hide_empty = isset( $query_vars['hide_empty'] ) ? $query_vars['hide_empty'] : '';
		if ( ! $hide_empty ) {
			return [];
		}

		$hierarchical = isset( $query_vars['hierarchical'] ) ? $query_vars['hierarchical'] : '';
		if ( ! $hierarchical ) {
			return [
				'range' => [
					'count' => [
						'gte' => 1,
					],
				],
			];
		}

		return [
			'bool' => [
				'should' => [
					[
						'range' => [
							'count' => [
								'gte' => 1,
							],
						],
					],
					[
						'range' => [
							'hierarchy.children.count' => [
								'gte' => 1,
							],
						],
					],
				],
			],
		];
	}

	/**
	 * Parse the `child_of` WP Term Query arg and transform it into an ES query clause.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return array
	 */
	protected function parse_child_of( $query_vars ) {
		if ( empty( $query_vars['child_of'] ) || count( $query_vars['taxonomy'] ) > 1 ) {
			return [];
		}

		return [
			'bool' => [
				'must' => [
					'match_phrase' => [
						'hierarchy.ancestors.terms' => (int) $query_vars['child_of'],
					],
				],
			],
		];
	}

	/**
	 * Parse the `parent` WP Term Query arg and transform it into an ES query clause.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return array
	 */
	protected function parse_parent( $query_vars ) {
		if ( ! isset( $query_vars['parent'] ) || '' === $query_vars['parent'] ) {
			return [];
		}

		return [
			'bool' => [
				'must' => [
					'term' => [
						'parent' => (int) $query_vars['parent'],
					],
				],
			],
		];
	}

	/**
	 * Parse the `childless` WP Term Query arg and transform it into an ES query clause.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return array
	 */
	protected function parse_childless( $query_vars ) {
		if ( empty( $query_vars['childless'] ) ) {
			return [];
		}

		return [
			'bool' => [
				'must' => [
					'term' => [
						'hierarchy.children.terms' => 0,
					],
				],
			],
		];
	}

	/**
	 * Parse WP Term Query meta queries and transform them into ES query clauses.
	 *
	 * @since 5.1.0
	 * @param array $query_vars WP_Term_Query arguments
	 * @return array
	 */
	protected function parse_meta_queries( $query_vars ) {
		$meta_queries = [];
		/**
		 * Support `meta_key`, `meta_value`, and `meta_compare` query args
		 */
		if ( ! empty( $query_vars['meta_key'] ) ) {
			$meta_query_array = [
				'key' => $query_vars['meta_key'],
			];

			if ( isset( $query_vars['meta_value'] ) && '' !== $query_vars['meta_value'] ) {
				$meta_query_array['value'] = $query_vars['meta_value'];
			}

			if ( isset( $query_vars['meta_compare'] ) ) {
				$meta_query_array['compare'] = $query_vars['meta_compare'];
			}

			$meta_queries[] = $meta_query_array;
		}

		/**
		 * Support 'meta_query' query var.
		 */
		if ( ! empty( $query_vars['meta_query'] ) ) {
			$meta_queries = array_merge( $meta_queries, $query_vars['meta_query'] );
		}

		if ( ! empty( $meta_queries ) ) {
			$built_meta_queries = $this->build_meta_query( $meta_queries );

			if ( $built_meta_queries ) {
				return $built_meta_queries;
			}
		}

		return [];
	}

	/**
	 * If in a search context, using `name__like`, or `description__like` set search fields, otherwise query everything.
	 *
	 * @since 5.1.0
	 * @param array $formatted_args Formatted Elasticsearch query
	 * @param array $query_vars     WP_Term_Query arguments
	 * @return array
	 */
	protected function maybe_set_search_fields( $formatted_args, $query_vars ) {
		if ( empty( $query_vars['search'] ) && empty( $query_vars['name__like'] ) && empty( $query_vars['description__like'] ) ) {
			$formatted_args['query']['match_all'] = [
				'boost' => 1,
			];

			return $formatted_args;
		}

		$search        = ! empty( $query_vars['search'] ) ? $query_vars['search'] : '';
		$search_fields = [];

		if ( ! empty( $query_vars['name__like'] ) ) {
			$search          = $query_vars['name__like'];
			$search_fields[] = 'name';
		}

		if ( ! empty( $query_vars['description__like'] ) ) {
			$search          = $query_vars['description__like'];
			$search_fields[] = 'description';
		}

		/**
		 * Allow for search field specification
		 */
		if ( ! empty( $query_vars['search_fields'] ) ) {
			$search_fields = $query_vars['search_fields'];
		}

		if ( ! empty( $search_fields ) ) {
			$prepared_search_fields = [];

			if ( ! empty( $search_fields['meta'] ) ) {
				$metas = (array) $search_fields['meta'];

				foreach ( $metas as $meta ) {
					$prepared_search_fields[] = 'meta.' . $meta . '.value';
				}

				unset( $search_fields['meta'] );
			}

			$prepared_search_fields = array_merge( $search_fields, $prepared_search_fields );
		} else {
			$prepared_search_fields = [
				'name',
				'slug',
				'taxonomy',
				'description',
			];
		}

		/**
		 * Filter fields to search on Term query
		 *
		 * @hook ep_term_search_fields
		 * @param  {array} $search_fields Search fields
		 * @param  {array} $query_vars Query variables
		 * @since  3.4
		 * @return {array} New search fields
		 */
		$prepared_search_fields = apply_filters( 'ep_term_search_fields', $prepared_search_fields, $query_vars );

		$search_algorithm        = $this->get_search_algorithm( $search, $prepared_search_fields, $query_vars );
		$formatted_args['query'] = $search_algorithm->get_query( 'term', $search, $prepared_search_fields, $query_vars );

		return $formatted_args;
	}

	/**
	 * If needed set the `fields` ES query clause.
	 *
	 * @since 5.1.0
	 * @param array $formatted_args Formatted Elasticsearch query
	 * @param array $query_vars     WP_Term_Query arguments
	 * @return array
	 */
	protected function maybe_set_fields( $formatted_args, $query_vars ) {
		if ( ! isset( $query_vars['fields'] ) ) {
			return $formatted_args;
		}

		switch ( $query_vars['fields'] ) {
			case 'ids':
				$formatted_args['_source'] = [
					'includes' => [
						'term_id',
					],
				];
				break;

			case 'id=>name':
				$formatted_args['_source'] = [
					'includes' => [
						'term_id',
						'name',
					],
				];
				break;

			case 'id=>parent':
				$formatted_args['_source'] = [
					'includes' => [
						'term_id',
						'parent',
					],
				];
				break;

			case 'id=>slug':
				$formatted_args['_source'] = [
					'includes' => [
						'term_id',
						'slug',
					],
				];
				break;

			case 'names':
				$formatted_args['_source'] = [
					'includes' => [
						'name',
					],
				];
				break;
			case 'tt_ids':
				$formatted_args['_source'] = [
					'includes' => [
						'term_taxonomy_id',
					],
				];
				break;
		}

		return $formatted_args;
	}

	/**
	 * Sets the ORDER BY clause for term queries to order terms by their term_id.
	 *
	 * @param array $clauses The SQL clauses array to modify.
	 * @return array The modified SQL clauses array with the ORDER BY clause set to term_id.
	 *
	 * @since 5.2.0
	 */
	public function set_orderby( $clauses ): array {
		$clauses['orderby'] = 'ORDER BY t.term_id';
		return $clauses;
	}
}
