<?php
declare( strict_types=1 );

namespace FiboFilters\Indexer;

use FiboFilters\Config\Env;
use FiboFilters\Filters\FilterSources;
use FiboFilters\Filters\SourceAnalyser;
use FiboFilters\Filters\StoreFilters;
use FiboFilters\Integrations\Multilingual;
use FiboFilters\Sorting\Sorting;
use FiboFilters\Util\ArrayUtil;
use FiboFilters\Util\Cache;
use FiboFilters\Util\FilterUtil;
use FiboFilters\Util\Helpers;
use FiboFilters\WooCommerce\Helper as WooCommerceHelper;

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

/**
 * Class SourceQuery
 */
class SourceQuery implements SourceQueryInterface {

	const DATA_TYPE = [
		'value'                    => '1',
		'id_value_slug_and_parent' => '2',
		'id_value_and_slug'        => '3',
		'value_and_key'            => '4',
		'value_and_label'          => '5',
	];

	/** @var string */
	private $type = 'filters_source_query';

	/** @var int[] */
	private $post_in;

	/** @var string */
	private $select_type;

	/** @var bool */
	private $skip_filters;

	/** @var string */
	private $context;

	/** @var string */
	private $query;

	/** @var BuildInfo */
	private $build_info;

	/** @var Cache */
	private $cache;

	/** @var Config */
	private $config;

	/** @var FilterSources */
	private $filter_sources;

	/** @var Multilingual */
	private $multilingual;

	/** @var StoreFilters */
	private $store_filters;

	/** @var WooCommerceHelper */
	private $wc_helper;

	/** @var Sorting */
	private $sorting;

	/** @var array */
	private $transform_data_cache = [];

	/** @var array */
	private $number_extended_overrides_cache = [];

	public function __construct( BuildInfo $build_info, Cache $cache, Config $config, FilterSources $filter_sources, Multilingual $multilingual, StoreFilters $store_filters, WooCommerceHelper $wc_helper, Sorting $sorting, array $args ) {
		$this->build_info     = $build_info;
		$this->cache          = $cache;
		$this->config         = $config;
		$this->filter_sources = $filter_sources;
		$this->multilingual   = $multilingual;
		$this->store_filters  = $store_filters;
		$this->wc_helper      = $wc_helper;
		$this->sorting        = $sorting;

		$this->set_args( $args );

		/**
		 * Filter that allows retrieving values with hierarchy reconstruction for a given list of terms
		 *
		 * @see self::get_ancestor_tree_as_values()
		 */
		add_filter(
			'fibofilters/indexer/helper/get_ancestor_tree_as_values',
			[ $this, 'get_ancestor_tree_as_values' ],
			10,
			2
		);

		/**
		 * Filter that allows retrieving children values for a given parent value
		 *
		 * @see FilterUtil::get_children()
		 */
		add_filter(
			'fibofilters/indexer/helper/get_children_values',
			[ 'FiboFilters\Util\FilterUtil','get_children' ],
			10,
			2
		);

		/**
		 * Filter that allows filtering values and preserving hierarchy.
		 *
		 * @see FilterUtil::filter_values_preserve_hierarchy()
		 */
		add_filter(
			'fibofilters/indexer/helper/filter_values_preserve_hierarchy',
			[ 'FiboFilters\Util\FilterUtil','filter_values_preserve_hierarchy' ],
			10,
			2
		);
	}

	/**
	 * @param array $args Arguments.
	 */
	private function set_args( array $args ): void {
		$defaults = [
			'select_type'  => 'data',
			'post_in'      => [],
			'skip_filters' => false,
			'context'      => '',
		];

		$args = wp_parse_args( $args, $defaults );

		$this->post_in      = $args['post_in'];
		$this->select_type  = $args['select_type'];
		$this->skip_filters = $args['skip_filters'];
		$this->context      = $args['context'];
	}

	/**
	 * Build SQL query which select products with all necessary fields
	 *
	 * @return string
	 */
	protected function build_query(): string {
		global $wpdb;
		$join = '';

		// -------------------------------
		// SELECT
		// -------------------------------
		$select = '';

		// Select product ID.
		$select .= $this->select_id();

		if ( $this->select_type === 'counters' ) {
			// Select product type.
			$select .= $this->select_product_type();
		} elseif ( $this->select_type === 'data' ) {
			// Select product type.
			$select .= $this->select_product_type();
		}

		if ( $this->select_type === 'data' ) {
			if ( get_option( 'woocommerce_hide_out_of_stock_items' ) !== 'yes' ) {
				/**
				 * When the “Hide out of stock items from the catalog” from WooCommerce option is disabled,
				 * we index product variations but clear all their filter values. This is necessary because we want
				 * to display these products, but they should disappear as soon as any filter is applied.
				 */
				add_filter( 'fibofilters/indexer/filters_source_query/exclude_out_of_stock_variations_while_filtering', '__return_true', 5 );
			}

			foreach ( $this->store_filters->get_all_filters() as $filter ) {
				$source_obj  = $this->filter_sources->get_source( $filter['source'] );
				$source_data = $source_obj->get_data();
				if ( empty( $source_data ) ) {
					continue;
				}

				if (
					$source_data['value'] === 'cf__stock_status' &&
					apply_filters( 'fibofilters/indexer/filters_source_query/exclude_out_of_stock_variations_while_filtering', false )
				) {
					// Stock status will always be selected in this case, no need to select it again.
					continue;
				}

				switch ( $source_data['sourceType'] ) {
					case 'taxonomy':
						if ( $source_data['rawValue'] === 'product_cat' ) {
							$select .= $this->select_terms( $source_data['rawValue'], self::DATA_TYPE['id_value_slug_and_parent'], $source_data['value'] );
						} elseif ( $filter['type'] === 'range' ) {
							$select .= $this->select_terms( $source_data['rawValue'], self::DATA_TYPE['value'], $source_data['value'] );
						} else {
							$select .= $this->select_terms( $source_data['rawValue'], self::DATA_TYPE['id_value_and_slug'], $source_data['value'] );
						}
						break;
					case 'custom_field':
						$select .= $this->select_custom_field( $source_data['rawValue'], $source_data['value'] );
						break;
					case 'advanced_custom_field':
						if ( $filter['type'] === 'range' ) {
							$select .= $this->select_advanced_custom_field( $source_data['rawValue'], self::DATA_TYPE['value'], $source_data['value'] );
						} else {
							$select .= $this->select_advanced_custom_field( $source_data['rawValue'], self::DATA_TYPE['value_and_key'], $source_data['value'] );
						}
						break;
					case 'on_sale':
						$select .= $this->select_on_sale( $source_data['value'] );
						break;
					case 'price':
						$select .= $this->select_prices( $source_data['value'] );
						break;
					case 'search':
						$select .= $this->select_search( $source_data['value'] );
						break;
					case 'featured':
						$select .= $this->select_featured( $source_data['value'] );
						break;
				}
			}

			// We need this information to be able to filter out of stock product variations.
			if ( apply_filters( 'fibofilters/indexer/filters_source_query/exclude_out_of_stock_variations_while_filtering', false ) ) {
				$select .= $this->select_custom_field( '_stock_status', 'cf__stock_status' );
			}

			// Select data for ordering.
			if ( apply_filters( 'fibofilters/config/override_ordering_enabled', false ) ) {
				$all_filter_sources    = $this->store_filters->get_all_filter_sources();
				$catalog_orderby_keys  = array_keys( $this->sorting->get_catalog_orderedby() );
				$sources_for_filtering = array_keys( $this->filter_sources->get_sources_for_ordering() );
				$maybe_used_sources    = [];
				if ( in_array( 'rating', $catalog_orderby_keys, true ) ) {
					$maybe_used_sources[] = 'cf__wc_average_rating';
				}
				if ( in_array( 'title', $catalog_orderby_keys, true ) || in_array( 'title-desc', $catalog_orderby_keys, true ) ) {
					$maybe_used_sources[] = 'search';
				}
				foreach ( $maybe_used_sources as $maybe_used_source ) {
					if ( ! in_array( $maybe_used_source, $all_filter_sources, true ) && in_array( $maybe_used_source, $sources_for_filtering, true ) ) {
						$source_obj  = $this->filter_sources->get_source( $maybe_used_source );
						$source_data = $source_obj->get_data();
						if ( ! empty( $source_data ) && $maybe_used_source === 'search' ) {
							$select .= $this->select_search( $source_data['value'] );
						} elseif ( ! empty( $source_data ) ) {
							$select .= $this->select_custom_field( $source_data['rawValue'], $source_data['value'] );
						}
					}
				}

				if ( in_array( 'menu_order', $catalog_orderby_keys, true ) ) {
					$select .= $this->select_menu_order();
				}
				if ( in_array( 'price', $catalog_orderby_keys, true ) || in_array( 'price-desc', $catalog_orderby_keys, true ) ) {
					$select .= $this->select_prices( 'order_by_price' );
				}
				if ( in_array( 'date', $catalog_orderby_keys, true ) ) {
					$select .= $this->select_order_by_date();
				}
				if ( in_array( 'modified-desc', $catalog_orderby_keys, true ) ) {
					$select .= $this->select_order_by_modified();
				}
				if ( apply_filters( 'fibofilters/request/product_order/featured_first', false ) ) {
					$select .= $this->select_order_by_featured();
				}
				if ( apply_filters( 'fibofilters/request/product_order/in_stock_first', false ) ) {
					$select .= $this->select_order_by_stock_status();
				}
				if ( in_array( 'ratings_count', $catalog_orderby_keys, true ) ) {
					$select .= $this->select_order_by_rating_count();
				}

				// Select data for custom sorting.
				$custom_sort_options_used = [];
				foreach ( $this->sorting->get_custom_sort_options() as $sort_option ) {
					if ( $sort_option['source'] === 'postmeta' && ! in_array( 'cf__' . $sort_option['postmeta_key'], $custom_sort_options_used, true ) ) {
						$select .= $this->select_custom_field( $sort_option['postmeta_key'], 'cf__' . $sort_option['postmeta_key'] );

						$custom_sort_options_used[] = $sort_option['value'];
					}
				}
			}

			// Select post language.
			if ( $this->multilingual->is_multilingual() ) {
				$select .= $this->select_language();
			}
		}

		// -------------------------------
		// WHERE
		// -------------------------------
		$where = '';

		// Set range of products set (if it is set).
		$where .= $this->where_narrow_down_to_the_set();

		// Narrow all posts to only products.
		$where .= $this->where_post_types( [ 'product', 'product_variation' ] );

		// Get only published posts.
		$where .= $this->where_published();

		if ( get_option( 'woocommerce_hide_out_of_stock_items' ) === 'yes' ) {
			$sql_parts = $this->sql_exclude_out_of_stock();
			$select    = 'DISTINCT ' . $select;
			$join     .= $sql_parts['join'];
			$where    .= $sql_parts['where'];
		}

		/**
		 * Exclude products with visibility "Hidden".
		 *
		 * When single variations are supported, this is not needed,
		 * because parents may be hidden and it's variations visible.
		 */
		if ( ! apply_filters( 'fibofilters/config/single_variations_support', false ) ) {
			$sql_parts = $this->sql_exclude_hidden();
			$where    .= $sql_parts['where'];
		}

		$select = apply_filters( 'fibofilters/indexer/filters_source_query/select', $select, $this, $this->select_type );
		$join   = apply_filters( 'fibofilters/indexer/filters_source_query/join', $join, $this, $this->select_type );
		$where  = apply_filters( 'fibofilters/indexer/filters_source_query/where', $where, $this, $this->select_type );

		$sql = "SELECT $select
                FROM $wpdb->posts posts
                $join
                WHERE  1=1
                $where
               ";

		$sql = apply_filters( 'fibofilters/indexer/filters_source_query/query', $sql, $this, $this->select_type );

		$this->query = $sql;

		return $sql;
	}

	public function get_source_values( array $source_data ): array {
		global $wpdb;

		$select = '';

		switch ( $source_data['sourceType'] ) {
			case 'taxonomy':
				$select .= $this->select_terms( $source_data['rawValue'], self::DATA_TYPE['value'], $source_data['value'] );
				break;
			case 'custom_field':
				$select .= $this->select_custom_field( $source_data['rawValue'], $source_data['value'] );
				break;
			case 'advanced_custom_field':
				$select .= $this->select_advanced_custom_field( $source_data['rawValue'], $source_data['value'] );
				break;
			case 'on_sale':
				$select .= $this->select_on_sale( $source_data['value'] );
				break;
			case 'price':
				$select .= $this->select_prices( $source_data['value'] );
				break;
		}

		$select = trim( $select, ',' );

		// -------------------------------
		// WHERE
		// -------------------------------
		$where = '';

		// Narrow all posts to only products.
		$where .= $this->where_post_types( [ 'product' ] );

		// Get only published posts.
		$where .= $this->where_published();

		$join = '';

		$sql = "SELECT DISTINCT $select
                FROM $wpdb->posts posts
                $join
                WHERE  1=1
                $where
               ";

		$rows = $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

		$response = [];

		if ( ! empty( $rows ) && ! is_wp_error( $rows ) ) {
			$response = $rows;
		}

		return $response;
	}

	/**
	 * Part of the SQL where statement which narrows down to the specific IDs set
	 *
	 * @return string part of the SQL WHERE
	 */
	protected function where_narrow_down_to_the_set(): string {
		global $wpdb;

		if ( empty( $this->post_in ) ) {
			return '';
		}

		$placeholders = array_fill( 0, count( $this->post_in ), '%d' );
		$format       = implode( ', ', $placeholders );

		$where = $wpdb->prepare( " AND posts.ID IN ($format)", $this->post_in ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

		return $this->skip_filters ? $where : apply_filters( 'fibofilters/indexer/filters_source_query/where/narrow_set', $where );
	}

	/**
	 * Part of the SQL where statement which narrows down to the selected post types
	 *
	 * @param array $post_types Post types.
	 *
	 * @return string part of the SQL WHERE
	 */
	protected function where_post_types( array $post_types = [] ): string {
		global $wpdb;
		$where = '';

		if ( count( $post_types ) === 1 ) {
			$where = $wpdb->prepare( ' AND (post_type = %s) ', $post_types[0] );
		} elseif ( count( $post_types ) > 1 ) {
			$placeholders = array_fill( 0, count( $post_types ), '%s' );
			$format       = implode( ', ', $placeholders );
			$where        = $wpdb->prepare( " AND post_type IN ($format)", $post_types ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		}

		return $this->skip_filters ? $where : apply_filters( 'fibofilters/indexer/filters_source_query/where/post_types', $where );
	}

	/**
	 * Part of the SQL where statement which narrows down to the published posts
	 *
	 * With filter, private products can be included as well (visible to admins only).
	 *
	 * @return string part of the SQL WHERE
	 */
	protected function where_published(): string {
		$where = " AND post_status = 'publish' ";

		if ( apply_filters( 'fibofilters/show_private_products_to_privileged_users', false ) ) {
			$where = " AND (post_status = 'publish' OR post_status = 'private') ";
		}

		return $this->skip_filters ? $where : apply_filters( 'fibofilters/indexer/filters_source_query/where/published', $where );
	}

	/**
	 * Part of the SQL where and join statements which don't index products with the stock status "outofstock"
	 *
	 * @return array SQL join and where parts
	 */
	protected function sql_exclude_out_of_stock(): array {
		global $wpdb;

		$sql_parts = [
			'join'  => " JOIN $wpdb->postmeta AS pm_stock ON posts.ID = pm_stock.post_id ",
			'where' => " AND pm_stock.meta_key = '_stock_status'
                         AND pm_stock.meta_value != 'outofstock' ",
		];

		return $this->skip_filters ? $sql_parts : apply_filters( 'fibofilters/indexer/filters_source_query/exclude_out_of_stock', $sql_parts );
	}

	/**
	 * Part of the SQL where statement which don't index products with "Hidden" visibility.
	 *
	 * @return array SQL where part
	 */
	protected function sql_exclude_hidden(): array {
		global $wpdb;

		$visibility_term_ids = wc_get_product_visibility_term_ids();

		if ( empty( $visibility_term_ids ) ) {
			return [ 'where' => '' ];
		}

		$sql_parts = [
			'where' => $wpdb->prepare(
				" AND (SELECT COUNT(1)
						FROM $wpdb->term_relationships
						WHERE term_taxonomy_id IN (%d, %d) AND object_id = posts.ID ) != 2 ",
				$visibility_term_ids['exclude-from-search'],
				$visibility_term_ids['exclude-from-catalog']
			),
		];

		return $this->skip_filters ? $sql_parts : apply_filters( 'fibofilters/indexer/filters_source_query/exclude_hidden', $sql_parts );
	}

	/**
	 * Part of the SQL select statement which retrieves the product ID
	 *
	 * @param string $group_name The name of the group of data.
	 *
	 * @return string part of the SQL SELECT
	 */
	public function select_id( string $group_name = 'ID' ): string {
		global $wpdb;

		$select = $wpdb->prepare( 'posts.ID AS %s', $group_name );

		return $this->skip_filters ? $select : apply_filters( 'fibofilters/indexer/filters_source_query/select/id', $select, $group_name );
	}

	/**
	 * Part of the SQL select statement which retrieves the values of selected custom field
	 *
	 * @param string $key Key of custom field.
	 * @param string $group_name The name of the group of data.
	 *
	 * @return string part of the SQL SELECT
	 */
	public function select_custom_field( string $key, string $group_name = '' ): string {
		global $wpdb;

		if ( empty( $group_name ) ) {
			$key        = sanitize_key( $key );
			$group_name = 'cf_' . $key;
		}
		$select = $wpdb->prepare(
			", (SELECT meta_value FROM $wpdb->postmeta WHERE post_id = posts.ID AND meta_key=%s LIMIT 1) AS %s",
			$key,
			$group_name
		);

		return $this->skip_filters ? $select : apply_filters( 'fibofilters/indexer/filters_source_query/select/custom_field', $select, $key, $group_name );
	}

	/**
	 * Part of the SQL select statement which retrieves the menu order
	 *
	 * @param string $group_name The name of the group of data.
	 *
	 * @return string part of the SQL SELECT
	 */
	public function select_menu_order( string $group_name = 'menu_order' ): string {
		global $wpdb;

		if ( empty( $group_name ) ) {
			$group_name = 'menu_order';
		}
		$select = $wpdb->prepare(
			", (SELECT menu_order FROM $wpdb->posts WHERE ID = posts.ID) AS %s",
			$group_name
		);

		return $this->skip_filters ? $select : apply_filters( 'fibofilters/indexer/filters_source_query/select/menu_order', $select, $group_name );
	}

	/**
	 * Part of the SQL select statement which retrieves date for ordering
	 *
	 * @param string $group_name The name of the group of data.
	 *
	 * @return string part of the SQL SELECT
	 */
	public function select_order_by_date( string $group_name = 'order_by_date' ): string {
		global $wpdb;

		if ( empty( $group_name ) ) {
			$group_name = 'order_by_date';
		}
		// Select post date as timestamp.
		$select = $wpdb->prepare(
			", (SELECT FLOOR(UNIX_TIMESTAMP(post_date) / 3600) FROM $wpdb->posts WHERE ID = posts.ID) AS %s",
			$group_name
		);

		return $this->skip_filters ? $select : apply_filters( 'fibofilters/indexer/filters_source_query/select/order_by_date', $select, $group_name );
	}

	/**
	 * Part of the SQL select statement which retrieves modified date for ordering
	 *
	 * @param string $group_name The name of the group of data.
	 *
	 * @return string part of the SQL SELECT
	 */
	public function select_order_by_modified( string $group_name = 'order_by_modified' ): string {
		global $wpdb;

		if ( empty( $group_name ) ) {
			$group_name = 'order_by_modified';
		}
		// Select post date as timestamp.
		$select = $wpdb->prepare(
			", (SELECT FLOOR(UNIX_TIMESTAMP(post_modified) / 3600) FROM $wpdb->posts WHERE ID = posts.ID) AS %s",
			$group_name
		);

		return $this->skip_filters ? $select : apply_filters( 'fibofilters/indexer/filters_source_query/select/order_by_modified', $select, $group_name );
	}

	/**
	 * Part of the SQL select statement which retrieves the values of selected ACF field
	 *
	 * @param string $key Key of custom field.
	 * @param string $type_of_data Type of data to retrieve (see self::DATA_TYPE).
	 * @param string $group_name The name of the group of data.
	 *
	 * @return string part of the SQL SELECT
	 */
	public function select_advanced_custom_field( string $key, string $type_of_data, string $group_name = '' ): string {
		global $wpdb;

		if ( empty( $group_name ) ) {
			$key        = sanitize_key( $key );
			$group_name = 'acf_' . $key;
		}

		if ( $type_of_data === self::DATA_TYPE['value'] ) {
			$concat = sprintf( "CONCAT( %s, ' ___ ', meta_value)", $type_of_data );
			$select = $wpdb->prepare(
				", (SELECT $concat FROM $wpdb->postmeta WHERE post_id = posts.ID AND meta_key = %s LIMIT 1) AS %s",
				$key,
				$group_name
			);
		} else {
			$group_concat = sprintf( "CONCAT( %s, ' ___ ', GROUP_CONCAT( meta_value SEPARATOR ' __ ' ) )", $type_of_data );
			$select       = $wpdb->prepare(
				", (SELECT $group_concat FROM $wpdb->postmeta WHERE post_id = posts.ID AND meta_key IN(%s, %s) LIMIT 1) AS %s
				",
				$key,
				'_' . $key,
				$group_name
			);
		}

		return $this->skip_filters ? $select : apply_filters( 'fibofilters/indexer/filters_source_query/select/advanced_custom_field', $select, $key, $group_name );
	}

	/**
	 * Part of the SQL select statement which retrieves the values of terms for selected taxonomy
	 *
	 * @param string $taxonomy Taxonomy slug.
	 * @param string $type_of_data Type of data to retrieve (value: values of the terms; desc: description of the terms; id_and_value: ids and values).
	 *
	 * @param string $group_name The name of the group of data.
	 *
	 * @return string part of the SQL SELECT
	 */
	public function select_terms( string $taxonomy, string $type_of_data = self::DATA_TYPE['value'], string $group_name = '' ): string {
		global $wpdb;

		$select = '';

		if ( ! empty( $taxonomy ) && taxonomy_exists( $taxonomy ) ) {
			if ( empty( $group_name ) ) {
				$taxonomy   = sanitize_key( $taxonomy );
				$group_name = 'tax_' . $taxonomy;

			}

			$group_concat = sprintf( "GROUP_CONCAT( %s, ' ___ ', t.name SEPARATOR ' || ')", $type_of_data );

			if ( $type_of_data === self::DATA_TYPE['id_value_and_slug'] ) {
				$group_concat = sprintf( "GROUP_CONCAT( %s, ' ___ ', t.term_id, ' __ ', t.name, ' __ ', t.slug SEPARATOR ' || ')", $type_of_data );
			}

			if ( $type_of_data === self::DATA_TYPE['id_value_slug_and_parent'] ) {
				$group_concat = sprintf( "GROUP_CONCAT( %s, ' ___ ', t.term_id, ' __ ', t.name, ' __ ', t.slug, ' __ ', tt.parent SEPARATOR ' || ')", $type_of_data );
			}

			$excluded_sql = $this->get_selection_sql( 'excluded', $taxonomy );
			$included_sql = $this->get_selection_sql( 'included', $taxonomy );

			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$select = $wpdb->prepare(
				", (SELECT $group_concat
                             FROM $wpdb->terms AS t
                             INNER JOIN $wpdb->term_taxonomy AS tt ON t.term_id = tt.term_id
                             INNER JOIN $wpdb->term_relationships AS tr ON tr.term_taxonomy_id = tt.term_taxonomy_id
                             WHERE tt.taxonomy = %s
                             AND tr.object_id = posts.ID
                             $excluded_sql
                             $included_sql
                             ) AS %s",
				$taxonomy,
				$group_name
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		}

		return $this->skip_filters ? $select : apply_filters( 'fibofilters/indexer/filters_source_query/select/terms', $select, $taxonomy, $group_name, $type_of_data );
	}

	/**
	 * The SQL which retrieves the values of attributes for selected taxonomy for current set of products.
	 * It bases on the wc_product_attributes_lookup table.
	 *
	 * @param string $taxonomy Taxonomy slug.
	 * @param string $type_of_data Type of data to retrieve (see: self::DATA_TYPE).
	 *
	 * @param string $group_name The name of the group of data.
	 *
	 * @return string part of the SQL SELECT
	 */
	public function select_attributes_from_lookup_grouped( string $taxonomy, string $type_of_data = self::DATA_TYPE['value'], string $group_name = '' ): string {
		global $wpdb;

		$select = '';

		if ( ! empty( $taxonomy ) && taxonomy_exists( $taxonomy ) ) {
			if ( empty( $group_name ) ) {
				$taxonomy   = sanitize_key( $taxonomy );
				$group_name = 'tmp_tax_' . $taxonomy;

			}

			$group_concat = sprintf( "GROUP_CONCAT( %s, ' ___ ', t.name SEPARATOR ' || ')", $type_of_data );

			if ( $type_of_data === self::DATA_TYPE['id_value_and_slug'] ) {
				$group_concat = sprintf( "GROUP_CONCAT( %s, ' ___ ', l.term_id, ' __ ', t.name, ' __ ', t.slug SEPARATOR ' || ')", $type_of_data );
			}

			$excluded_sql = $this->get_selection_sql( 'excluded', $taxonomy );
			$included_sql = $this->get_selection_sql( 'included', $taxonomy );

			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$placeholders = array_fill( 0, count( $this->post_in ), '%d' );
			$format       = implode( ', ', $placeholders );
			$select       = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
				"SELECT product_id, $group_concat AS %s
                             FROM {$wpdb->prefix}wc_product_attributes_lookup AS l
                             INNER JOIN $wpdb->terms AS t ON t.term_id = l.term_id
                             WHERE l.taxonomy = %s
                             $excluded_sql
                             $included_sql
                             AND l.product_id IN ($format) GROUP BY (l.product_id)
                             ",
				array_merge( [ $group_name ], [ $taxonomy ], $this->post_in )
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		}

		return $this->skip_filters ? $select : apply_filters( 'fibofilters/indexer/filters_source_query/select/attributes_grouped', $select, $taxonomy, $group_name, $type_of_data );
	}

	/**
	 * Part of the SQL select statement which retrieves on sale state.
	 *
	 * @param string $group_name The name of the group of data.
	 *
	 * @return string part of the SQL SELECT
	 */
	public function select_on_sale( string $group_name = 'on_sale' ): string {
		global $wpdb;

		// This query return '1' or NULL (NULL values are not indexed).
		$select = $wpdb->prepare(
			", (SELECT onsale FROM $wpdb->wc_product_meta_lookup WHERE product_id = ID AND onsale = '1' ) AS %s",
			$group_name
		);

		return $this->skip_filters ? $select : apply_filters( 'fibofilters/indexer/filters_source_query/select/on_sale', $select, $group_name );
	}

	/**
	 * Part of the SQL select statement which retrieves the values of prices.
	 *
	 * @param string $group_name The name of the group of data.
	 *
	 * @return string part of the SQL SELECT
	 */
	public function select_prices( string $group_name = 'price' ): string {
		global $wpdb;

		// TODO Można dla wartości liczbowych utworzyć osobną tabelę valuelist_num. Będzie to szybciej działać.
		$select = $wpdb->prepare(
			", (SELECT meta_value FROM $wpdb->postmeta WHERE post_id = posts.ID AND meta_key='_price' LIMIT 1) AS %s",
			$group_name
		);

		return $this->skip_filters ? $select : apply_filters( 'fibofilters/indexer/filters_source_query/select/price', $select, $group_name );
	}

	/**
	 * Part of the SQL select statement which retrieves the value for search.
	 *
	 * @param string $group_name The name of the group of data.
	 *
	 * @return string part of the SQL SELECT
	 */
	public function select_search( string $group_name = 'search' ): string {
		global $wpdb;

		$select = $wpdb->prepare( ', posts.post_title AS %s', $group_name );

		return $this->skip_filters ? $select : apply_filters( 'fibofilters/indexer/filters_source_query/select/search', $select, $group_name );
	}

	/**
	 * Part of the SQL select statement which retrieves the language code
	 *
	 * @param string $group_name The name of the group of data.
	 *
	 * @return string part of the SQL SELECT
	 */
	public function select_language( string $group_name = 'lang' ): string {
		global $wpdb;

		$select    = '';
		$languages = $this->multilingual->get_languages();

		if ( $this->multilingual->get_provider_name() === 'WPML' ) {
			$translations_table = $wpdb->prefix . 'icl_translations';

			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$select = $wpdb->prepare(
				", (SELECT language_code
                                 FROM $translations_table
                                 WHERE element_type = 'post_product'
                                 AND element_id = posts.ID
                                 AND language_code IN ('" . implode( "', '", esc_sql( $languages ) ) . "') LIMIT 1) AS %s",
				$group_name
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		}

		if ( $this->multilingual->get_provider_name() === 'Polylang' ) {
			$select = $wpdb->prepare(
				", (SELECT slug
                                 FROM $wpdb->terms
                                 WHERE term_id = (
                                     SELECT t.term_id
                                     FROM $wpdb->terms AS t
                                     INNER JOIN $wpdb->term_taxonomy AS tt ON t.term_id = tt.term_id
                                     INNER JOIN $wpdb->term_relationships AS tr ON tr.term_taxonomy_id = tt.term_taxonomy_id
                                     WHERE tt.taxonomy = 'language'
                                     AND tr.object_id = posts.ID
                                     LIMIT 1
                                 )) AS %s",
				$group_name
			);
		}

		return $this->skip_filters ? $select : apply_filters( 'fibofilters/indexer/filters_source_query/select/lang', $select );
	}

	/**
	 * Part of the SQL select statement which retrieves the product type.
	 * Allowed types: simple, variable, grouped, external
	 *
	 * @return string
	 */
	public function select_product_type(): string {
		global $wpdb;

		return ", (SELECT DISTINCT t.slug
                             FROM $wpdb->terms AS t
                             INNER JOIN $wpdb->term_taxonomy AS tt ON t.term_id = tt.term_id
                             INNER JOIN $wpdb->term_relationships AS tr ON tr.term_taxonomy_id = tt.term_taxonomy_id
                             WHERE tt.taxonomy = 'product_type'
                             AND tr.object_id = posts.ID
                             ) AS product_type";
	}

	public function select_order_by_featured( string $group_name = 'order_by_featured' ): string {
		global $wpdb;

		$visibility_term_ids = wc_get_product_visibility_term_ids();
		$featured_term       = $visibility_term_ids['featured'] ?? 0;

		// This query return '1' or '0', so its ease to sort by it.
		return $wpdb->prepare(
			", (SELECT COUNT(1)
                  FROM $wpdb->terms AS t
                 INNER JOIN $wpdb->term_taxonomy AS tt ON t.term_id = tt.term_id
                 INNER JOIN $wpdb->term_relationships AS tr ON tr.term_taxonomy_id = tt.term_taxonomy_id
                 WHERE tt.taxonomy = 'product_visibility'
                 AND t.term_id = %d
                 AND tr.object_id = posts.ID
				 ) AS %s",
			$featured_term,
			$group_name
		);
	}

	public function select_featured( string $group_name = 'featured' ): string {
		global $wpdb;

		$visibility_term_ids = wc_get_product_visibility_term_ids();
		$featured_term       = $visibility_term_ids['featured'] ?? 0;

		// This query return '1' or NULL (NULL values are not indexed).
		return $wpdb->prepare(
			", (SELECT NULLIF(
				(SELECT COUNT(1)
                  FROM $wpdb->terms AS t
                 INNER JOIN $wpdb->term_taxonomy AS tt ON t.term_id = tt.term_id
                 INNER JOIN $wpdb->term_relationships AS tr ON tr.term_taxonomy_id = tt.term_taxonomy_id
                 WHERE tt.taxonomy = 'product_visibility'
                 AND t.term_id = %d
                 AND tr.object_id = posts.ID
				 ), 0)) AS %s",
			$featured_term,
			$group_name
		);
	}

	public function select_order_by_stock_status( string $group_name = 'order_by_stock_status' ): string {
		global $wpdb;

		$case_sql = $this->wc_helper->create_sql_case_expression_for_stock_status( 'stock_status' );

		return $wpdb->prepare(
			", (SELECT $case_sql
                      FROM $wpdb->wc_product_meta_lookup
                      WHERE product_id = posts.ID
                      LIMIT 1) AS %s",
			$group_name
		);
	}

	public function select_order_by_rating_count( string $group_name = 'ratings_count' ): string {
		global $wpdb;

		$select = $wpdb->prepare(
			", (SELECT rating_count FROM $wpdb->wc_product_meta_lookup WHERE product_id = posts.ID) AS %s",
			$group_name
		);

		return $this->skip_filters
			? $select
			: apply_filters( 'fibofilters/indexer/filters_source_query/select/ratings_count', $select, $group_name );
	}



	/**
	 * Retrieves data from the database based on the current query parameters.
	 *
	 * @return array The retrieved data.
	 */
	public function get_data(): array {
		global $wpdb;

		$this->build_query();

		do_action( 'fibofilters/indexer/filters_source_query/before_request', $this, $this->select_type );

		$group_concat_max_len = apply_filters( 'fibofilters/indexer/filters_source_query/group_concat_max_len', 100000 );

		if ( ! empty( $group_concat_max_len ) ) {
			$wpdb->query( $wpdb->prepare( 'SET SESSION group_concat_max_len = %d', absint( $group_concat_max_len ) ) );
		}

		if ( $this->select_type === 'ids' ) {
			$query = apply_filters( 'fibofilters/indexer/filters_source_query/request', $this->query, $this, $this->select_type );
			$rows  = $wpdb->get_col( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			if ( ! empty( $rows ) && is_array( $rows ) ) {
				$rows = array_map( 'intval', $rows );
			}
		} elseif ( $this->select_type === 'counters' ) {
			$query = apply_filters( 'fibofilters/indexer/filters_source_query/request', $this->query, $this, $this->select_type );
			$rows  = (array) $wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		} else {
			$query = apply_filters( 'fibofilters/indexer/filters_source_query/request', $this->query, $this, $this->select_type );
			$rows  = (array) $wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

			$user_locale    = get_user_locale();
			$locale         = get_locale();
			$restore_locale = false;
			/**
			 * When the website language is different from the user's language, we load translations that match the
			 * website language. Otherwise, e.g. the "On sale" label will be in the user's language, not the website's.
			 */
			if ( $locale !== $user_locale && ! $this->multilingual->is_multilingual() ) {
				$restore_locale = true;
				unload_textdomain( 'fibofilters', true );
				switch_to_locale( $locale );
				load_plugin_textdomain( Env::$plugin_slug, false, Env::$languages_path_rel );
				$this->filter_sources->reset_sources();

				// We need to reload WooCommerce translations due to the "On sale" filter values.
				WC()->load_plugin_textdomain();
			}

			if ( ! empty( $rows ) ) {
				/*
				 * Additionally, we collect some data outside the main query to avoid subqueries
				 * and fetch data for the entire set of products.
				 *
				 * Here we retrieve product parent ID (for variations).
				 */
				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				$product_ids  = wp_list_pluck( $rows, 'ID' );
				$placeholders = array_fill( 0, count( $product_ids ), '%d' );
				$format       = implode( ', ', $placeholders );

				$product_parents = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT ID, post_parent
                             FROM {$wpdb->prefix}posts
                             WHERE ID IN ($format) AND post_status = 'publish' AND post_type = 'product_variation'
                             ",
						$product_ids
					),
					OBJECT_K
				);
				foreach ( $rows as $index => $item ) {
					if ( isset( $product_parents[ $item['ID'] ] ) && intval( $product_parents[ $item['ID'] ]->post_parent ) > 0 ) {
						$rows[ $index ]['parent_id'] = intval( $product_parents[ $item['ID'] ]->post_parent );
					} else {
						$rows[ $index ]['parent_id'] = null;
					}
				}
				// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

				/**
				 * Retrieving total sales for ordering.
				 */
				if ( apply_filters( 'fibofilters/config/override_ordering_enabled', false ) ) {
					$catalog_orderby_keys = array_keys( $this->sorting->get_catalog_orderedby() );
					if ( in_array( 'popularity', $catalog_orderby_keys, true ) ) {
						$total_sales = $wpdb->get_results(
							$wpdb->prepare(
								"SELECT product_id, total_sales FROM $wpdb->wc_product_meta_lookup WHERE product_id IN ($format)",
								$product_ids
							),
							OBJECT_K
						);

						if ( $this->context === 'updater' ) {
							$max_total_sales = intval( $this->build_info->get_info( 'max_total_sales', 'main' ) );
						} else {
							$max_total_sales = intval( $this->build_info->get_info( 'max_total_sales', $this->config->get_index_role() ) );
						}

						foreach ( $rows as $index => $item ) {
							if ( $max_total_sales > 0 ) {
								if ( isset( $total_sales[ $item['ID'] ] ) && intval( $total_sales[ $item['ID'] ]->total_sales ) > 0 ) {
									$rows[ $index ]['order_by_sales'] = (string) round( sqrt( sqrt( intval( $total_sales[ $item['ID'] ]->total_sales ) / $max_total_sales ) ), 7 );
								} elseif ( isset( $total_sales[ $item['parent_id'] ] ) && intval( $total_sales[ $item['parent_id'] ]->total_sales ) > 0 ) {
									// For variations get the total sales from the parent product.
									$rows[ $index ]['order_by_sales'] = (string) round( sqrt( sqrt( intval( $total_sales[ $item['parent_id'] ]->total_sales ) / $max_total_sales ) ), 7 );
								} else {
									$rows[ $index ]['order_by_sales'] = '0';
								}
							} else {
								$rows[ $index ]['order_by_sales'] = '0';
							}
						}
					}
				}

				/*
				 * Retrieving attributes from the "product_attribute_lookup" table
				 */
				foreach ( $this->store_filters->get_all_filters() as $filter ) {
					$source_obj  = $this->filter_sources->get_source( $filter['source'] );
					$source_data = $source_obj->get_data();
					if ( empty( $source_data ) ) {
						continue;
					}
					if ( $source_data['sourceType'] === 'taxonomy' && substr( $source_data['rawValue'], 0, 3 ) === 'pa_' ) {
						if ( $filter['type'] === 'range' ) {
							$select = $this->select_attributes_from_lookup_grouped( $source_data['rawValue'], self::DATA_TYPE['value'], 'tmp_' . $source_data['value'] );
						} else {
							$select = $this->select_attributes_from_lookup_grouped( $source_data['rawValue'], self::DATA_TYPE['id_value_and_slug'], 'tmp_' . $source_data['value'] );
						}
						$lookpup_rows = (array) $wpdb->get_results( $select, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
						$lookpup_rows = wp_list_pluck( $lookpup_rows, 'tmp_' . $source_data['value'], 'product_id' );
						foreach ( $rows as $index => $row ) {
							if ( isset( $lookpup_rows[ (int) $row['ID'] ] ) ) {
								$rows[ $index ][ 'tmp_' . $source_data['value'] ] = $lookpup_rows[ (int) $row['ID'] ];
							}
						}
					}
				}

				$i                 = 0;
				$variable_products = [];
				$variable_products_and_variants_to_remove_i = [];
				foreach ( $rows as $item ) {
					// Variable product to remove.
					if ( ! empty( $item['product_type'] ) && in_array( $item['product_type'], $this->wc_helper->get_variations_wrapper_product_types(), true ) ) {
						$variable_products[ (int) $item['ID'] ]       = $item;
						$variable_products_and_variants_to_remove_i[] = $i;
					}

					$i++;
				}

				// Overwriting product prices if there is inconsistency in tax settings.
				$price_display_mode = $this->wc_helper->get_price_display_mode();
				if ( ! empty( $price_display_mode ) ) {
					foreach ( $rows as $i => $row ) {
						if ( ! isset( $row['price'] ) ) {
							continue;
						}
						$product = wc_get_product( $row['ID'] );
						if ( $price_display_mode === 'excl' ) {
							$rows[ $i ]['price'] = (string) wc_get_price_excluding_tax( $product );
						} elseif ( $price_display_mode === 'incl' ) {
							$rows[ $i ]['price'] = (string) wc_get_price_including_tax( $product );
						}
					}
				}

				$i = 0;
				foreach ( $rows as $item ) {
					// Prepare variations.
					if ( ! empty( $item['parent_id'] ) && is_numeric( $item['parent_id'] ) ) {
						$parent_id = absint( $item['parent_id'] );

						if ( isset( $variable_products[ $parent_id ] ) ) {
							$parent_data = $variable_products[ $parent_id ];
						} else {
							$parent_data                     = $this->get_parent_product_data( $parent_id );
							$variable_products[ $parent_id ] = $parent_data;
						}

						if ( ! empty( $parent_data ) ) {
							/**
							 * Remove variants that are associated with the parent product, that is not a variable product.
							 * This can happen when a product is changed from a variable product to a simple product (by manual changes in the database).
							 */
							if ( isset( $parent_data['product_type'] ) && ! in_array( $parent_data['product_type'], $this->wc_helper->get_variations_wrapper_product_types(), true ) ) {
								$variable_products_and_variants_to_remove_i[] = $i;
							} else {
								/**
								 * When a variable product has the "Draft" status, its variants have the "Publish" status,
								 * and we can exclude them here. In this case, the parent data is empty,
								 * so we know that the variant needs to be deleted.
								 */
								$rows[ $i ] = $this->merge_parent_and_variation_data( $parent_data, $item );
							}
						} else {
							$variable_products_and_variants_to_remove_i[] = $i;
						}
					}

					$rows[ $i ] = $this->remove_tmp_data( $rows[ $i ] );

					$i++;
				}

				// Remove variable products and variants without parents from the set.
				foreach ( $variable_products_and_variants_to_remove_i as $product_id ) {
					unset( $rows[ $product_id ] );
				}

				$rows = array_values( $rows );

				/**
				 * This filter is used by plugins for multiple currency support.
				 */
				$rows = apply_filters( 'fibofilters/indexer/filters_source_query/data/unparsed', $rows, $this );

				$process_computed_sources = $this->has_filter_with_computed_source();

				$current_language = $this->multilingual->is_multilingual() ? $this->multilingual->get_current_language() : '';

				// Parse values.
				foreach ( $rows as $i => $item ) {
					if (
						$this->multilingual->is_multilingual()
						&& isset( $item['lang'] )
						&& Helpers::is_language_code( $item['lang'] )
						&& $this->multilingual->get_current_language() !== $item['lang']
					) {
						$this->multilingual->switch_language( $item['lang'] );

						// We need to reload the translation files to have correct translations for "On sale" and "Stock status".
						unload_textdomain( 'fibofilters', true );
						switch_to_locale( get_locale() );
						load_plugin_textdomain( Env::$plugin_slug, false, Env::$languages_path_rel );
						WC()->load_plugin_textdomain();

						do_action( 'fibofilters/indexer/filters_source_query/unparsed_row/language_switch', $item['lang'] );
					}

					// Adding computed source values.
					if ( $process_computed_sources ) {
						[ $new_item, $is_item_modified ] = $this->add_computed_source_values_to_item( $item );
						if ( $is_item_modified ) {
							$item = $new_item;
						}
					}

					/**
					 * Unpacking multiple ACF field values.
					 *
					 * If a field contains multiple values, they are stored in a serialized form
					 * and need to be unpacked into our way of writing multiple values.
					 *
					 * E.g.
					 * From:
					 * "acf_select" => "4 ___ a:2:{i:0;s:7:"option2";i:1;s:7:"option3";} __ field_666aa29d50411"
					 * or (if rows in wp_postmeta are inverted):
					 * "acf_select" => "4 ___ field_666aa29d50411 __ a:2:{i:0;s:7:"option2";i:1;s:7:"option3";}"
					 * to:
					 * "acf_select" => "4 ___ option2 __ field_666aa29d50411 || 4 ___ option3 __ field_666aa29d50411"
					 */
					foreach ( $item as $item_key => $item_value ) {
						if ( ! is_string( $item_value ) ) {
							continue;
						}
						if ( strpos( $item_key, 'acf_' ) === 0 && preg_match( '/^\d+ ___ field_/', $item_value ) ) {
							// Reverse the order of the field key and the value.
							$pieces = explode( ' ___ ', $item_value );
							if ( count( $pieces ) === 2 ) {
								$pieces2 = explode( ' __ ', $pieces[1] );
								if ( count( $pieces2 ) === 2 ) {
									$item_value        = sprintf( '%s ___ %s __ %s', $pieces[0], $pieces2[1], $pieces2[0] );
									$item[ $item_key ] = $item_value;
								}
							}
						}
						if ( strpos( $item_key, 'acf_' ) === 0 && preg_match( '/ _{2,3} a:/', $item_value ) ) {
							$pieces_with_type = explode( ' ___ ', $item_value );
							if ( count( $pieces_with_type ) !== 2 ) {
								continue;
							}
							$pieces = explode( ' __ ', $pieces_with_type[1] );
							if ( count( $pieces ) !== 2 ) {
								continue;
							}
							$values = maybe_unserialize( $pieces[0] );
							if ( ! is_array( $values ) ) {
								continue;
							}
							$field_key         = $pieces[1];
							$item[ $item_key ] = join(
								' || ',
								array_map(
									function ( $value ) use ( $pieces_with_type, $field_key ) {
										return sprintf( '%s ___ %s __ %s', $pieces_with_type[0], $value, $field_key );
									},
									$values
								)
							);
						}
					}

					$parsed_row = (array) array_combine(
						array_keys( $item ),
						array_map(
							[
								$this,
								'format_source_values',
							],
							array_keys( $item ),
							array_values( $item )
						)
					);
					$rows[ $i ] = $parsed_row;
				}
			}

			/**
			 * Adding ancestor values for terms of hierarchical taxonomies and filtering out hidden terms.
			 */
			foreach ( $rows as $i => $row ) {
				foreach ( $row as $source => $value ) {
					if ( ! is_array( $value ) || empty( $value ) || $source === 'product_type' || $source === 'parent_id' ) {
						continue;
					}

					$ancestors_values = $this->get_ancestor_values_for_source( $source, $value );
					if ( ! empty( $ancestors_values ) ) {
						$rows[ $i ][ $source ] = array_merge( $value, $ancestors_values );
					}

					$hidden_term_ids       = apply_filters( 'fibofilters/indexer/filters_source_query/hidden_term_ids/source=' . $source, [] );
					$rows[ $i ][ $source ] = FilterUtil::filter_values_preserve_hierarchy( $rows[ $i ][ $source ], $hidden_term_ids );
				}
			}

			// Get terms order values.
			$rows = $this->maybe_get_order( $rows );

			// Switch back to the original language if it was changed.
			if ( ! empty( $current_language ) ) {
				$this->multilingual->switch_language( $current_language );
			}

			if ( $restore_locale ) {
				unload_textdomain( 'fibofilters', true );
				restore_previous_locale();
				load_plugin_textdomain( Env::$plugin_slug, false, Env::$languages_path_rel );

				WC()->load_plugin_textdomain();
			}

			$rows = $this->maybe_transform_multivalues( $rows );

			// Cleaning variant data with out-of-stock status, because even though they will be in the index, they will be considered during filtering.
			if ( apply_filters( 'fibofilters/indexer/filters_source_query/exclude_out_of_stock_variations_while_filtering', false ) ) {
				foreach ( $rows as $i => $row ) {
					if ( ! empty( $row['parent_id'] ) ) {
						if ( ! empty( $row['cf__stock_status'][0]['value'] ) && $row['cf__stock_status'][0]['value'] !== 'outofstock' ) {
							continue;
						}
						foreach ( $this->store_filters->get_all_filters() as $filter ) {
							if ( $filter['source'] === 'cf__stock_status' ) {
								continue;
							}
							// We don’t clear the product’s categories so that the counters next to category links remain correct.
							if ( $filter['source'] === 'tax_product_cat' && ( $filter['type'] === 'category' || $filter['type'] === 'category-hierarchical' ) ) {
								continue;
							}
							if ( isset( $rows[ $i ][ $filter['source'] ] ) ) {
								$rows[ $i ][ $filter['source'] ] = [];
							}
						}
					}
				}
			}
		}

		$response = [];

		if ( ! empty( $rows ) && ! is_wp_error( $rows ) ) {
			$response = $rows;
		}

		return apply_filters( 'fibofilters/indexer/filters_source_query/data', $response, $this, $this->select_type );
	}

	/**
	 * Get SQL
	 */
	public function get_query(): string {
		return $this->query;
	}

	/**
	 * Format values of the source.
	 *
	 * @param string      $key Source data name.
	 * @param null|string $value Encoded value data.
	 *
	 * @return null|array|int
	 */
	protected function format_source_values( string $key, $value ) {
		$struct = [];
		if ( $value === null ) {
			return $struct;
		}

		if ( $key === 'price' ) {
			$value = $this->normalize_price( $value );
		}

		if ( in_array( $key, [ 'ID', 'parent_id' ], true ) ) {
			$struct = (int) $value;
		} elseif ( $key === 'search' ) {
			$struct = [
				[
					'value'        => $value,
					'label'        => '',
					'label_url'    => '',
					'parent_value' => '0',
					'order'        => '0',
				],
			];
		} elseif ( $key === 'lang' ) {
			$struct = $value;
		} else {
			$struct = $this->parse_from_literal( $key, $value );
		}

		return $struct;
	}

	/**
	 * Source Query select multivalued fields as compact string using GROUP_CONCAT
	 *
	 * This method parse these data
	 * e.g. "940 __ Soil-Lab || 1475 __ Hydrometer Testing"
	 * We have to translate it to array structure
	 *
	 * @param string $key Source data name.
	 * @param string $value Encoded value data.
	 *
	 * @return array
	 */
	protected function parse_from_literal( string $key, string $value ): array {
		$struct = [];
		$terms  = explode( ' || ', $value );

		foreach ( $terms as $term ) {
			$type_and_data = explode( ' ___ ', $term );
			$type          = count( $type_and_data ) > 1 ? $type_and_data[0] : '0';
			$data          = count( $type_and_data ) > 1 ? $type_and_data[1] : $type_and_data[0];
			$pieces        = explode( ' __ ', $data );

			$value        = '';
			$label        = '';
			$label_url    = '';
			$parent_value = '0';

			if ( $type === '0' ) { // no type.
				$value = $pieces[0];
				$label = $pieces[0];
			} elseif ( $type === self::DATA_TYPE['value'] ) {
				$value = $pieces[0];
				$label = $pieces[0];
			} elseif ( $type === self::DATA_TYPE['id_value_slug_and_parent'] ) {
				$value        = $pieces[0];
				$label        = $pieces[1];
				$label_url    = urldecode( $pieces[2] );
				$parent_value = $pieces[3];
			} elseif ( $type === self::DATA_TYPE['id_value_and_slug'] ) {
				$value     = $pieces[0];
				$label     = $pieces[1];
				$label_url = urldecode( $pieces[2] );
			} elseif ( $type === self::DATA_TYPE['value_and_key'] ) {
				// When for an ACF field in the database we only have one record with the field key and no value,
				// then here we only have a single element – the key.
				if ( count( $pieces ) < 2 ) {
					$label = $pieces[0];
				} else {
					$value = $pieces[0];
					$label = $pieces[1];
				}
			} elseif ( $type === self::DATA_TYPE['value_and_label'] ) {
				$value = $pieces[0];
				$label = $pieces[1];
			}

			list( $value, $label, $label_url ) = $this->maybe_transform_data( $key, $value, $label, $label_url );

			// Skip empty values, e.g. when ACF field is empty.
			if ( $value === '' ) {
				continue;
			}

			$struct[] = [
				'value'        => $value,
				'label'        => $this->guess_value_label( $label, $value, $key ),
				'label_url'    => $label_url,
				'parent_value' => $parent_value,
				'order'        => '0',
			];
		}

		return $struct;
	}

	/**
	 * Guess label of the data value
	 *
	 * @param string $label Label.
	 * @param string $value Value.
	 * @param string $col_name Column.
	 */
	protected function guess_value_label( string $label, string $value, string $col_name ): string {
		// Stock status.
		if ( $col_name === 'cf__stock_status' ) {
			$labels = wc_get_product_stock_status_options();
			if ( array_key_exists( $value, $labels ) ) {
				$label = $labels[ $value ];
			}
		}

		// On sale.
		if ( $col_name === 'on_sale' && $value === '1' ) {
			$label = _x( 'On sale', '[front]', 'fibofilters' );
		}

		// Featured.
		if ( $col_name === 'featured' && $value === '1' ) {
			$label = _x( 'Featured', '[front]', 'fibofilters' );
		}

		return apply_filters( 'fibofilters/indexer/filters_source_query/value_label', $label, $value, $col_name );
	}

	/**
	 * Get parent product data
	 *
	 * @param int $parent_id Parent ID.
	 *
	 * @return array
	 */
	protected function get_parent_product_data( int $parent_id ): array {
		global $wpdb;
		$data = [];

		$orig_post_in     = $this->post_in;
		$orig_select_type = $this->select_type;

		$this->post_in     = [ $parent_id ];
		$this->select_type = 'data';

		$rows = $wpdb->get_results( $this->build_query(), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

		if ( ! empty( $rows[0] ) ) {
			$data = $rows[0];
		}

		$this->post_in     = $orig_post_in;
		$this->select_type = $orig_select_type;

		return (array) $data;
	}

	/**
	 * Merge variable product data with variations.
	 *
	 * @param array $parent_data Parent data.
	 * @param array $variation_data Variation data.
	 *
	 * @return array
	 */
	protected function merge_parent_and_variation_data( array $parent_data, array $variation_data ): array {

		// Copy attributes from lookup into proper place.
		foreach ( $variation_data as $source => $value ) {
			if ( substr( $source, 0, 11 ) === 'tmp_tax_pa_' && ! empty( $value ) ) {
				$main_key                    = substr( $source, 4 );
				$variation_data[ $main_key ] = $value;
				unset( $variation_data[ $source ] );
			}
		}

		// Copy parent's data into variations beside ID, attributes, price, on sale and stock status.
		foreach ( $parent_data as $parent_source => $parent_value ) {
			$is_tmp_attr = substr( $parent_source, 0, 11 ) === 'tmp_tax_pa_';
			$is_attr     = substr( $parent_source, 0, 7 ) === 'tax_pa_';

			// Copy everything from parent besides attributes and predefined list of keys.
			if (
				! in_array( $parent_source, [ 'ID', 'cf__stock_status', 'price', 'parent_id', 'product_type', 'on_sale', 'order_by_price' ], true )
				&& ! $is_attr
				&& ! $is_tmp_attr ) {
				$variation_data[ $parent_source ] = $parent_value;
			}

			// Copy attributes from parent if attribute exist in parent and doesn't exist in variations.
			if ( $is_attr ) {
				if ( empty( $variation_data[ $parent_source ] ) && ! empty( $parent_value ) ) {
					/**
					 * Copy attribute in two cases:
					 * - Just non-taxonomy attribute
					 * - The attribute is a taxonomy term and is not used to create variations. In this case, the child
					 *   product has its own attributes and does not need to be copied. This is important because when
					 *   a given attribute is excluded from indexing, the child product may have it empty and would
					 *   be copied from the parent, and we do not want that.
					 */
					if ( strpos( $parent_source, 'tax_pa_' ) === false ) {
						$variation_data[ $parent_source ] = $parent_value;
					} elseif ( ! $this->is_attribute_used_for_variations( (int) $parent_data['ID'], str_replace( 'tax_', '', $parent_source ) ) ) {
						$variation_data[ $parent_source ] = $parent_value;
					}
				}
			}
		}

		return $variation_data;
	}

	/**
	 * Remove temporary attributes loaded from lookup table.
	 *
	 * @param array $item Product data.
	 *
	 * @return array
	 */
	protected function remove_tmp_data( array $item ): array {
		foreach ( $item as $source => $value ) {
			if ( substr( $source, 0, 11 ) === 'tmp_tax_pa_' ) {
				unset( $item[ $source ] );
			}
		}

		return $item;
	}

	/**
	 * Retrieve the order of terms from the database
	 *
	 * @param array $rows Result rows.
	 *
	 * @return array
	 */
	protected function maybe_get_order( array $rows ): array {
		global $wpdb;

		if ( empty( $rows ) ) {
			return $rows;
		}

		$term_ids           = [];
		$sources_with_order = [];

		// Get taxonomies with ordered terms.
		foreach ( $this->store_filters->get_all_filters() as $filter ) {
			$source_obj  = $this->filter_sources->get_source( $filter['source'] );
			$source_data = $source_obj->get_data();
			if ( empty( $source_data ) ) {
				continue;
			}
			if ( isset( $source_data['orderByTermOrder'] ) && $source_data['orderByTermOrder'] ) {
				$sources_with_order[] = $filter['source'];
			}
		}

		foreach ( $rows as $row ) {
			foreach ( $row as $key => $values ) {
				if ( in_array( $key, $sources_with_order, true ) ) {
					$term_ids = array_merge( $term_ids, wp_list_pluck( $values, 'value' ) );
				}
			}
		}

		$term_ids = array_unique( $term_ids );

		if ( empty( $term_ids ) ) {
			return $rows;
		}

		$placeholders = array_fill( 0, count( $term_ids ), '%d' );
		$format       = implode( ', ', $placeholders );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		$results = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT t.term_id, tm.meta_value as 'order'
                             FROM $wpdb->terms AS t
                             INNER JOIN $wpdb->termmeta AS tm ON tm.term_id = t.term_id AND tm.meta_key = 'order'
                             WHERE t.term_id IN ($format)
                             ",
				$term_ids
			),
			OBJECT_K
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

		if ( empty( $results ) ) {
			return $rows;
		}

		foreach ( $rows as $index => $row ) {
			foreach ( $row as $key => $values ) {
				if ( ! is_array( $values ) ) {
					continue;
				}
				foreach ( $values as $value_index => $value ) {
					if ( isset( $value['value'] ) && isset( $results[ intval( $value['value'] ) ] ) ) {
						$rows[ $index ][ $key ][ $value_index ]['order'] = $results[ intval( $value['value'] ) ]->order;
					}
				}
			}
		}

		return $rows;
	}

	/**
	 * Get SQL for excluding or including term IDs
	 *
	 * @param string $selection_type Selection type. Options: 'excluded', 'included'.
	 * @param string $taxonomy Taxonomy name.
	 */
	private function get_selection_sql( string $selection_type, string $taxonomy ): string {
		global $wpdb;

		if ( ! in_array( $selection_type, [ 'excluded', 'included' ], true ) ) {
			return '';
		}
		if ( ! taxonomy_exists( $taxonomy ) ) {
			return '';
		}
		$cache_key         = $this->type . '_term_ids_' . $selection_type . '_' . $taxonomy;
		$selected_term_ids = $this->cache->get( $cache_key, 'source_query' );
		if ( $selected_term_ids === false ) {
			$selected_term_ids = apply_filters( "fibofilters/indexer/{$this->type}/term_ids/{$selection_type}/{$taxonomy}", [] );

			if ( ! empty( $selected_term_ids ) && is_taxonomy_hierarchical( $taxonomy ) ) {
				$children_term_ids = [];
				foreach ( $selected_term_ids as $term_id ) {
					$children = get_term_children( $term_id, $taxonomy );
					if ( is_array( $children ) && ! empty( $children ) ) {
						$children_term_ids = array_merge( $children_term_ids, $children );
					}
				}

				$selected_term_ids = array_merge( $selected_term_ids, $children_term_ids );
				$selected_term_ids = array_unique( $selected_term_ids );
			}

			$selected_term_ids = array_filter( $selected_term_ids, 'is_int' );
			$this->cache->set( $cache_key, $selected_term_ids, 'source_query' );
		}
		$selection_sql = '';
		if ( is_array( $selected_term_ids ) && ! empty( $selected_term_ids ) ) {
			$placeholders = array_fill( 0, count( $selected_term_ids ), '%d' );
			$format       = implode( ', ', $placeholders );
			if ( $selection_type === 'excluded' ) {
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				$selection_sql = $wpdb->prepare( "AND t.term_id NOT IN ($format)", $selected_term_ids );
			} else {
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				$selection_sql = $wpdb->prepare( "AND t.term_id IN ($format)", $selected_term_ids );
			}
		}

		return (string) $selection_sql;
	}

	/**
	 * Check if product attribute is used for variations
	 *
	 * @param int    $product_id Product ID.
	 * @param string $attribute_name Attribute name.
	 */
	private function is_attribute_used_for_variations( int $product_id, string $attribute_name ): bool {
		$cache_key = $this->type . '_attribute_used_for_variations_' . $product_id . '_' . $attribute_name;
		$data      = $this->cache->get( $cache_key, 'source_query' );

		if ( isset( $data[ $attribute_name ] ) ) {
			return $data[ $attribute_name ];
		}

		$product = wc_get_product( $product_id );
		if ( ! is_a( $product, 'WC_Product' ) ) {
			$this->cache->set( $cache_key, [ $attribute_name => false ], 'source_query' );

			return false;
		}

		foreach ( $product->get_attributes() as /** @var $attribute \WC_Product_Attribute */ $attribute ) {
			if ( $attribute->get_name() === $attribute_name ) {
				$this->cache->set( $cache_key, [ $attribute_name => $attribute->get_variation() ], 'source_query' );

				return $attribute->get_variation();
			}
		}
		$this->cache->set( $cache_key, [ $attribute_name => false ], 'source_query' );

		return false;
	}

	/**
	 * Transform data
	 *
	 * This method is used to transform data from database to proper format if needed.
	 * E.g. transform "10 cm" to "10" for range filter.
	 *
	 * @param string $key Key.
	 * @param string $value Value.
	 * @param string $label Label.
	 * @param string $label_url Label URL.
	 *
	 * @return array
	 */
	private function maybe_transform_data( $key, $value, $label, $label_url ): array {
		// Ignore some keys.
		if ( in_array( $key, [ 'ID', 'parent_id', 'lang', 'product_type' ], true ) ) {
			return [ $value, $label, $label_url ];
		}

		// Check if we need to transform data.
		if ( ! isset( $this->transform_data_cache[ $key ] ) ) {
			$this->transform_data_cache[ $key ] = false;

			$filters = $this->store_filters->get_all_filters();
			foreach ( $filters as $filter ) {
				if ( $filter['source'] === $key && $filter['type'] === 'range' && isset( $filter['analyzed_type'] ) && $filter['analyzed_type'] === 'number_extended' ) {
					$this->transform_data_cache[ $key ] = 'number_extended';
					break;
				}
			}
		}

		// Get overrides for the source.
		if ( ! isset( $this->number_extended_overrides_cache[ $key ] ) ) {
			$this->number_extended_overrides_cache[ $key ] = apply_filters( "fibofilters/indexer/number_extended_overrides/source={$key}", [] );
		}

		// Transform data as extender number.
		if ( $this->transform_data_cache[ $key ] === 'number_extended' ) {
			if ( ! empty( $this->number_extended_overrides_cache[ $key ] && is_array( $this->number_extended_overrides_cache[ $key ] ) ) ) {
				if ( isset( $this->number_extended_overrides_cache[ $key ][ $value ] ) ) {
					$value = (string) $this->number_extended_overrides_cache[ $key ][ $value ];
				}
			}

			preg_match( SourceAnalyser::NUMBER_EXTENDED_REGEX, $value, $matches );
			$value = isset( $matches[1] ) ? str_replace( ',', '.', $matches[1] ) : '';
		}

		return [ $value, $label, $label_url ];
	}

	public function get_ancestor_values_for_source( string $source_name, array $values ): array {
		$source_obj   = $this->filter_sources->get_source( $source_name );
		$source_value = $source_obj->get_data_attr( 'value' );

		if ( empty( $source_value ) || strpos( $source_value, 'tax_' ) === false ) {
			return [];
		}

		$taxonomy = str_replace( 'tax_', '', $source_value );

		if ( ! is_taxonomy_hierarchical( $taxonomy ) ) {
			return [];
		}

		return $this->get_ancestor_values_for_taxonomy( $values, $taxonomy );
	}

	private function get_ancestor_values_for_taxonomy( array $values, string $taxonomy ): array {
		$ancestors_values = [];
		$value_ids        = array_map( 'intval', wp_list_pluck( $values, 'value' ) );
		$added_ids        = [];
		foreach ( $values as $value ) {
			if ( $value['parent_value'] !== '0' ) {
				$ancestors = $this->get_ancestor_term_ids( (int) $value['parent_value'], $taxonomy );

				if ( empty( $ancestors ) ) {
					continue;
				}

				$args = [
					'taxonomy'   => $taxonomy,
					'hide_empty' => false,
					'include'    => $ancestors,
				];

				if ( $this->multilingual->is_multilingual() ) {
					$terms = $this->multilingual->get_terms_in_language( $args, $value['lang'] ?? '' );
				} else {
					$terms = get_terms( $args );
				}

				if ( ! is_array( $terms ) ) {
					continue;
				}
				foreach ( $terms as $term ) {
					// Avoid double terms.
					if (
						in_array( $term->term_id, $value_ids, true ) ||
						in_array( $term->term_id, $added_ids, true )
					) {
						continue;
					}

					$ancestors_values[] = [
						'value'        => (string) $term->term_id,
						'label'        => $term->name,
						'label_url'    => $term->slug,
						'parent_value' => (string) $term->parent,
					];

					$added_ids[] = $term->term_id;
				}
			}
		}

		return $ancestors_values;
	}

	/**
	 * Get ancestor term IDs for a given term ID and taxonomy
	 *
	 * Response includes the term ID itself.
	 *
	 * @param int    $term_id Term ID.
	 * @param string $taxonomy Taxonomy name.
	 */
	private function get_ancestor_term_ids( int $term_id, string $taxonomy ): array {
		$cache_key = 'taxonomy_ancestors_' . $term_id . $taxonomy;
		$ancestors = $this->cache->get( $cache_key, 'source_query' );

		if ( is_array( $ancestors ) ) {
			return $ancestors;
		}

		$term      = get_term( $term_id, $taxonomy );
		$ancestors = [];

		// This way we know that we have a valid term object.
		if ( is_object( $term ) && is_a( $term, '\WP_Term' ) ) {
			$ancestors[] = $term->term_id;
		}

		while ( ! is_wp_error( $term ) && ! empty( $term->parent ) && ! in_array( $term->parent, $ancestors, true ) ) {
			$ancestors[] = $term->parent;
			$term        = get_term( $term->parent, $taxonomy );
		}

		$this->cache->set( $cache_key, $ancestors, 'source_query' );

		return $ancestors;
	}

	/**
	 * Get ancestor tree as values
	 *
	 * This method is used to get the ancestor tree of WP_Terms as array values.
	 * If we have a category tree like this: A -> B -> C, and we have a term C,
	 * then we will return an array with values for A, B, and C.
	 *
	 * @param array      $values Initial array of values.
	 * @param \WP_Term[] $object_terms Array of WP_Term object to get ancestor tree from.
	 */
	public function get_ancestor_tree_as_values( $values, $object_terms ): array {
		if ( empty( $object_terms ) || ! is_array( $object_terms ) || ! ( isset( $object_terms[0] ) && is_a( $object_terms[0], 'WP_Term' ) ) ) {
			return $values;
		}

		$taxonomy = $object_terms[0]->taxonomy;

		if ( ! is_taxonomy_hierarchical( $taxonomy ) ) {
			return $values;
		}

		$values_arr = array_map(
			function ( $term ) {
				return [
					'value'        => (string) $term->term_id,
					'parent_value' => (string) $term->parent,
					'lang'         => '',
				];
			},
			$object_terms
		);

		$ancestor_values = $this->get_ancestor_values_for_taxonomy( $values_arr, $taxonomy );

		$result = [];
		foreach ( $object_terms as $term ) {
			if ( ! is_a( $term, 'WP_Term' ) ) {
				continue;
			}

			$result[] = [
				'value'        => (string) $term->term_id,
				'label'        => $term->name,
				'label_url'    => $term->slug,
				'parent_value' => (string) $term->parent,
			];
		}

		return array_merge( $result, $ancestor_values );
	}

	/**
	 * Add computed source values to the product data.
	 *
	 * @param array $item Item data.
	 */
	protected function add_computed_source_values_to_item( array $item ): array {
		$modified = false;

		foreach ( $this->store_filters->get_all_filters() as $filter ) {
			$source_obj  = $this->filter_sources->get_source( $filter['source'] );
			$source_data = $source_obj->get_data();
			if ( empty( $source_data ) ) {
				continue;
			}

			if ( $source_data['sourceType'] !== 'computed' ) {
				continue;
			}

			$computed = $source_data['callback']( $item['ID'], $item['parent_id'] );

			$item[ $source_data['value'] ] = '';
			switch ( $filter['type'] ) {
				case 'checkboxes':
				case 'select':
					if ( is_scalar( $computed ) ) {
						$item[ $source_data['value'] ] = (string) $computed;
						$modified                      = true;
					} elseif ( ArrayUtil::is_array_of_scalars( $computed ) ) {
						$item[ $source_data['value'] ] = join( ' || ', $computed );
						$modified                      = true;
					} elseif ( isset( $computed[0]['parent_value'] ) && ArrayUtil::is_array_of_arrays( $computed ) ) {
						$computed_values_formated = [];
						foreach ( $computed as $computed_value ) {
							if ( isset( $computed_value['value'], $computed_value['label'], $computed_value['label_url'], $computed_value['parent_value'] ) ) {
								// Store computed value in the format: self::DATA_TYPE['id_value_slug_and_parent'].
								$computed_values_formated[] = sprintf( '2 ___ %s __ %s __ %s __ %s', $computed_value['value'], $computed_value['label'], $computed_value['label_url'], $computed_value['parent_value'] );
							}
						}
						$computed_values_formated = array_unique( $computed_values_formated );
						if ( ! empty( $computed_values_formated ) ) {
							$item[ $source_data['value'] ] = join( ' || ', $computed_values_formated );
							$modified                      = true;
						}
					}
					break;
				case 'buttons':
				case 'radio':
					if ( is_scalar( $computed ) ) {
						$item[ $source_data['value'] ] = (string) $computed;
						$modified                      = true;
					} elseif ( ArrayUtil::is_array_of_scalars( $computed ) ) {
						$item[ $source_data['value'] ] = join( ' || ', $computed );
						$modified                      = true;
					}
					break;
				case 'range':
					/**
					 * Cases:
					 * - 1
					 * - 5.5
					 * - '1.5'
					 */
					if ( is_numeric( $computed ) ) {
						$item[ $source_data['value'] ] = (string) $computed;
						$modified                      = true;
					} elseif ( ArrayUtil::is_array_of_numeric( $computed ) ) {
						$item[ $source_data['value'] ] = join( ' || ', $computed );
						$modified                      = true;
					}
					break;
				case 'toggle':
					/**
					 * Cases:
					 * - [ '1' , 'In stock' ]
					 * - [ 'yes' , 'In stock' ]
					 * - [ 'true' , 'In stock' ]
					 * - [ 'on' , 'In stock' ]
					 */
					if ( is_array( $computed ) && ArrayUtil::is_indexed_array( $computed ) && count( $computed ) === 2 ) {
						$item[ $source_data['value'] ] = sprintf( '5 ___ %s __ %s', trim( (string) $computed[0] ), trim( (string) $computed[1] ) );
						$modified                      = true;
					}
					break;
				case 'color':
					if ( is_string( $computed ) ) {
						$item[ $source_data['value'] ] = $computed;
						$modified                      = true;
					} elseif ( ArrayUtil::is_array_of_strings( $computed ) ) {
						/**
						 * Cases:
						 * - [ 'Red' , 'Green', 'Blue' ]
						 * - [ '#ff0000', '#00ff00', '#0000ff' ]
						 */
						$computed                      = array_unique( $computed );
						$item[ $source_data['value'] ] = join( ' || ', $computed );
						$modified                      = true;
					} elseif ( ArrayUtil::is_array_of_arrays( $computed ) ) {
						/**
						 * Cases:
						 * - [ [ '#ff0000', 'Red'] ] - single color.
						 * - [ [ '#ff0000', 'Red'] , [ '#00ff00', 'Green' ], [ '#0000ff', 'Blue' ] ]
						 */
						$computed_values_formated = [];
						foreach ( $computed as $computed_value ) {
							if ( ArrayUtil::is_array_of_strings( $computed_value ) && count( $computed_value ) === 2 ) {
								// Store computed value in the format: self::DATA_TYPE['value_and_label'].
								$computed_values_formated[] = sprintf( '5 ___ %s __ %s', trim( $computed_value[0] ), trim( $computed_value[1] ) );
							}
						}
						$computed_values_formated = array_unique( $computed_values_formated );
						if ( ! empty( $computed_values_formated ) ) {
							$item[ $source_data['value'] ] = join( ' || ', $computed_values_formated );
							$modified                      = true;
						}
					}
					break;
			}
		}

		return [ $item, $modified ];
	}

	/**
	 * Check if we have at least one filter with computed source.
	 */
	protected function has_filter_with_computed_source(): bool {
		foreach ( $this->store_filters->get_all_filters() as $filter ) {
			$source_obj  = $this->filter_sources->get_source( $filter['source'] );
			$source_data = $source_obj->get_data();
			if ( empty( $source_data ) ) {
				continue;
			}

			if ( $source_data['sourceType'] === 'computed' ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Transform multiple values in range filter to a single value with max_value.
	 *
	 * This is needed only whet logic is 'range-continuous'.
	 *
	 * @param array $rows Rows with data to index.
	 */
	protected function maybe_transform_multivalues( array $rows ): array {
		$multivalues_continuous_sources = [];

		foreach ( $this->store_filters->get_all_filters() as $filter ) {
			if ( $filter['logic'] === 'range-continuous' ) {
				$multivalues_continuous_sources[] = $filter['source'];
			}
		}

		if ( ! empty( $multivalues_continuous_sources ) ) {
			foreach ( $rows as $i => $row ) {
				foreach ( $row as $source => $values ) {
					if ( is_array( $values ) && count( $values ) > 0 && in_array( $source, $multivalues_continuous_sources, true ) ) {
						usort(
							$values,
							function ( $a, $b ) {
								return (float) $a['value'] <=> (float) $b['value'];
							}
						);
						$values[0]['max_value'] = $values[ count( $values ) - 1 ]['value'];
						// Remove all values except the first one.
						$rows[ $i ][ $source ] = [ $values[0] ];
					}
				}
			}
		}

		return $rows;
	}

	/**
	 * Normalize price to have consistent format.
	 *
	 * @param string|float|int $price Price.
	 *
	 * @return string
	 */
	private function normalize_price( $price ): string {
		if ( strpos( (string) $price, ' || ' ) !== false ) {
			return join(
				' || ',
				array_map(
					function ( $item ) {
						return wc_format_decimal( $item, wc_get_price_decimals(), true );
					},
					explode( ' || ', $price )
				)
			);
		}

		return wc_format_decimal( $price, wc_get_price_decimals(), true );
	}
}
