<?php
declare(strict_types=1);

namespace Automattic\WooCommerce\StoreApi\Utilities;

use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
use Automattic\WooCommerce\Internal\ProductFilters\Interfaces\QueryClausesGenerator;
use WC_Tax;

/**
 * Product Query class.
 *
 * Helper class to handle product queries for the API.
 */
class ProductQuery implements QueryClausesGenerator {
	/**
	 * Prepare query args to pass to WP_Query for a REST API request.
	 *
	 * @param \WP_REST_Request $request Request data.
	 * @return array
	 */
	public function prepare_objects_query( $request ) {
		$args = array(
			'offset'              => $request['offset'],
			'order'               => $request['order'],
			'orderby'             => $request['orderby'],
			'paged'               => $request['page'],
			'post__in'            => $request['include'],
			'post__not_in'        => $request['exclude'],
			'posts_per_page'      => $request['per_page'] ? $request['per_page'] : -1,
			'post_parent__in'     => $request['parent'],
			'post_parent__not_in' => $request['parent_exclude'],
			'search'              => $request['search'], // This uses search rather than s intentionally to handle searches internally.
			'slug'                => $request['slug'],
			'fields'              => 'ids',
			'ignore_sticky_posts' => true,
			'post_status'         => ProductStatus::PUBLISH,
			'date_query'          => array(),
			'post_type'           => 'product',
		);

		// If searching for a specific SKU or slug, allow any post type.
		if ( ! empty( $request['sku'] ) || ! empty( $request['slug'] ) ) {
			$args['post_type'] = array( 'product', 'product_variation' );
		}

		// Taxonomy query to filter products by type, category, tag, shipping class, and attribute.
		$tax_query = array();

		// Filter product type by slug.
		if ( ! empty( $request['type'] ) ) {
			if ( ProductType::VARIATION === $request['type'] ) {
				$args['post_type'] = 'product_variation';
			} else {
				$args['post_type'] = 'product';
				$tax_query[]       = array(
					'taxonomy' => 'product_type',
					'field'    => 'slug',
					'terms'    => $request['type'],
				);
			}
		}

		if ( 'date' === $args['orderby'] ) {
			$args['orderby'] = 'date ID';
		}

		// Set before into date query. Date query must be specified as an array of an array.
		if ( isset( $request['before'] ) ) {
			$args['date_query'][0]['before'] = $request['before'];
		}

		// Set after into date query. Date query must be specified as an array of an array.
		if ( isset( $request['after'] ) ) {
			$args['date_query'][0]['after'] = $request['after'];
		}

		// Set date query column. Defaults to post_date.
		if ( isset( $request['date_column'] ) && ! empty( $args['date_query'][0] ) ) {
			$args['date_query'][0]['column'] = 'post_' . $request['date_column'];
		}

		// Set custom args to handle later during clauses.
		$custom_keys = array(
			'sku',
			'min_price',
			'max_price',
			'stock_status',
		);

		foreach ( $custom_keys as $key ) {
			if ( ! empty( $request[ $key ] ) ) {
				$args[ $key ] = $request[ $key ];
			}
		}

		$operator_mapping = array(
			'in'     => 'IN',
			'not_in' => 'NOT IN',
			'and'    => 'AND',
		);

		// Gets all registered product taxonomies and prefixes them with `tax_`.
		// This is needed to avoid situations where a user registers a new product taxonomy with the same name as default field.
		// eg an `sku` taxonomy will be mapped to `tax_sku`.
		$all_product_taxonomies = array_map(
			function ( $value ) {
				return '_unstable_tax_' . $value;
			},
			get_taxonomies( array( 'object_type' => array( 'product' ) ), 'names' )
		);

		// Map between taxonomy name and arg key.
		$default_taxonomies = array(
			'product_cat'   => 'category',
			'product_tag'   => 'tag',
			'product_brand' => 'brand',
		);

		$taxonomies = array_merge( $all_product_taxonomies, $default_taxonomies );

		// Set tax_query for each passed arg.
		foreach ( $taxonomies as $taxonomy => $key ) {
			if ( ! empty( $request[ $key ] ) ) {
				$type        = is_numeric( $request[ $key ][0] ) ? 'term_id' : 'slug';
				$operator    = $request->get_param( $key . '_operator' ) && isset( $operator_mapping[ $request->get_param( $key . '_operator' ) ] ) ? $operator_mapping[ $request->get_param( $key . '_operator' ) ] : 'IN';
				$tax_query[] = array(
					'taxonomy' => $taxonomy,
					'field'    => $type,
					'terms'    => $request[ $key ],
					'operator' => $operator,
				);
			}
		}

		// Filter by attributes.
		if ( ! empty( $request['attributes'] ) ) {
			$att_queries = array();

			foreach ( $request['attributes'] as $attribute ) {
				if ( empty( $attribute['term_id'] ) && empty( $attribute['slug'] ) ) {
					continue;
				}
				if ( in_array( $attribute['attribute'], wc_get_attribute_taxonomy_names(), true ) ) {
					$operator      = isset( $attribute['operator'], $operator_mapping[ $attribute['operator'] ] ) ? $operator_mapping[ $attribute['operator'] ] : 'IN';
					$att_queries[] = array(
						'taxonomy' => $attribute['attribute'],
						'field'    => ! empty( $attribute['term_id'] ) ? 'term_id' : 'slug',
						'terms'    => ! empty( $attribute['term_id'] ) ? $attribute['term_id'] : $attribute['slug'],
						'operator' => $operator,
					);
				}
			}

			if ( 1 < count( $att_queries ) ) {
				// Add relation arg when using multiple attributes.
				$relation    = $request->get_param( 'attribute_relation' ) && isset( $operator_mapping[ $request->get_param( 'attribute_relation' ) ] ) ? $operator_mapping[ $request->get_param( 'attribute_relation' ) ] : 'IN';
				$tax_query[] = array(
					'relation' => $relation,
					$att_queries,
				);
			} else {
				$tax_query = array_merge( $tax_query, $att_queries );
			}
		}

		// Build tax_query if taxonomies are set.
		if ( ! empty( $tax_query ) && 'product_variation' !== $args['post_type'] ) {
			if ( ! empty( $args['tax_query'] ) ) {
				$args['tax_query'] = array_merge( $tax_query, $args['tax_query'] ); // phpcs:ignore
			} else {
				$args['tax_query'] = $tax_query; // phpcs:ignore
			}
		} else {
			// For product_variantions we need to convert the tax_query to a meta_query.
			if ( ! empty( $args['tax_query'] ) ) {
				$args['meta_query'] = $this->convert_tax_query_to_meta_query( array_merge( $tax_query, $args['tax_query'] ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
			} else {
				$args['meta_query'] = $this->convert_tax_query_to_meta_query( $tax_query ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
			}
		}

		// Filter featured.
		if ( is_bool( $request['featured'] ) ) {
			$args['tax_query'][] = array(
				'taxonomy' => 'product_visibility',
				'field'    => 'name',
				'terms'    => 'featured',
				'operator' => true === $request['featured'] ? 'IN' : 'NOT IN',
			);
		}

		// Filter by on sale products.
		if ( is_bool( $request['on_sale'] ) ) {
			$on_sale_key = $request['on_sale'] ? 'post__in' : 'post__not_in';
			$on_sale_ids = wc_get_product_ids_on_sale();

			// Use 0 when there's no on sale products to avoid return all products.
			$on_sale_ids = empty( $on_sale_ids ) ? array( 0 ) : $on_sale_ids;

			$args[ $on_sale_key ] += $on_sale_ids;
		}

		$catalog_visibility = $request->get_param( 'catalog_visibility' );
		$rating             = $request->get_param( 'rating' );
		$visibility_options = wc_get_product_visibility_options();

		if ( in_array( $catalog_visibility, array_keys( $visibility_options ), true ) ) {
			$exclude_from_catalog = CatalogVisibility::SEARCH === $catalog_visibility ? '' : 'exclude-from-catalog';
			$exclude_from_search  = CatalogVisibility::CATALOG === $catalog_visibility ? '' : 'exclude-from-search';

			$args['tax_query'][] = array(
				'taxonomy'      => 'product_visibility',
				'field'         => 'name',
				'terms'         => array( $exclude_from_catalog, $exclude_from_search ),
				'operator'      => CatalogVisibility::HIDDEN === $catalog_visibility ? 'AND' : 'NOT IN',
				'rating_filter' => true,
			);
		}

		if ( $rating ) {
			$rating_terms = array();
			foreach ( $rating as $value ) {
				$rating_terms[] = 'rated-' . $value;
			}
			$args['tax_query'][] = array(
				'taxonomy' => 'product_visibility',
				'field'    => 'name',
				'terms'    => $rating_terms,
			);
		}

		$orderby = $request->get_param( 'orderby' );
		$order   = $request->get_param( 'order' );

		$ordering_args   = wc()->query->get_catalog_ordering_args( $orderby, $order );
		$args['orderby'] = $ordering_args['orderby'];
		$args['order']   = $ordering_args['order'];

		if ( 'include' === $orderby ) {
			$args['orderby'] = 'post__in';
		} elseif ( 'id' === $orderby ) {
			$args['orderby'] = 'ID'; // ID must be capitalized.
		} elseif ( 'slug' === $orderby ) {
			$args['orderby'] = 'name';
		}

		if ( $ordering_args['meta_key'] ) {
			$args['meta_key'] = $ordering_args['meta_key']; // phpcs:ignore
		}

		// Filter by related products.
		if ( ! empty( $request['related'] ) ) {
			$product_id = absint( $request['related'] );
			$limit      = ! empty( $request['per_page'] ) ? (int) $request['per_page'] : 100;
			$related    = wc_get_related_products( $product_id, $limit );

			if ( ! empty( $related ) ) {
				$args['post__in'] = ! empty( $args['post__in'] )
					? array_values( array_intersect( $args['post__in'], $related ) )
					: array_values( $related );
			} else {
				// No related products found, return empty result.
				$args['post__in'] = array( 0 );
			}
		}

		return $args;
	}

	/**
	 * Convert the tax_query to a meta_query which is needed to support filtering by attributes for variations.
	 *
	 * @param array $tax_query The tax_query to convert.
	 * @return array
	 */
	public function convert_tax_query_to_meta_query( $tax_query ) {
		$meta_query = array();

		foreach ( $tax_query as $tax_query_item ) {
			$taxonomy = $tax_query_item['taxonomy'];
			$terms    = $tax_query_item['terms'];

			$meta_key = 'attribute_' . $taxonomy;

			$meta_query[] = array(
				'key'   => $meta_key,
				'value' => $terms,
			);

			if ( isset( $tax_query_item['operator'] ) ) {
				$meta_query[0]['compare'] = $tax_query_item['operator'];
			}
		}

		return $meta_query;
	}

	/**
	 * Get results of query.
	 *
	 * @param \WP_REST_Request $request Request data.
	 * @return array
	 */
	public function get_results( $request ) {
		$query_args = $this->prepare_objects_query( $request );

		add_filter( 'posts_clauses', array( $this, 'add_query_clauses' ), 10, 2 );

		$query       = new \WP_Query();
		$results     = $query->query( $query_args );
		$total_posts = $query->found_posts;

		// Out-of-bounds, run the query again without LIMIT for total count.
		if ( $total_posts < 1 && $query_args['paged'] > 1 ) {
			unset( $query_args['paged'] );
			$count_query = new \WP_Query();
			$count_query->query( $query_args );
			$total_posts = $count_query->found_posts;
		}

		remove_filter( 'posts_clauses', array( $this, 'add_query_clauses' ), 10 );

		return array(
			'results' => $results,
			'total'   => (int) $total_posts,
			'pages'   => $query->query_vars['posts_per_page'] > 0 ? (int) ceil( $total_posts / (int) $query->query_vars['posts_per_page'] ) : 1,
		);
	}

	/**
	 * Get objects.
	 *
	 * @param \WP_REST_Request $request Request data.
	 * @return array
	 */
	public function get_objects( $request ) {
		$results = $this->get_results( $request );

		if ( ! empty( $results['results'] ) ) {
			// Prime caches to reduce future queries.
			_prime_post_caches( $results['results'] );
		}

		return array(
			'objects' => array_map( 'wc_get_product', $results['results'] ),
			'total'   => $results['total'],
			'pages'   => $results['pages'],
		);
	}

	/**
	 * Get last modified date for all products as an HTTP-date (RFC 7232).
	 *
	 * The result is cached in the 'wc_products' object cache group and invalidated via the
	 * clean_post_cache hook in WC_Post_Data::invalidate_products_last_modified().
	 *
	 * Note: This intentionally does NOT use WordPress core's wp_cache_get_last_changed() /
	 * wp_cache_set_last_changed() pattern. Those functions are designed for opaque cache-key
	 * salting where auto-seeding with the current time on a cache miss is acceptable (a wrong
	 * salt simply causes a cache miss and re-query). Here, the value is exposed to clients via
	 * the Last-Modified HTTP header for collection cache invalidation. Auto-seeding with "now"
	 * on a cache miss would force all clients to unnecessarily invalidate their local caches.
	 * Instead, on a cache miss we fall back to the database to get the real last modification
	 * time and cache that.
	 *
	 * @return string|null HTTP-date formatted string, or null if no products exist.
	 */
	public function get_last_modified() {
		$last_modified = wp_cache_get( 'last_modified', 'wc_products' );

		if ( false === $last_modified ) {
			global $wpdb;

			$last_modified_gmt = $wpdb->get_var(
				"SELECT MAX( post_modified_gmt ) FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' )"
			);

			if ( ! $last_modified_gmt ) {
				return null;
			}

			$last_modified = gmdate( 'D, d M Y H:i:s', strtotime( $last_modified_gmt ) ) . ' GMT';
			wp_cache_set( 'last_modified', $last_modified, 'wc_products' );
		}

		return $last_modified;
	}

	/**
	 * Add in conditional search filters for products.
	 *
	 * @param array     $args Query args.
	 * @param \WP_Query $wp_query WP_Query object.
	 * @return array
	 */
	public function add_query_clauses( array $args, \WP_Query $wp_query ): array {
		global $wpdb;

		if ( $wp_query->get( 'search' ) ) {
			$search         = '%' . $wpdb->esc_like( $wp_query->get( 'search' ) ) . '%';
			$search_query   = wc_product_sku_enabled()
				? $wpdb->prepare( " AND ( $wpdb->posts.post_title LIKE %s OR wc_product_meta_lookup.sku LIKE %s ) ", $search, $search )
				: $wpdb->prepare( " AND $wpdb->posts.post_title LIKE %s ", $search );
			$args['where'] .= $search_query;
			$args['join']   = $this->append_product_sorting_table_join( $args['join'] );
		}

		if ( $wp_query->get( 'sku' ) ) {
			$skus = explode( ',', $wp_query->get( 'sku' ) );
			// Include the current string as a SKU too.
			if ( 1 < count( $skus ) ) {
				$skus[] = $wp_query->get( 'sku' );
			}
			$args['join']   = $this->append_product_sorting_table_join( $args['join'] );
			$args['where'] .= ' AND wc_product_meta_lookup.sku IN (\'' . implode( '\',\'', array_map( 'esc_sql', $skus ) ) . '\')';
		}

		if ( $wp_query->get( 'slug' ) ) {
			$slugs = explode( ',', $wp_query->get( 'slug' ) );
			// Include the current string as a slug too.
			if ( 1 < count( $slugs ) ) {
				$slugs[] = $wp_query->get( 'slug' );
			}
			$args['join']   = $this->append_product_sorting_table_join( $args['join'] );
			$post_name__in  = implode( '","', array_map( 'esc_sql', $slugs ) );
			$args['where'] .= " AND $wpdb->posts.post_name IN (\"$post_name__in\")";
		}

		if ( $wp_query->get( 'stock_status' ) ) {
			$args['join']   = $this->append_product_sorting_table_join( $args['join'] );
			$args['where'] .= ' AND wc_product_meta_lookup.stock_status IN (\'' . implode( '\',\'', array_map( 'esc_sql', $wp_query->get( 'stock_status' ) ) ) . '\')';
		} elseif ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
			$args['join']   = $this->append_product_sorting_table_join( $args['join'] );
			$args['where'] .= ' AND wc_product_meta_lookup.stock_status NOT IN (\'outofstock\')';
		}

		if ( $wp_query->get( 'min_price' ) || $wp_query->get( 'max_price' ) ) {
			$args = $this->add_price_filter_clauses( $args, $wp_query );
		}

		return $args;
	}

	/**
	 * Add in conditional price filters.
	 *
	 * @param array     $args Query args.
	 * @param \WC_Query $wp_query WC_Query object.
	 * @return array
	 */
	protected function add_price_filter_clauses( $args, $wp_query ) {
		global $wpdb;

		$adjust_for_taxes = $this->adjust_price_filters_for_displayed_taxes();
		$args['join']     = $this->append_product_sorting_table_join( $args['join'] );

		if ( $wp_query->get( 'min_price' ) ) {
			$min_price_filter = $this->prepare_price_filter( $wp_query->get( 'min_price' ) );

			if ( $adjust_for_taxes ) {
				$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $min_price_filter, 'max_price', '>=' );
			} else {
				$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.max_price >= %f ', $min_price_filter );
			}
		}

		if ( $wp_query->get( 'max_price' ) ) {
			$max_price_filter = $this->prepare_price_filter( $wp_query->get( 'max_price' ) );

			if ( $adjust_for_taxes ) {
				$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $max_price_filter, 'min_price', '<=' );
			} else {
				$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.min_price <= %f ', $max_price_filter );
			}
		}

		return $args;
	}

	/**
	 * Get query for price filters when dealing with displayed taxes.
	 *
	 * @param float  $price_filter Price filter to apply.
	 * @param string $column Price being filtered (min or max).
	 * @param string $operator Comparison operator for column.
	 * @return string Constructed query.
	 */
	protected function get_price_filter_query_for_displayed_taxes( $price_filter, $column = 'min_price', $operator = '>=' ) {
		global $wpdb;

		// Select only used tax classes to avoid unwanted calculations.
		$product_tax_classes = $wpdb->get_col( "SELECT DISTINCT tax_class FROM {$wpdb->wc_product_meta_lookup};" );

		if ( empty( $product_tax_classes ) ) {
			return '';
		}

		$or_queries = array();

		// We need to adjust the filter for each possible tax class and combine the queries into one.
		foreach ( $product_tax_classes as $tax_class ) {
			$adjusted_price_filter = $this->adjust_price_filter_for_tax_class( $price_filter, $tax_class );
			$or_queries[]          = $wpdb->prepare(
				'( wc_product_meta_lookup.tax_class = %s AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f )',
				$tax_class,
				$adjusted_price_filter
			);
		}

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
		return $wpdb->prepare(
			' AND (
				wc_product_meta_lookup.tax_status = "taxable" AND ( 0=1 OR ' . implode( ' OR ', $or_queries ) . ')
				OR ( wc_product_meta_lookup.tax_status != "taxable" AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f )
			) ',
			$price_filter
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
	}

	/**
	 * If price filters need adjustment to work with displayed taxes, this returns true.
	 *
	 * This logic is used when prices are stored in the database differently to how they are being displayed, with regards
	 * to taxes.
	 *
	 * @return boolean
	 */
	protected function adjust_price_filters_for_displayed_taxes() {
		$display  = get_option( 'woocommerce_tax_display_shop' );
		$database = wc_prices_include_tax() ? 'incl' : 'excl';

		return $display !== $database;
	}

	/**
	 * Converts price filter from subunits to decimal.
	 *
	 * @param string|int $price_filter Raw price filter in subunit format.
	 * @return float Price filter in decimal format.
	 */
	protected function prepare_price_filter( $price_filter ) {
		return floatval( $price_filter / ( 10 ** wc_get_price_decimals() ) );
	}

	/**
	 * Adjusts a price filter based on a tax class and whether or not the amount includes or excludes taxes.
	 *
	 * This calculation logic is based on `wc_get_price_excluding_tax` and `wc_get_price_including_tax` in core.
	 *
	 * @param float  $price_filter Price filter amount as entered.
	 * @param string $tax_class Tax class for adjustment.
	 * @return float
	 */
	protected function adjust_price_filter_for_tax_class( $price_filter, $tax_class ) {
		$tax_display    = get_option( 'woocommerce_tax_display_shop' );
		$tax_rates      = WC_Tax::get_rates( $tax_class );
		$base_tax_rates = WC_Tax::get_base_tax_rates( $tax_class );

		// If prices are shown incl. tax, we want to remove the taxes from the filter amount to match prices stored excl. tax.
		if ( 'incl' === $tax_display ) {
			/**
			 * Filters if taxes should be removed from locations outside the store base location.
			 *
			 * The woocommerce_adjust_non_base_location_prices filter can stop base taxes being taken off when dealing
			 * with out of base locations. e.g. If a product costs 10 including tax, all users will pay 10
			 * regardless of location and taxes.
			 *
			 * @since 2.6.0
			 *
			 * @internal Matches filter name in WooCommerce core.
			 *
			 * @param boolean $adjust_non_base_location_prices True by default.
			 * @return boolean
			 */
			$taxes = apply_filters( 'woocommerce_adjust_non_base_location_prices', true ) ? WC_Tax::calc_tax( $price_filter, $base_tax_rates, true ) : WC_Tax::calc_tax( $price_filter, $tax_rates, true );
			return $price_filter - array_sum( $taxes );
		}

		// If prices are shown excl. tax, add taxes to match the prices stored in the DB.
		$taxes = WC_Tax::calc_tax( $price_filter, $tax_rates, false );

		return $price_filter + array_sum( $taxes );
	}

	/**
	 * Join wc_product_meta_lookup to posts if not already joined.
	 *
	 * @param string $sql SQL join.
	 * @return string
	 */
	protected function append_product_sorting_table_join( $sql ) {
		global $wpdb;

		if ( ! strstr( $sql, 'wc_product_meta_lookup' ) ) {
			$sql .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id ";
		}
		return $sql;
	}
}
