<?php
/** @noinspection GrazieInspection */

namespace CouponsPlus\App\Offers\BuiltIn;

use WC_Cart;
use Exception;
use CouponsPlus\App\Coupon\CardData;
use CouponsPlus\App\Conditions\ItemsSet;
use CouponsPlus\App\Conditions\CartItem;
use CouponsPlus\App\Conditions\PendingCartItem;
use CouponsPlus\App\Coupon\Tree\Cart\CartModifier;
use CouponsPlus\Original\Collections\MappedObject;
use CouponsPlus\App\Coupon\Tree\Cart\AutoAddedItem;
use CouponsPlus\App\Coupon\Tree\Cart\AutoAddedItems;
use CouponsPlus\App\Coupon\Tree\Finders\Sets\OfferItems;
use CouponsPlus\App\Coupon\Tree\Cart\CartModifiedException;
use CouponsPlus\App\Conditions\BuiltIn\Filters\NumberOfItems;
use CouponsPlus\App\Coupon\Tree\Abilities\OffersItemsSetResolvable;
use CouponsPlus\App\Coupon\Tree\Sets\Factories\PriceSorterFactory;
use CouponsPlus\App\Offers\Offer;
use CouponsPlus\Original\Collections\Collection;
use CouponsPlus\Original\Collections\Mapper\Types;

use Automattic\WooCommerce\Blocks\BlockTypes\CheckoutPaymentBlock;
use CouponsPlus\App\Coupon\Tree\Factories\ConditionFilterOrOfferFromDataFactory;

use function CouponsPlus\App\Coupon\Tree\TestableHooks\usingSorter;
use function CouponsPlus\App\Coupon\Tree\TestableHooks\excludingTestableTypes;

/**
 * This is a simple BOGO for the same type of item.
 */
#[\AllowDynamicProperties]
Class BuyXGetX extends Offer implements OffersItemsSetResolvable
{
    public const TYPE = 'BuyXGetX';

    protected CartModifier $cartModifier;

    public static function getName() : string
    {
        //return __('Get X (Same product)', 'coupons-plus-for-woocommerce');
        return __('Same Products', 'coupons-plus-for-woocommerce');
    }

    public static function getDescription() : string
    {
        return __('Apply the discount to extra items of the same type of product as the filtered items.', 'coupons-plus-for-woocommerce');
    }
    public static function getNotes(): array
    {
        return [
            /*__ ('This offer will apply the discounts to additional items from the same product type of the filtered items in the cart. For example: In a "Buy One T-shirt, Get One Free" sale, if a customer adds a $30 shirt and a $25 shirt, the $25 shirt will be free...')*/
        ];
    }

    /**
     *  Not used in 3.0, but required for the legacy interface.
     */
    static public function getIconUrl(): string
    {
        return '';
    }

    public static function getOptions() : Collection
    {
        /** @noinspection PhpUnhandledExceptionInspection */
        return new Collection([
            'quantityType' => Types::STRING()->withDefault('fixed')->allowed([
                __('Fixed Quantity', 'coupons-plus-for-woocommerce') => 'fixed',
                __('Duplicate Quantity x2', 'coupons-plus-for-woocommerce') => 'duplicate',
            ])->meta([
/*                'alternativeLabels' => [
                    'fixed' => __('Classic BOGO mode. Will apply the discount to extra items. If there are extra eligible items in the cart, the discount will be applied to them. Otherwise, extra items will be added to the cart.', 'coupons-plus-for-woocommerce'),
                ],*/
                'labelDescriptions' => [
                    'duplicate' => [__('Will always duplicate the eligible items in the cart. For example, if the customer added 2 eligible items, 2 extra items will be added to the cart. If the customer added 5, another extra 5 items will be automatically added to the cart. It differs from Fixed Quantity in that Fixed Quantity will attempt to  apply the discount to extra existing items in the cart if available, it will only add them if there are no extra eleigible items in the cart. Duplicate Quantity will always duplicate the items regardless of if there are eligible items in the cart that can be discounted.', 'coupons-plus-for-woocommerce')],
                    'fixed' => [__('Classic BOGO mode. Will apply the discount to extra items. If there are extra eligible items in the cart, the discount will be applied to them. Otherwise, extra items will be added to the cart.', 'coupons-plus-for-woocommerce')],
                ],
                'isPreview' => [
                    'duplicate' => true,
                ]
            ]),
            'quantity' => Types::INTEGER()->withDefault(1)->minimum(1),
            'discount' => [
                'type' => Types::STRING()->withDefault('free')->allowed([
                    __('Free', 'coupons-plus-for-woocommerce') => 'free',
                    __('Percentage Off (%)', 'coupons-plus-for-woocommerce') => 'percentage',
                    __('Fixed Discount ($)', 'coupons-plus-for-woocommerce') => 'amount',
                    __('Fixed Price', 'coupons-plus-for-woocommerce') => 'fixed-price',
                ]),
                'amount' => Types::FLOAT(),
            ],
            /**
             * This is also used by BuyXGetY (in the dashboard from the popup panel of the 'Products' mode. do not delete it unless you've refactored that.)
             */
            'missingItemsInCart' => [
                'mode' => Types::STRING()->withDefault('auto-add')->allowed([
                    __('Automatically add them to the cart', 'coupons-plus-for-woocommerce') => 'auto-add',
                    __('Let the customer add them manually', 'coupons-plus-for-woocommerce') => 'manual-add',
                ]),
            ]
        ]);
    }

    public function onOptionsLoaded()
    {
        $this->priceSorterFactory = new PriceSorterFactory;
        $this->cartModifier = new CartModifier;
    }


    /**
     * @param ItemsSet $itemsSet
     * @param OfferItems $offerItems
     * @throws CartModifiedException
     * @noinspection PhpDocMissingThrowsInspection
     */
    public function apply(ItemsSet $itemsSet): void
    {
        /**
         * So at this point we have the correct items in the cart, and in the correct quantity.
         * Now we just need to register the discounts to them.
         */
        $this->registerDiscounts($itemsSet);
    }

    /**
     * Gets the items to apply the discount to.
     * Will add the items to the cart if we need to.
     * @throws CartModifiedException|Exception
     */
    public function resolveItemsToUse(ItemsSet $filteredItems) : OfferItems
    {
        $itemsSetPassedToThisOffer = $this->prePickOneGroup($filteredItems);

        /**
         * Get the items to apply the discount to.
         * (!) Execution may end early if we need to add products to the cart.
         */
        if ($this->options->quantityType->is('duplicate')) {
            $itemsToDiscountInCart = $this->resolveDuplicate($itemsSetPassedToThisOffer);
        } else {
            // here instead of throwing an exception, let's return the cart item that shall be added to the cart and then pass it along offer items
            $itemsToDiscountInCart = $this->resolveFixed($itemsSetPassedToThisOffer);
        }

        /**
         * Ok so if we are here it means this instance of the offer did not need to add anything to the cart.
         */
        // we have the correct quantity because of the method getItemsToDiscountFromExistingCartItems()
        return new OfferItems(
            $itemsToDiscountInCart,
            /**
             * The filtered items passed to this offer are the ones used to make the offer eligible, so we return them as-is.
             */
            $itemsSetPassedToThisOffer
        );
    }

    /**
     * Currently only generated by the ORInclusive testable group type. Any other testable group type generate a single itemsset
     */
    protected function prePickOneGroup(ItemsSet $itemsSetPassedToThisOffer): ItemsSet
    {
        $groups = $itemsSetPassedToThisOffer->getGroups();

        if ($groups->haveAny()) {
            // we got groups, so lets pick the one that is the most expensive
            (object) $mostExpensive = [
                'amount' => 0.00,
                'index' => 0,
            ];
            /** @var ItemsSet $group */
            foreach ($groups->asArray() as $index => $group) {
                if (bccomp($group->getTotalCost(), $mostExpensive['amount'], 2) > 0) {
                    $mostExpensive['amount'] = $group->getTotalCost();
                    $mostExpensive['index'] = $index;
                }
            }

            /** @var ItemsSet $itemsToUse */
            $itemsToUse = $groups->get($mostExpensive['index']) ?? $groups->first();
            /**
             * In the same instance so that we preserve the source path
             */
            $itemsSetPassedToThisOffer->setItems($itemsToUse->getItems());
        }

        return $itemsSetPassedToThisOffer;
    }


    protected function resolveFixed(ItemsSet $itemsSetPassedToThisOffer) : ItemsSet
    {
        $itemsToDiscountInCart = $this->getItemsToDiscountFromExistingCartItems($itemsSetPassedToThisOffer);
        // from those items, we need to figure out if we need to add more to the cart BASED on the quantity
        $itemsInTheCartToDiscountQuantity = $itemsToDiscountInCart->getTotalQuantity();

        if ($itemsInTheCartToDiscountQuantity <  $this->options->quantity && $this->options->missingItemsInCart->mode->is('auto-add')) {
            /**
             * So what if we have 0 eligible items in the cart besides the ones eligible from the "buy" side?
             * we get the cheapest one from the 'buy' side (the passed itemsset to this offer), ONLY if we have 0 eligible items in
             * the cart.
             */
            $itemsToAddToTheCart = $itemsInTheCartToDiscountQuantity === 0? $itemsSetPassedToThisOffer : $itemsToDiscountInCart;
            $remainingQuantity = $this->options->quantity - $itemsInTheCartToDiscountQuantity;
            /**
             * Execution of this offer instance will finish after adding the product to the cart
             * A new instance of the engine will run after the cart changes, and this offer will run again.
             */
            /*$this->addMissingQuantityToCart(
                $itemsToAddToTheCart,
                $this->options->quantity - $itemsInTheCartToDiscountQuantity
            );*/
            (object) $itemToAddToCart = PendingCartItem::create(
                $this->getCheapestToAddToCart($itemsToAddToTheCart),
                $remainingQuantity
            );

            $itemsToDiscountInCart->addItem($itemToAddToCart);
        }

        return $itemsToDiscountInCart;
    }

    private function getItemsToDiscountFromExistingCartItems(ItemsSet $itemsSetPassedToThisOffer) : ItemsSet
    {
        /**
         * Run the same origin filters but now ordered by cheapest
         */
        return usingSorter(
            $this->priceSorterFactory->create(PriceSorterFactory::CHEAPEST),
            function () use ($itemsSetPassedToThisOffer): ItemsSet {
                /**
                 * Run the same origin filters but without the NumberOfItems filter
                 */
                $cheapestEligibleInTheCart = excludingTestableTypes([NumberOfItems::TYPE],
                    function () use ($itemsSetPassedToThisOffer): ItemsSet {
                        // so probably here we may want to exclude other items in the set, like discounted items
                        // so ItemsSet::createFromAllCartItems() may probably have to look like ItemsSet::createFromAllAvailableCartItems() or smth like that
                        $allCartItemsExcludingThePassedItems = ItemsSet::createFromAllEligibleCartItemsDuringDiscovery()->without(
                            $itemsSetPassedToThisOffer
                        );
                        $excluded = $itemsSetPassedToThisOffer->getSourcePath()
                            ->getTestableComposite()
                            ->filterSet($allCartItemsExcludingThePassedItems);

                        return $excluded;
                    });
                    /**
                     * Ok so we got the items to discount from existing items in the cart,
                     * fill the qty required as much as possible
                     */
                    // don't do selectQuantityOrdered() here because we already have the items ordered by cheapest from the code above
                    return $cheapestEligibleInTheCart->selectQuantityOrdered(
                        'maximum',
                        // this HAS to be maximum, otherwise if we have an incomplete qty in the cart, the items would be ignored, and we don't want that, we want to get as many as possible bellow the required qty, with no minimum
                        $this->options->quantity
                    );
            }
        );
    }

    /**
     * Ok, so here a product will be added to the cart, which WILL cause
     * the whole engine to run again (including this offer) because the cart changed,
     * before control is returned to this offer again.
     * BUT, doing so will make this offer ran again, potentially several times, and we don't want that.
     * hmm..., maybe we can use a global flag to prevent execution of the tree if we added to the cart?
     *But then what if we had more offers that would need to run after the cart changed? (eg: we added a 50% off product and we have another tree node in the grove that would qualify for a specific cart subtotal and bya adding the 50% off product, we'd qualify for the cart subtotal offer as well.). so i think i'll just add the products and halt execution of the current tree in favor of the new one that will run after the cart changes.

     * @throws CartModifiedException|Exception
     */
    private function addMissingQuantityToCart(ItemsSet $itemsToDiscountInCart, int $remainingQuantity)
    {
        $cheapest = $this->getCheapestToAddToCart($itemsToDiscountInCart);

        /**
         * todo: so we need to check stock here
         * todo: need to recover from exceptions here, if an exception THAT IS NOT A CartModifiedException, add the second cheapest, and so on, UNTIL WE HAVE NOTHING TO ADD THEN JUST ADD A NOTIFICATION
         */
        $this->cartModifier->addFromCart($cheapest, $remainingQuantity, $this->id);
    }

    private function registerDiscounts(ItemsSet $itemsToDiscount)
    {
        /**
         * Let's not register any discounts
         */
        if (!$itemsToDiscount->hasItems()) {
            return;
        }

        $offerFactory = new ConditionFilterOrOfferFromDataFactory($this->coupon);

        if ($this->options->discount->type->is('fixed-price')) {
            $offerData = new CardData(
                FixedPriceInternal::TYPE,
                new MappedObject([
                    'amount' => $this->options->discount->amount,
                ])
            );
        } else {
            // todo: let's refactor the FREE logic to the discount offer so that it is a native option of discount
            $isFree = $this->options->discount->type->is('free');

            $offerData = new CardData(
                Discount::TYPE,
                new MappedObject([
                        'type' => $isFree ? 'percentage' : $this->options->discount->type,
                        'amount' => $isFree ? 100 : $this->options->discount->amount,
                        'scope' => 'filtereditems',
                    ]
                )
            );
        }
        $discount = $offerFactory->create(
            'offer',
            $offerData
        );

        $discount->apply($itemsToDiscount);
    }

    private function resolveDuplicate(ItemsSet $itemsSetPassedToThisOffer): ItemsSet
    {
        /**
         * This will get us all the items available to duplicate. for example:
         *  -Blue T-shirt A - 1
         *  -Red T-shirt G  - 2
         *
         *  So we need to duplicate 1 of the first and 2 of the second.
         *
         *  *IMPORTANT*: This WILL ignore those that have been added by us and those that weren't added by us but were used for the
         *  duplication so us so as to prevent an unlimited loop.
         */
        $itemsToDuplicateAndRemove = $this->getItemsToDuplicateAndRemoveFromExistingCartItems($itemsSetPassedToThisOffer);

        /**
         * TODO: we need to account for items that were manually removed by the user
         * case 1: user removes the same item that was added by us (this will auto add it)
         * case 2: i forgot right now but there are more cases
         */

        // first lets remove extra items since re-calculation of the cart can be delayed (not now though)
        /** @noinspection NullPointerExceptionInspection */
        /** @var CartItem $itemToRemove */
        foreach ($itemsToDuplicateAndRemove->get('remove')->asArray() as $itemToRemove) {
            $this->cartModifier->removeFromCart(
                $itemToRemove,
                $this->id
            );
        }

        /** @var CartItem $itemToDuplicate */
        /** @noinspection NullPointerExceptionInspection */
        foreach ($itemsToDuplicateAndRemove->get('duplicate')->asArray() as $itemToDuplicate) {
            /**
             * Execution of this offer instance will finish after adding the product to the cart
             * A new instance of the engine will run after the cart changes, and this offer will run again until there are no more
             * items to duplicate.
             */
            $this->cartModifier->addFromCart(
                $itemToDuplicate,
                $itemToDuplicate->getState('quantity'),
                $this->id
            );
        }

        /**
         * Return the items added by us because this is a duplicate.
         * These are the items that will be discounted.
         */
        return ItemsSet::automaticallyAddedByOffer($this->id);
    }

    private function getItemsToDuplicateAndRemoveFromExistingCartItems(ItemsSet $itemsSetPassedToThisOffer): Collection
    {
        // full cart item objects
        (object) $itemsToDuplicate = new Collection([]);
        // cart item objects with specific quantities to remove
        (object) $itemsToRemove = new Collection([]);
        (object) $autoAddedItemsByThisOffer = AutoAddedItems::allByComponent($this->id);

        /** @var CartItem $cartItemPassedToThisOffer */
        foreach ($itemsSetPassedToThisOffer->withVirtualItemsRolledBackIfPossible()->getItems()->asArray() as $cartItemPassedToThisOffer) {

            (object) $autoAddedItem = $autoAddedItemsByThisOffer->find(
                function (AutoAddedItem $autoAddedItem) use ($cartItemPassedToThisOffer) {
                        return $autoAddedItem->cartItemKey === $cartItemPassedToThisOffer->getState('key');
                    }
            );

            if (!$autoAddedItem) {
                // hasn't been added by us, so we need to duplicate it
                $itemsToDuplicate->push($cartItemPassedToThisOffer);
            } else {
                // if the items added by us have a reminder, we need to reduce the quantity to duplicate
                $quantityAddedByUs = $autoAddedItem->quantity;
                $quantityInTheCart = $cartItemPassedToThisOffer->getState('quantity');
                $quantityAddedByUser = $quantityInTheCart < $quantityAddedByUs ? $quantityInTheCart : $quantityInTheCart - $quantityAddedByUs;

                if ($quantityAddedByUs < $quantityAddedByUser) {
                    /**
                     * IMPORTANT:
                     * USE withState() here to create a new instance of the cart item with the adjusted quantity,
                     * otherwise we may affect other parts of the code that may be using the same instance of the cart item.
                     */
                    $itemsToDuplicate->push($cartItemPassedToThisOffer->withState([
                        'quantity' => $quantityInTheCart - $quantityAddedByUs
                    ]));
                } else if ($quantityAddedByUser * 2 !== $quantityInTheCart) {
                    // the user seems to have removed or changed somehow the quantities in the cart, so let's adjust them becuase we always want double the quantity
                    $this->cartModifier->modify(function (WC_Cart $cart) use (
                        $quantityAddedByUser,
                        $cartItemPassedToThisOffer) {
                        $quantity = $quantityAddedByUser * 2;
                        AutoAddedItems::registerItems($cartItemPassedToThisOffer, $quantity / 2, $this->id, true);
                        $cart->set_quantity(
                            $cartItemPassedToThisOffer->getState('key'),
                            $quantity,
                            true
                        );
                        $cart->calculate_totals();
                    });
                    /*$cartItemPassedToThisOffer->setState('quantity', $quantityAddedByUs - $quantityInTheCart);
                    $itemsToRemove->push($cartItemPassedToThisOffer);*/
                } /** @noinspection MissingOrEmptyGroupStatementInspection */ else {
                    // quantities are equal, nothing to duplicate
                }
            }

        }

        return new Collection([
            'duplicate' => $itemsToDuplicate,
            'remove' => $itemsToRemove,
        ]);

/*        /**
         * So basiclly here we'll, uh, ignore:
         * -items added by us
        return $itemsSetPassedToThisOffer->withVirtualItemsRolledBackIfPossible()->without(
            ItemsSet::automaticallyAddedByOffer($this->id)
        );*/
    }

    /**
     * @param ItemsSet $itemsToDiscountInCart
     * @return Collection|mixed|null
     */
    private function getCheapestToAddToCart(ItemsSet $itemsToDiscountInCart)
    {
        /**
         * WE NEED TO ORDER THEM BY CHEAPEST HERE BECAUSE The itemsset **MIGHT** be the original itemsset passed to this offer, which is ordered by most expensive
         */
        $itemsToDiscountInCart->setItemsSorter($this->priceSorterFactory->create(PriceSorterFactory::CHEAPEST));

        $cheapest = $itemsToDiscountInCart->selectQuantityOrdered('maximum', 1)->getItems()->first();

        return $cheapest;
    }
}