<?php
/**
 * Popup Upsell storefront rendering.
 *
 * @package PRECO
 */

namespace PRECO\Includes\Frontend\Pages;

use PRECO\Includes\Frontend\Core\CartConditions;
use PRECO\Includes\Frontend\Core\PreviewMode;
use PRECO\Includes\Frontend\Core\RecommendationsCache;
use PRECO\Includes\Frontend\Core\RecommendationsEngine;
use PRECO\Includes\Frontend\Core\RecommendationsRenderer;

defined( 'ABSPATH' ) || exit;

/**
 * Renders Popup Upsell campaigns.
 *
 * Unlike the other page recommenders, a Popup Upsell campaign has no fixed
 * DOM position — it targets a page (product/cart/checkout) but is shown as
 * a JS-triggered modal based on a trigger condition (time delay or a button
 * click) plus cart/customer conditions. It therefore does not extend
 * PageRecommendationsBase (which assumes one page_key + fixed hook
 * locations); instead it hooks `wp_footer` once and loops over every
 * enabled campaign, gating each server-side before emitting a hidden
 * popup wrapper that `popup-upsell.js` decides when to reveal.
 */
class PopupUpsellRecommendations {

	const OPTION_NAME = 'preco_popup_upsells';

	/**
	 * Extra candidates fetched beyond the configured offer limit when a
	 * popup's result is cacheable, so that diffing out THIS visitor's live
	 * cart contents (see render_one()) still leaves enough to fill the
	 * limit rather than under-filling because the cache slot happened to be
	 * warmed by a different visitor's cart.
	 */
	const CACHE_ID_PAD = 10;

	/**
	 * Recommendations engine.
	 *
	 * @var RecommendationsEngine
	 */
	private $engine;

	/**
	 * Recommendations renderer.
	 *
	 * @var RecommendationsRenderer
	 */
	private $renderer;

	/**
	 * Recommendations cache.
	 *
	 * @var RecommendationsCache
	 */
	private $cache;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->engine   = new RecommendationsEngine();
		$this->renderer = new RecommendationsRenderer();
		$this->cache    = new RecommendationsCache();

		add_action( 'wp_footer', array( $this, 'render_popups' ), 30 );
	}

	/**
	 * Render every eligible popup for the current request.
	 *
	 * @return void
	 */
	public function render_popups() {
		if ( ! function_exists( 'WC' ) ) {
			return;
		}

		// The builder's live iframe renders the storefront to preview page
		// locations being edited; a Popup Upsell has no fixed position in
		// that layout and would just appear as an unexplained modal, so it
		// never fires during a preview request.
		if ( PreviewMode::is_active_request() ) {
			return;
		}

		$current_page = $this->detect_current_page();
		if ( ! $current_page ) {
			return;
		}

		$popups = get_option( self::OPTION_NAME, array() );
		if ( ! is_array( $popups ) || empty( $popups ) ) {
			return;
		}

		foreach ( $this->resolve_winners( $popups, $current_page ) as $popup ) {
			$this->render_one( $popup, $current_page );
		}
	}

	/**
	 * Reduce every eligible popup for this request down to at most one per
	 * "trigger moment" — popups that would otherwise fire at the same
	 * instant (e.g. two "After 10 seconds" popups, or two popups both bound
	 * to the "Proceed to checkout" click) compete for that moment, and only
	 * the most specific trigger-product match wins:
	 * specific_products > specific_categories > all_products. Popups whose
	 * trigger moments don't coincide (e.g. 10s vs. 100s) never compete and
	 * can each render independently.
	 *
	 * @param array  $popups Raw popup records from the option.
	 * @param string $current_page Current page key.
	 * @return array Winning popup records, keyed by nothing in particular.
	 */
	private function resolve_winners( $popups, $current_page ) {
		$winners = array();

		foreach ( $popups as $popup ) {
			if ( ! is_array( $popup ) ) {
				continue;
			}
			if ( ! $this->is_eligible( $popup, $current_page ) ) {
				continue;
			}

			$moment_key = $this->trigger_moment_key( $popup );
			$rank       = $this->trigger_product_specificity( $popup );
			$id         = isset( $popup['id'] ) ? (int) $popup['id'] : 0;

			// Same moment + same specificity (e.g. two "specific product"
			// popups both matching): the more recently created popup (higher
			// id) wins.
			if (
				! isset( $winners[ $moment_key ] ) ||
				$rank < $winners[ $moment_key ]['rank'] ||
				( $rank === $winners[ $moment_key ]['rank'] && $id > $winners[ $moment_key ]['id'] )
			) {
				$winners[ $moment_key ] = array(
					'popup' => $popup,
					'rank'  => $rank,
					'id'    => $id,
				);
			}
		}

		return array_map(
			function ( $winner ) {
				return $winner['popup'];
			},
			array_values( $winners )
		);
	}

	/**
	 * Key identifying the instant a popup's trigger condition fires.
	 * Two popups sharing a key would otherwise appear at the same instant.
	 *
	 * @param array $popup Popup record.
	 * @return string
	 */
	private function trigger_moment_key( $popup ) {
		$trigger_condition = isset( $popup['triggerCondition'] ) && is_array( $popup['triggerCondition'] ) ? $popup['triggerCondition'] : array();
		$type              = isset( $trigger_condition['type'] ) ? (string) $trigger_condition['type'] : 'after_time';

		if ( 'after_time' === $type ) {
			// Distinct delays are distinct moments; click-based triggers have
			// no value, so the type alone is the moment.
			$value = isset( $trigger_condition['value'] ) ? (string) $trigger_condition['value'] : '';
			return $type . ':' . $value;
		}

		return $type;
	}

	/**
	 * Specificity rank of a popup's Trigger Product setting; lower wins.
	 *
	 * @param array $popup Popup record.
	 * @return int
	 */
	private function trigger_product_specificity( $popup ) {
		$type = isset( $popup['triggerProductType'] ) ? (string) $popup['triggerProductType'] : 'specific_products';

		switch ( $type ) {
			case 'specific_products':
				return 1;
			case 'specific_categories':
				return 2;
			case 'all_products':
				return 3;
			default:
				return 4;
		}
	}

	/**
	 * Detect which popup "page" the current request matches, if any.
	 *
	 * @return string|null
	 */
	private function detect_current_page() {
		if ( function_exists( 'is_product' ) && \is_product() ) {
			return 'product-page';
		}
		if ( function_exists( 'is_cart' ) && \is_cart() ) {
			return 'cart';
		}
		if (
			function_exists( 'is_checkout' ) && \is_checkout() &&
			! ( function_exists( 'is_order_received_page' ) && \is_order_received_page() )
		) {
			return 'checkout';
		}
		return null;
	}

	/**
	 * Whether a popup should be considered for rendering on this request.
	 *
	 * @param array  $popup Popup record.
	 * @param string $current_page Current page key.
	 * @return bool
	 */
	private function is_eligible( $popup, $current_page ) {
		if ( isset( $popup['enabled'] ) && false === $popup['enabled'] ) {
			return false;
		}
		if ( ! isset( $popup['page'] ) || (string) $popup['page'] !== $current_page ) {
			return false;
		}
		if ( ! $this->trigger_product_matches( $popup, $current_page ) ) {
			return false;
		}
		// Cart Condition isn't a setting for Product Page popups, so it's
		// never evaluated there even if a row was saved before a page change.
		if ( 'product-page' !== $current_page && ! $this->cart_conditions_pass( $popup ) ) {
			return false;
		}
		return true;
	}

	/**
	 * Check whether the current context (viewed product / cart contents)
	 * matches the popup's configured Trigger Product setting: every product
	 * ('all_products'), a specific category list, or a specific product list.
	 *
	 * @param array  $popup Popup record.
	 * @param string $current_page Current page key.
	 * @return bool
	 */
	private function trigger_product_matches( $popup, $current_page ) {
		$type = isset( $popup['triggerProductType'] ) ? (string) $popup['triggerProductType'] : 'specific_products';

		if ( 'all_products' === $type ) {
			return true;
		}

		if ( 'specific_categories' === $type ) {
			$trigger_categories = isset( $popup['triggerCategories'] ) && is_array( $popup['triggerCategories'] )
				? array_map( 'sanitize_title', $popup['triggerCategories'] )
				: array();

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

			if ( 'product-page' === $current_page ) {
				$viewed_id = (int) get_the_ID();
				return $viewed_id > 0 && count( array_intersect( $this->product_category_slugs( $viewed_id ), $trigger_categories ) ) > 0;
			}

			return count( array_intersect( $this->cart_category_slugs(), $trigger_categories ) ) > 0;
		}

		if ( 'specific_products' !== $type ) {
			return false;
		}

		$trigger_products = isset( $popup['triggerProducts'] ) && is_array( $popup['triggerProducts'] )
			? array_map( 'absint', $popup['triggerProducts'] )
			: array();

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

		if ( 'product-page' === $current_page ) {
			$viewed_id = (int) get_the_ID();
			return $viewed_id > 0 && in_array( $viewed_id, $trigger_products, true );
		}

		// cart / checkout: at least one product in the cart must match.
		$cart_ids = $this->cart_product_ids();
		return count( array_intersect( $cart_ids, $trigger_products ) ) > 0;
	}

	/**
	 * Product category slugs (product_cat) for a single product.
	 *
	 * @param int $product_id Product id.
	 * @return string[]
	 */
	private function product_category_slugs( $product_id ) {
		$terms = get_the_terms( $product_id, 'product_cat' );
		if ( ! is_array( $terms ) ) {
			return array();
		}
		$slugs = array();
		foreach ( $terms as $term ) {
			if ( ! empty( $term->slug ) ) {
				$slugs[] = (string) $term->slug;
			}
		}
		return array_values( array_unique( array_filter( $slugs ) ) );
	}

	/**
	 * Product category slugs (product_cat) across every product in the cart.
	 *
	 * @return string[]
	 */
	private function cart_category_slugs() {
		$slugs = array();
		foreach ( $this->cart_product_ids() as $product_id ) {
			$slugs = array_merge( $slugs, $this->product_category_slugs( $product_id ) );
		}
		return array_values( array_unique( $slugs ) );
	}

	/**
	 * Evaluate the popup's Cart Condition rows (all AND'd).
	 *
	 * @param array $popup Popup record.
	 * @return bool
	 */
	private function cart_conditions_pass( $popup ) {
		$conditions = isset( $popup['cartConditions'] ) && is_array( $popup['cartConditions'] ) ? $popup['cartConditions'] : array();
		return CartConditions::passes( $conditions );
	}

	/**
	 * Product IDs currently in the cart.
	 *
	 * @return int[]
	 */
	private function cart_product_ids() {
		$ids = array();
		if ( WC()->cart && method_exists( WC()->cart, 'get_cart' ) ) {
			foreach ( WC()->cart->get_cart() as $item ) {
				if ( isset( $item['product_id'] ) ) {
					$ids[] = absint( $item['product_id'] );
				}
			}
		}
		return array_values( array_unique( array_filter( $ids ) ) );
	}

	/**
	 * Render a single eligible popup's hidden markup.
	 *
	 * @param array  $popup Popup record.
	 * @param string $current_page Current page key.
	 * @return void
	 */
	private function render_one( $popup, $current_page ) {
		$rules_filtered   = $this->filter_enabled( isset( $popup['rules'] ) && is_array( $popup['rules'] ) ? $popup['rules'] : array() );
		$sorting_filtered = $this->filter_enabled( isset( $popup['sorting'] ) && is_array( $popup['sorting'] ) ? $popup['sorting'] : array() );

		// Same guard as LocationRenderer: don't render an unconfigured "all products" query.
		if ( empty( $rules_filtered ) && empty( $sorting_filtered ) ) {
			return;
		}

		$cart_ids   = $this->cart_product_ids();
		$match_mode = isset( $popup['matchMode'] ) && 'any' === (string) $popup['matchMode'] ? 'any' : 'all';

		// IMPORTANT: this result is cached and shared across every visitor
		// (RecommendationsCache::STABLE_CONTEXT_KEYS deliberately excludes
		// visitor-specific data from the cache key), so the cart must NOT be
		// passed as `exclude_ids` here — baking one visitor's cart into the
		// query would leak into every other visitor's cached result for the
		// TTL window, e.g. showing a product that's already in a DIFFERENT
		// visitor's cart. A padded candidate count is fetched instead, and
		// the current cart is diffed out live, per request, below.
		$context = array( 'match_mode' => $match_mode );
		if ( 'product-page' === $current_page ) {
			$context['product_id'] = (int) get_the_ID();
		}

		$limit        = isset( $popup['limit'] ) && (int) $popup['limit'] > 0 ? (int) $popup['limit'] : 4;
		$padded_limit = min( $limit + self::CACHE_ID_PAD, 500 );

		if ( $this->cache->should_skip_cache( $sorting_filtered ) || $this->cache->rules_require_no_cache( $rules_filtered ) ) {
			$product_ids = $this->engine->get_product_ids( $rules_filtered, $sorting_filtered, $context, $padded_limit );
		} else {
			$cache_key   = $this->cache->make_key( 'popup-upsell', 'popup-upsell', (int) $popup['id'], $rules_filtered, $sorting_filtered, $context, $padded_limit );
			$product_ids = $this->cache->get( $cache_key );

			if ( null === $product_ids ) {
				$product_ids = $this->engine->get_product_ids( $rules_filtered, $sorting_filtered, $context, $padded_limit );
				$this->cache->set( $cache_key, is_array( $product_ids ) ? $product_ids : array(), $this->cache->resolve_ttl( $rules_filtered ) );
			}
		}

		// Cart exclusion happens here instead of at query time (see above),
		// then the list is re-trimmed to the configured limit — cheap and
		// in-memory, and always reflects this exact visitor's current cart
		// regardless of which visitor's request originally warmed the cache.
		$product_ids = array_slice( array_values( array_diff( array_map( 'absint', (array) $product_ids ), $cart_ids ) ), 0, $limit );
		if ( empty( $product_ids ) ) {
			return;
		}

		$trigger_condition = isset( $popup['triggerCondition'] ) && is_array( $popup['triggerCondition'] ) ? $popup['triggerCondition'] : array();
		$trigger_type      = isset( $trigger_condition['type'] ) ? (string) $trigger_condition['type'] : 'after_time';
		$trigger_value     = isset( $trigger_condition['value'] ) ? $trigger_condition['value'] : '';

		echo '<div class="preco-popup-upsell" data-preco-popup data-popup-id="' . esc_attr( (string) $popup['id'] ) . '"' .
			' data-page="' . esc_attr( $current_page ) . '"' .
			' data-trigger-type="' . esc_attr( $trigger_type ) . '"' .
			' data-trigger-value="' . esc_attr( (string) $trigger_value ) . '"' .
			' style="display:none">';
		echo '<div class="preco-popup-upsell__overlay" data-preco-popup-close></div>';
		echo '<div class="preco-popup-upsell__panel">';
		echo '<div class="preco-popup-upsell__close" data-preco-popup-close aria-label="' . esc_attr__( 'Close', 'wow-ai-product-recommendations' ) . '">&times;</div>';
		echo '<div class="preco-popup-upsell__body">';

		$list_item = array(
			// Rendered by RecommendationsRenderer as a .preco-recommendations__label,
			// same as every other page recommendation — no popup-specific header markup.
			'label'        => isset( $popup['name'] ) ? (string) $popup['name'] : '',
			'design'       => isset( $popup['design'] ) && is_array( $popup['design'] ) ? $popup['design'] : array(),
			'custom_props' => array(
				// page_key drives analytics attribution (AnalyticsTable::resolve_page()) via
				// data-page-source; the popup is its own attribution bucket regardless of the
				// underlying page it renders on.
				'page_key'     => 'popup-upsell',
				'location_key' => 'popup-upsell',
			),
		);
		$this->renderer->render_products( $product_ids, 'preco-popup-upsell__grid', $list_item );

		echo '</div>';
		echo '</div>';
		echo '</div>';
	}

	/**
	 * Filter out disabled rule/sorting rows.
	 *
	 * @param array $items Rows.
	 * @return array
	 */
	private function filter_enabled( $items ) {
		$out = array();
		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			if ( isset( $item['enabled'] ) && false === $item['enabled'] ) {
				continue;
			}
			$out[] = $item;
		}
		return $out;
	}
}
