<?php
declare(strict_types=1);

namespace Automattic\WooCommerce\Blocks\BlockTypes\ProductCollection;

use Automattic\WooCommerce\Enums\OrderItemType;
use InvalidArgumentException;

/**
 * HandlerRegistry class.
 * Manages collection handlers.
 */
class HandlerRegistry {

	/**
	 * Associative array of collection handlers.
	 *
	 * @var array
	 */
	protected $collection_handler_store = [];

	/**
	 * Register handlers for a collection.
	 *
	 * @param string        $collection_name The name of the collection.
	 * @param callable      $build_query     The query builder callable.
	 * @param callable|null $frontend_args   Optional frontend args callable.
	 * @param callable|null $editor_args     Optional editor args callable.
	 * @param callable|null $preview_query   Optional preview query callable.
	 *
	 * @throws InvalidArgumentException If handlers are already registered for the collection.
	 */
	public function register_collection_handlers( $collection_name, $build_query, $frontend_args = null, $editor_args = null, $preview_query = null ) {
		if ( isset( $this->collection_handler_store[ $collection_name ] ) ) {
			throw new InvalidArgumentException( 'Collection handlers already registered for ' . esc_html( $collection_name ) );
		}

		$this->collection_handler_store[ $collection_name ] = [
			'build_query'   => $build_query,
			'frontend_args' => $frontend_args,
			'editor_args'   => $editor_args,
			'preview_query' => $preview_query,
		];

		return $this->collection_handler_store[ $collection_name ];
	}

	/**
	 * Register core collection handlers.
	 */
	public function register_core_collections() {
		$this->register_collection_handlers(
			'woocommerce/product-collection/hand-picked',
			function ( $collection_args, $common_query_values, $query ) {
				// For Hand-Picked collection, if no products are selected, we should return an empty result set.
				// This ensures that the collection doesn't display any products until the user explicitly chooses them.
				if ( empty( $query['handpicked_products'] ) ) {
					return array(
						'post__in' => array( -1 ),
					);
				}
			}
		);

		$this->register_collection_handlers(
			'woocommerce/product-collection/by-category',
			function ( $collection_args, $common_query_values, $query ) {
				// For Products by Category collection, if no category is selected, we should return an empty result set.
				if ( empty( $query['taxonomies_query'] ) ) {
					return array(
						'post__in' => array( -1 ),
					);
				}
			}
		);

		$this->register_collection_handlers(
			'woocommerce/product-collection/by-tag',
			function ( $collection_args, $common_query_values, $query ) {
				// For Products by Tag collection, if no tag is selected, we should return an empty result set.
				if ( empty( $query['taxonomies_query'] ) ) {
					return array(
						'post__in' => array( -1 ),
					);
				}
			}
		);

		$this->register_collection_handlers(
			'woocommerce/product-collection/by-brand',
			function ( $collection_args, $common_query_values, $query ) {
				// For Products by Brand collection, if no brand is selected, we should return an empty result set.
				if ( empty( $query['taxonomies_query'] ) ) {
					return array(
						'post__in' => array( -1 ),
					);
				}
			}
		);

		$this->register_collection_handlers(
			'woocommerce/product-collection/related',
			function ( $collection_args ) {
				// No products should be shown if no related product reference is set.
				if ( empty( $collection_args['relatedProductReference'] ) ) {
					return array(
						'post__in' => array( -1 ),
					);
				}

				$category_callback = function () use ( $collection_args ) {
					return $collection_args['relatedBy']['categories'];
				};

				$tag_callback = function () use ( $collection_args ) {
					return $collection_args['relatedBy']['tags'];
				};

				add_filter( 'woocommerce_product_related_posts_relate_by_category', $category_callback, PHP_INT_MAX );
				add_filter( 'woocommerce_product_related_posts_relate_by_tag', $tag_callback, PHP_INT_MAX );

				$related_products = wc_get_related_products(
					$collection_args['relatedProductReference'],
					// Use a higher limit so that the result set contains enough products for the collection to subsequently filter.
					100,
					array(),
					$collection_args['relatedBy']
				);

				remove_filter( 'woocommerce_product_related_posts_relate_by_category', $category_callback, PHP_INT_MAX );
				remove_filter( 'woocommerce_product_related_posts_relate_by_tag', $tag_callback, PHP_INT_MAX );

				if ( empty( $related_products ) ) {
					return array(
						'post__in' => array( -1 ),
					);
				}

				// Have it filter the results to products related to the one provided.
				return array(
					'post__in' => $related_products,
				);
			},
			function ( $collection_args, $query ) {
				$product_reference = $query['productReference'] ?? null;
				// Infer the product reference from the location if an explicit product is not set.
				if ( empty( $product_reference ) ) {
					$location = $collection_args['productCollectionLocation'];
					if ( isset( $location['type'] ) && 'product' === $location['type'] ) {
						$product_reference = $location['sourceData']['productId'];
					}
				}

				$collection_args['relatedProductReference'] = $product_reference;
				$collection_args['relatedBy']               = ! isset( $query['relatedBy'] ) ? array(
					'categories' => true,
					'tags'       => true,
				) : array(
					'categories' => isset( $query['relatedBy']['categories'] ) && true === $query['relatedBy']['categories'],
					'tags'       => isset( $query['relatedBy']['tags'] ) && true === $query['relatedBy']['tags'],
				);

				return $collection_args;
			},
			function ( $collection_args, $query, $request ) {
				$product_reference = $request->get_param( 'productReference' );
				// In some cases the editor will send along block location context that we can infer the product reference from.
				if ( empty( $product_reference ) ) {
					$location = $collection_args['productCollectionLocation'];
					if ( isset( $location['type'] ) && 'product' === $location['type'] ) {
						$product_reference = $location['sourceData']['productId'];
					}
				}

				$collection_args['relatedProductReference'] = $product_reference;

				$related_by                   = $request->get_param( 'relatedBy' );
				$collection_args['relatedBy'] = ! isset( $related_by ) ? array(
					'categories' => true,
					'tags'       => true,
				) : array(
					'categories' => rest_sanitize_boolean( $related_by['categories'] ?? false ),
					'tags'       => rest_sanitize_boolean( $related_by['tags'] ?? false ),
				);

				return $collection_args;
			}
		);

		$this->register_collection_handlers(
			'woocommerce/product-collection/upsells',
			function ( $collection_args ) {
				$product_reference = $collection_args['upsellsProductReferences'] ?? null;
				// No products should be shown if no upsells product reference is set.
				if ( empty( $product_reference ) ) {
					return array(
						'post__in' => array( -1 ),
					);
				}

				$products = array_filter( array_map( 'wc_get_product', $product_reference ) );

				if ( empty( $products ) ) {
					return array(
						'post__in' => array( -1 ),
					);
				}

				$all_upsells = array_reduce(
					$products,
					function ( $acc, $product ) {
						return array_merge(
							$acc,
							$product->get_upsell_ids()
						);
					},
					array()
				);

				// Remove duplicates and product references. We don't want to display
				// what's already in cart.
				$unique_upsells = array_unique( $all_upsells );
				$upsells        = array_diff( $unique_upsells, $product_reference );

				return array(
					'post__in' => empty( $upsells ) ? array( -1 ) : $upsells,
				);
			},
			function ( $collection_args, $query ) {
				$product_references = isset( $query['productReference'] ) ? array( $query['productReference'] ) : null;
				// Infer the product reference from the location if an explicit product is not set.
				if ( empty( $product_references ) ) {
					$location = $collection_args['productCollectionLocation'];
					if ( isset( $location['type'] ) && 'product' === $location['type'] ) {
						$product_references = array( $location['sourceData']['productId'] );
					}

					if ( isset( $location['type'] ) && 'cart' === $location['type'] ) {
						$product_references = $location['sourceData']['productIds'];
					}

					if ( isset( $location['type'] ) && 'order' === $location['type'] ) {
						$product_references = $this->get_product_ids_from_order( $location['sourceData']['orderId'] ?? 0 );
					}
				}

				$collection_args['upsellsProductReferences'] = $product_references;
				return $collection_args;
			},
			function ( $collection_args, $query, $request ) {
				$product_reference = $request->get_param( 'productReference' );
				// In some cases the editor will send along block location context that we can infer the product reference from.
				if ( empty( $product_reference ) ) {
					$location = $collection_args['productCollectionLocation'];
					if ( isset( $location['type'] ) && 'product' === $location['type'] ) {
						$product_reference = $location['sourceData']['productId'];
					}
				}

				$collection_args['upsellsProductReferences'] = array( $product_reference );
				return $collection_args;
			}
		);

		$this->register_collection_handlers(
			'woocommerce/product-collection/cross-sells',
			function ( $collection_args ) {
				$product_reference = $collection_args['crossSellsProductReferences'] ?? null;
				// No products should be shown if no cross-sells product reference is set.
				if ( empty( $product_reference ) ) {
					return array(
						'post__in' => array( -1 ),
					);
				}

				$products = array_filter( array_map( 'wc_get_product', $product_reference ) );

				if ( empty( $products ) ) {
					return array(
						'post__in' => array( -1 ),
					);
				}

				$all_cross_sells = array_reduce(
					$products,
					function ( $acc, $product ) {
						return array_merge(
							$acc,
							$product->get_cross_sell_ids()
						);
					},
					array()
				);

				// Remove duplicates and product references. We don't want to display
				// what's already in cart.
				$unique_cross_sells = array_unique( $all_cross_sells );
				$cross_sells        = array_diff( $unique_cross_sells, $product_reference );

				return array(
					'post__in' => empty( $cross_sells ) ? array( -1 ) : $cross_sells,
				);
			},
			function ( $collection_args, $query ) {
				$product_references = isset( $query['productReference'] ) ? array( $query['productReference'] ) : null;
				// Infer the product reference from the location if an explicit product is not set.
				if ( empty( $product_references ) ) {
					$location = $collection_args['productCollectionLocation'];
					if ( isset( $location['type'] ) && 'product' === $location['type'] ) {
						$product_references = array( $location['sourceData']['productId'] );
					}

					if ( isset( $location['type'] ) && 'cart' === $location['type'] ) {
						$product_references = $location['sourceData']['productIds'];
					}

					if ( isset( $location['type'] ) && 'order' === $location['type'] ) {
						$product_references = $this->get_product_ids_from_order( $location['sourceData']['orderId'] ?? 0 );
					}
				}

				$collection_args['crossSellsProductReferences'] = $product_references;
				return $collection_args;
			},
			function ( $collection_args, $query, $request ) {
				$product_reference = $request->get_param( 'productReference' );
				// In some cases the editor will send along block location context that we can infer the product reference from.
				if ( empty( $product_reference ) ) {
					$location = $collection_args['productCollectionLocation'];
					if ( isset( $location['type'] ) && 'product' === $location['type'] ) {
						$product_reference = $location['sourceData']['productId'];
					}
				}

				$collection_args['crossSellsProductReferences'] = array( $product_reference );
				return $collection_args;
			}
		);

		// Best-sellers and new-arrivals: register preview_query only.
		// These collections handle their main queries via JS/REST, but need
		// a preview fallback to show recent products in the email editor
		// when the store has no best-sellers or new arrivals yet.
		// The build_query callback is a no-op that returns an empty array
		// so merge_queries() has nothing extra to merge.
		$noop_build_query = function () {
			return array();
		};

		$this->register_collection_handlers(
			'woocommerce/product-collection/best-sellers',
			$noop_build_query,
			null,
			null,
			function () {
				return $this->get_recent_product_ids_query();
			}
		);

		$this->register_collection_handlers(
			'woocommerce/product-collection/new-arrivals',
			$noop_build_query,
			null,
			null,
			function () {
				return $this->get_recent_product_ids_query();
			}
		);

		$this->register_collection_handlers(
			'woocommerce/product-collection/cart-contents',
			function ( $collection_args ) {
				$cart_product_ids = $collection_args['cartProductIds'] ?? null;
				if ( empty( $cart_product_ids ) ) {
					return array( 'post__in' => array( -1 ) );
				}
				return array( 'post__in' => $cart_product_ids );
			},
			function ( $collection_args, $query ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
				$collection_args['cartProductIds'] = $this->get_cart_product_ids( null );
				return $collection_args;
			},
			function ( $collection_args, $query, $request ) {
				$collection_args['cartProductIds'] = $this->get_cart_product_ids( $request );
				return $collection_args;
			}
		);
		return $this->collection_handler_store;
	}

	/**
	 * Get collection handler by name.
	 *
	 * @param string $name Collection name.
	 * @return array|null Collection handler array or null if not found.
	 */
	public function get_collection_handler( $name ) {
		return $this->collection_handler_store[ $name ] ?? null;
	}

	/**
	 * Removes any custom collection handlers for the given collection.
	 *
	 * @param string $collection_name The name of the collection to unregister.
	 */
	public function unregister_collection_handlers( $collection_name ) {
		unset( $this->collection_handler_store[ $collection_name ] );
	}


	/**
	 * Get product IDs from an order.
	 *
	 * @param int $order_id The order ID.
	 * @return array<int> The product IDs.
	 */
	private function get_product_ids_from_order( $order_id ) {
		$product_references = array();
		if ( empty( $order_id ) ) {
			return $product_references;
		}

		$order = wc_get_order( $order_id );
		if ( $order ) {
			$product_references = array_filter(
				array_map(
					function ( $item ) {
						return $item->get_product_id();
					},
					$order->get_items( OrderItemType::LINE_ITEM )
				)
			);
		}
		return $product_references;
	}

	/**
	 * Get a query that returns the most recent published products.
	 * Used as a fallback for preview mode when the specific collection query
	 * might return no results (e.g., no best sellers yet in a new store).
	 *
	 * @return array Query args to show recent products.
	 */
	private function get_recent_product_ids_query() {
		$recent_product_ids = wc_get_products(
			array(
				'status'  => 'publish',
				'orderby' => 'date',
				'order'   => 'DESC',
				'limit'   => 10,
				'return'  => 'ids',
			)
		);

		return array(
			'post__in' => ! empty( $recent_product_ids ) ? $recent_product_ids : array( -1 ),
		);
	}

	/**
	 * Get cart product IDs from various sources.
	 * Handles loading cart products from location context or request params.
	 *
	 * @param \WP_REST_Request|null $request Optional REST request for editor context.
	 * @return array<int> The product IDs from the cart. Returns recent products for preview in editor context only.
	 */
	private function get_cart_product_ids( $request = null ) {
		if ( $request ) {
			// In editor context (REST request), show sample products for preview. Only emails to the customer show live data.
			$recent_product_ids = wc_get_products(
				array(
					'status'  => 'publish',
					'orderby' => 'date',
					'order'   => 'DESC',
					'limit'   => 3,
					'return'  => 'ids',
				)
			);
			return ! empty( $recent_product_ids ) ? $recent_product_ids : array();
		}

		// In frontend/email context, return empty array when no cart is found.
		return array();
	}
}
