<?php
/**
 * Recommendations Query
 *
 * @package   EDD\Pro\Recommendations
 * @copyright Copyright (c) 2025, Sandhills Development, LLC
 * @license   https://opensource.org/licenses/gpl-2.0.php GNU Public License
 * @since     3.6.2
 */

namespace EDD\Pro\Recommendations;

// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore

/**
 * Recommendations Query class
 *
 * @since 3.6.2
 */
class Query {

	/**
	 * The API instance.
	 *
	 * @var API
	 */
	private $api;

	/**
	 * The cart contents.
	 *
	 * @var array
	 */
	private $cart_contents;

	/**
	 * The cache group.
	 *
	 * @var string
	 */
	private $cache_group = 'edd_recommendations';

	/**
	 * The cache duration.
	 *
	 * @var int
	 */
	private $cache_duration = HOUR_IN_SECONDS;

	/**
	 * Constructor.
	 *
	 * @since 3.6.2
	 */
	public function __construct() {
		$this->api = new API();
	}

	/**
	 * Get the recommendations for the cart.
	 *
	 * @since 3.6.2
	 * @param int|array $product_ids The product IDs.
	 * @param array     $args        The arguments.
	 * @return array The recommendations.
	 */
	public function get_cart_recommendations( $product_ids = array(), $args = array() ): array {
		if ( empty( $product_ids ) ) {
			$product_ids = wp_list_pluck( $this->get_cart_contents(), 'id' );
		}

		$args                = wp_parse_args(
			$args,
			array(
				'product_ids'   => $product_ids,
				'type'          => 'complementary',
				'limit'         => 5,
				'exclude_owned' => true,
				'exclude_ids'   => array(),
				'filters'       => array(),
				'use_cache'     => true,
			)
		);
		$args['exclude_ids'] = $this->get_excluded_products( $args['product_ids'], $args );

		// Try to get recommendations from cached post meta first.
		$cached_recommendations = $this->get_saved_recomendations( $args['product_ids'] );
		if ( ! empty( $cached_recommendations ) ) {
			$filtered = $this->filter_and_score_recommendations(
				$cached_recommendations,
				$args['exclude_ids'],
				$args['limit']
			);

			// If we got enough recommendations from cache, return them.
			if ( ! empty( $filtered ) ) {
				return $filtered;
			}
		}

		// Fallback to API if no cached data or insufficient results.
		if ( ! empty( $args['use_cache'] ) ) {
			$cache_key = $this->get_cache_key( $args );
			$cache     = wp_cache_get( $cache_key, $this->cache_group );
			if ( false !== $cache ) {
				return $cache;
			}
		}

		$response = $this->api->get_recommendations( $args );
		if ( is_wp_error( $response ) ) {
			return array();
		}

		$recommendations = ! empty( $response['recommendations'] ) ? $response['recommendations'] : array();
		$filtered        = array();
		foreach ( $recommendations as $recommendation ) {
			$filtered = array_merge( $filtered, $recommendation );
		}

		if ( ! empty( $args['use_cache'] ) ) {
			wp_cache_set( $cache_key, $filtered, $this->cache_group, $this->cache_duration );
		}

		return $filtered;
	}

	/**
	 * Clear the cache.
	 *
	 * @since 3.6.2
	 * @return void
	 */
	public function clear_cache(): void {
		wp_cache_flush_group( $this->cache_group );
	}

	/**
	 * Build the exclusion list.
	 *
	 * @since 3.6.2
	 * @param int|array $product_ids The product IDs.
	 * @param array     $args       The arguments.
	 * @return array The exclusion list.
	 */
	private function get_excluded_products( $product_ids, $args ): array {
		$exclude_ids = (array) $product_ids;

		// Add explicitly excluded IDs.
		if ( ! empty( $args['exclude_ids'] ) ) {
			$exclude_ids = array_merge( $exclude_ids, $args['exclude_ids'] );
		}

		// Add customer-owned products.
		if ( ! empty( $args['exclude_owned'] ) ) {
			$owned_ids   = $this->get_customer_products();
			$exclude_ids = array_merge( $exclude_ids, $owned_ids );
		}

		// Add products in cart.
		if ( ! empty( $args['exclude_in_cart'] ) ) {
			$cart_items = $this->get_cart_contents();
			if ( ! empty( $cart_items ) ) {
				$cart_ids    = wp_list_pluck( $cart_items, 'id' );
				$exclude_ids = array_merge( $exclude_ids, $cart_ids );
			}
		}

		// Add hidden downloads.
		$hidden_ids  = $this->get_hidden_downloads();
		$exclude_ids = array_merge( $exclude_ids, $hidden_ids );

		return array_unique( array_filter( array_map( 'intval', $exclude_ids ) ) );
	}

	/**
	 * Get the products owned by a customer.
	 *
	 * @since 3.6.2
	 * @param int $customer_id The customer ID.
	 * @return array The product IDs.
	 */
	private function get_customer_products( $customer_id = null ) {
		if ( ! $customer_id ) {
			$customer = edd_get_customer_by( 'user_id', get_current_user_id() );
		} else {
			$customer = edd_get_customer( $customer_id );
		}

		if ( ! $customer ) {
			return array();
		}

		return array_map(
			'intval',
			edd_get_order_items(
				array(
					'status__in'  => edd_get_deliverable_order_item_statuses(),
					'order_query' => array(
						'customer_id' => $customer->id,
					),
					'fields'      => 'product_id',
					'groupby'     => 'product_id',
					'number'      => 999999,
				)
			)
		);
	}

	/**
	 * Get the cart contents.
	 *
	 * @since 3.6.2
	 * @return array The cart contents.
	 */
	private function get_cart_contents(): array {
		if ( ! $this->cart_contents ) {
			$this->cart_contents = edd_get_cart_contents();
		}

		return $this->cart_contents;
	}

	/**
	 * Get hidden download IDs. These downloads can be ingested into the
	 * recommendations service, but should not be recommended to customers.
	 *
	 * @since 3.6.2
	 * @return array The hidden download IDs.
	 */
	private function get_hidden_downloads(): array {
		$hidden_downloads = get_transient( 'edd_hd_ids' );

		// The transient, if it exists, comes from the Hide Download extension. We are not going to set it here.
		if ( false === $hidden_downloads ) {
			$query = new \WP_Query(
				array(
					'post_type'      => 'download',
					'post_status'    => 'any',
					'posts_per_page' => -1,
					'fields'         => 'ids',
					'meta_query'     => array(
						array(
							'key'     => '_edd_hide_download',
							'compare' => 'EXISTS',
						),
					),
				)
			);

			$hidden_downloads = $query->posts;
		}

		/**
		 * Filter the hidden download IDs.
		 *
		 * @since 3.6.2
		 * @param array $hidden_downloads The hidden download IDs.
		 * @return array The hidden download IDs.
		 */
		return apply_filters( 'edd_recommendations_hidden_downloads', array_map( 'intval', $hidden_downloads ) );
	}

	/**
	 * Get the cache key.
	 *
	 * @since 3.6.2
	 * @param array $args The arguments.
	 * @return string The cache key.
	 */
	private function get_cache_key( $args ): string {
		$key_args = array(
			'user_id'     => get_current_user_id(),
			'product_ids' => $args['product_ids'],
			'type'        => $args['type'],
			'limit'       => $args['limit'],
		);

		return 'r_' . md5( serialize( $key_args ) );
	}

	/**
	 * Get recommendations saved in post meta for multiple products.
	 *
	 * @since 3.6.2
	 * @param array $product_ids The product IDs to get recommendations for.
	 * @return array The aggregated recommendations from all products.
	 */
	private function get_saved_recomendations( $product_ids ): array {
		$all_recommendations = array();

		foreach ( $product_ids as $product_id ) {
			$cached = get_post_meta( $product_id, '_edd_cached_recommendations', true );
			if ( ! empty( $cached ) && is_array( $cached ) ) {
				$all_recommendations = array_merge( $all_recommendations, $cached );
			}
		}

		return $all_recommendations;
	}

	/**
	 * Filter and score recommendations based on exclusions and frequency.
	 *
	 * This method:
	 * - Removes excluded products
	 * - Deduplicates recommendations
	 * - Boosts scores for products recommended by multiple cart items
	 * - Sorts by score and limits results
	 *
	 * @since 3.6.2
	 * @param array $all_recommendations All recommendations from cached data.
	 * @param array $exclude_ids         Product IDs to exclude.
	 * @param int   $limit               Maximum number of recommendations to return.
	 * @return array Filtered and scored recommendations.
	 */
	private function filter_and_score_recommendations( $all_recommendations, $exclude_ids, $limit ): array {
		$scored = array();

		// Aggregate and track recommendation frequency.
		foreach ( $all_recommendations as $recommendation ) {
			if ( empty( $recommendation['product_id'] ) ) {
				continue;
			}

			$product_id = is_numeric( $recommendation['product_id'] ) ? (int) $recommendation['product_id'] : $recommendation['product_id'];

			// Skip excluded products.
			if ( in_array( $product_id, $exclude_ids, true ) ) {
				continue;
			}

			if ( isset( $scored[ $product_id ] ) ) {
				// Product recommended multiple times - use the highest score.
				$scored[ $product_id ]['score'] = max( $scored[ $product_id ]['score'], $recommendation['score'] );
				++$scored[ $product_id ]['frequency'];
			} else {
				$scored[ $product_id ]              = $recommendation;
				$scored[ $product_id ]['frequency'] = 1;
			}
		}

		// Apply frequency boost - products recommended for multiple cart items are more relevant.
		foreach ( $scored as $product_id => &$recommendation ) {
			if ( $recommendation['frequency'] > 1 ) {
				// Boost score by 10% for each additional recommendation source.
				$recommendation['score'] *= ( 1 + ( 0.1 * ( $recommendation['frequency'] - 1 ) ) );
			}
		}
		unset( $recommendation );

		// Sort by score descending.
		usort(
			$scored,
			function ( $a, $b ) {
				return $b['score'] <=> $a['score'];
			}
		);

		// Limit results.
		$scored = array_slice( $scored, 0, $limit );

		// Remove internal frequency field.
		foreach ( $scored as &$recommendation ) {
			$recommendation['product_id'] = intval( $recommendation['product_id'] );
			unset( $recommendation['frequency'] );
		}

		return array_values( $scored );
	}
}
