<?php
namespace Bricks;

if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly

class Query {
	// The query unique ID
	private $id = '';

	// Element ID
	public $element_id = '';

	// Element settings
	public $settings = [];

	// Query vars
	public $query_vars = [];

	// Type of object queried: 'post', 'term', 'user'
	public $object_type = 'post';

	// Query result (WP_Posts | WP_Term_Query | WP_User_Query | Other)
	public $query_result;

	// Query results total
	public $count = 0;

	// Query results total pages
	public $max_num_pages = 1;

	// Is looping
	public $is_looping = false;

	// When looping, keep the iteration index
	public $loop_index = 0;

	// When looping, keep the object
	public $loop_object = null;

	// Store the original post before looping to restore the context (nested loops)
	private $original_post_id = 0;

	// Cache key
	private $cache_key = false;

	// Store query history (including those destroyed)
	public static $query_history = [];

	/**
	 * Class constructor
	 *
	 * @param array $element
	 */
	public function __construct( $element = [] ) {
		$this->register_query();

		$this->element_id = ! empty( $element['id'] ) ? $element['id'] : '';

		// Check for stored query in query history (@since 1.9.1)
		$query_instance = self::get_query_by_element_id( $this->element_id );

		if ( $query_instance ) {
			// Assign the history query instance properties to this instance, avoid running the query again
			foreach ( $query_instance as $key => $value ) {
				if ( $key === 'id' ) {
					continue;
				}
				$this->$key = $value;
			}
		} else {
			$this->object_type = ! empty( $element['settings']['query']['objectType'] ) ? $element['settings']['query']['objectType'] : 'post';

			// Remove object type from query vars to avoid future conflicts
			unset( $element['settings']['query']['objectType'] );

			$this->settings = ! empty( $element['settings'] ) ? $element['settings'] : [];

			// STEP: Set the query vars from the element settings (@since 1.8)
			$this->query_vars = self::prepare_query_vars_from_settings( $this->settings );

			// STEP: Perform the query, set the query result, count and max_num_pages (@since 1.8)
			$this->run();

			/**
			 * Filter: Force query run (to skip add_to_history() method below)
			 *
			 * AJAX filter plugins, etc. might want to use this.
			 *
			 * @since 1.9.2: Set $query_vars['bricks_force_run'] = true to force run query rerun (i.e. inside Query Editor or custom code snippet)
			 *
			 * @see https://academy.bricksbuilder.io/article/filter-bricks-query-force_run/
			 *
			 * @since 1.9.1.1
			 */
			$force_run = apply_filters( 'bricks/query/force_run', false, $this ) || ( isset( $this->query_vars['bricks_force_run'] ) && $this->query_vars['bricks_force_run'] );

			/**
			 * STEP: Add query instance to query history (Query::$query_history) to access & reuse query instance later
			 *
			 * Only for WP core query types (post, term, user) as other potentially nested query types (e.g. ACF, Meta Box, Woo cart content, etc.) don't have a unique ID.
			 *
			 * @since 1.9.1
			 */
			if ( in_array( $this->object_type, [ 'post', 'term', 'user' ] ) && ! $force_run ) {
				$this->add_to_history();
			}
		}
	}

	/**
	 * Get query instance by element ID from the query history
	 *
	 * @since 1.9.1
	 */
	public static function get_query_by_element_id( $element_id = '', $is_dynamic_data = false ) {
		if ( empty( $element_id ) ) {
			return false;
		}

		$query           = false;
		$history_queries = self::$query_history;

		// Check if any query history element_id matches the given element_id
		if ( ! empty( $history_queries ) ) {
			$query_history_id = self::generate_query_history_id( $element_id );

			if ( isset( $history_queries[ $query_history_id ] ) ) {
				$query = $history_queries[ $query_history_id ];
			}

			// If using in dynamic data, and no query history found, maybe user wants to get query history based on $element_id
			if ( ! $query && $is_dynamic_data && self::is_looping() ) {
				if ( isset( $history_queries[ $element_id ] ) ) {
					$query = $history_queries[ $element_id ];
				}
			}
		}

		return $query;
	}

	/**
	 * Add current query instance to query history
	 *
	 * @since 1.9.1
	 */
	public function add_to_history() {
		$identifier = self::generate_query_history_id( $this->element_id );

		if ( $identifier ) {
			self::$query_history[ $identifier ] = $this;
		}
	}

	/**
	 * Generate a unique identifier for the query history
	 *
	 * Use combination of element_id, nested_query_object_type, nested_query_element_id, nested_loop_object_id.
	 *
	 * @since 1.9.1
	 */
	public static function generate_query_history_id( $element_id ) {
		$unique_id        = [];
		$looping_query_id = self::is_any_looping();

		if ( $looping_query_id && $looping_query_id !== $element_id ) {
			$unique_id[] = self::get_query_element_id( $looping_query_id );
			$unique_id[] = $element_id;
			$unique_id[] = self::get_query_object_type( $looping_query_id );

			// Get loop ID
			$loop_id = self::get_loop_object_id( $looping_query_id );
			if ( $loop_id ) {
				$unique_id[] = $loop_id;
			}

			// Return: No loop ID found
			else {
				return;
			}
		} else {
			$unique_id[] = $element_id;
		}

		return implode( '_', $unique_id );
	}

	/**
	 * Add query to global store
	 */
	public function register_query() {
		global $bricks_loop_query;
		$this->id = Helpers::generate_random_id( false );

		if ( ! is_array( $bricks_loop_query ) ) {
			$bricks_loop_query = [];
		}

		$bricks_loop_query[ $this->id ] = $this;
	}

	/**
	 * Calling unset( $query ) does not destroy query quickly enough
	 *
	 * Have to call the 'destroy' method explicitly before unset.
	 */
	public function __destruct() {
		$this->destroy();
	}

	/**
	 * Use the destroy method to remove the query from the global store
	 *
	 * @return void
	 */
	public function destroy() {
		global $bricks_loop_query;

		unset( $bricks_loop_query[ $this->id ] );
	}

	/**
	 * Get the query cache
	 *
	 * @since 1.5
	 *
	 * @return mixed
	 */
	public function get_query_cache() {
		if ( ! isset( Database::$global_settings['cacheQueryLoops'] ) || ! bricks_is_frontend() || bricks_is_builder_call() ) {
			return false;
		}

		// Check: Nesting query?
		$parent_query_id  = self::is_any_looping();
		$parent_object_id = $parent_query_id ? self::get_loop_object_id( $parent_query_id ) : 0;

		// Include in the cache key a representation of the query vars to break cache for certain scenarios like pagination or search keywords
		$query_vars = wp_json_encode( $this->query_vars );

		// Get & set query loop cache (@since 1.5)
		$this->cache_key = md5( "brx_query_{$this->element_id}_{$query_vars}_{$parent_object_id}" );

		return wp_cache_get( $this->cache_key, 'bricks' );
	}

	/**
	 * Set the query cache
	 *
	 * @since 1.5
	 *
	 * @return void
	 */
	public function set_query_cache( $object ) {
		if ( ! $this->cache_key ) {
			return;
		}

		wp_cache_set( $this->cache_key, $object, 'bricks', MINUTE_IN_SECONDS );
	}

	/**
	 * Prepare query_vars for the Query before running it
	 * Remove unwanted keys, set defaults, populate correct query vars, etc.
	 * Static method to be used by other classes. (Bricks\Database)
	 *
	 * @since 1.8
	 */
	public static function prepare_query_vars_from_settings( $settings = [], $fallback_element_id = '' ) {
		$query_vars = $settings['query'] ?? [];

		// Some elements already built the query vars. (carousel, related-posts) (@since 1.9.3)
		if ( isset( $query_vars['bricks_skip_query_vars'] ) ) {
			return $query_vars;
		}

		// Unset infinite scroll
		if ( isset( $query_vars['infinite_scroll'] ) ) {
			unset( $query_vars['infinite_scroll'] );
		}

		// Unset isLiveSearch (@since 1.9.6)
		if ( isset( $query_vars['is_live_search'] ) ) {
			unset( $query_vars['is_live_search'] );
		}

		// Do not use meta_key if orderby is not set to meta_value or meta_value_num (@since 1.8)
		if ( isset( $query_vars['meta_key'] ) ) {
			$orderby = isset( $query_vars['orderby'] ) ? $query_vars['orderby'] : '';
			if ( ! in_array( $orderby, [ 'meta_value', 'meta_value_num' ] ) ) {
				unset( $query_vars['meta_key'] );
			}
		}

		$object_type = self::get_query_object_type();
		$element_id  = self::get_query_element_id();

		/**
		 * $object_type and $element_id are empty when this method is called in pre_get_post (main query)
		 * Reason: We just call prepare_query_vars_from_settings() without initializing the Query class
		 * Impact: Some query_vars will be missing because not going through the switch statement and Bricks PHP filters not fired
		 *
		 * @since 1.9.1
		 */
		if ( empty( $object_type ) ) {
			$object_type = $settings['query']['objectType'] ?? 'post';
		}

		if ( empty( $element_id ) && ! empty( $fallback_element_id ) ) {
			$element_id = $fallback_element_id;
		}

		/**
		 * Use PHP editor
		 *
		 * Returns PHP array with query arguments
		 *
		 * Supported if 'objectType' is 'post', 'term' or 'user'.
		 * No merge query.
		 *
		 * @since 1.9.1
		 */
		if ( isset( $query_vars['useQueryEditor'] ) && ! empty( $query_vars['queryEditor'] ) && in_array( $object_type, [ 'post','term','user' ] ) ) {
			// Return: Code execution not enabled (Bricks setting or filter)
			if ( ! Helpers::code_execution_enabled() ) {
				return [];
			}

			$post_id = Database::$page_data['preview_or_post_id'];

			// Sanitize element code (queryEditor)
			$signature                    = $query_vars['signature'] ?? false;
			$php_query_raw                = $query_vars['queryEditor'];
			$php_query_raw                = Helpers::sanitize_element_php_code( $post_id, $element_id, $php_query_raw, $signature );
			$php_query_raw                = is_string( $php_query_raw ) && ! isset( $php_query_raw['error'] ) ? bricks_render_dynamic_data( $php_query_raw, $post_id ) : '';
			$query_vars['posts_per_page'] = get_option( 'posts_per_page' );

			// Define an anonymous function that simulates the scope for user code
			$execute_user_code = function () use ( $php_query_raw ) {
				// Initialize a variable to capture the result of user code
				$user_result = null;

				// Capture user code output using output buffering
				ob_start();

				// Execute the user code
				$user_result = eval( $php_query_raw );

				// Get the captured output
				ob_get_clean();

				// Return the user code result
				return $user_result;
			};

			ob_start();

			// Prepare & set error reporting
			$error_reporting = error_reporting( E_ALL );
			$display_errors  = ini_get( 'display_errors' );
			ini_set( 'display_errors', 1 );

			try {
				$php_query = $execute_user_code();
			} catch ( \Exception $error ) {
				echo 'Exception: ' . $error->getMessage();
				return;
			} catch ( \ParseError $error ) {
				echo 'ParseError: ' . $error->getMessage();
				return;
			} catch ( \Error $error ) {
				echo 'Error: ' . $error->getMessage();
				return;
			}

			// Reset error reporting
			ini_set( 'display_errors', $display_errors );
			error_reporting( $error_reporting );

			// @see https://www.php.net/manual/en/function.eval.php
			if ( version_compare( PHP_VERSION, '7', '<' ) && $php_query === false || ! empty( $error ) ) {
				// $php_query = $error;
				ob_end_clean();
			} else {
				ob_get_clean();
			}

			$object_type = empty( $object_type ) ? 'post' : $object_type;

			if ( ! empty( $php_query ) && is_array( $php_query ) ) {
				$query_vars          = array_merge( $query_vars, $php_query );
				$query_vars['paged'] = self::get_paged_query_var( $query_vars );

				if ( $object_type === 'term' ) {
					// Handle term pagination (#86bwwav1e)
					$query_vars = self::get_term_pagination_query_var( $query_vars );
				}
			}

			/**
			 * php Editor not triggering query_vars, new query filters unable to merge query_vars (@since 1.9.6)
			 */
			$query_vars = apply_filters( "bricks/{$object_type}s/query_vars", $query_vars, $settings, $element_id );

			return $query_vars;
		}

		// Meta Query vars
		$query_vars = self::parse_meta_query_vars( $query_vars );

		// Set different query vars depending on the object type
		switch ( $object_type ) {
			case 'post':
				// Attachments
				$query_attachments      = false;
				$query_only_attachments = false;

				// post_type can be 'string' or 'array'
				$post_type = ! empty( $query_vars['post_type'] ) ? $query_vars['post_type'] : false;

				if ( $post_type ) {
					if ( is_array( $post_type ) ) {
						$query_attachments = in_array( 'attachment', $post_type );

						if ( $query_attachments && count( $post_type ) === 1 ) {
							$query_only_attachments = true;
						}
					} else {
						$query_attachments      = $post_type === 'attachment';
						$query_only_attachments = $post_type === 'attachment';
					}
				}

				$query_vars['post_status'] = 'publish';

				/**
				 * Post type 'attachment' included: Add post status 'inherit'
				 *
				 * @see: https://developer.wordpress.org/reference/classes/wp_query/#post-type-parameters
				 */
				if ( $query_attachments ) {
					$query_vars['post_status'] = [ 'inherit', 'publish' ];
				}

				// Query ONLY attachments: Set 'post_mime_type' query var
				if ( $query_only_attachments ) {
					$mime_types = isset( $query_vars['post_mime_type'] ) ? bricks_render_dynamic_data( $query_vars['post_mime_type'] ) : 'image';

					$mime_types = explode( ',', $mime_types );

					$query_vars['post_mime_type'] = $mime_types;
				}

				// Page & Pagination
				// @since 1.7.1 - Standardize use the get_paged_query_var() function to get the paged value
				$query_vars['paged'] = self::get_paged_query_var( $query_vars );

				// Value must be -1 or > 1 (0 is not allowed)
				$query_vars['posts_per_page'] = ! empty( $query_vars['posts_per_page'] ) ? intval( $query_vars['posts_per_page'] ) : get_option( 'posts_per_page' );

				// Exclude current post
				if ( isset( $query_vars['exclude_current_post'] ) ) {
					// @since 1.8 - Capture exclude_current_post value inside builder call
					if ( is_single() || is_page() || bricks_is_builder_call() ) {
						// Current post not working with populate content in builder mode (@since 1.9.5)
						$post_id                      = ! self::is_any_looping() && isset( Database::$page_data['preview_or_post_id'] ) ? Database::$page_data['preview_or_post_id'] : get_the_ID();
						$query_vars['post__not_in'][] = $post_id;
					}

					unset( $query_vars['exclude_current_post'] );
				}

				// @since 1.5 - Post parent
				if ( isset( $query_vars['post_parent'] ) ) {
					$post_parent = bricks_render_dynamic_data( $query_vars['post_parent'] );

					if ( strpos( $post_parent, ',' ) !== false ) {
						$post_parent = explode( ',', $post_parent );

						// @since 1.7.1
						$query_vars['post_parent__in'] = (array) $post_parent;

						unset( $query_vars['post_parent'] );
					} else {
						$query_vars['post_parent'] = (int) $post_parent;
					}
				}

				// Tax query
				$query_vars = self::set_tax_query_vars( $query_vars );

				// @see: https://academy.bricksbuilder.io/article/filter-bricks-posts-merge_query/
				$merge_query = apply_filters( 'bricks/posts/merge_query', true, $element_id );

				/**
				 * Merge wp_query vars and posts element query vars
				 *
				 * @since 1.7: Merge query only if 'disable_query_merge' control is not set!
				 * @since 1.9.9: Merge query only if 'woo_disable_query_merge' control is not set! (Products element)
				 */
				if ( $merge_query &&
					( is_archive() || is_author() || is_search() || is_home() ) &&
					empty( $query_vars['disable_query_merge'] ) &&
					empty( $query_vars['woo_disable_query_merge'] )
				) {
					global $wp_query;

					$query_vars = wp_parse_args( $query_vars, $wp_query->query );
				}

				/**
				 * REST API /load_query_page adds "_merge_vars" to the query to make sure the archive context is maintained on infinite scroll
				 * Not in use (@since 1.9.5)
				 *
				 * @since 1.5.1
				 */
				if ( ! empty( $query_vars['_merge_vars'] ) ) {
					$merge_query_vars = $query_vars['_merge_vars'];

					unset( $query_vars['_merge_vars'] );

					$query_vars = wp_parse_args( $query_vars, $merge_query_vars );
				}

				// @see: https://academy.bricksbuilder.io/article/filter-bricks-posts-query_vars/
				// @since 1.3.6 Added $element_id
				$query_vars = apply_filters( 'bricks/posts/query_vars', $query_vars, $settings, $element_id );
				break;

			case 'term':
				// Number. Default is "0" (all) but as a safety procedure we limit the number
				$query_vars['number'] = isset( $query_vars['number'] ) ? $query_vars['number'] : get_option( 'posts_per_page' );

				// Paged - set the paged key to the correct value (#86bwqwa31)
				$query_vars['paged'] = self::get_paged_query_var( $query_vars );

				// Handle term pagination (#86bwwav1e)
				$query_vars = self::get_term_pagination_query_var( $query_vars );

				// Hide empty
				if ( isset( $query_vars['show_empty'] ) ) {
					$query_vars['hide_empty'] = false;

					unset( $query_vars['show_empty'] );
				}

				// Current Post Term - (@since 1.8.4)
				if ( isset( $query_vars['current_post_term'] ) ) {
					// Current post term not working with populate content in builder mode (@since 1.9.5)
					$post_id                  = ! self::is_any_looping() && isset( Database::$page_data['preview_or_post_id'] ) ? Database::$page_data['preview_or_post_id'] : get_the_ID();
					$query_vars['object_ids'] = $post_id;

					unset( $query_vars['current_post_term'] );
				}

				if ( isset( $query_vars['child_of'] ) ) {
					$query_vars['child_of'] = bricks_render_dynamic_data( $query_vars['child_of'] );
				}

				if ( isset( $query_vars['parent'] ) ) {
					$query_vars['parent'] = bricks_render_dynamic_data( $query_vars['parent'] );
				}

				// Include & Exclude terms
				if ( isset( $query_vars['tax_query'] ) ) {
					$query_vars['include'] = self::convert_terms_to_ids( $query_vars['tax_query'] );

					unset( $query_vars['tax_query'] );
				}

				if ( isset( $query_vars['tax_query_not'] ) ) {
					$query_vars['exclude'] = self::convert_terms_to_ids( $query_vars['tax_query_not'] );

					unset( $query_vars['tax_query_not'] );
				}

				// @see: https://academy.bricksbuilder.io/article/filter-bricks-terms-query_vars/
				$query_vars = apply_filters( 'bricks/terms/query_vars', $query_vars, $settings, $element_id );
				break;

			case 'user':
				// Unset post_type
				if ( isset( $query_vars['post_type'] ) ) {
					unset( $query_vars['post_type'] );
				}

				// Current Post Author - (@since 1.9.1)
				if ( isset( $query_vars['current_post_author'] ) ) {
					$current_post = get_post(); // Get the current post object
					// Check if the current post has an author
					if ( is_a( $current_post, 'WP_Post' ) && ! empty( $current_post->post_author ) ) {
						$query_vars['include'] = $current_post->post_author;
					}

					unset( $query_vars['current_post_author'] );
				}

				// Paged
				$query_vars['paged'] = self::get_paged_query_var( $query_vars );

				// Pagination (number, offset, paged). Default is "-1" but as a safety procedure we limit the number (0 is not allowed)
				$query_vars['number'] = ! empty( $query_vars['number'] ) ? $query_vars['number'] : get_option( 'posts_per_page' );

				// Pagination: Fix the offset value (@since 1.5)
				$offset = ! empty( $query_vars['offset'] ) ? $query_vars['offset'] : 0;

				// Store the original offset value (@since 1.9.1)
				$query_vars['original_offset'] = $offset;

				if ( ! empty( $offset ) && $query_vars['paged'] !== 1 ) {
					$query_vars['offset'] = ( $query_vars['paged'] - 1 ) * $query_vars['number'] + $offset;
				}

				// @see: https://academy.bricksbuilder.io/article/filter-bricks-users-query_vars/
				$query_vars = apply_filters( 'bricks/users/query_vars', $query_vars, $settings, $element_id );
				break;
		}

		return $query_vars;
	}

	/**
	 * Perform the query (maybe cache)
	 *
	 * Set $this->query_result, $this->count, $this->max_num_pages
	 *
	 * @return void (@since 1.8)
	 */
	public function run() {
		$count         = $this->count;
		$max_num_pages = $this->max_num_pages;
		$query_vars    = $this->query_vars;

		/**
		 * NOTE: Query for live_search should not run on page load
		 *
		 * However, this will cause many issues.
		 * - Elements not showing on the initial page load and their JS will not be enqueue. Subsequent AJAX search unable to initialize the JS
		 * - Templates are not populated with content on initial page load, especially popup templates. Subsequent AJAX search unable trigger the popup
		 *
		 * Current solution: Run the query on initial page load, remove them in render() method if live_search is enabled
		 *
		 * @since 1.9.6
		 */
		switch ( $this->object_type ) {
			case 'post':
				$result = $this->run_wp_query();

				// STEP: Populate the total count
				$count = empty( $query_vars['no_found_rows'] ) ? $result->found_posts : ( is_array( $result->posts ) ? count( $result->posts ) : 0 );

				$max_num_pages = empty( $query_vars['posts_per_page'] ) ? 1 : ceil( $count / $query_vars['posts_per_page'] );
				break;

			case 'term':
				$term_result = $this->run_wp_term_query();
				$result      = $term_result['terms'];
				$count       = $term_result['total'];

				// STEP: Get the original offset value (@since 1.9.1)
				$original_offset = ! empty( $query_vars['original_offset'] ) ? $query_vars['original_offset'] : 0;

				// STEP: Populate the total count
				if ( ! empty( $query_vars['number'] ) ) {
					// Subtract the $original_offset to fix pagination (@since 1.9.1)
					$count = $count > 0 ? $count - $original_offset : 0;
				}

				// STEP : Populate the max number of pages
				$max_num_pages = empty( $query_vars['number'] ) || count( $result ) < 1 ? 1 : ceil( $count / $query_vars['number'] );
				break;

			case 'user':
				$users_query = $this->run_wp_user_query();

				// STEP: The query result
				$result = $users_query->get_results();

				// STEP: Populate the total count of the users in this query
				$count = $users_query->get_total();

				// STEP: Get the original offset value (@since 1.9.1)
				$original_offset = ! empty( $query_vars['original_offset'] ) ? $query_vars['original_offset'] : 0;

				// STEP: Subtract the $original_offset to fix pagination (@since 1.9.1)
				$count = $count > 0 ? $count - $original_offset : 0;

				// STEP : Populate the max number of pages
				$max_num_pages = empty( $query_vars['number'] ) || count( $result ) < 1 ? 1 : ceil( $count / $query_vars['number'] );
				break;

			default:
				// Allow other query providers to return a query result (Woo Cart, ACF, Metabox...)
				$result = apply_filters( 'bricks/query/run', [], $this );

				$count = ! empty( $result ) && is_array( $result ) ? count( $result ) : 0;
				break;
		}

		/**
		 * Set the query result, count and max_num_pages in a centralized way
		 * Previously this was done in run_wp_query(), run_wp_term_query() and run_wp_user_query()
		 * Filters provided
		 *
		 * @see https://academy.bricksbuilder.io/article/filter-bricks-query-result/
		 * @see https://academy.bricksbuilder.io/article/filter-bricks-query-result_count/
		 * @see https://academy.bricksbuilder.io/article/filter-bricks-query-result_max_num_pages/ (@since 1.9.1)
		 *
		 * @since 1.8
		 */
		$this->query_result = apply_filters( 'bricks/query/result', $result, $this );
		$this->count        = apply_filters( 'bricks/query/result_count', $count, $this );

		// Pagination element relies on this value (@since 1.9.1)
		$this->max_num_pages = apply_filters( 'bricks/query/result_max_num_pages', $max_num_pages, $this );
	}

	/**
	 * Run WP_Term_Query
	 *
	 * @see https://developer.wordpress.org/reference/classes/wp_term_query/
	 *
	 * @return array Terms (WP_Term)
	 */
	public function run_wp_term_query() {
		// Cache?
		$result = $this->get_query_cache();

		if ( $result === false ) {
			$terms_query = new \WP_Term_Query( $this->query_vars );

			// Run another query to get the total count, set number to 0 to avoid limit
			$total_terms_query = new \WP_Term_Query( array_merge( $this->query_vars, [ 'number' => 0 ] ) );

			$result = [
				'terms' => $terms_query->get_terms(),
				'total' => count( $total_terms_query->get_terms() ),
			];

			$this->set_query_cache( $result );
		}

		return $result;
	}

	/**
	 * Run WP_User_Query
	 *
	 * @see https://developer.wordpress.org/reference/classes/wp_user_query/
	 *
	 * @return WP_User_Query (@since 1.8)
	 */
	public function run_wp_user_query() {
		// Cache?
		$users_query = $this->get_query_cache();

		if ( $users_query === false ) {
			$users_query = new \WP_User_Query( $this->query_vars );

			$this->set_query_cache( $users_query );
		}

		return $users_query;
	}

	/**
	 * Run WP_Query
	 *
	 * @return object
	 */
	public function run_wp_query() {
		// Cache?
		$posts_query = $this->get_query_cache();

		if ( $posts_query === false ) {
			add_action( 'pre_get_posts', [ $this, 'set_pagination_with_offset' ], 5 );
			add_filter( 'found_posts', [ $this, 'fix_found_posts_with_offset' ], 5, 2 );

			$use_random_seed = self::use_random_seed( $this->query_vars );

			// @since 1.7.1 - Avoid duplicate posts when using 'rand' orderby
			if ( $use_random_seed ) {
				add_filter( 'posts_orderby', [ $this, 'set_bricks_query_loop_random_order_seed' ], 11 );
			}

			/**
			 * Set builder preview query_vars as we are not relying on setup_query function in includes/elements/base.php anymore
			 * Shouldn't merge with preview query_vars if 'disable_query_merge' is set (#86bx7cfxp)
			 * Shouldn't merge with preview query_vars if 'woo_disable_query_merge' is set for Products element (@since 1.9.9)
			 *
			 * @since 1.9.1
			 */
			if ( Helpers::is_bricks_preview() && ! isset( $this->query_vars['disable_query_merge'] ) && ! isset( $this->query_vars['woo_disable_query_merge'] ) ) {
				$post_id                    = Database::$page_data['preview_or_post_id'];
				$builder_preview_query_vars = Helpers::get_template_preview_query_vars( $post_id );

				// Use custom deep merge function instead of wp_parse_args() as second parameter is just a default value (@since 1.9.4)
				$this->query_vars = self::merge_query_vars( $this->query_vars, $builder_preview_query_vars );
			}

			/**
			 * Use main query if:
			 * - User set is_archive_main_query to true
			 * - Not in builder preview
			 * - Not in single post / page / attachment (@since 1.9.2)
			 * - Not infinite scroll or load more request (@since 1.9.2)
			 * - Not render_query_result request (@since 1.9.3)
			 *
			 * Otherwise, init a new query.
			 *
			 * @since 1.9.1
			 */
			$is_archive_main_query = isset( $this->settings['query']['is_archive_main_query'] ) ? true : false;

			if ( $is_archive_main_query && ! Helpers::is_bricks_preview() && ! is_singular() && ! Api::is_current_endpoint( 'load_query_page' ) && ! Api::is_current_endpoint( 'query_result' ) && ! Api::is_current_endpoint( 'load_popup_content' ) ) {
				global $wp_query;
				$posts_query = $wp_query;
			} else {
				$posts_query = new \WP_Query( $this->query_vars );
			}

			// @since 1.7.1 - Avoid duplicate posts when using 'rand' orderby
			if ( $use_random_seed ) {
				remove_filter( 'posts_orderby', [ $this, 'set_bricks_query_loop_random_order_seed' ], 11 );
			}

			remove_action( 'pre_get_posts', [ $this, 'set_pagination_with_offset' ], 5 );
			remove_filter( 'found_posts', [ $this, 'fix_found_posts_with_offset' ], 5, 2 );

			$this->set_query_cache( $posts_query );
		}

		return $posts_query;
	}

	/**
	 * Get the page number for a query based on the query var "paged"
	 *
	 * @since 1.5
	 *
	 * @return integer
	 */
	public static function get_paged_query_var( $query_vars ) {
		$paged = 1;

		/**
		 * Return paged 1 if 'disable_query_merge' is true
		 *
		 * Avoid query_var param merged accidentally if 'disable_query_merge' is true
		 *
		 * Return paged 1 if 'woo_disable_query_merge' is true for Product elements (@since 1.9.9)
		 *
		 * @since 1.7.1
		 */
		if ( isset( $query_vars['disable_query_merge'] ) || isset( $query_vars['woo_disable_query_merge'] ) ) {
			return $paged;
		}

		if ( get_query_var( 'page' ) ) {
			// Check for 'page' on static front page
			$paged = get_query_var( 'page' );
		} elseif ( get_query_var( 'paged' ) ) {
			$paged = get_query_var( 'paged' );
		} else {
			$paged = ! empty( $query_vars['paged'] ) ? abs( $query_vars['paged'] ) : 1;
		}

		return intval( $paged );
	}

	/**
	 * Parse the Meta Query vars through the DD logic
	 *
	 * @Since 1.5
	 *
	 * @param array $query_vars
	 * @return array
	 */
	public static function parse_meta_query_vars( $query_vars ) {
		if ( empty( $query_vars['meta_query'] ) ) {
			return $query_vars;
		}

		foreach ( $query_vars['meta_query'] as $key => $query_item ) {
			if ( isset( $query_vars['meta_query'][ $key ]['id'] ) ) {
				unset( $query_vars['meta_query'][ $key ]['id'] );
			}

			if ( empty( $query_vars['meta_query'][ $key ]['value'] ) ) {
				continue;
			}

			$query_vars['meta_query'][ $key ]['value'] = bricks_render_dynamic_data( $query_vars['meta_query'][ $key ]['value'] );
		}

		if ( ! empty( $query_vars['meta_query_relation'] ) ) {
			$query_vars['meta_query']['relation'] = $query_vars['meta_query_relation'];
		}

		unset( $query_vars['meta_query_relation'] );

		return $query_vars;
	}

	/**
	 * Set 'tax_query' vars (e.g. Carousel, Posts, Related Posts)
	 *
	 * Include & exclude terms of different taxonomies
	 *
	 * @since 1.3.2
	 */
	public static function set_tax_query_vars( $query_vars ) {
		// Include terms
		if ( isset( $query_vars['tax_query'] ) ) {
			$terms     = $query_vars['tax_query'];
			$tax_query = [];

			foreach ( $terms as $term ) {
				if ( ! is_string( $term ) ) {
					continue;
				}

				$term_parts = explode( '::', $term );
				$taxonomy   = isset( $term_parts[0] ) ? $term_parts[0] : false;
				$term       = isset( $term_parts[1] ) ? $term_parts[1] : false;

				if ( ! $taxonomy || ! $term ) {
					continue;
				}

				if ( isset( $tax_query[ $taxonomy ] ) ) {
					$tax_query[ $taxonomy ]['terms'][] = $term;
				} else {
					$tax_query[ $taxonomy ] = [
						'taxonomy' => $taxonomy,
						'field'    => 'term_id',
						'terms'    => [ $term ],
					];
				}
			}

			$tax_query = array_values( $tax_query );

			if ( count( $tax_query ) > 1 ) {
				$tax_query['relation'] = 'OR';

				$query_vars['tax_query'] = [ $tax_query ];
			} else {
				$query_vars['tax_query'] = $tax_query;
			}
		}

		// Exclude terms
		if ( isset( $query_vars['tax_query_not'] ) ) {
			$terms             = $query_vars['tax_query_not'];
			$tax_query_exclude = [];

			foreach ( $query_vars['tax_query_not'] as $term ) {
				if ( ! is_string( $term ) ) {
					continue;
				}

				$term_parts = explode( '::', $term );
				$taxonomy   = $term_parts[0];
				$term       = $term_parts[1];

				if ( isset( $tax_query_exclude[ $taxonomy ] ) ) {
					$tax_query_exclude[ $taxonomy ]['terms'][] = $term;
				} else {
					$tax_query_exclude[ $taxonomy ] = [
						'taxonomy' => $taxonomy,
						'field'    => 'term_id',
						'terms'    => [ $term ],
						'operator' => 'NOT IN',
					];
				}
			}

			$tax_query_exclude = array_values( $tax_query_exclude );

			if ( count( $tax_query_exclude ) > 1 ) {
				$tax_query_exclude['relation'] = 'AND';

				$query_vars['tax_query'][] = [ $tax_query_exclude ];
			} else {
				$query_vars['tax_query'][] = $tax_query_exclude;
			}

			unset( $query_vars['tax_query_not'] );
		}

		if ( isset( $query_vars['tax_query_advanced'] ) ) {
			foreach ( $query_vars['tax_query_advanced'] as $tax_query ) {
				if ( empty( $tax_query['terms'] ) ) {
					continue;
				}

				$tax_query['terms'] = bricks_render_dynamic_data( $tax_query['terms'] );

				if ( strpos( $tax_query['terms'], ',' ) ) {
					$tax_query['terms'] = explode( ',', $tax_query['terms'] );
					$tax_query['terms'] = array_map( 'trim', $tax_query['terms'] );
				}

				unset( $tax_query['id'] );

				if ( isset( $tax_query['include_children'] ) ) {
					$tax_query['include_children'] = filter_var( $tax_query['include_children'], FILTER_VALIDATE_BOOLEAN );
				}

				$query_vars['tax_query'][] = $tax_query;
			}
		}

		if ( isset( $query_vars['tax_query'] ) && is_array( $query_vars['tax_query'] ) && count( $query_vars['tax_query'] ) > 1 ) {
			$query_vars['tax_query']['relation'] = isset( $query_vars['tax_query_relation'] ) ? $query_vars['tax_query_relation'] : 'AND';
		}

		unset( $query_vars['tax_query_relation'] );
		unset( $query_vars['tax_query_advanced'] );

		return $query_vars;
	}

	/**
	 * Modifies $query offset variable to make pagination work in combination with offset.
	 *
	 * @see https://codex.wordpress.org/Making_Custom_Queries_using_Offset_and_Pagination
	 * Note that the link recommends exiting the filter if $query->is_paged returns false,
	 * but then max_num_pages on the first page is incorrect.
	 *
	 * @param \WP_Query $query WordPress query.
	 */
	public function set_pagination_with_offset( $query ) {
		if ( ! isset( $this->query_vars['offset'] ) ) {
			return;
		}

		$new_offset = $this->query_vars['offset'] + ( $query->get( 'paged', 1 ) - 1 ) * $query->get( 'posts_per_page' );
		$query->set( 'offset', $new_offset );
	}

	/**
	 * Handle term pagination
	 *
	 * @since 1.9.8
	 */
	public static function get_term_pagination_query_var( $query_vars ) {
		// Pagination: Fix the offset value
		$offset = ! empty( $query_vars['offset'] ) ? $query_vars['offset'] : 0;

		// Store the original offset value
		$query_vars['original_offset'] = $offset;

		// If pagination exists, and number is limited (!= 0), use $offset as the pagination trigger
		if ( isset( $query_vars['paged'] ) && $query_vars['paged'] !== 1 && ! empty( $query_vars['number'] ) ) {
			$query_vars['offset'] = ( $query_vars['paged'] - 1 ) * $query_vars['number'] + $offset;
		}

		return $query_vars;
	}

	/**
	 * By default, WordPress includes offset posts into the final post count.
	 * This method excludes them.
	 *
	 * @see https://codex.wordpress.org/Making_Custom_Queries_using_Offset_and_Pagination
	 * Note that the link recommends exiting the filter if $query->is_paged returns false,
	 * but then max_num_pages on the first page is incorrect.
	 *
	 * @param int       $found_posts Found posts.
	 * @param \WP_Query $query WordPress query.
	 * @return int Modified found posts.
	 */
	public function fix_found_posts_with_offset( $found_posts, $query ) {
		if ( ! isset( $this->query_vars['offset'] ) ) {
			return $found_posts;
		}

		return $found_posts - $this->query_vars['offset'];
	}

	/**
	 * Set the initial loop index (needed for the infinite scroll)
	 *
	 * @since 1.5
	 */
	public function init_loop_index() {
		$paged  = isset( $this->query_vars['paged'] ) ? $this->query_vars['paged'] : 1;
		$offset = isset( $this->query_vars['offset'] ) ? $this->query_vars['offset'] : 0;

		// Type: post
		if ( $this->object_type == 'post' ) {
			// 'posts_per_page' not set by default when using 'queryEditor' (@since 1.9.1)
			$posts_per_page = isset( $this->query_vars['posts_per_page'] ) ? intval( $this->query_vars['posts_per_page'] ) : get_option( 'posts_per_page' );

			return $offset + ( $posts_per_page > 0 ? ( $paged - 1 ) * $posts_per_page : 0 );
		}

		// Type: term
		if ( $this->object_type == 'term' ) {
			return isset( $this->query_vars['offset'] ) ? $this->query_vars['offset'] : 0;
		}

		// Type: user
		if ( $this->object_type == 'user' ) {
			return $offset + ( $this->query_vars['number'] > 0 ? ( $paged - 1 ) * $this->query_vars['number'] : 0 );
		}

		return 0;
	}

	/**
	 * Main render function
	 *
	 * @param string  $callback to render each item.
	 * @param array   $args callback function args.
	 * @param boolean $return_array whether returns a string or an array of all the iterations.
	 */
	public function render( $callback, $args, $return_array = false ) {
		// Remove array keys
		$args = array_values( $args );

		// Query results
		$query_result = $this->query_result;

		$content = [];

		$this->loop_index = $this->init_loop_index();

		$this->is_looping = true;

		// @see https://academy.bricksbuilder.io/article/action-bricks-query-before_loop (@since 1.7.2)
		do_action( 'bricks/query/before_loop', $this, $args );

		// Query is empty
		if ( empty( $this->count ) ) {
			$this->is_looping = false;
			$content[]        = $this->get_no_results_content();
		}

		// Iterate
		else {
			// STEP: Loop posts
			if ( $this->object_type == 'post' ) {
				$this->original_post_id = get_the_ID();

				while ( $query_result->have_posts() ) {
					$query_result->the_post();

					$this->loop_object = get_post();

					$part = call_user_func_array( $callback, $args );

					$content[] = self::parse_dynamic_data( $part, get_the_ID() );

					$this->loop_index++;
				}
			}

			// STEP: Loop terms
			elseif ( $this->object_type == 'term' ) {
				foreach ( $query_result as $term_object ) {
					$this->loop_object = $term_object;

					$part = call_user_func_array( $callback, $args );

					$content[] = self::parse_dynamic_data( $part, get_the_ID() );

					$this->loop_index++;
				}
			}

			// STEP: Loop users
			elseif ( $this->object_type == 'user' ) {
				foreach ( $query_result as $user_object ) {
					$this->loop_object = $user_object;

					$part = call_user_func_array( $callback, $args );

					$content[] = self::parse_dynamic_data( $part, get_the_ID() );

					$this->loop_index++;
				}
			}

			// STEP: Other render providers (wooCart, ACF repeater, Meta Box groups)
			else {
				$this->original_post_id = get_the_ID();

				foreach ( $query_result as $loop_key => $loop_object ) {
					// @see: https://academy.bricksbuilder.io/article/filter-bricks-query-loop_object/
					$this->loop_object = apply_filters( 'bricks/query/loop_object', $loop_object, $loop_key, $this );

					$part = call_user_func_array( $callback, $args );

					$content[] = self::parse_dynamic_data( $part, get_the_ID() );

					$this->loop_index++;
				}
			}

			// STEP: Remove the HTML content if live_search is enabled as it's not needed on initial page load (@since 1.9.6)
			$is_live_search = $this->settings['query']['is_live_search'] ?? false;
			if ( $is_live_search && ! Api::is_current_endpoint( 'query_result' ) && Helpers::enabled_query_filters() ) {
				$content = [];
			}
		}

		// @see https://academy.bricksbuilder.io/article/action-bricks-query-after_loop (@since 1.7.2)
		do_action( 'bricks/query/after_loop', $this, $args );

		$this->loop_object = null;

		$this->is_looping = false;

		$this->reset_postdata();

		return $return_array ? $content : implode( '', $content );
	}

	public static function parse_dynamic_data( $content, $post_id ) {
		if ( is_array( $content ) ) {
			if ( isset( $content['background']['image']['useDynamicData'] ) ) {
				$size = isset( $content['background']['image']['size'] ) ? $content['background']['image']['size'] : BRICKS_DEFAULT_IMAGE_SIZE;

				$images = Integrations\Dynamic_Data\Providers::render_tag( $content['background']['image']['useDynamicData'], $post_id, 'image', [ 'size' => $size ] );

				if ( isset( $images[0] ) ) {
					$content['background']['image']['url'] = is_numeric( $images[0] ) ? wp_get_attachment_image_url( $images[0], $size ) : $images[0];

					unset( $content['background']['image']['useDynamicData'] );
				}
			}

			return map_deep( $content, [ 'Bricks\Integrations\Dynamic_Data\Providers', 'render_content' ] );
		} else {
			return bricks_render_dynamic_data( $content, $post_id );
		}
	}

	/**
	 * Reset the global $post to the parent query or the global $wp_query
	 *
	 * @since 1.5
	 *
	 * @return void
	 */
	public function reset_postdata() {
		// Reset is not needed
		if ( empty( $this->original_post_id ) ) {
			return;
		}

		$looping_query_id = self::is_any_looping();

		// Not a nested query, reset global query
		if ( ! $looping_query_id ) {
			wp_reset_postdata();
		}

		// Set the parent query context
		global $post;

		$post = get_post( $this->original_post_id );

		setup_postdata( $post );
	}

	/**
	 * Get the current Query object
	 *
	 * @return Query
	 */
	public static function get_query_object( $query_id = false ) {
		global $bricks_loop_query;

		if ( ! is_array( $bricks_loop_query ) || $query_id && ! array_key_exists( $query_id, $bricks_loop_query ) ) {
			return false;
		}

		return $query_id ? $bricks_loop_query[ $query_id ] : end( $bricks_loop_query );
	}

	/**
	 * Get the current Query object type
	 *
	 * @return string
	 */
	public static function get_query_object_type( $query_id = '' ) {
		$query = self::get_query_object( $query_id );

		return $query ? $query->object_type : '';
	}

	/**
	 * Get the object of the current loop iteration
	 *
	 * @return mixed
	 */
	public static function get_loop_object( $query_id = '' ) {
		$query = self::get_query_object( $query_id );

		return $query ? $query->loop_object : null;
	}

	/**
	 * Get the object ID of the current loop iteration
	 *
	 * @return mixed
	 */
	public static function get_loop_object_id( $query_id = '' ) {
		$object = self::get_loop_object( $query_id );

		$object_id = 0;

		if ( is_a( $object, 'WP_Post' ) ) {
			$object_id = $object->ID;
		}

		if ( is_a( $object, 'WP_Term' ) ) {
			$object_id = $object->term_id;
		}

		if ( is_a( $object, 'WP_User' ) ) {
			$object_id = $object->ID;
		}

		/**
		 * Non-WP query loops (ACF, Meta Box, Woo Cart, etc.)
		 *
		 * @since 1.9.1.1
		 */
		if ( ! $object_id ) {
			$any          = self::is_any_looping( $query_id );
			$query_object = self::get_query_object( $any );

			if ( is_a( $query_object, 'Bricks\Query' ) ) {
				$object_id = $query_object->loop_index;
			}
		}

		// @see: https://academy.bricksbuilder.io/article/filter-bricks-query-loop_object_id/
		return apply_filters( 'bricks/query/loop_object_id', $object_id, $object, $query_id );
	}

	/**
	 * Get the object type of the current loop iteration
	 *
	 * @return mixed
	 */
	public static function get_loop_object_type( $query_id = '' ) {
		$object = self::get_loop_object( $query_id );

		$object_type = null;

		if ( is_a( $object, 'WP_Post' ) ) {
			$object_type = 'post';
		}

		if ( is_a( $object, 'WP_Term' ) ) {
			$object_type = 'term';
		}

		if ( is_a( $object, 'WP_User' ) ) {
			$object_type = 'user';
		}

		// @see: https://academy.bricksbuilder.io/article/filter-bricks-query-loop_object_type/
		return apply_filters( 'bricks/query/loop_object_type', $object_type, $object, $query_id );
	}

	/**
	 * Get the current loop iteration index
	 *
	 * @since 1.10: Add $query_id to get the loop index of a specific query
	 *
	 * @return mixed
	 */
	public static function get_loop_index( $query_id = '' ) {
		// For AJAX popup to simulate is_looping if context being set (@since 1.9.4)
		$force_loop_index = apply_filters( 'bricks/query/force_loop_index', '' );

		if ( $force_loop_index !== '' ) {
			return $force_loop_index;
		}

		$query = self::get_query_object( $query_id );

		return $query && $query->is_looping ? $query->loop_index : '';
	}

	/**
	 * Get a unique identifier for the current looping query
	 *
	 * @param string $type 'query', 'interaction', 'popup'
	 * @return string
	 * @since 1.10
	 */
	public static function get_looping_unique_identifier( $type = 'query' ) {
		$looping_query_id = self::is_any_looping();

		if ( ! $looping_query_id ) {
			return '';
		}

		/**
		 * Looping dynamic data CSS
		 *
		 * Example: background-image, color, etc.
		 *
		 * @since 1.10
		 */
		if ( $type === 'query' ) {
			// Top level loop
			if ( self::get_looping_level() < 1 ) {
				// Format: loop_index
				// NOTE: Query element ID not needed, plus we remain backward compatible
				$unique_loop_id = [
					// self::get_query_element_id( $looping_query_id ),
					self::get_loop_index( $looping_query_id ),
				];
			}

			// Nested loop
			else {
				// Format: parent_element_id:parent_loop_index:query_element_id:loop_index
				$parent_loop_id = self::get_parent_loop_id();
				$unique_loop_id = [
					self::get_query_element_id( $parent_loop_id ),
					self::get_loop_index( $parent_loop_id ),
					self::get_query_element_id( $looping_query_id ),
					self::get_loop_index( $looping_query_id ),
				];
			}
		}

		/**
		 * For AJAX popup data attribute: data-popup-loop-id
		 * For interactions data attribute: data-interaction-loop-id
		 *
		 * Avoid incorrect popup trigger in nested loops
		 *
		 * @since 1.9.4
		 */
		else {
			// Format: query_element_id:loop_index:object_type:object_id
			$unique_loop_id = [
				self::get_query_element_id( $looping_query_id ),
				self::get_loop_index( $looping_query_id ),
				self::get_loop_object_type( $looping_query_id ),
				self::get_loop_object_id( $looping_query_id ),
			];
		}

		return implode( ':', $unique_loop_id );
	}

	/**
	 * Check if the render function is looping (in the current query)
	 *
	 * @param string $element_id Checks if the element_id matches the element that is set to loop (e.g. container).
	 *
	 * @return boolean
	 */
	public static function is_looping( $element_id = '', $query_id = '' ) {
		// For AJAX popup to simulate is_looping if context being set (@since 1.9.4)
		$force_is_looping = apply_filters( 'bricks/query/force_is_looping', false, $query_id, $element_id );

		if ( $force_is_looping ) {
			return true;
		}

		$query = self::get_query_object( $query_id );

		if ( ! $query ) {
			return false;
		}

		if ( empty( $element_id ) ) {
			return $query->is_looping;
		}

		// Still here, search for the element_id query
		$query = self::get_query_for_element_id( $element_id );

		return $query ? $query->is_looping : false;
	}

	/**
	 * Get query object created for a specific element ID
	 *
	 * @param string $element_id
	 * @return mixed
	 */
	public static function get_query_for_element_id( $element_id = '' ) {
		if ( empty( $element_id ) ) {
			return false;
		}

		global $bricks_loop_query;

		if ( empty( $bricks_loop_query ) ) {
			return false;
		}

		foreach ( $bricks_loop_query as $key => $query ) {
			if ( $query->element_id == $element_id ) {
				return $query;
			}
		}

		return false;
	}

	/**
	 * Get element ID of query loop element
	 *
	 * @param object $query Defaults to current query.
	 *
	 * @since 1.4
	 *
	 * @return string|boolean Element ID or false
	 */
	public static function get_query_element_id( $query = '' ) {
		$query = self::get_query_object( $query );

		return ! empty( $query->element_id ) ? $query->element_id : false;
	}

	/**
	 * Get the current looping level
	 *
	 * @return int
	 * @since 1.10
	 */
	public static function get_looping_level() {
		global $bricks_loop_query;

		$query_ids = array_reverse( array_keys( $bricks_loop_query ) );

		$looping_queries = array_filter(
			$query_ids,
			function( $query_id ) use ( $bricks_loop_query ) {
				return $bricks_loop_query[ $query_id ]->is_looping;
			}
		);

		$level = count( $looping_queries ) > 0 ? count( $looping_queries ) - 1 : 0;
		return $level;
	}

	/**
	 * Get the direct parent loop ID
	 *
	 * @since 1.10
	 */
	public static function get_parent_loop_id() {
		$current_looping_id = self::is_any_looping();

		if ( ! $current_looping_id ) {
			return false;
		}

		global $bricks_loop_query;

		$query_ids = array_reverse( array_keys( $bricks_loop_query ) );

		$looping_queries = array_filter(
			$query_ids,
			function( $query_id ) use ( $bricks_loop_query ) {
				return $bricks_loop_query[ $query_id ]->is_looping;
			}
		);

		$looping_queries = array_values( $looping_queries );

		if ( count( $looping_queries ) < 2 ) {
			return false;
		}

		$parent_loop_id = false;

		foreach ( $looping_queries as $key => $query_id ) {
			if ( $query_id == $current_looping_id ) {
				$parent_loop_id = $looping_queries[ $key + 1 ];
				break;
			}
		}

		return $parent_loop_id;
	}

	/**
	 * Check if there is any active query looping (nested queries) and if yes, return the query ID of the most deep query
	 *
	 * @return mixed
	 */
	public static function is_any_looping() {
		global $bricks_loop_query;

		if ( empty( $bricks_loop_query ) ) {
			return false;
		}

		$query_ids = array_reverse( array_keys( $bricks_loop_query ) );

		foreach ( $query_ids as $query_id ) {
			if ( $bricks_loop_query[ $query_id ]->is_looping ) {
				return $query_id;
			}
		}

		return false;
	}

	/**
	 * Convert a list of option strings taxonomy::term_id into a list of term_ids
	 */
	public static function convert_terms_to_ids( $terms = [] ) {
		if ( empty( $terms ) ) {
			return [];
		}

		$options = [];

		foreach ( $terms as $term ) {
			if ( ! is_string( $term ) ) {
				continue;
			}

			$term_parts = explode( '::', $term );
			// $taxonomy   = $term_parts[0];

			$options[] = $term_parts[1];
		}

		return $options;
	}

	public function get_no_results_content() {
		// Return: Avoid showing no results message when infinite scroll is enabled (@since 1.5.6)
		if ( Api::is_current_endpoint( 'load_query_page' ) ) {
			return '';
		}

		// Return: Avoid showing no results message when live search is enabled and not on query_results API endpoint (@since 1.9.6)
		if ( isset( $this->settings['query']['is_live_search'] ) && ! Api::is_current_endpoint( 'query_result' ) ) {
			return '';
		}

		$template_id = $this->settings['query']['no_results_template'] ?? false;
		$text        = $this->settings['query']['no_results_text'] ?? '';
		$content     = '';

		if ( $template_id || $text ) {
			// Use template if set
			if ( $template_id ) {
				$content = do_shortcode( '[bricks_template id="' . $template_id . '"]' );
			} else {
				$content = bricks_render_dynamic_data( $text );
				$content = do_shortcode( $content );
			}

			/**
			 * Use custom HTML tag if set
			 *
			 * Must wrap content inside .bricks-posts-nothing-found to target via JavaScript.
			 *
			 * @since 1.9.8
			 */
			$html_tag = Helpers::get_html_tag_from_element_settings( $this->settings, 'div' );
			$wrapper  = "<$html_tag" . ' class="bricks-posts-nothing-found" style="width: inherit; max-width: 100%; grid-column: 1/-1">';

			// Special case for table row
			if ( $html_tag === 'tr' ) {
				$wrapper .= '<td colspan="100%">';
			}

			$content = $wrapper . $content;

			// Special case for table row
			if ( $html_tag === 'tr' ) {
				$content .= '</td>';
			}

			$content .= "</$html_tag>";

			// Inline styles needed if query result via AJAX is empty and using a template
			if ( Api::is_current_endpoint( 'query_result' ) && $template_id ) {
				$content .= '<style>';
				$content .= Assets::$inline_css['global_classes'];
				$content .= Assets::$inline_css[ "template_$template_id" ];
				$content .= '</style>';
			}
		}

		// @see: https://academy.bricksbuilder.io/article/filter-bricks-query_no_results_content/
		$content = apply_filters( 'bricks/query/no_results_content', $content, $this->settings, $this->element_id );

		return $content;
	}

	/**
	 * Check if the query is using random seed
	 * Use random seed when: 'orderby' is 'rand' && 'randomSeedTtl' > 0
	 * Default: 60 minutes
	 *
	 * @param array $query_vars
	 * @return boolean
	 * @since 1.9.8
	 */
	public static function use_random_seed( $query_vars = [] ) {
		return isset( $query_vars['orderby'] ) && $query_vars['orderby'] === 'rand' && ! ( isset( $query_vars['randomSeedTtl'] ) && absint( $query_vars['randomSeedTtl'] ) === 0 );
	}

	/**
	 * Get the random seed statement for the query
	 *
	 * @param string $element_id
	 * @param array  $query_vars
	 * @return string
	 * @since 1.9.8
	 */
	public static function get_random_seed_statement( $element_id = '', $query_vars = [] ) {
		if ( empty( $element_id ) || ! isset( $query_vars['orderby'] ) || $query_vars['orderby'] !== 'rand' ) {
			return '';
		}

		// Transient name is based on the element ID
		$transient_name = "bricks_query_loop_random_seed_{$element_id}";
		$random_seed    = get_transient( $transient_name );

		if ( ! $random_seed ) {
			// Generate a random seed for this query
			$random_seed = rand( 0, 99999 );

			// Default transient TTL is 60 minutes
			$random_seed_ttl = ! empty( $query_vars['randomSeedTtl'] ) ? absint( $query_vars['randomSeedTtl'] ) : 60;

			set_transient( $transient_name, $random_seed, $random_seed_ttl * MINUTE_IN_SECONDS );
		}

		return 'RAND(' . $random_seed . ')';
	}

	/**
	 * Use random seed to make sure the order is the same for all queries of the same element
	 *
	 * The transient is also deleted when the random seed setting inside the query loop control is changed.
	 *
	 * @param string $order_statement
	 * @return string
	 * @since 1.7.1
	 */
	public function set_bricks_query_loop_random_order_seed( $order_statement ) {
		$random_seed_statement = self::get_random_seed_statement( $this->element_id, $this->query_vars );

		if ( ! empty( $random_seed_statement ) ) {
			return $random_seed_statement;
		}

		return $order_statement;
	}

	/**
	 * All query arguments that can be set for the archive query
	 * https://developer.wordpress.org/reference/classes/wp_query/#parameters
	 *
	 * @return array
	 *
	 * @since 1.8
	 */
	public static function archive_query_arguments() {
		$arguments = [
			'post_type',
			'post_status',
			'p',
			'page_id',
			'name',
			'pagename',
			'page',
			'hour',
			'minute',
			'second',
			'year',
			'monthnum',
			'day',
			'w',
			'm',
			'cat',
			'category_name',
			'category__and',
			'category__in',
			'category__not_in',
			'tag',
			'tag_id',
			'tag__and',
			'tag__in',
			'tag__not_in',
			'tag_slug__and',
			'tag_slug__in',
			'taxonomy',
			'term',
			'field',
			'operator',
			'include_children',
			'paged',
			'posts_per_page',
			'nopaging',
			'offset',
			'ignore_sticky_posts',
			'post_parent',
			'post_parent__in',
			'post_parent__not_in',
			'post__in',
			'post__not_in',
			'post_name__in',
			'author',
			'author_name',
			'author__in',
			'author__not_in',
			's',
			'exact',
			'sentence',
			'meta_key',
			'meta_value',
			'meta_value_num',
			'meta_compare',
			'meta_query',
			'date_query',
			'cache_results',
			'update_post_term_cache',
			'update_post_meta_cache',
			'no_found_rows',
			'order',
			'orderby',
			'perm',
			'post_mime_type',
			'comment_count',
			'comment_status',
			'post_comment_status',
			'tax_query', // @since 1.9.8 (#86by08fg0)
		];

		// NOTE: Undocumented
		return apply_filters( 'bricks/query/archive_query_arguments', $arguments );
	}

	/**
	 * All bricks query object types that can be set for the archive query.
	 * If there is custom query by user and it might be used as archive query, should be added here.
	 *
	 * @return array
	 *
	 * @since 1.8
	 */
	public static function archive_query_supported_object_types() {
		// @since 1.9.1 - Only post query should be supported (WP_Query)
		$object_types = [
			'post',
			// 'term',
			// 'user',
		];

		// NOTE: Undocumented
		return apply_filters( 'bricks/query/archive_query_supported_object_types', $object_types );
	}

	/**
	 * Merge two query vars arrays, instead of using wp_parse_args
	 *
	 * wp_parse_args will only set those values that are not already set in the original array.
	 *
	 * @see https://developer.wordpress.org/reference/functions/wp_parse_args/
	 *
	 * @since 1.9.4
	 */
	public static function merge_query_vars( $original_query_vars, $merging_query_vars ) {
		foreach ( $merging_query_vars as $key => $value ) {
			// If the key already exists in the $original_query_vars, and the value is an array, merge the two arrays
			if ( isset( $original_query_vars[ $key ] ) && is_array( $original_query_vars[ $key ] ) && is_array( $value ) ) {
				/**
				 * Handle special case for 'tax_query'
				 * merging via key might be wrong, as the key is just index of the array
				 */
				if ( $key === 'tax_query' ) {
					$original_query_vars[ $key ] = self::merge_tax_or_meta_query_vars( $original_query_vars[ $key ], $value, 'tax' );
				}

				/**
				 * Handle special case for 'meta_query'
				 *
				 * This logic is still needed for 'meta_query' to work correctly.
				 * Otherwise will merge wrongly into wrong array when performing query filter.
				 *
				 * @since 1.9.8
				 */
				elseif ( $key === 'meta_query' && Api::is_current_endpoint( 'query_result' ) ) {
					$original_query_vars[ $key ] = self::merge_tax_or_meta_query_vars( $original_query_vars[ $key ], $value, 'meta' );
				}

				else {
					$original_query_vars[ $key ] = self::merge_query_vars( $original_query_vars[ $key ], $value ); // Recursively merge arrays (@since 1.9.6)
				}

			} else {
				$original_query_vars[ $key ] = $value;
			}
		}

		return $original_query_vars;
	}

	/**
	 * Special case for merging 'tax_query' and 'meta_query' vars
	 *
	 * Only merge if the 'taxonomy' or 'key' are identical.
	 *
	 * @since 1.9.6
	 */
	public static function merge_tax_or_meta_query_vars( $original_tax_query, $merging_tax_query, $type = 'tax' ) {
		$original_tax_query = array_values( $original_tax_query );
		$merging_tax_query  = array_values( $merging_tax_query );
		$target_key         = $type === 'tax' ? 'taxonomy' : 'key';

		// Merge tax_query or meta_query vars
		foreach ( $merging_tax_query as $merging_tax_query_item ) {
			$found = false;

			foreach ( $original_tax_query as &$original_tax_query_item ) { // Use reference to modify original array
				if ( $type === 'meta' ) {
					/**
					 * Meta merge logic
					 *
					 * Only merge if the 'key' is identical && 'compare' is identical.
					 *
					 * @since 1.9.8
					 */
					if ( isset( $original_tax_query_item[ $target_key ] ) &&
						isset( $merging_tax_query_item[ $target_key ] ) &&
						$original_tax_query_item[ $target_key ] === $merging_tax_query_item[ $target_key ] &&
						isset( $original_tax_query_item['compare'] ) &&
						isset( $merging_tax_query_item['compare'] ) &&
						$original_tax_query_item['compare'] === $merging_tax_query_item['compare']
					) {
						$found = true;

						// Merge the rest of the properties
						$original_tax_query_item = self::merge_query_vars( $original_tax_query_item, $merging_tax_query_item );
					}
				}

				elseif ( $type === 'tax' ) {
					// Taxonomy merge logic
					if ( isset( $original_tax_query_item[ $target_key ] ) &&
						isset( $merging_tax_query_item[ $target_key ] ) &&
						$original_tax_query_item[ $target_key ] === $merging_tax_query_item[ $target_key ]
					) {
						$found = true;

						// Convert terms to array if it's not already
						if ( isset( $original_tax_query_item['terms'] ) && ! is_array( $original_tax_query_item['terms'] ) ) {
							$original_tax_query_item['terms'] = [ $original_tax_query_item['terms'] ];
						}
						if ( isset( $merging_tax_query_item['terms'] ) && ! is_array( $merging_tax_query_item['terms'] ) ) {
							$merging_tax_query_item['terms'] = [ $merging_tax_query_item['terms'] ];
						}

						// Merge terms if they exist in both original and merging items
						if ( isset( $original_tax_query_item['terms'] ) && isset( $merging_tax_query_item['terms'] ) ) {
							$original_tax_query_item['terms'] = array_merge( $original_tax_query_item['terms'], $merging_tax_query_item['terms'] );
						} else {
							// If one of the items doesn't have terms, just copy the terms from the merging item
							$original_tax_query_item['terms'] = isset( $merging_tax_query_item['terms'] ) ? $merging_tax_query_item['terms'] : $original_tax_query_item['terms'];
						}

						// Remove the operator if it's already set in the original item
						unset( $merging_tax_query_item['operator'] );

						// Merge the rest of the properties
						$original_tax_query_item = self::merge_query_vars( $original_tax_query_item, $merging_tax_query_item );
					}
				}
			}

			if ( ! $found ) {
				$original_tax_query[] = $merging_tax_query_item;
			}
		}

		return $original_tax_query;
	}
}
