<?php
/**
 * Coupon discounts calculation class.
 *
 * @since 2.5.12
 */

namespace Masteriyo\Addons\Coupons;

use Masteriyo\Addons\Coupons\Enums\CouponDiscountType;
use Masteriyo\Notice;

defined( 'ABSPATH' ) || exit;

class CouponDiscounts {

	/**
	 * Reference to cart or order object.
	 *
	 * @since 2.5.12
	 *
	 * @var \Masteriyo\Cart\Cart|\Masteriyo\Models\Order\Order|null
	 */
	protected $context;

	/**
	 * An array of items to discount.
	 *
	 * @since 2.5.12
	 *
	 * @var array
	 */
	protected $items = array();

	/**
	 * An array of discounts which have been applied to items.
	 *
	 * @since 2.5.12
	 *
	 * @var array[] Code => Item Key => Value
	 */
	protected $discounts = array();

	/**
	 * Constructor.
	 *
	 * @since 2.5.12
	 *
	 * @param \Masteriyo\Cart\Cart|\Masteriyo\Models\Order\Order $object Cart or order object.
	 */
	public function __construct( $object = null ) {
		if ( is_a( $object, \Masteriyo\Cart\Cart::class ) ) {
			$this->set_items_from_cart( $object );
		} elseif ( is_a( $object, \Masteriyo\Models\Order\Order::class ) ) {
			$this->set_items_from_order( $object );
		}

		do_action( 'masteriyo_discounts_init', $this, $object );
	}

	/**
	 * Set items directly. Used by \Masteriyo\Cart\Totals.
	 *
	 * @since 2.5.12
	 *
	 * @param array $items Items to set.
	 */
	public function set_items( $items ) {
		$this->items     = $items;
		$this->discounts = array();
		uasort( $this->items, array( $this, 'sort_by_price' ) );
	}

	/**
	 * Normalize cart items which will be discounted.
	 *
	 * @since 2.5.12
	 *
	 * @param \Masteriyo\Cart\Cart $cart Cart object.
	 */
	public function set_items_from_cart( $cart ) {
		$this->items     = array();
		$this->discounts = array();

		if ( ! is_a( $cart, \Masteriyo\Cart\Cart::class ) ) {
			return;
		}

		$this->context = $cart;

		foreach ( $cart->get_cart() as $key => $cart_item ) {
			$item                = new \stdClass();
			$item->key           = $key;
			$item->object        = $cart_item;
			$item->product       = $cart_item['data'];
			$item->quantity      = $cart_item['quantity'];
			$item->price         = masteriyo_add_number_precision_deep( (float) $item->product->get_price() * (float) $item->quantity );
			$this->items[ $key ] = $item;
		}

		uasort( $this->items, array( $this, 'sort_by_price' ) );
	}

	/**
	 * Normalize order items which will be discounted.
	 *
	 * @since 2.5.12
	 *
	 * @param \Masteriyo\Models\Order\Order $order Order object.
	 */
	public function set_items_from_order( $order ) {
		$this->items     = array();
		$this->discounts = array();

		if ( ! is_a( $order, \Masteriyo\Models\Order\Order::class ) ) {
			return;
		}

		$this->context = $order;

		foreach ( $order->get_items() as $order_item ) {
			$item           = new \stdClass();
			$item->key      = $order_item->get_id();
			$item->object   = $order_item;
			$item->product  = $order_item->get_product();
			$item->quantity = $order_item->get_quantity();
			$item->price    = masteriyo_add_number_precision_deep( $order_item->get_subtotal() );

			if ( $order->get_prices_include_tax() ) {
				$item->price += masteriyo_add_number_precision_deep( $order_item->get_subtotal_tax() );
			}

			$this->items[ $order_item->get_id() ] = $item;
		}

		uasort( $this->items, array( $this, 'sort_by_price' ) );
	}

	/**
	 * Sort by price.
	 *
	 * @since 2.5.12
	 *
	 * @param object $a First element.
	 * @param object $b Second element.
	 *
	 * @return integer
	 */
	protected function sort_by_price( $a, $b ) {
		$price_1 = $a->price * $a->quantity;
		$price_2 = $b->price * $b->quantity;
		if ( $price_1 === $price_2 ) {
			return 0;
		}
		return ( $price_1 < $price_2 ) ? 1 : -1;
	}

	/**
	 * Get the context.
	 *
	 * @since 2.5.12
	 *
	 * @return \Masteriyo\Cart\Cart|\Masteriyo\Models\Order\Order|null
	 */
	public function get_context() {
		return $this->context;
	}

	/**
	 * Get items.
	 *
	 * @since  2.5.12
	 *
	 * @return object[]
	 */
	public function get_items() {
		return $this->items;
	}

	/**
	 * Get discount by key with or without precision.
	 *
	 * @since  2.5.12
	 *
	 * @param  string $key name of discount row to return.
	 * @param  bool   $in_cents Should the totals be returned in cents, or without precision.
	 * @return float
	 */
	public function get_discount( $key, $in_cents = false ) {
		$item_discount_totals = $this->get_discounts_by_item( $in_cents );
		return isset( $item_discount_totals[ $key ] ) ? $item_discount_totals[ $key ] : 0;
	}

	/**
	 * Get all discount totals.
	 *
	 * @since  2.5.12
	 *
	 * @param  bool $in_cents Should the totals be returned in cents, or without precision.
	 * @return array
	 */
	public function get_discounts( $in_cents = false ) {
		$discounts = $this->discounts;
		return $in_cents ? $discounts : masteriyo_remove_number_precision_deep( $discounts );
	}

	/**
	 * Get all discount totals per item.
	 *
	 * @since  2.5.12
	 *
	 * @param  bool $in_cents Should the totals be returned in cents, or without precision.
	 * @return array
	 */
	public function get_discounts_by_item( $in_cents = false ) {
		$discounts            = $this->discounts;
		$item_discount_totals = (array) array_shift( $discounts );

		foreach ( $discounts as $item_discounts ) {
			foreach ( $item_discounts as $item_key => $item_discount ) {
				$item_discount_totals[ $item_key ] += $item_discount;
			}
		}

		return $in_cents ? $item_discount_totals : masteriyo_remove_number_precision_deep( $item_discount_totals );
	}

	/**
	 * Get all discount totals per coupon.
	 *
	 * @since  2.5.12
	 *
	 * @param  bool $in_cents Should the totals be returned in cents, or without precision.
	 * @return array
	 */
	public function get_discounts_by_coupon( $in_cents = false ) {
		$coupon_discount_totals = array_map( 'array_sum', $this->discounts );

		return $in_cents ? $coupon_discount_totals : masteriyo_remove_number_precision_deep( $coupon_discount_totals );
	}

	/**
	 * Get discounted price of an item without precision.
	 *
	 * @since  2.5.12
	 *
	 * @param  object $item Get data for this item.
	 * @return float
	 */
	public function get_discounted_price( $item ) {
		return masteriyo_remove_number_precision_deep( $this->get_discounted_price_in_cents( $item ) );
	}

	/**
	 * Get discounted price of an item to precision (in cents).
	 *
	 * @since 2.5.12
	 *
	 * @param object $item Get data for this item.
	 *
	 * @return integer
	 */
	public function get_discounted_price_in_cents( $item ) {
		return absint( masteriyo_round( $item->price - $this->get_discount( $item->key, true ) ) );
	}

	/**
	 * Apply a discount to all items using a coupon.
	 *
	 * @since 2.5.12
	 *
	 * @param \Masteriyo\Addons\Coupons\Models\Coupon $coupon Coupon object being applied to the items.
	 * @param bool $validate Set to false to skip coupon validation.
	 *
	 * @return bool|\WP_Error True if applied or WP_Error instance in failure.
	 */
	public function apply_coupon( $coupon, $validate = true ) {
		if ( ! is_a( $coupon, \Masteriyo\Addons\Coupons\Models\Coupon::class ) ) {
			return new \WP_Error( 'invalid_coupon', __( 'Invalid coupon', 'learning-management-system' ) );
		}

		$is_valid = $validate ? $coupon->is_valid( $this->context ) : true;

		if ( is_wp_error( $is_valid ) ) {
			masteriyo_display_notice(
				$is_valid->get_error_message(),
				Notice::ERROR
			);
			return $is_valid;
		}

		if ( ! isset( $this->discounts[ $coupon->get_code() ] ) ) {
			$this->discounts[ $coupon->get_code() ] = array_fill_keys( array_keys( $this->items ), 0 );
		}

		$items_to_apply = $this->get_items_to_apply_coupon( $coupon );

		switch ( $coupon->get_discount_type() ) {
			case CouponDiscountType::PERCENTAGE_CART:
				$this->apply_coupon_percent( $coupon, $items_to_apply );
				break;
			case CouponDiscountType::FIXED_CART:
				$this->apply_coupon_fixed_cart( $coupon, $items_to_apply );
				break;
		}

		return true;
	}

	/**
	 * Apply percent discount to items and granted discount amount.
	 *
	 * @since 2.5.12
	 *
	 * @param \Masteriyo\Addons\Coupons\Models\Coupon $coupon Coupon object. Passed through filters.
	 * @param array $items_to_apply Array of items to apply the coupon to.
	 *
	 * @return integer Total discounted.
	 */
	protected function apply_coupon_percent( $coupon, $items_to_apply ) {
		$total_discount        = 0;
		$cart_total            = 0;
		$limit_usage_qty       = 0;
		$applied_count         = 0;
		$adjust_final_discount = true;

		$coupon_amount = $coupon->get_discount_amount();

		foreach ( $items_to_apply as $item ) {
			// Find out how much price is available to discount for the item.
			$discounted_price = $this->get_discounted_price_in_cents( $item );

			// Get the price we actually want to discount, based on settings.
			$price_to_discount = ( 'yes' === get_option( 'masteriyo_calc_discounts_sequentially', 'no' ) ) ? $discounted_price : masteriyo_round( $item->price );

			// See how many and what price to apply to.
			$apply_quantity    = $limit_usage_qty && ( $limit_usage_qty - $applied_count ) < $item->quantity ? $limit_usage_qty - $applied_count : $item->quantity;
			$apply_quantity    = max( 0, apply_filters( 'masteriyo_coupon_get_apply_quantity', $apply_quantity, $item, $coupon, $this ) );
			$price_to_discount = ( $price_to_discount / $item->quantity ) * $apply_quantity;

			// Run coupon calculations.
			$discount = floor( $price_to_discount * ( $coupon_amount / 100 ) );

			if ( is_a( $this->context, \Masteriyo\Cart\Cart::class ) && has_filter( 'masteriyo_coupon_get_discount_amount' ) ) {
				// Send through the legacy filter, but not as cents.
				$filtered_discount = masteriyo_add_number_precision( apply_filters( 'masteriyo_coupon_get_discount_amount', masteriyo_remove_number_precision( $discount ), masteriyo_remove_number_precision( $price_to_discount ), $item->object, false, $coupon ) );

				if ( $filtered_discount !== $discount ) {
					$discount              = $filtered_discount;
					$adjust_final_discount = false;
				}
			}

			$discount       = masteriyo_round_discount( min( $discounted_price, $discount ), 0 );
			$cart_total     = $cart_total + $price_to_discount;
			$total_discount = $total_discount + $discount;
			$applied_count  = $applied_count + $apply_quantity;

			// Store code and discount amount per item.
			$this->discounts[ $coupon->get_code() ][ $item->key ] += $discount;
		}

		// Work out how much discount would have been given to the cart as a whole and compare to what was discounted on all line items.
		$cart_total_discount = masteriyo_round_discount( $cart_total * ( $coupon_amount / 100 ), 0 );

		if ( $total_discount < $cart_total_discount && $adjust_final_discount ) {
			$total_discount += $this->apply_coupon_remainder( $coupon, $items_to_apply, $cart_total_discount - $total_discount );
		}

		return $total_discount;
	}

	/**
	 * Apply fixed product discount to items.
	 *
	 * @since  2.5.12
	 *
	 * @param  \Masteriyo\Addons\Coupons\Models\Coupon $coupon Coupon object. Passed through filters.
	 * @param  array     $items_to_apply Array of items to apply the coupon to.
	 * @param  int       $amount Fixed discount amount to apply in cents. Leave blank to pull from coupon.
	 * @return int Total discounted.
	 */
	protected function apply_coupon_fixed_product( $coupon, $items_to_apply, $amount = null ) {
		$total_discount  = 0;
		$amount          = $amount ? $amount : masteriyo_add_number_precision( $coupon->get_discount_amount() );
		$limit_usage_qty = 0;
		$applied_count   = 0;

		foreach ( $items_to_apply as $item ) {
			// Find out how much price is available to discount for the item.
			$discounted_price = $this->get_discounted_price_in_cents( $item );

			// Get the price we actually want to discount, based on settings.
			$price_to_discount = ( 'yes' === get_option( 'masteriyo_calc_discounts_sequentially', 'no' ) ) ? $discounted_price : $item->price;

			// Run coupon calculations.
			if ( $limit_usage_qty ) {
				$apply_quantity = $limit_usage_qty - $applied_count < $item->quantity ? $limit_usage_qty - $applied_count : $item->quantity;
				$apply_quantity = max( 0, apply_filters( 'masteriyo_coupon_get_apply_quantity', $apply_quantity, $item, $coupon, $this ) );
				$discount       = min( $amount, $item->price / $item->quantity ) * $apply_quantity;
			} else {
				$apply_quantity = apply_filters( 'masteriyo_coupon_get_apply_quantity', $item->quantity, $item, $coupon, $this );
				$discount       = $amount * $apply_quantity;
			}

			if ( is_a( $this->context, '\Masteriyo\Cart\Cart' ) && has_filter( 'masteriyo_coupon_get_discount_amount' ) ) {
				// Send through the legacy filter, but not as cents.
				$discount = masteriyo_add_number_precision( apply_filters( 'masteriyo_coupon_get_discount_amount', masteriyo_remove_number_precision( $discount ), masteriyo_remove_number_precision( $price_to_discount ), $item->object, false, $coupon ) );
			}

			$discount       = min( $discounted_price, $discount );
			$total_discount = $total_discount + $discount;
			$applied_count  = $applied_count + $apply_quantity;

			/**
			 * Filters the discount amount applied to a coupon.
			 *
			 * @since 2.11.0
			 *
			 * @param float $discount The discount to apply the coupon to.
			 * @param array $item The item data.
			 * @param \Masteriyo\Addons\Coupons\Models\Coupon  $coupon The coupon object.
			 * @param \Masteriyo\Addons\Coupons\CouponDiscounts $this The CouponDiscounts instance.
			 *
			 * @return float The filtered amount to apply the coupon to.
			 */
			$discount = apply_filters( 'masteriyo_coupon_get_apply_discount_amount', $discount, $item, $coupon, $this );

			// Store code and discount amount per item.
			$this->discounts[ $coupon->get_code() ][ $item->key ] += $discount;
		}
		return $total_discount;
	}

	/**
	 * Apply fixed cart discount to items.
	 *
	 * @since 2.5.12
	 *
	 * @param \Masteriyo\Addons\Coupons\Models\Coupon $coupon Coupon object. Passed through filters.
	 * @param array $items_to_apply Array of items to apply the coupon to.
	 * @param integer $amount Fixed discount amount to apply in cents. Leave blank to pull from coupon.
	 *
	 * @return integer Total discounted.
	 */
	protected function apply_coupon_fixed_cart( $coupon, $items_to_apply, $amount = null ) {
		$total_discount = 0;
		$amount         = $amount ? $amount : masteriyo_add_number_precision( $coupon->get_discount_amount() );
		$items_to_apply = array_filter( $items_to_apply, array( $this, 'filter_products_with_price' ) );
		$item_count     = array_sum( wp_list_pluck( $items_to_apply, 'quantity' ) );

		if ( ! $item_count ) {
			return $total_discount;
		}

		if ( ! $amount ) {
			// If there is no amount we still send it through so filters are fired.
			$total_discount = $this->apply_coupon_fixed_product( $coupon, $items_to_apply, 0 );
		} else {
			$per_item_discount = absint( $amount / $item_count ); // round it down to the nearest cent.

			if ( $per_item_discount > 0 ) {
				$total_discount = $this->apply_coupon_fixed_product( $coupon, $items_to_apply, $per_item_discount );

				/**
				 * If there is still discount remaining, repeat the process.
				 */
				if ( $total_discount > 0 && $total_discount < $amount ) {
					$total_discount += $this->apply_coupon_fixed_cart( $coupon, $items_to_apply, $amount - $total_discount );
				}
			} elseif ( $amount > 0 ) {
				$total_discount += $this->apply_coupon_remainder( $coupon, $items_to_apply, $amount );
			}
		}
		return $total_discount;
	}

	/**
	 * Deal with remaining fractional discounts by splitting it over items
	 * until the amount is expired, discounting 1 cent at a time.
	 *
	 * @since 2.5.12
	 *
	 * @param  \Masteriyo\Addons\Coupons\Models\Coupon $coupon Coupon object if applicable. Passed through filters.
	 * @param  array     $items_to_apply Array of items to apply the coupon to.
	 * @param  int       $amount Fixed discount amount to apply.
	 * @return int Total discounted.
	 */
	protected function apply_coupon_remainder( $coupon, $items_to_apply, $amount ) {
		$total_discount = 0;

		foreach ( $items_to_apply as $item ) {
			for ( $i = 0; $i < $item->quantity; $i ++ ) {
				// Find out how much price is available to discount for the item.
				$price_to_discount = $this->get_discounted_price_in_cents( $item );

				// Run coupon calculations.
				$discount = min( $price_to_discount, 1 );

				// Store totals.
				$total_discount += $discount;

				// Store code and discount amount per item.
				$this->discounts[ $coupon->get_code() ][ $item->key ] += $discount;

				if ( $total_discount >= $amount ) {
					break 2;
				}
			}
			if ( $total_discount >= $amount ) {
				break;
			}
		}
		return $total_discount;
	}

	/**
	 * Get items which the coupon should be applied to.
	 *
	 * @since  2.5.12
	 *
	 * @return array
	 */
	protected function get_items_to_apply_coupon() {
		$items_to_apply = array();

		foreach ( $this->get_items_to_validate() as $item ) {
			$item_to_apply = clone $item; // Clone the item so changes to this item do not affect the originals.

			if ( 0 === $this->get_discounted_price_in_cents( $item_to_apply ) || 0 >= $item_to_apply->quantity ) {
				continue;
			}

			$items_to_apply[] = $item_to_apply;
		}
		return $items_to_apply;
	}

	/**
	 * Get items to validate.
	 *
	 * @since  2.5.12
	 * @return object[]
	 */
	public function get_items_to_validate() {
		return apply_filters( 'masteriyo_coupon_get_items_to_validate', $this->get_items(), $this );
	}

	/**
	 * Filter out all products which have been fully discounted to 0.
	 * Used as array_filter callback.
	 *
	 * @since  2.5.12
	 *
	 * @param  object $item Get data for this item.
	 * @return bool
	 */
	protected function filter_products_with_price( $item ) {
		return $this->get_discounted_price_in_cents( $item ) > 0;
	}
}
