<?php
/**
 * Checkout Order Bump storefront rendering.
 *
 * @package PRECO
 */

namespace PRECO\Includes\Frontend\Pages;

use PRECO\Includes\Frontend\Core\PreviewMode;
use PRECO\Includes\Frontend\Core\RecommendationsCache;
use PRECO\Includes\Frontend\Core\RecommendationsEngine;
use PRECO\Includes\Analytics\AnalyticsTable;
use PRECO\Includes\Preco;

defined( 'ABSPATH' ) || exit;

/**
 * Renders Checkout Order Bump campaigns.
 *
 * An Order Bump is the checkout-only sibling of a Popup Upsell: same
 * trigger-product + rules/sorting configuration, but rendered as static
 * inline HTML at a fixed checkout position ("After Order Summary",
 * "Before Place Order Button" or "After Place Order Button") instead of a
 * JS-triggered modal — so there are no page/trigger-condition/cart-condition
 * settings and no layout settings. Each offered product carries a checkbox
 * whose click adds the product to the cart (optionally at a discounted
 * price) via a nonce'd checkout URL and reloads the page; products already
 * in the cart are excluded from the offer.
 */
class OrderBumpRecommendations {

	const OPTION_NAME = 'preco_order_bumps';

	/**
	 * Nonce action prefix for the checkbox add-to-cart URL. The product and
	 * bump ids are appended to the action, so a nonce is only valid for the
	 * exact offer the server rendered — swapping either query arg in the
	 * payload invalidates it.
	 */
	const NONCE_ACTION = 'preco_order_bump_add';

	/**
	 * Extra candidates fetched beyond the configured offer limit when a bump's
	 * result is cacheable, so that diffing out THIS visitor's live cart
	 * contents (see get_offer_products()) 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;

	/**
	 * Display position keys → classic checkout hooks.
	 *
	 * - after-order-summary: fires once the order review table (items +
	 *   totals) has finished rendering, before the payment method list —
	 *   the bump appears after the customer sees what they're buying.
	 * - before-place-order / after-place-order: bracket the submit button,
	 *   after payment method has been chosen.
	 */
	const POSITION_HOOKS = array(
		'after-order-summary' => 'woocommerce_review_order_before_payment',
		'before-place-order'  => 'woocommerce_review_order_before_submit',
		'after-place-order'   => 'woocommerce_review_order_after_submit',
	);

	/**
	 * Display position keys → block checkout inner blocks, and whether the
	 * bump is prepended or appended relative to that block's own content.
	 */
	const POSITION_BLOCKS = array(
		'after-order-summary' => array(
			'block' => 'woocommerce/checkout-order-summary-block',
			'mode'  => 'append',
		),
		'before-place-order'  => array(
			'block' => 'woocommerce/checkout-actions-block',
			'mode'  => 'prepend',
		),
		'after-place-order'   => array(
			'block' => 'woocommerce/checkout-actions-block',
			'mode'  => 'append',
		),
	);

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

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

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

		add_action( 'wp_loaded', array( $this, 'maybe_handle_add_to_cart' ), 20 );
		add_action( 'woocommerce_before_calculate_totals', array( $this, 'apply_discounts' ), 20 );

		foreach ( self::POSITION_HOOKS as $position => $hook ) {
			add_action(
				$hook,
				function () use ( $position ) {
					$this->render_position( $position );
				}
			);
		}

		// Block checkout: prepend/append the bump inside the matching inner block.
		foreach ( self::POSITION_BLOCKS as $position => $def ) {
			add_filter(
				'render_block_' . $def['block'],
				function ( $block_content ) use ( $position, $def ) {
					ob_start();
					$this->render_position( $position );
					$bump_content = ob_get_clean();

					return 'prepend' === $def['mode']
						? $bump_content . $block_content
						: $block_content . $bump_content;
				}
			);
		}
	}

	/**
	 * Render the single winning bump for the given position, if any.
	 *
	 * Only one bump is ever shown per checkout position — when multiple
	 * bumps are configured for the same position and more than one is
	 * eligible for the current cart, resolve_winner() picks exactly one.
	 *
	 * @param string $position Position key (see POSITION_HOOKS).
	 * @return void
	 */
	public function render_position( $position ) {
		if ( ! $this->is_checkout_page() ) {
			return;
		}

		// The builder's live iframe previews checkout page locations being
		// edited; an Order Bump isn't one of those locations and would just
		// clutter the preview, so it never renders during a preview request.
		if ( PreviewMode::is_active_request() ) {
			return;
		}

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

		$winner = $this->resolve_winner( $bumps, $position );
		if ( null !== $winner ) {
			$this->render_one( $winner );
		}
	}

	/**
	 * Reduce every bump eligible for this position down to at most one
	 * winner. Competing bumps (same position, both eligible for the current
	 * cart) are ranked by the specificity of their Trigger Product setting —
	 * specific_products > specific_categories > all_products — with ties
	 * (e.g. two "specific product" bumps both matching) broken in favor of
	 * the more recently created bump (higher id), mirroring
	 * PopupUpsellRecommendations::resolve_winners().
	 *
	 * @param array  $bumps Raw bump records from the option.
	 * @param string $position Position key.
	 * @return array|null Winning bump record, or null if none are eligible.
	 */
	private function resolve_winner( $bumps, $position ) {
		$winner      = null;
		$winner_rank = null;
		$winner_id   = null;

		foreach ( $bumps as $bump ) {
			if ( ! is_array( $bump ) || ! $this->is_eligible( $bump, $position ) ) {
				continue;
			}

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

			if (
				null === $winner ||
				$rank < $winner_rank ||
				( $rank === $winner_rank && $id > $winner_id )
			) {
				$winner      = $bump;
				$winner_rank = $rank;
				$winner_id   = $id;
			}
		}

		return $winner;
	}

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

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

	/**
	 * Whether the current request is the checkout page (not order received).
	 *
	 * @return bool
	 */
	private function is_checkout_page() {
		return function_exists( 'WC' ) &&
			function_exists( 'is_checkout' ) && \is_checkout() &&
			! ( function_exists( 'is_order_received_page' ) && \is_order_received_page() );
	}

	/**
	 * Whether a bump should render at this position on this request.
	 *
	 * @param array  $bump Bump record.
	 * @param string $position Position key.
	 * @return bool
	 */
	private function is_eligible( $bump, $position ) {
		if ( isset( $bump['enabled'] ) && false === $bump['enabled'] ) {
			return false;
		}

		$configured = isset( $bump['displayPosition'] ) ? (string) $bump['displayPosition'] : 'after-order-summary';
		// Bumps saved before the "after-order-summary" position existed used
		// the now-removed 'before-order-summary' key; treat it as an alias so
		// bumps configured under the old UI keep rendering.
		if ( 'before-order-summary' === $configured ) {
			$configured = 'after-order-summary';
		}
		if ( $configured !== $position ) {
			return false;
		}

		return $this->trigger_product_matches( $bump );
	}

	/**
	 * Check whether the cart contents match the bump's configured Trigger
	 * Product setting: every product ('all_products'), a specific category
	 * list, or a specific product list, mirroring PopupUpsellRecommendations.
	 *
	 * @param array $bump Bump record.
	 * @return bool
	 */
	private function trigger_product_matches( $bump ) {
		$type = isset( $bump['triggerProductType'] ) ? (string) $bump['triggerProductType'] : 'specific_products';

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

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

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

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

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

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

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

		return count( array_intersect( $this->cart_product_ids(), $trigger_products ) ) > 0;
	}

	/**
	 * 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 ) ) );
	}

	/**
	 * 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 ) );
	}

	/**
	 * Render a single bump container.
	 *
	 * @param array $bump Bump record.
	 * @return void
	 */
	private function render_one( $bump ) {
		$products = $this->get_offer_products( $bump );
		if ( empty( $products ) ) {
			return;
		}

		$heading  = isset( $bump['heading'] ) ? trim( (string) $bump['heading'] ) : '';
		$discount = $this->normalize_discount( $bump );

		echo '<div class="preco-order-bump" data-preco-order-bump data-bump-id="' . esc_attr( (string) $bump['id'] ) . '">';
		if ( '' !== $heading ) {
			echo '<h4 class="preco-order-bump__heading">' . esc_html( $heading ) . '</h4>';
		}
		// .preco-recommendations wrapper + data-card-style="slider" opt the
		// grid into the shared recommendations-slider.js; one column at every
		// breakpoint gives the single-card slider with prev/next arrows.
		echo '<div class="preco-recommendations preco-order-bump__list">';
		echo '<div class="preco-recommendations__grid" data-card-style="slider" style="--preco-cs-cols-lg:1;--preco-cs-cols-md:1;--preco-cs-cols-sm:1;">';

		foreach ( $products as $product ) {
			$this->render_item( $product, $bump, $discount );
		}

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

	/**
	 * Resolve the offered products for a bump: engine query, minus anything
	 * already in the cart, capped to the configured limit.
	 *
	 * @param array $bump Bump record.
	 * @return \WC_Product[]
	 */
	private function get_offer_products( $bump ) {
		$rules   = $this->filter_enabled( isset( $bump['rules'] ) && is_array( $bump['rules'] ) ? $bump['rules'] : array() );
		$sorting = $this->filter_enabled( isset( $bump['sorting'] ) && is_array( $bump['sorting'] ) ? $bump['sorting'] : array() );

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

		$cart_ids   = $this->cart_product_ids();
		$match_mode = isset( $bump['matchMode'] ) && 'any' === (string) $bump['matchMode'] ? 'any' : 'all';
		$limit      = isset( $bump['limit'] ) && (int) $bump['limit'] > 0 ? (int) $bump['limit'] : 2;

		// 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. A padded candidate count is fetched instead, and the
		// current cart is diffed out live, per request, below.
		$context      = array( 'match_mode' => $match_mode );
		$padded_limit = min( $limit + self::CACHE_ID_PAD, 500 );

		if ( $this->cache->should_skip_cache( $sorting ) || $this->cache->rules_require_no_cache( $rules ) ) {
			$product_ids = $this->engine->get_product_ids( $rules, $sorting, $context, $padded_limit );
		} else {
			$cache_key   = $this->cache->make_key( 'order-bump', 'order-bump', (int) $bump['id'], $rules, $sorting, $context, $padded_limit );
			$product_ids = $this->cache->get( $cache_key );

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

		// 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 array();
		}

		$products = wc_get_products(
			array(
				'include' => array_values( $product_ids ),
				'limit'   => count( $product_ids ),
				'orderby' => 'include',
				'status'  => 'publish',
			)
		);

		$valid = array();
		foreach ( $products as $product ) {
			if ( ! $product instanceof \WC_Product ) {
				continue;
			}
			if ( ! in_array( $product->get_type(), Preco::PRODUCT_RENDER_TYPES, true ) || ! $product->is_purchasable() || ! $product->is_in_stock() ) {
				continue;
			}
			$valid[] = $product;
		}

		return $valid;
	}

	/**
	 * Render one offer card.
	 *
	 * @param \WC_Product $product Product.
	 * @param array       $bump Bump record.
	 * @param array       $discount Normalized discount { amount, type }.
	 * @return void
	 */
	private function render_item( $product, $bump, $discount ) {
		$add_url = wp_nonce_url(
			add_query_arg(
				array(
					'preco_bump_add' => $product->get_id(),
					'preco_bump_id'  => (int) $bump['id'],
				),
				wc_get_checkout_url()
			),
			self::NONCE_ACTION . '_' . $product->get_id() . '_' . (int) $bump['id']
		);

		// The bump discount applies to the regular price, mirroring
		// apply_discounts(); it's only shown when it beats the price the
		// customer would pay anyway (a deeper sale price wins).
		$display_price = (float) wc_get_price_to_display( $product );
		$regular_price = (float) wc_get_price_to_display( $product, array( 'price' => $product->get_regular_price() ) );
		$bump_price    = $this->discounted_price( $regular_price, $discount );

		if ( $bump_price < $display_price ) {
			$price_html = '<del>' . wc_price( $regular_price ) . '</del> <ins>' . wc_price( $bump_price ) . '</ins>';
		} else {
			$price_html = wc_price( $display_price );
		}

		echo '<div class="preco-card preco-order-bump__item">';
		echo '<label class="preco-order-bump__label">';
		echo '<input type="checkbox" class="preco-order-bump__checkbox" data-add-url="' . esc_url( $add_url ) . '" aria-label="' .
			/* translators: %s = product name */
			esc_attr( sprintf( __( 'Add %s to your order', 'wow-ai-product-recommendations' ), $product->get_name() ) ) . '">';
		echo '<span class="preco-order-bump__media">' . wp_kses_post( $product->get_image( 'woocommerce_gallery_thumbnail' ) ) . '</span>';
		echo '<span class="preco-order-bump__info">';
		echo '<span class="preco-order-bump__title">';
		if ( ! empty( $bump['productViewable'] ) ) {
			// Nested inside the surrounding <label>: browsers route a click
			// on this link to navigation, not to the label's associated
			// checkbox, so opening the product page never also adds it to
			// cart.
			echo '<a href="' . esc_url( $product->get_permalink() ) . '">' . esc_html( $product->get_name() ) . '</a>';
		} else {
			echo esc_html( $product->get_name() );
		}
		echo '</span>';
		echo '<span class="preco-order-bump__price">' . wp_kses_post( $price_html ) . '</span>';
		echo '</span>';
		echo '</label>';
		echo '</div>';
	}

	/**
	 * Handle the checkbox click: add the bump product to the cart (tagged
	 * with the bump id so the discount follows it) and reload checkout.
	 *
	 * @return void
	 */
	public function maybe_handle_add_to_cart() {
		if ( ! isset( $_GET['preco_bump_add'], $_GET['preco_bump_id'] ) || ! function_exists( 'WC' ) || ! WC()->cart ) {
			return;
		}

		$product_id = absint( $_GET['preco_bump_add'] );
		$bump_id    = absint( $_GET['preco_bump_id'] );

		// The ids are part of the nonce action, so tampering with either
		// query arg makes verification fail — only URLs the server rendered
		// for this exact product/bump pair are accepted.
		$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
		if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION . '_' . $product_id . '_' . $bump_id ) ) {
			return;
		}

		$product = $product_id ? wc_get_product( $product_id ) : null;
		$bump    = $this->find_bump( $bump_id );

		$bump_active = null !== $bump &&
			! ( isset( $bump['enabled'] ) && false === $bump['enabled'] );

		if (
			$bump_active && $product && in_array( $product->get_type(), Preco::PRODUCT_RENDER_TYPES, true ) &&
			$product->is_purchasable() && $product->is_in_stock()
		) {
			WC()->cart->add_to_cart(
				$product_id,
				1,
				0,
				array(),
				array(
					'preco_order_bump_id'       => $bump_id,
					// Snapshot of the discount at add time, kept as cart item
					// meta for downstream use (order meta, reporting). Pricing
					// never reads it back — apply_discounts() recomputes from
					// the bump's current settings, so this can't be abused to
					// force a different discount.
					'preco_order_bump_discount' => $this->normalize_discount( $bump ),
					// Same attribution keys the shared recommendation widgets use,
					// so Analytics::copy_page_source_to_order_item() picks this line
					// up for order-placed/purchased tracking like any other Preco
					// recommendation source.
					'preco_page_source'         => 'order-bump',
					'preco_location_source'     => 'order-bump',
				)
			);

			// The bump's own add-to-cart flow is a plain GET redirect, so it
			// never goes through the REST /events/add_to_cart endpoint or the
			// click-attribution cookie — record the add-to-cart event directly.
			AnalyticsTable::record( AnalyticsTable::PAGE_ORDER_BUMP, AnalyticsTable::TYPE_ADD_TO_CART, 1 );
		}

		// Always redirect to a clean checkout URL so a reload can't re-add.
		wp_safe_redirect( wc_get_checkout_url() );
		exit;
	}

	/**
	 * Re-price cart items that were added through a bump.
	 *
	 * The discount is recomputed on every totals run from the product's
	 * current *regular* price (fresh wc_get_product lookup, so repeated
	 * before_calculate_totals runs can't compound the reduction) and from
	 * the bump's current settings — deleting or disabling the bump reverts
	 * the item to full price rather than honoring a stale discount. If the
	 * product's own sale price is lower than the bump price, the sale price
	 * wins: the bump can never make an item more expensive.
	 *
	 * @param \WC_Cart $cart Cart.
	 * @return void
	 */
	public function apply_discounts( $cart ) {
		foreach ( $cart->get_cart() as $item ) {
			if ( empty( $item['preco_order_bump_id'] ) || ! isset( $item['data'] ) || ! $item['data'] instanceof \WC_Product ) {
				continue;
			}

			$bump = $this->find_bump( (int) $item['preco_order_bump_id'] );
			if ( null === $bump || ( isset( $bump['enabled'] ) && false === $bump['enabled'] ) ) {
				continue;
			}

			$catalog = wc_get_product( $item['data']->get_id() );
			if ( ! $catalog ) {
				continue;
			}

			$regular = (float) $catalog->get_regular_price();
			if ( $regular <= 0 ) {
				continue;
			}

			$current    = (float) $catalog->get_price();
			$discounted = $this->discounted_price( $regular, $this->normalize_discount( $bump ) );
			if ( $discounted < $current ) {
				$item['data']->set_price( $discounted );
			}
		}
	}

	/**
	 * Find a bump record by id.
	 *
	 * @param int $bump_id Bump id.
	 * @return array|null
	 */
	private function find_bump( $bump_id ) {
		$bumps = get_option( self::OPTION_NAME, array() );
		if ( ! is_array( $bumps ) ) {
			return null;
		}
		foreach ( $bumps as $bump ) {
			if ( is_array( $bump ) && isset( $bump['id'] ) && (int) $bump['id'] === (int) $bump_id ) {
				return $bump;
			}
		}
		return null;
	}

	/**
	 * Normalize a bump's discount config.
	 *
	 * The builder's Discount toggle is the source of truth for whether a
	 * discount applies at all — an `enabled: false` record forces the amount
	 * to 0 here regardless of what's stored, so turning the toggle off (even
	 * without clearing the amount field) actually stops the discount from
	 * being applied. Missing `enabled` (records saved before the toggle
	 * existed) defaults to true so previously configured discounts keep
	 * working.
	 *
	 * @param array $bump Bump record.
	 * @return array { amount: float >= 0, type: 'percentage'|'fixed' }
	 */
	private function normalize_discount( $bump ) {
		$discount = isset( $bump['discount'] ) && is_array( $bump['discount'] ) ? $bump['discount'] : array();
		$enabled  = ! isset( $discount['enabled'] ) || (bool) $discount['enabled'];
		$amount   = $enabled && isset( $discount['amount'] ) ? max( 0, (float) $discount['amount'] ) : 0;
		$type     = isset( $discount['type'] ) && 'fixed' === (string) $discount['type'] ? 'fixed' : 'percentage';

		if ( 'percentage' === $type ) {
			$amount = min( 100, $amount );
		}

		return array(
			'amount' => $amount,
			'type'   => $type,
		);
	}

	/**
	 * Apply a normalized discount to a price.
	 *
	 * @param float $price Base price.
	 * @param array $discount Normalized discount.
	 * @return float
	 */
	private function discounted_price( $price, $discount ) {
		if ( $discount['amount'] <= 0 ) {
			return $price;
		}
		$reduced = 'fixed' === $discount['type']
			? $price - $discount['amount']
			: $price * ( 1 - $discount['amount'] / 100 );
		return max( 0, round( $reduced, wc_get_price_decimals() ) );
	}

	/**
	 * 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;
	}
}
