<?php
/**
 * Recommendations engine.
 *
 * @package PRECO
 */

namespace PRECO\Includes\Frontend\Core;

use PRECO\Includes\Frontend\Engine\Filters\PriceHelper;
use PRECO\Includes\Frontend\Engine\Filters\QueryBuilder;
use PRECO\Includes\Frontend\Engine\Sorting\ManualSorter;
use PRECO\Includes\Frontend\Engine\Sorting\PreferredIdsProvider;
use PRECO\Includes\Frontend\Engine\Sorting\SortingHelper;
use PRECO\Includes\Frontend\Core\Engine\OnSaleRuleHelper;

defined( 'ABSPATH' ) || exit;

/**
 * Recommendations engine.
 */
class RecommendationsEngine {

	/**
	 * Price helper.
	 *
	 * @var PriceHelper
	 */
	private $price;

	/**
	 * Sorting helper.
	 *
	 * @var SortingHelper
	 */
	private $sorting;

	/**
	 * Preferred IDs provider.
	 *
	 * @var PreferredIdsProvider
	 */
	private $preferred;

	/**
	 * Manual sorter.
	 *
	 * @var ManualSorter
	 */
	private $manual_sorter;

	/**
	 * Query builder.
	 *
	 * @var QueryBuilder
	 */
	private $query_builder;

	/**
	 * On-sale rule helper.
	 *
	 * @var OnSaleRuleHelper
	 */
	private $on_sale;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->price         = new PriceHelper();
		$this->sorting       = new SortingHelper();
		$this->preferred     = new PreferredIdsProvider( $this->sorting );
		$this->manual_sorter = new ManualSorter( $this->sorting, $this->price );
		$this->query_builder = new QueryBuilder( $this->sorting, $this->price );
		$this->on_sale       = new OnSaleRuleHelper();
	}

	/**
	 * Get recommended product IDs.
	 *
	 * @param array $rules Rules.
	 * @param array $sorting Sorting.
	 * @param array $context Context.
	 * @param int   $limit Limit.
	 * @return int[]
	 */
	public function get_product_ids( $rules, $sorting, $context, $limit ) {
		$limit = max( 0, (int) $limit );
		if ( 0 === $limit ) {
			return array();
		}

		$rules   = is_array( $rules ) ? $rules : array();
		$sorting = is_array( $sorting ) ? $sorting : array();
		$context = is_array( $context ) ? $context : array();

		// Build a definitive exclusion set once. Applied to every final result regardless
		// of which query path ran — ensures no filter (on_sale, products_viewed, etc.)
		// can ever return the current product or cart items.
		$hard_exclude = array();
		if ( ! empty( $context['product_id'] ) ) {
			$hard_exclude[] = absint( $context['product_id'] );
		}
		if ( isset( $context['exclude_ids'] ) && is_array( $context['exclude_ids'] ) ) {
			$hard_exclude = array_merge( $hard_exclude, array_map( 'absint', $context['exclude_ids'] ) );
		}
		$hard_exclude = array_values( array_unique( array_filter( $hard_exclude ) ) );

		$match_mode = isset( $context['match_mode'] ) && 'any' === (string) $context['match_mode'] ? 'any' : 'all';

		// ----------------------------------------------------------------
		// "Match Any" (OR): run a separate WP_Query per filter rule and
		// union the results. Gate rules (current_category / current_tag /
		// current_attribute) still require ALL to pass before any query runs.
		// WP_Query cannot OR across tax_query + meta_query at the top level,
		// so per-rule union is the only reliable approach.
		// ----------------------------------------------------------------
		if ( 'any' === $match_mode ) {
			$gate_types   = array( 'current_category', 'current_tag', 'current_attribute' );
			$gate_rules   = array();
			$filter_rules = array();

			foreach ( $rules as $rule ) {
				if ( ! is_array( $rule ) ) {
					continue;
				}
				if ( isset( $rule['enabled'] ) && false === $rule['enabled'] ) {
					continue;
				}
				$trigger = isset( $rule['triggerType'] ) ? (string) $rule['triggerType'] : '';
				if ( in_array( $trigger, $gate_types, true ) ) {
					$gate_rules[] = $rule;
				} else {
					$filter_rules[] = $rule;
				}
			}

			// All context gates must pass (AND semantics for gates).
			if ( ! empty( $gate_rules ) ) {
				// Use 'all' context so build_query_args applies AND for the gate check.
				$gate_ctx  = array_merge( $context, array( 'match_mode' => 'all' ) );
				$gate_args = $this->query_builder->build_query_args( $gate_rules, array(), $gate_ctx, 1 );
				if ( isset( $gate_args['post__in'] ) && array( 0 ) === $gate_args['post__in'] ) {
					return array();
				}
			}

			// Run every filter rule individually and union the results.
			$all_ctx   = array_merge( $context, array( 'match_mode' => 'all' ) );
			$per_limit = max( $limit, $limit * 5 );
			$union_ids = array();

			$rules_to_run = empty( $filter_rules ) ? $gate_rules : $filter_rules;

			foreach ( $rules_to_run as $rule ) {
				$r_args = $this->query_builder->build_query_args( array( $rule ), $sorting, $all_ctx, $per_limit );
				if ( empty( $r_args ) ) {
					continue;
				}
				// Guard: if gate already blocked this rule, skip.
				if ( isset( $r_args['post__in'] ) && array( 0 ) === $r_args['post__in'] ) {
					continue;
				}
				$r_args['fields']                 = 'ids';
				$r_args['no_found_rows']          = true;
				$r_args['suppress_filters']       = false;
				$r_args['update_post_meta_cache'] = false;
				$r_args['update_post_term_cache'] = false;
				$q_r                              = new \WP_Query( $r_args );
				$r_ids                            = is_array( $q_r->posts )
					? array_values( array_filter( array_map( 'absint', $q_r->posts ) ) )
					: array();
				$union_ids                        = array_merge( $union_ids, $r_ids );
			}

			$union_ids = array_values( array_unique( array_filter( $union_ids ) ) );

			if ( empty( $union_ids ) ) {
				return array();
			}

			// PHP-side on-sale and price filtering on the union.
			if ( $this->on_sale->should_exclude_on_sale_from_rules( $rules ) ) {
				$union_ids = $this->on_sale->filter_out_on_sale_products( $union_ids );
			}
			$price_bounds = $this->price->resolve_bounds_from_rules( $rules, $context );
			if ( ! empty( $price_bounds ) ) {
				$union_ids = $this->price->filter_ids_by_bounds( $union_ids, $price_bounds );
			}

			if ( empty( $union_ids ) ) {
				return array();
			}

			// Sort the union in PHP.
			$preferred_rank_maps = $this->preferred->get_preferred_rank_maps_from_sorting( $sorting, $context, $limit );
			$enabled_sort_rules  = $this->sorting->get_enabled_sorting_rules( $sorting );
			if ( ! empty( $enabled_sort_rules ) ) {
				$union_ids = $this->manual_sorter->sort_ids_by_sorting_rules( $union_ids, $sorting, $context, $preferred_rank_maps );
			}

			if ( ! empty( $hard_exclude ) ) {
				$union_ids = array_values( array_diff( $union_ids, $hard_exclude ) );
			}

			return array_slice( $union_ids, 0, $limit );
		}

		// ----------------------------------------------------------------
		// "Match All" (AND): single WP_Query — build_query_args handles relation.
		// ----------------------------------------------------------------
		$exclude_on_sale = $this->on_sale->should_exclude_on_sale_from_rules( $rules );

		$price_bounds = $this->price->resolve_bounds_from_rules( $rules, $context );

		$preferred_rank_maps = $this->preferred->get_preferred_rank_maps_from_sorting( $sorting, $context, $limit );
		$preferred_union     = array();
		foreach ( $preferred_rank_maps as $map ) {
			if ( is_array( $map ) ) {
				$preferred_union = array_merge( $preferred_union, array_keys( $map ) );
			}
		}
		$preferred_union = array_values( array_unique( array_filter( array_map( 'absint', $preferred_union ) ) ) );

		$enabled_sort_rules  = $this->sorting->get_enabled_sorting_rules( $sorting );
		$has_multi_sort      = count( $enabled_sort_rules ) > 1;
		$has_price_sort      = false;
		$has_popularity_sort = false;
		foreach ( $enabled_sort_rules as $sr ) {
			$sr_trigger = isset( $sr['triggerType'] ) ? (string) $sr['triggerType'] : '';
			if ( 'price' === $sr_trigger ) {
				$has_price_sort = true;
			}
			if ( 'popularity' === $sr_trigger ) {
				$has_popularity_sort = true;
			}
		}

		// If we might filter/sort in PHP, fetch extra candidates so PHP sorting
		// has the full candidate pool (not just the first $limit by WP_Query order).
		// Cap at 500 to prevent runaway queries on large catalogs.
		$fetch_limit = $limit;
		if ( ! empty( $price_bounds ) || $has_multi_sort || $has_price_sort || $has_popularity_sort || ! empty( $preferred_union ) ) {
			$fetch_limit = min( max( $limit, (int) $limit * 10 ), 500 );
		}
		// Always pad by the hard-exclude count so the PHP strip never leaves us
		// short of $limit results even when WooCommerce suppresses post__not_in.
		$fetch_limit += count( $hard_exclude );

		$args = $this->query_builder->build_query_args( $rules, $sorting, $context, $fetch_limit );
		if ( empty( $args ) ) {
			return array();
		}

		$preferred_ids = array();
		if ( ! empty( $preferred_union ) ) {
			$args_pref = $args;
			$in        = $preferred_union;
			if ( isset( $args_pref['post__in'] ) && is_array( $args_pref['post__in'] ) ) {
				$in = array_values( array_intersect( $in, array_map( 'absint', $args_pref['post__in'] ) ) );
			}
			if ( ! empty( $in ) ) {
				$args_pref['post__in']       = $in;
				$args_pref['orderby']        = 'post__in';
				$args_pref['order']          = 'ASC';
				$args_pref['posts_per_page'] = count( $in );
				$q_pref                      = new \WP_Query( $args_pref );
				$preferred_ids               = is_array( $q_pref->posts ) ? array_values( array_filter( array_map( 'absint', $q_pref->posts ) ) ) : array();
			}
		}

		$args_main = $args;
		if ( ! empty( $preferred_ids ) ) {
			$args_main['post__not_in']   = array_values( array_unique( array_merge( $args_main['post__not_in'] ?? array(), $preferred_ids ) ) );	// phpcs:ignore
			$args_main['posts_per_page'] = max( 0, (int) $fetch_limit - count( $preferred_ids ) );
		}

		$q_main = new \WP_Query( $args_main );
		$ids    = is_array( $q_main->posts ) ? array_values( array_filter( array_map( 'absint', $q_main->posts ) ) ) : array();
		if ( ! empty( $preferred_ids ) ) {
			$ids = array_values( array_unique( array_merge( $preferred_ids, $ids ) ) );
		}
		if ( empty( $ids ) ) {
			return array();
		}

		// Deterministic PHP-side on-sale filter (covers grouped/variable products reliably).
		if ( $exclude_on_sale ) {
			$ids = $this->on_sale->filter_out_on_sale_products( $ids );
		}

		// Deterministic PHP-side price filter (avoids meta query inconsistencies).
		if ( ! empty( $price_bounds ) ) {
			$ids = $this->price->filter_ids_by_bounds( $ids, $price_bounds );
		}

		// Apply multi-sorting with priorities deterministically in PHP.
		if ( ! empty( $enabled_sort_rules ) ) {
			$ids = $this->manual_sorter->sort_ids_by_sorting_rules( $ids, $sorting, $context, $preferred_rank_maps );
		}

		if ( ! empty( $hard_exclude ) ) {
			$ids = array_values( array_diff( $ids, $hard_exclude ) );
		}

		return array_slice( $ids, 0, $limit );
	}
}
