<?php
declare( strict_types=1 );

namespace FiboFilters\Filtering;

use FiboFilters\Config\AssetsLoader;
use FiboFilters\Config\Env;
use FiboFilters\Filters\FiltersManager;
use FiboFilters\Filters\FiltersQuery;
use FiboFilters\Filters\StoreFilters;
use FiboFilters\Index\Documents;
use FiboFilters\Indexer\Builder;
use FiboFilters\Integrations\Multilingual;
use FiboFilters\Sorting\Sorting;
use FiboFilters\Struct\QueryContext;
use FiboFilters\Util\FilterUtil;
use FiboFilters\Util\Helpers;
use FiboFilters\Util\Localization;
use FiboFilters\WP\Helper as WPHelper;
use FiboFilters\WooCommerce\Helper as WCHelper;
use WP_Query;

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

/**
 * Class Request
 */
class Request {
	/** @var array */
	private $get_vars = [];

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

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

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

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

	/** @var bool */
	private $maybe_redirect_to_valid_url_done = false;

	/** @var AssetsLoader */
	private $assets_loader;

	/** @var Builder */
	private $builder;

	/** @var FiltersManager */
	private $filters_manager;

	/** @var FiltersQuery */
	private $filters_query;

	/** @var Documents */
	private Documents $documents;

	/** @var Front */
	private $front;

	/** @var Localization */
	private $localization;

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

	/** @var WPHelper */
	private $wp_helper;

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

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

	/** @var VariationAdjuster */
	private $variation_adjuster;

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

	public function __construct(
		AssetsLoader $assets_loader,
		Builder $builder,
		FiltersManager $filters_manager,
		FiltersQuery $filters_query,
		Documents $documents,
		Localization $localization,
		Multilingual $multilingual,
		Front $front,
		WPHelper $wp_helper,
		WCHelper $wc_helper,
		StoreFilters $store_filters,
		VariationAdjuster $variation_adjuster,
		Sorting $sorting
	) {
		$this->assets_loader      = $assets_loader;
		$this->builder            = $builder;
		$this->filters_manager    = $filters_manager;
		$this->filters_query      = $filters_query;
		$this->documents          = $documents;
		$this->localization       = $localization;
		$this->multilingual       = $multilingual;
		$this->front              = $front;
		$this->wp_helper          = $wp_helper;
		$this->wc_helper          = $wc_helper;
		$this->store_filters      = $store_filters;
		$this->variation_adjuster = $variation_adjuster;
		$this->sorting            = $sorting;
	}

	public function init(): void {
		$this->parse_json();

		// FiboSearch searches with priority 900001.
		add_action( 'pre_get_posts', [ $this, 'prepare_request_data' ], 900005 );
		add_action( 'wp', [ $this, 'update_context' ], 5 );
		add_action( 'template_redirect', [ $this, 'set_config' ], 5 );
		add_action( 'template_redirect', [ $this, 'generate_output' ] );
		add_action( 'template_redirect', [ $this, 'add_scripts_hooks' ], 20 );
		add_action( 'template_redirect', [ $this, 'maybe_adjust_variation_images_and_urls' ], 30 );
		add_action( 'template_redirect', [ $this, 'add_front_hooks' ], PHP_INT_MAX - 10 );

		// Get single products.
		add_action( 'template_redirect', [ $this, 'get_single_products' ], 20 );
		add_action( 'fibofilters/request/single_products/before_loop', [ $this, 'before_single_products_loop' ], 15 );

		add_filter( 'fibofilters/request/get_context', [ $this, 'get_context' ] );
		add_filter( 'fibofilters/request/get_single_product_ids', [ $this, 'get_single_product_ids' ] );

		add_filter( 'posts_clauses', [ $this, 'custom_initial_order_by' ], 100, 2 );
	}

	public function get_output(): array {
		return $this->output;
	}

	/**
	 * Prepare output data for the frontend app.
	 *
	 * @param WP_Query $query WP query.
	 *
	 * @return void
	 */
	public function prepare_request_data( WP_Query $query ): void {
		if ( ! $query->is_main_query() ) {
			return;
		}

		if (
			is_admin() ||
			wp_doing_cron() ||
			wp_doing_ajax() ||
			( defined( 'WP_CLI' ) && WP_CLI ) ||
			$query->get( 'fibofilters_get_base_product_ids' ) === true || // Prevent WP_Query infinite loop.
			( isset( $_GET['nofibofilters'] ) && in_array( $_GET['nofibofilters'], [ '1', '2' ], true ) ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			in_array( apply_filters( 'fibofilters/nofibofilters', '' ), [ '1', '2' ], true )
		) {
			return;
		}

		// STEP 1: [CONTEXT] Find out what WordPress view we are in.
		$this->set_context( $query );

		$this->output = [
			'config'               => [],
			'index_valid'          => true,
			'context'              => $this->context,
			'base_products_ids'    => [],
			'searched_product_ids' => [],
			'filters'              => [],
			'translations'         => [],
		];

		// Break early if we are requesting single products.
		if ( $this->is_get_products_request() ) {
			return;
		}

		// STEP 2: Check if index is valid.
		$language = $this->multilingual->is_multilingual() ? $this->multilingual->get_current_language() : '';
		if ( ! $this->builder->is_index_valid( 'main', $language ) ) {
			$this->output['index_valid'] = false;
		}

		// Break early if index is not ready - in this case, the front-end application will not run anyway.
		if ( ! $this->output['index_valid'] || ! $this->output['context']['show_filters'] ) {
			return;
		}

		// STEP 3: Backend filtering.
		$this->sanitize_and_set_get_vars();
		$searched_product_ids = [];

		if ( ! empty( $this->get_vars ) ) {
			// Initialization of the filters instances based on user-selected values.
			$this->filters_manager->set_filter_instances_by_http_get_params( $this->get_vars, $language );

			// Maybe redirect to a URL with valid filter values.
			$this->maybe_redirect_to_valid_url( $language );

			$this->user_query = $this->filters_manager->create_complex_query();
			if ( ! empty( $this->user_query ) ) {
				$this->filters_query->set_user_query( $this->user_query );
				/**
				 * The filter() method allows us to pass a list of product IDs, but this is determined
				 * later and is not required here.
				 */
				$filtered_product_and_variations_ids = $this->filters_query->filter( null, $language );

				$ctx                  = new QueryContext( $language );
				$filtered_product_ids = $this->documents->get_parent_ids_only( $filtered_product_and_variations_ids, $ctx );

				// STEP 3a: Integration with FiboSearch Pro.
				if ( did_action( 'fibofilters/integrations/plugin/late_init/fibosearch_pro' ) > 0 ) {
					$user_query = $this->filters_query->get_user_query();
					foreach ( $user_query as $elementary_query ) {
						if ( $elementary_query['source'] === 'search' && $elementary_query['logic'] === 'search-fibosearch-pro' ) {
							$search_result        = DGWT_WCAS()->searchPosts(
								$elementary_query['values'][0],
								[
									'post_type' => 'product',
									'per_page'  => - 1,
									'fields'    => 'ids',
								]
							);
							$searched_product_ids = $search_result['results'];
							$filtered_product_ids = array_values( array_intersect( $filtered_product_ids, $search_result['results'] ) );

							$result_positions = array_flip( $search_result['results'] );

							// Sort filtered products according to the order in the FiboSearch results.
							usort(
								$filtered_product_ids,
								function ( $x, $y ) use ( $result_positions ) {
									return ( $result_positions[ $x ] ?? PHP_INT_MAX ) <=> ( $result_positions[ $y ] ?? PHP_INT_MAX );
								}
							);
						}
					}
				}

				if ( apply_filters( 'fibofilters/config/single_variations_support', false ) ) {
					$filtered_product_ids = array_merge( $filtered_product_ids, $filtered_product_and_variations_ids );
				}

				if ( empty( $filtered_product_ids ) ) {
					$filtered_product_ids = [ - 1 ];
				}

				// Allow third-party code to fetch filtered product IDs, e.g. in the "late init" mode.
				add_filter(
					'fibofilters/request/filtered_product_ids',
					function () use ( $filtered_product_ids ) {
						return $filtered_product_ids;
					}
				);

				/**
				 * If the "late init" mode is active, we do not modify the query here.
				 * The user must take care of modifying the query using above filter.
				 */
				if ( ! $this->output['context']['late_init'] ) {
					if ( $filtered_product_ids === [ - 1 ] || ( ! empty( $this->context['conditions']['is_fibosearch'] ) && $query->get( 'post__in' ) === [ - 1 ] ) ) {
						$query->set( 'post__in', [ - 1 ] );
					} elseif ( ! empty( $this->context['conditions']['is_fibosearch'] ) ) {
						$query->set( 'post__in', array_intersect( $query->get( 'post__in' ), $filtered_product_ids ) );
					} elseif ( ! empty( $this->context['conditions']['is_post_with_shortcode'] ) ) {
						add_action(
							'pre_get_posts',
							function ( $wc_query ) use ( $filtered_product_ids ) {
								if ( $wc_query->get( 'fibofilters' ) === 1 && $wc_query->get( 'wc_query' ) === 'product_query' ) {
									$wc_query->set( 'post__in', $filtered_product_ids );
								}
							}
						);
					} elseif ( ! empty( $this->context['internal']['is_page_with_elementor_woocommerce_products'] ) ) {
						add_filter(
							'woocommerce_shortcode_products_query',
							function ( $query_args ) use ( $filtered_product_ids ) {
								if ( Helpers::is_running_inside_class( 'ElementorPro\Modules\Woocommerce\Classes\Products_Renderer' ) ) {
									$query_args['post__in'] = $filtered_product_ids;
								}
								return $query_args;
							}
						);
					} elseif ( apply_filters( 'fibofilters/request/skip_main_query_filtering', false ) ) {
						// Do nothing.
					} else {
						$query->set( 'post__in', $filtered_product_ids );
						// Sort by IDs to maintain the order from the FiboSearch results.
						if ( ! empty( $searched_product_ids ) ) {
							$query->set( 'orderby', 'post__in' );
						}
					}
				}

				// Reset instances.
				$this->filters_manager->instances = [];
			}
		}

		// STEP 4: Prepare all filters with values.
		$this->filters_manager->set_all_filter_instances( $language );
		foreach ( $this->filters_manager->get_instances() as $instance ) {
			$instance->set_context( $this->context );
			if ( $instance->get_type() === 'range' || $instance->get_type() === 'search' ) {
				continue;
			}
			$this->filters_manager->fill_filter_with_all_values( $instance, $language );

			// Add to context info about sibling categories for category filter.
			if ( $instance->get_type() === 'category' && $instance->get_prop( 'showing_sibling_categories' ) ) {
				$this->context['details']['showing_sibling_categories'] = true;
			}
			// Add to context info about sibling categories for category hierarchical filter.
			if ( $instance->get_type() === 'category-hierarchical' && $instance->get_prop( 'showing_sibling_categories' ) ) {
				$this->context['details']['showing_sibling_categories'] = true;
			}
		}
		$this->output['filters'] = $this->filters_manager->get_filters_data( 'frontend-filtering' );

		// STEP 5: Get frontend translations.
		$this->output['translations'] = $this->localization->get_front_translations();

		// In "late init" mode, the base product ids will be retrieved later.
		if ( $this->output['context']['late_init'] ) {
			return;
		}

		// STEP 6: Get base product IDs.
		$this->output['base_products_ids'] = $this->get_base_product_ids_by_wp_query();
		// STEP 6a: Maybe get base product IDs for root context.
		// This is required for calculating counters in Category filter if sibling categories are shown.
		$this->output['root_base_products_ids'] = [];
		if ( ! empty( $this->context['conditions']['is_product_taxonomy'] ) && ! empty( $this->context['details']['showing_sibling_categories'] ) ) {
			$this->output['root_base_products_ids'] = $this->get_base_product_ids_by_wp_query( true );
		}

		// STEP 7: Set searched product IDs.
		$this->output['searched_product_ids'] = $searched_product_ids;
	}

	/**
	 * The configuration is set at the latest possible action
	 *
	 * After "template_redirect" there is "wp_head" and within it "wp_enqueue_scripts".
	 */
	public function set_config(): void {
		global $wp_rewrite;

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

		/**
		 * We interrupt if after the "template_redirect" action it turns out that
		 * the request is of a different type than normal.
		 *
		 * See: wp-includes/template-loader.php
		 */
		if ( is_robots() || is_favicon() || is_feed() || is_trackback() ) {
			return;
		}

		$config = $this->front->get_config();

		// These configuration values require access to the filter list.
		$config['content_aware_filters_ratio_per_source'] = [];
		foreach ( $this->output['filters'] as $filter ) {
			$by_id       = apply_filters( "fibofilters/config/content_aware_filters_ratio/id={$filter['id']}", $config['content_aware_filters_ratio'] );
			$by_url_slug = apply_filters( "fibofilters/config/content_aware_filters_ratio/url_slug={$filter['url_slug']}", $config['content_aware_filters_ratio'] );

			if ( $by_id !== $config['content_aware_filters_ratio'] ) {
				$config['content_aware_filters_ratio_per_source'][ $filter['source_id'] ] = $by_id;
			}
			if ( $by_url_slug !== $config['content_aware_filters_ratio'] ) {
				$config['content_aware_filters_ratio_per_source'][ $filter['source_id'] ] = $by_url_slug;
			}
		}

		// Single products fetch URL.
		$current_url = ( is_ssl() ? 'https://' : 'http://' ) . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
		// Remove pagination from the URL.
		$current_url = (string) preg_replace( '/\/' . $wp_rewrite->pagination_base . '\/\d+\//', '/', $current_url );

		if ( $this->context['conditions']['is_product_taxonomy'] ) {
			$term                      = get_queried_object();
			$single_products_fetch_url = get_term_link( $term );
		} elseif ( $this->context['conditions']['is_product_search'] ) {
			$single_products_fetch_url = $current_url;
		} elseif ( ! empty( $this->context['conditions']['is_post_with_shortcode'] ) ) {
			$queried_object = get_queried_object();
			if ( ! empty( $queried_object->ID ) ) {
				$single_products_fetch_url = esc_url( get_permalink( $queried_object->ID ) );
			} else {
				$single_products_fetch_url = wc_get_page_id( 'shop' ) > 0 ? esc_url( get_permalink( wc_get_page_id( 'shop' ) ) ) : esc_url( home_url( '/shop/' ) );
			}
		} else {
			$single_products_fetch_url = wc_get_page_id( 'shop' ) > 0 ? esc_url( get_permalink( wc_get_page_id( 'shop' ) ) ) : esc_url( home_url( '/shop/' ) );
		}

		$config['single_products_fetch_url'] = apply_filters( 'fibofilters/config/single_products_fetch_url', $single_products_fetch_url );

		$this->output['config'] = $config;
	}

	public function generate_output(): void {
		global $wp_query;

		// Break early if we are requesting single products.
		if ( $this->is_get_products_request() ) {
			return;
		}

		if ( ! $this->can_show_filters() ) {
			return;
		}

		// No products found.
		ob_start();
		wc_get_template( 'loop/no-products-found.php' );
		$this->output['html']['noProductsFound'] = preg_replace( '/<link [^>]*>/i', '', (string) ob_get_clean() );

		$this->output['html']['productPlaceholder']     = $this->get_product_placeholder_html();
		$this->output['html']['paginationDummy']        = $this->get_pagination_dummy_html();
		$this->output['html']['paginationDummy']        = $this->get_pagination_dummy_html();
		$this->output['html']['resultCountSingleDummy'] = $this->get_result_count_dummy_html( 'single' );
		$this->output['html']['resultCountAllDummy']    = $this->get_result_count_dummy_html( 'all' );
		$this->output['html']['resultCountRangeDummy']  = $this->get_result_count_dummy_html( 'range' );
		$this->output['html']['orderby']                = $this->get_orderby_html();
		$this->output['html']['loopStart']              = preg_replace( '/<link [^>]*>/i', '', $this->wc_helper->woocommerce_product_loop_start() );

		/**
		 * This is required for WooCommerce to properly render templates
		 * related to the number of products and pagination.
		 */
		if ( $wp_query->get( 'fibofilters' ) === 1 && $wp_query->get( 'wc_query' ) === 'product_query' ) {
			wc_set_loop_prop( 'total', $wp_query->found_posts );
			wc_set_loop_prop( 'per_page', $wp_query->get( 'posts_per_page' ) );
			wc_set_loop_prop( 'current_page', max( 1, $wp_query->get( 'paged' ) ) );
			wc_set_loop_prop( 'total_pages', $wp_query->max_num_pages );
		}
	}

	public function get_context() {
		if ( ! did_action( 'wp' ) ) {
			/**
			 * The context is set in the "pre_get_posts" action and then updated in the "wp" action.
			 */
			_doing_it_wrong( '\FiboFilters\Filtering\Request::get_context()', 'Do not use this filter before the "wp" action.', '1.2.0' );
		}

		return $this->context;
	}

	public function add_front_hooks() {
		$hook_into_shop_loop = ! empty( $this->context['show_filters'] ) && $this->context['show_filters'] && ( $this->context['conditions']['is_shop'] || $this->context['conditions']['is_product_search'] || $this->context['conditions']['is_product_taxonomy'] );
		$has_active_filters  = ! empty( $this->user_query );
		$this->front->add_hooks( $hook_into_shop_loop, $has_active_filters );
	}

	public function add_scripts_hooks() {
		add_action( isset( $this->context['late_init'] ) && $this->context['late_init'] ? 'wp_footer' : 'wp_enqueue_scripts', [ $this, 'enqueue_scripts' ], 5 );
	}

	public function enqueue_scripts() {
		if ( ! $this->can_show_filters() ) {
			return;
		}

		$output = $this->get_output();

		// Getting base product IDs if "Late init" mode is active.
		if ( isset( $output['context']['late_init'] ) && $output['context']['late_init'] ) {
			$base_products = apply_filters( 'fibofilters/request/late_init/base_products', [] );
			if ( is_array( $base_products ) && ! empty( $base_products ) ) {
				$first_item = reset( $base_products );
				if ( is_a( $first_item, 'WC_Product' ) ) {
					$output['base_products_ids'] = array_map(
						function ( $product ) {
							return $product->get_id();
						},
						$base_products
					);
				} elseif ( is_a( $first_item, 'WP_Post' ) ) {
					$output['base_products_ids'] = array_map(
						function ( $product ) {
							return $product->ID;
						},
						$base_products
					);
				} elseif ( is_numeric( $first_item ) ) {
					$output['base_products_ids'] = array_filter( array_map( 'intval', $base_products ) );
				}
			}
		}

		$has_active_filters = ! empty( $this->user_query );
		$this->assets_loader->enqueue_front_assets( $output, $has_active_filters );
	}

	public function maybe_adjust_variation_images_and_urls() {
		if ( $this->is_get_products_request() ) {
			return;
		}

		$this->variation_adjuster->adjust_variation_images_and_urls_by_get_vars( $this->get_vars, $this->front->get_config_value( 'filters_affecting_variation_images_and_urls', [] ) );
	}

	/**
	 * Product placeholder HTML
	 */
	private function get_product_placeholder_html(): string {
		ob_start();
		require_once Env::$path . '/partials/views/front/product-placeholder.php';
		$product_placeholder = (string) ob_get_clean();

		$product_placeholder = (string) apply_filters( 'fibofilters/request/html/product_placeholder', $product_placeholder );

		// Ensure that the product placeholder has the required class.
		if ( strpos( $product_placeholder, 'fibofilters-product-placeholder' ) === false ) {
			$processor = new \WP_HTML_Tag_Processor( $product_placeholder );
			if ( $processor->next_tag() ) {
				$processor->add_class( 'fibofilters-product-placeholder' );
				$product_placeholder = $processor->get_updated_html();
			}
		}

		return $product_placeholder;
	}

	/**
	 * Dummy pagination HTML
	 */
	private function get_pagination_dummy_html(): string {
		ob_start();

		$args = [
			'total'        => 2,
			'current_page' => 1,
			'base'         => esc_url_raw( add_query_arg( 'product-page', '%#%', false ) ),
			'format'       => '?product-page=%#%',
		];

		if ( ! wc_get_loop_prop( 'is_shortcode' ) ) {
			$args['format'] = '';
			$args['base']   = esc_url_raw( str_replace( '999999999', '%#%', remove_query_arg( 'add-to-cart', get_pagenum_link( 999999999, false ) ) ) );
		}

		wc_get_template( 'loop/pagination.php', $args );

		return (string) ob_get_clean();
	}

	/**
	 * Dummy result count HTML.
	 *
	 * @param string $type Type of product count.
	 *
	 * @return string
	 */
	private function get_result_count_dummy_html( string $type ): string {
		ob_start();

		switch ( $type ) {
			case 'single':
				$args = [
					'total'     => 1,
					'per_page'  => 2,
					'current'   => 1,
					'orderedby' => '',
				];
				break;
			case 'all':
				$args = [
					'total'     => 99,
					'per_page'  => - 1,
					'current'   => 1,
					'orderedby' => '',
				];
				break;
			case 'range':
				$args = [
					'total'     => 3,
					'per_page'  => 2,
					'current'   => 1,
					'orderedby' => '',
				];
				break;
		}

		wc_get_template( 'loop/result-count.php', $args );
		$html = (string) ob_get_clean();

		// Normalize En dash.
		return preg_replace(
			[
				'/(\d+)-(\d+)/', // Hyphen.
				'/(\d+)–(\d+)/', // En dash.
				'/(\d+)—(\d+)/', // Em dash.
				'/(\d+)&#8211;(\d+)/', // En dash.
				'/(\d+)&#8212;(\d+)/', // Em dash.
				'/(\d+)&#x2013;(\d+)/', // En dash.
				'/(\d+)&#x2014;(\d+)/', // Em dash.
			],
			'$1&ndash;$2',
			$html
		);
	}

	/**
	 * Ordering HTML
	 */
	private function get_orderby_html(): string {
		ob_start();
		woocommerce_catalog_ordering();

		return (string) ob_get_clean();
	}

	/**
	 * @return void
	 */
	private function sanitize_and_set_get_vars(): void {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( empty( $_GET ) ) {
			return;
		}

		$url_slugs = $this->store_filters->get_all_filter_url_slugs();

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		foreach ( $_GET as $param => $raw_value ) {
			if ( ! is_string( $param ) || ! is_string( $raw_value ) ) {
				continue;
			}
			$param_sanitized = FilterUtil::sanitize_value( $param );
			if ( ! empty( $param_sanitized ) ) {
				$filter_type = '';
				if ( in_array( $param_sanitized, $url_slugs, true ) ) {
					$filter_type = $this->store_filters->get_filter_type_by_url_slug( $param_sanitized );
				}
				if ( $filter_type === 'search' ) {
					$value = wc_clean( wp_unslash( $raw_value ) );
				} else {
					$value = FilterUtil::sanitize_value( $raw_value );
				}
				if ( $value !== '' ) {
					$this->get_vars[ $param_sanitized ] = $value;
				}
			}
		}
	}

	/**
	 * @param WP_Query $query WP_Query.
	 */
	private function set_context( WP_Query $query ): void {
		$context = [
			'show_filters' => false,
			'conditions'   => [
				'is_shop'                => false,
				'is_product_taxonomy'    => false,
				'is_product_search'      => false,
				'is_fibosearch'          => false,
				'is_post_with_shortcode' => false,
				'has_active_filters'     => false,
			],
			'details'      => [],
			/**
			 * Late init mode:
			 * - Scripts and styles are enqueued in the footer
			 * - User must take care of displaying the filters and the "Show more products" button
			 * - User must pass a list of product IDs to display using the filter 'fibofilters/request/late_init/base_products_ids'
			 */
			'late_init'    => apply_filters( 'fibofilters/request/late_init', false ),
			'internal'     => [], // This key won't be passed to frontend.
		];

		if ( $this->is_get_products_request() ) {
			$context['is_get_products'] = true;
		}

		if ( ! $query->is_main_query() || ! function_exists( 'is_shop' ) ) {
			_doing_it_wrong( __FUNCTION__, 'Method called too early.', '0.0.2' );

			return;
		}

		if ( $context['late_init'] ) {
			$context['show_filters'] = true;
		}

		// Get query vars from main query.
		$query_vars = $query->query_vars;

		if (
			$query->is_search() &&
			(
				(
					$query->get( 'post_type' ) &&
					is_string( $query->get( 'post_type' ) ) &&
					$query->get( 'post_type' ) === 'product'
				) || is_product_taxonomy()
			)
		) {
			$context['conditions']['is_product_search'] = true;
			$context['show_filters']                    = true;
			$context['details']['s']                    = $query->get( 's' );
		}

		if ( is_shop() ) {
			$context['conditions']['is_shop'] = true;
			$context['show_filters']          = true;
		}

		if ( is_product_taxonomy() ) {
			$context['conditions']['is_product_taxonomy'] = true;
			$context['show_filters']                      = true;

			/** @var null|\WP_Term $term */
			$term = $query->get_queried_object();
			if ( isset( $term->term_id ) ) {
				$context['details']['taxonomy']       = $term->taxonomy;
				$context['details']['term_slug']      = $term->slug;
				$context['details']['term_id']        = $term->term_id;
				$context['details']['term_parent']    = $term->parent;
				$context['details']['term_label_url'] = FilterUtil::format_url_label( $term->name );
			}
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( isset( $_REQUEST['dgwt_wcas'] ) && $_REQUEST['dgwt_wcas'] === '1' && $context['conditions']['is_product_search'] ) {
			$context['conditions']['is_fibosearch'] = true;
			// FiboSearch clears the query "s" variable, so we need to retrieve its value some other way.
			$context['details']['s'] = $query->get( 'dgwt_wcas' );
		}

		/**
		 * Show filters when current post/page contains [products] shortcode with fibofilters=1 attribute.
		 */
		if ( ! $context['show_filters'] ) {
			$post_content = '';
			if ( is_a( $query->queried_object, 'WP_Post' ) && has_shortcode( $query->queried_object->post_content, 'products' ) ) {
				// Simple page or post.
				$post_content = $query->queried_object->post_content;
			} elseif ( ! empty( $query->get( 'page_id' ) ) && get_option( 'show_on_front' ) === 'page' && get_option( 'page_on_front' ) === $query->get( 'page_id' ) ) {
				// Page set as front page.
				$front_page = get_post( get_option( 'page_on_front' ) );
				if ( is_a( $front_page, 'WP_Post' ) && has_shortcode( $front_page->post_content, 'products' ) ) {
					$post_content = $front_page->post_content;
				}
			}
			if ( ! empty( $post_content ) ) {
				$products_shortcode_atts = $this->get_products_shortcode_atts( $post_content );
				if ( ! empty( $products_shortcode_atts ) ) {
					$context['show_filters']                         = true;
					$context['conditions']['is_post_with_shortcode'] = true;

					$type = 'products';
					if ( isset( $products_shortcode_atts['on_sale'] ) && wc_string_to_bool( $products_shortcode_atts['on_sale'] ) ) {
						$type = 'sale_products';
					} elseif ( isset( $products_shortcode_atts['best_selling'] ) && wc_string_to_bool( $products_shortcode_atts['best_selling'] ) ) {
						$type = 'best_selling_products';
					} elseif ( isset( $products_shortcode_atts['top_rated'] ) && wc_string_to_bool( $products_shortcode_atts['top_rated'] ) ) {
						$type = 'top_rated_products';
					}

					$wc_shortcode_products = new \WC_Shortcode_Products( $products_shortcode_atts, $type );
					$query_vars            = $wc_shortcode_products->get_query_args();

					$context['internal']['forced_query_args'] = [];
					// There is "limit" attr in shortcode.
					if ( ! empty( $query_vars['posts_per_page'] ) && $query_vars['posts_per_page'] > 0 ) {
						$context['internal']['forced_query_args']['posts_per_page'] = $query_vars['posts_per_page'];
						if ( ! isset( $products_shortcode_atts['paginate'] ) || $products_shortcode_atts['paginate'] === '0' || $products_shortcode_atts['paginate'] === 'false' ) {
							$context['internal']['forced_query_args']['nopaging'] = false;
						}
					}

					$this->adjustments_for_custom_products_shortcode();
				}
			}
		}

		list( $context, $query_vars ) = apply_filters( 'fibofilters/request/context_and_query_vars', [ $context, $query_vars ], $query );

		/**
		 * The context will be updated in the update_context method, and it can be filtered there.
		 *
		 * @see self::update_context()
		 */
		$this->context = $context;

		$this->main_query_vars = apply_filters( 'fibofilters/request/query_vars', $query_vars );
	}

	/**
	 * Delayed context update
	 *
	 * We use the woocommerce_get_loop_display_mode() function to check whether to display filters.
	 * However, calling this function in the set_context() method causes the WooCommerce loop to be set too early,
	 * as a result of which the woocommerce_products_will_display() function returns false,
	 * which causes undesirable effects, e.g. some themes do not display elements above the products.
	 */
	public function update_context(): void {
		if ( empty( $this->context ) ) {
			return;
		}

		$display_type = woocommerce_get_loop_display_mode();

		if ( isset( $this->context['conditions']['is_shop'] ) && $this->context['conditions']['is_shop'] ) {
			$this->context['show_filters'] = $display_type === 'products' || $display_type === 'both';
		}

		if (
			isset( $this->context['conditions']['is_product_taxonomy'] ) &&
			$this->context['conditions']['is_product_taxonomy'] &&
			isset( $this->context['details']['taxonomy'] ) &&
			$this->context['details']['taxonomy'] === 'product_cat'
		) {
			$this->context['show_filters'] = $display_type === 'products' || $display_type === 'both';
		}

		// We completely disable front-end interactions when filters are not ready.
		if ( empty( $this->filters_manager->get_instances() ) || ! $this->output['index_valid'] ) {
			$this->context['show_filters'] = false;
		}

		// Hide filters if the number of products is below the threshold.
		$filters_visibility_threshold = intval( apply_filters( 'fibofilters/request/filters_visibility_threshold', - 1 ) );
		if ( $this->output['base_products_ids'] && count( $this->output['base_products_ids'] ) < $filters_visibility_threshold ) {
			$this->context['show_filters'] = false;
		}

		$this->context['conditions']['has_active_filters'] = ! empty( $this->user_query );

		$this->context = apply_filters( 'fibofilters/request/context', $this->context );
	}

	public function get_base_product_ids_by_wp_query( $root_context = false ): array {
		global $wp_the_query, $wp_query;

		/**
		 * WARNING: If you want to modify args, do the same in the following method:
		 *
		 * @see \FiboFilters\Config\Troubleshooting::get_test_incorrect_base_product_ids
		 */
		$args = array_merge(
			$this->main_query_vars,
			[
				'paged'                            => 1,
				'posts_per_page'                   => - 1,
				'update_post_meta_cache'           => false,
				'update_post_term_cache'           => false,
				'cache_results'                    => true,
				'no_found_rows'                    => true,
				'nopaging'                         => true,
				'fields'                           => 'ids',
				'fibofilters_get_base_product_ids' => true,
			]
		);

		if ( ! empty( $this->context['internal']['forced_query_args'] ) ) {
			$args = array_merge( $args, $this->context['internal']['forced_query_args'] );
		}

		// If we are fetching base products for the root context, we want products without narrowing down by category or tag.
		if ( $root_context ) {
			unset( $args['product_cat'] );
			unset( $args['product_tag'] );
		}

		$args = apply_filters( 'fibofilters/request/base_product_ids/args', $args, $root_context );

		if ( apply_filters( 'fibofilters/request/base_product_ids/override_global_wp_query', false, $args, $root_context ) ) {
			/**
			 * We are temporarily overriding the global $wp_query, so that all actions or filters that
			 * are marked to run only for the main query will be run for our query.
			 */
			$current_wp_the_query = $wp_the_query;
			$current_wp_query     = $wp_query;

			// phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited
			$wp_query     = new WP_Query();
			$wp_the_query = $wp_query;
			$posts        = $wp_query->query( $args );

			$wp_query     = $current_wp_query;
			$wp_the_query = $current_wp_the_query;
			// phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited
		} else {
			$query = new WP_Query( $args );
			$posts = $query->posts;
		}

		if ( $root_context ) {
			return apply_filters( 'fibofilters/request/root_base_product_ids', $posts );
		}

		return apply_filters( 'fibofilters/request/base_product_ids', $posts );
	}

	/**
	 * Get single products
	 */
	public function get_single_products(): void {
		global $wp_query, $wp_the_query;

		if ( ! $this->is_get_products_request() ) {
			return;
		}

		/**
		 * We interrupt if after the "template_redirect" action it turns out that
		 * the request is of a different type than normal.
		 *
		 * See: wp-includes/template-loader.php
		 */
		if ( is_robots() || is_favicon() || is_feed() || is_trackback() ) {
			return;
		}

		/**
		 * If we are fetching products with the "page" strategy, we must force the display of products,
		 * because it may happen that categories instead of products are displayed on the shop's home page.
		 */
		if ( $this->front->get_config_value( 'products_fetching_strategy' ) === 'page' ) {
			add_filter( 'option_woocommerce_shop_page_display', fn() => 'product' );
		}

		if ( apply_filters( 'fibofilters/request/single_products/break_early', false ) ) {
			return;
		}

		$product_ids = $this->get_single_product_ids();

		$data = [
			'products' => [],
		];

		if ( empty( $product_ids ) ) {
			wp_send_json( $data );
		}

		$query_args = [
			'post_type'                       => 'product',
			'post_status'                     => 'publish',
			'ignore_sticky_posts'             => 1,
			'post__in'                        => $product_ids,
			'posts_per_page'                  => - 1,
			'fibofilters_get_single_products' => true,
		];

		$single_variations_support = apply_filters( 'fibofilters/config/single_variations_support', false );
		if ( $single_variations_support ) {
			add_action( 'pre_get_posts', [ $this, 'add_product_variation_post_type' ] );
		}

		$products_query = new WP_Query( $query_args );

		if ( $single_variations_support ) {
			remove_action( 'pre_get_posts', [ $this, 'add_product_variation_post_type' ] );
		}

		$wp_query = $products_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
		if ( apply_filters( 'fibofilters/request/single_products/override_wp_the_query', false ) ) {
			$wp_the_query = $products_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
		}

		do_action( 'fibofilters/request/single_products/before_loop' );

		/**
		 * Enforcing product visibility.
		 *
		 * This is necessary when the product has visibility set, e.g. "Search only" - without this filter,
		 * empty HTML will be returned for such a product.
		 */
		add_filter( 'woocommerce_product_is_visible', '__return_true' );

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( ! empty( $_REQUEST['queryItems'] ) && is_array( $_REQUEST['queryItems'] ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$this->variation_adjuster->adjust_singe_products_variation_images_and_urls_by_query_items( $_REQUEST['queryItems'], $this->front->get_config_value( 'filters_affecting_variation_images_and_urls', [] ) );
		}

		if ( $this->front->get_config_value( 'products_fetching_strategy' ) === 'page' ) {
			return;
		}

		if ( $products_query->have_posts() ) {
			while ( $products_query->have_posts() ) :
				$products_query->the_post();
				ob_start();
				wc_get_template_part( 'content', 'product' );
				// Trim tabs and new lines.
				$html                             = trim( str_replace( [ chr( 9 ), chr( 10 ), chr( 13 ) ], ' ', (string) ob_get_clean() ) );
				$html                             = preg_replace( '/ +/', ' ', $html );
				$data['products'][ get_the_ID() ] = $html;
			endwhile;
			$products_query->reset_postdata();
		}

		do_action( 'fibofilters/request/single_products/after_loop' );
		$data = apply_filters( 'fibofilters/request/single_products/output', $data );

		wp_send_json( $data );
	}

	/**
	 * Get single product IDs
	 */
	public function get_single_product_ids(): array {
		$ids = [];

		if ( ! $this->is_get_products_request() ) {
			return $ids;
		}

		$ids = ! empty( $_REQUEST['ids'] ) ? $_REQUEST['ids'] : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

		// This is needed for debugging purposes when the request is made with GET method.
		if ( is_string( $ids ) && $_SERVER['REQUEST_METHOD'] === 'GET' ) {
			$ids = explode( ',', $ids );
		}

		if ( ! empty( $ids ) ) {
			$ids = array_map( 'absint', $ids );
		}

		return array_values(
			array_filter(
				$ids,
				function ( $id ) {
					return $id > 0;
				}
			)
		);
	}

	public function before_single_products_loop(): void {
		// Some themes need to have an initialized loop (e.g. Porto).
		if ( apply_filters( 'fibofilters/request/single_products/start_loop', false ) ) {
			ob_start();
			wc_set_loop_prop( 'loop', '0' );
			wc_get_template( 'loop/loop-start.php' );
			ob_clean();
		}
	}

	public function can_show_filters(): bool {
		return isset( $this->context['show_filters'] ) && $this->context['show_filters'];
	}

	private function is_get_products_request(): bool {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		return isset( $_REQUEST['fibofilters-products'] );
	}

	/**
	 * Customizes the initial product ordering for base products and main queries.
	 *
	 * This function applies these types of sorting:
	 *  1. Sorts products by stock status, showing "in stock" products first.
	 *  2. Sorts products by "featured" status, showing featured products before others.
	 *  3. Sorts products by price, ensuring that products without a price are shown last when sorting by price ascending.
	 *
	 * @param array    $args An array of SQL clauses for the query.
	 * @param WP_Query $wp_query WordPress WP_Query object.
	 *
	 * @return array
	 */
	public function custom_initial_order_by( array $args, WP_Query $wp_query ): array {
		global $wpdb;

		$custom_orderby = [];
		$orderby        = $wp_query->get( 'orderby' );
		$order          = strtolower( (string) $wp_query->get( 'order' ) );

		// Return early if we are in context where filters are not shown.
		if ( empty( $this->context['show_filters'] ) ) {
			return $args;
		}

		// Return early if this is not a base product query, or it's not main query.
		if ( empty( $wp_query->query['fibofilters_get_base_product_ids'] ) && ! $wp_query->is_main_query() ) {
			return $args;
		}
		// Backwards compatibility.
		$instock_first = apply_filters( 'fibofilters/request/base_product_ids/order_by_stock_status', false );

		// Add sorting for "in stock" products if enabled.
		if ( $instock_first || apply_filters( 'fibofilters/request/product_order/in_stock_first', false ) ) {
			$apply_in_stock_first_orderby = $this->check_if_apply_additional_orderby( 'in_stock_first', $orderby, $order );

			if ( $apply_in_stock_first_orderby ) {
				if ( strpos( $args['join'], 'wc_product_meta_lookup' ) === false ) {
					$args['join'] .= " LEFT JOIN $wpdb->wc_product_meta_lookup wc_product_meta_lookup
                    ON $wpdb->posts.ID = wc_product_meta_lookup.product_id ";
				}

				$custom_orderby[] = $this->wc_helper->create_sql_case_expression_for_stock_status( 'wc_product_meta_lookup.stock_status' ) . ' DESC';
			}
		}

		// Add sorting for "featured" products if enabled.
		if ( apply_filters( 'fibofilters/request/product_order/featured_first', false ) ) {
			$apply_featured_first_orderby = $this->check_if_apply_additional_orderby( 'featured_first', $orderby, $order );

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

				if ( $featured_term ) {
					$args['join']    .= " LEFT JOIN {$wpdb->term_relationships} AS fibofilters_featured
                    ON $wpdb->posts.ID = fibofilters_featured.object_id
                    AND fibofilters_featured.term_taxonomy_id = $featured_term ";
					$custom_orderby[] = 'CASE WHEN fibofilters_featured.term_taxonomy_id IS NOT NULL THEN 1 ELSE 0 END DESC';
				}
			}
		}

		// Add sorting for products without price last when sorting by price ascending.
		if ( $orderby === 'price' && $order === 'asc' ) {
			if ( strpos( $args['join'], 'wc_product_meta_lookup' ) === false ) {
				$args['join'] .= " LEFT JOIN $wpdb->wc_product_meta_lookup wc_product_meta_lookup
                ON $wpdb->posts.ID = wc_product_meta_lookup.product_id ";
			}

			$custom_orderby[] = 'CASE WHEN wc_product_meta_lookup.min_price IS NULL OR wc_product_meta_lookup.min_price <= 0 THEN 1 ELSE 0 END ASC';
		}

		// Add sorting for products by ratings_count.
		if ( $orderby === 'ratings_count' ) {
			if ( strpos( $args['join'], 'AS wc_lookup' ) === false ) {
				$args['join'] .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} AS wc_lookup
		    ON {$wpdb->posts}.ID = wc_lookup.product_id";
			}

			$custom_orderby[] = 'wc_lookup.rating_count DESC';
			$custom_orderby[] = "{$wpdb->posts}.ID DESC";
		}

		/**
		 * We use the sorting value from the URL because we need its unchanged form,
		 * which may or may not include the “-asc” or “-desc” order, allowing us to precisely match the custom sorting.
		 *
		 * If default ordering is used, we do not apply any custom sorting here,
		 * so using $_REQUEST['orderby'] is safe in this context.
		 *
		 * ClickUp: 86c45dav1
		 */
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( isset( $_REQUEST['orderby'] ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$url_orderby_value = wc_clean( wp_unslash( $_REQUEST['orderby'] ) );
			if ( is_array( $url_orderby_value ) ) {
				$url_orderby_value = $url_orderby_value[0];
			}

			if ( ! empty( $url_orderby_value ) ) {
				list( $custom_join, $custom_orderby_query ) = $this->sorting->get_custom_orderby_query( $url_orderby_value );
				if ( ! empty( $custom_join ) ) {
					$args['join'] .= ' ' . $custom_join;
				}
				if ( ! empty( $custom_orderby_query ) ) {
					$custom_orderby[] = $custom_orderby_query;
				}
			}
		}

		// Add custom orderby clauses to the final orderby statement.
		if ( ! empty( $custom_orderby ) ) {
			$args['orderby'] = implode( ', ', $custom_orderby ) . ', ' . $args['orderby'];
		}

		return $args;
	}

	public function add_product_variation_post_type( $query ) {
		if ( $query->get( 'fibofilters_get_single_products' ) === true ) {
			$query->set( 'post_type', [ 'product', 'product_variation' ] );
		}
	}

	/**
	 * Get [products] shortcode attributes from content (only if it has fibofilters=1)
	 *
	 * @param string $content Post content.
	 */
	private function get_products_shortcode_atts( string $content ): array {
		$shortcodes = $this->wp_helper->get_shortcodes_from_content( $content );
		foreach ( $shortcodes as $shortcode ) {
			if ( $shortcode['tag'] === 'products' && isset( $shortcode['atts']['fibofilters'] ) ) {
				return $shortcode['atts'];
			}
		}

		return [];
	}

	/**
	 * Adjustments when a custom shortcode with products is used
	 */
	private function adjustments_for_custom_products_shortcode(): void {
		add_filter(
			'shortcode_atts_products',
			function ( $out, $pairs, $atts ) {
				if ( isset( $atts['fibofilters'] ) ) {
					$out['class'] = 'fibofilters-shortcode-products';
				}

				return $out;
			},
			10,
			3
		);

		add_filter(
			'fibofilters/config/products_loop_selectors',
			function () {
				return [ '.fibofilters-shortcode-products > .products' ];
			},
			PHP_INT_MAX - 10
		);
	}

	/**
	 * Maybe redirect to a URL with valid filter values.
	 *
	 * @param string $language Language.
	 */
	private function maybe_redirect_to_valid_url( string $language = '' ): void {
		global $wp;

		// Prevent running this method multiple times. If for first time we don't need to redirect, we don't need to check again.
		if ( $this->maybe_redirect_to_valid_url_done ) {
			return;
		}

		$patched_get_vars = $this->filters_manager->get_patched_get_vars( $this->get_vars, $this->context, $language );

		if ( ! empty( $patched_get_vars ) ) {
			$new_get_vars = array_merge( $this->get_vars, $patched_get_vars );

			// Remove empty keys - this means that all values was invalid.
			foreach ( $patched_get_vars as $key => $value ) {
				if ( empty( $value ) ) {
					unset( $new_get_vars[ $key ] );
				}
			}

			$current_url = home_url( add_query_arg( $new_get_vars, $wp->request ) );
			wp_safe_redirect( $current_url );
			exit;
		}

		$this->maybe_redirect_to_valid_url_done = true;
	}

	/**
	 * Parse JSON data from the POST request
	 */
	private function parse_json() {
		if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || $_SERVER['REQUEST_METHOD'] !== 'POST' ) {
			return;
		}
		$json = (string) file_get_contents( 'php://input' );
		if ( strpos( $json, '{' ) === 0 ) {
			$post_data = json_decode( $json, true );
			if ( isset( $post_data['fibofilters-products'] ) ) {
				$_POST = $post_data;
			}
		}
	}

	private function check_if_apply_additional_orderby( $type = '', $query_orderby = '', $query_order = '' ) {
		$apply_additional_orderby = true;
		$exceptions               = [];

		if ( $type === 'featured_first' ) {
			$exceptions = apply_filters( 'fibofilters/request/product_order/featured_first_exceptions', [] );
		} elseif ( $type === 'in_stock_first' ) {
			$exceptions = apply_filters( 'fibofilters/request/product_order/in_stock_first_exceptions', [] );
		}

		if ( empty( $exceptions ) ) {
			return $apply_additional_orderby;
		}
		/**
		 * It's too early to get default orderby with $wc_helper, so we need to check the current query orderby.
		 * Thanks to this, we don't need to check $_GET['orderby'].
		 */
		switch ( $query_orderby ) {
			case 'menu_order title':
				$apply_additional_orderby = ! in_array( 'menu_order', $exceptions, true );
				break;
			case 'popularity':
			case 'rating':
				$apply_additional_orderby = ! in_array( $query_orderby, $exceptions, true );
				break;
			case 'date ID':
				$apply_additional_orderby = ! in_array( 'date', $exceptions, true );
				break;
			case 'price':
				if ( strtolower( $query_order ) === 'asc' ) {
					$apply_additional_orderby = ! in_array( 'price', $exceptions, true );
				} elseif ( strtolower( $query_order ) === 'desc' ) {
					$apply_additional_orderby = ! in_array( 'price-desc', $exceptions, true );
				}
				break;
			case 'modified':
				if ( strtolower( $query_order ) === 'desc' ) {
					$apply_additional_orderby = ! in_array( 'modified-desc', $exceptions, true );
				}
				break;
			case 'title':
				if ( strtolower( $query_order ) === 'asc' ) {
					$apply_additional_orderby = ! in_array( 'title', $exceptions, true );
				} elseif ( strtolower( $query_order ) === 'desc' ) {
					$apply_additional_orderby = ! in_array( 'title-desc', $exceptions, true );
				}
				break;
		}

		if ( $apply_additional_orderby ) {
			/**
			 * We use the sorting value from the URL because we need its unchanged form,
			 * which may or may not include the “-asc” or “-desc” order, allowing us to precisely match the custom sorting.
			 *
			 * If default ordering is used, we don't need check applying additional orderby here,
			 * so using $_REQUEST['orderby'] is safe in this context.
			 *
			 * ClickUp: 86c45dav1
			 */
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended
			if ( isset( $_REQUEST['orderby'] ) ) {
				// phpcs:ignore WordPress.Security.NonceVerification.Recommended
				$url_orderby_value        = wc_clean( wp_unslash( $_REQUEST['orderby'] ) );
				$apply_additional_orderby = ! in_array( $url_orderby_value, $exceptions, true );
			}
		}

		return $apply_additional_orderby;
	}
}
