<?php

namespace CouponsPlus\App\Conditions;

use CouponsPlus\App\Coupon\CouponsManager;
use CouponsPlus\App\Coupon\CartComponentsSet;
use CouponsPlus\Original\Abilities\Comparable;
use CouponsPlus\App\Offers\OffersSetCollection;
use CouponsPlus\Original\Collections\Collection;
use CouponsPlus\App\Coupon\Tree\Cart\AutoAddedItem;
use CouponsPlus\App\Coupon\Tree\Cart\AutoAddedItems;
use CouponsPlus\App\Coupon\Tree\Sets\NoSpecialSorting;
use CouponsPlus\App\Coupon\Tree\Finders\Sets\OfferItems;
use CouponsPlus\App\Conditions\Abilities\TestableComposite;
use CouponsPlus\App\Coupon\Tree\Sets\Abilities\ItemsSetSource;
use CouponsPlus\App\Coupon\Tree\Sets\Abilities\HasItemsSetSorter;
use CouponsPlus\App\Coupon\Tree\Sets\Abilities\ItemsSetSourcePath;
use CouponsPlus\App\Coupon\Tree\Sets\SequentialItemsSetSourcePath;
use CouponsPlus\App\Coupon\Tree\Sets\Abilities\ItemsSetSorterInterface;

use function CouponsPlus\App\Coupon\Tree\TestableHooks\useItemsSetSorter;

#[\AllowDynamicProperties]
Class ItemsSet extends CartComponentsSet implements Comparable, HasItemsSetSorter
{
    protected ?ItemsSetSource $itemsSetSource = null;
    protected ItemsSetSourcePath $itemsSetSourcePath;
    /**
     * Never use this directly, always use getItemsSorter()
     * @internal Use getItemsSorter() method instead of accessing this property directly
     */
    protected ItemsSetSorterInterface $itemsSorter;
    protected Collection $groupedItems;

    public static function createFromArrayOfData(array $data): ItemsSet
    {
         return new static((new Collection($data))->map(static function(/*array|CartItem*/$cartItem) {
            if ($cartItem instanceof CartItem) {
                return $cartItem;
            }

            return new RealCartItem($cartItem);
        })->asArray());
    }

    public static function createFromAllCartItems() : ItemsSet
    {
        return static::createFromArrayOfData(WC()->cart->get_cart());
    }

    /**
     * Create an ItemsSet from order items for admin order editing context.
     */
    public static function createFromOrderItems(\WC_Order $order): ItemsSet
    {
        $items = [];

        /** @var \WC_Order_Item_Product $orderItem */
        foreach ($order->get_items() as $orderItem) {
            if (!$orderItem instanceof \WC_Order_Item_Product) {
                continue;
            }

            $product = $orderItem->get_product();

            if (!$product) {
                continue;
            }

            $items[] = OrderItem::createFromOrderItem($orderItem);
        }

        return new static($items);
    }

    /** This is a todo method but its here so that when we implement it, the components that use it are ready
     * todo: this will exclude:
     *      -items in the pre-filters
     *      -items that have had a discount applied to them
     *      -items that have been used for a discount offer (they were'nt discounted BUT they were used for a discount offer)
     */
    public static function createFromAllEligibleCartItems() : ItemsSet
    {
        // work in progress methd
        return static::createFromAllCartItems();
    }

    /**
     * This excludes all items that have been added to the offersset during offers discovery but
     * discounts have not yet been applied.
     */
    public static function createFromAllEligibleCartItemsDuringDiscovery(): ItemsSet
    {
        (object) $allCartItems = static::createFromAllCartItems();

        return $allCartItems->withoutCollectionOf(
            OffersSetCollection::getRegisteredItemSetsToUse(),
            'sum' // sum so that we do NOT union same items. so 1x product a and 2x product a will become 3x product a, because items used and discounted are different.
        );
    }

    public function __construct(...$arguments)
    {
        $this->itemsSetSourcePath = new SequentialItemsSetSourcePath([]);
        $this->itemsSorter = new NoSpecialSorting();
        $this->groupedItems = new Collection([]);

        //add_action('woocommerce_cart_item_removed', [$this, 'updateSet']);
        parent::__construct(...$arguments);
    }

    public static function automaticallyAddedByOffer(string $id) : ItemsSet
    {
        $autoAddedItemsData = AutoAddedItems::allByComponent($id);
        $autoAddedItems = new Collection([]);
        $allCartItems = static::createFromAllCartItems();

        $autoAddedItemsData->forEvery(function (AutoAddedItem $autoAddedItem) use ($autoAddedItems, $allCartItems) {
            /** @var CartItem $cartItem */
            $cartItem = $allCartItems->getItems()->find(function (CartItem $cartItem) use ($autoAddedItem): bool {
                return $cartItem->getState('key') === $autoAddedItem->cartItemKey;
            });

            if (!$cartItem) {
                return;
            }

            if (!WC()->cart->get_cart_item($cartItem->getState('key'))) {
                return;
            }

            $autoAddedItems->push($cartItem->extract($autoAddedItem->quantity));
        });

        return new static($autoAddedItems->asArray());
    }

    /**
     * Factory method to create new ItemsSet instances with the current sorter
     */
    public function createNewSet($items = []) : ItemsSet
    {
        $newSet = new static($items);

        $newSet->setItemsSorter($this->getItemsSorter());

        return $newSet;
    }

    /**
     * This doesnt seem to be used
     * @deprecated
     */
    public function __destruct()
    {
        remove_action('woocommerce_cart_item_removed', [$this, 'updateSet']);
    }

    public function getInitialItemsCollection(array $items) : Collection
    {
        return new Collection($items);

        return (new Collection($items))->map(function(/*array|CartItem*/$cartItem) {
            if ($cartItem instanceof CartItem) {
                return $cartItem;
            }

            return new RealCartItem($cartItem);
        });   
    }

    public function hasItems() : bool
    {
        return $this->getItems()->haveAny();
    }
    
    /**
     * DO NOT CHANGE THIS TO BE IMMUTABLE SINCE CODE USING THIS EXPECTS THE COLLECTION TO BE MUTABLE.
     *
     * Gets all ***PUBLICLY*** available items
     * Private items (such as those added by ExtraProduct) are ***NOT*** RETURNED!
     */
    public function getItems() : Collection
    {
        if (!CouponsManager::isLegacy()) {
            return $this->items;
        }
        /**
         * For bc with Coupons Plus 2-
         */
        return $this->items->filter(function(CartItem $cartItem) : bool {
            if (class_exists(\CouponsPlusPro\App\Coupon\Components\Offers\ExtraProduct::class, true)) {
                // only those added by the user and NOT by us.
                return !$cartItem->getState(
                    \CouponsPlusPro\App\Coupon\Components\Offers\ExtraProduct::$PRODUCT_ADDED_WITH_DISCOUNT_TYPE
                );
            }

            return true;
        });
    }

    public function without(ItemsSet $itemsSet) : ItemsSet
    {
        // Build a count map of quantities to exclude per cart key
        $quantitiesToExcludeByKey = [];
        foreach ($itemsSet->items->asArray() as $cartItem) {
            if (!$cartItem->canBeExcluded()) {
                continue;
            }
            $key = $cartItem->getState('key');
            $quantitiesToExcludeByKey[$key] = ($quantitiesToExcludeByKey[$key] ?? 0) + $cartItem->getState('quantity');
        }

        // If nothing to exclude, return self
        if (empty($quantitiesToExcludeByKey)) {
            return $this;
        }

        // Process items without splitting - work directly with quantities
        $remainingItems = [];
        /** @var CartItem $cartItem */
        foreach ($this->items->asArray() as $cartItem) {
            $key = $cartItem->getState('key');
            $itemQuantity = $cartItem->getState('quantity');

            if (!isset($quantitiesToExcludeByKey[$key]) || $quantitiesToExcludeByKey[$key] <= 0) {
                // Nothing to exclude for this key, keep entire item
                $remainingItems[] = $cartItem;
            } else {
                $toExclude = $quantitiesToExcludeByKey[$key];

                if ($toExclude >= $itemQuantity) {
                    // Exclude entire item
                    $quantitiesToExcludeByKey[$key] -= $itemQuantity;
                    // Don't add to remainingItems
                } else {
                    // Partial exclusion - extract remaining quantity
                    $remainingQuantity = $itemQuantity - $toExclude;
                    $quantitiesToExcludeByKey[$key] = 0;
                    $remainingItems[] = $cartItem->extract($remainingQuantity);
                }
            }
        }

        return $this->createNewSet($remainingItems);
    }

    /**
     * sum will take repeating items as unique.
     *      eg: product a x 1 and product a x 2 will yield product a x 3
     *          product b x 1 and product b x 1 will yield product b x 2
     * union will take the max qty of repeating items.
     *      eg: product a x 1 and product a x 2 will yield product a x 2
     *          product b x 1 and product b x 1 will yield product b x 1
     * @param 'sum'|'union' $operation
     */
    public function withoutCollectionOf(Collection $itemSetsToExclude, string $operation = 'sum') : ItemsSet
    {
        if ($operation === 'sum') {
            (object) $itemsSet = $this->createNewSet($this->items->asArray());

            foreach ($itemSetsToExclude->asArray() as $itemSetToExclude) {
                $itemsSet = $itemsSet->without($itemSetToExclude);
            }

            return $itemsSet;
        }

        /**
         * Union operation, only take the max qty of a repeating item
         */
        (object) $ignorableItemsSets = $itemSetsToExclude;

        // here union all the ignorable sets into one single set to prevent sum'ing overlapping items multiple times
        // so product b x 1 and product b x 2 should yield the set with product b x 2 (highest qty) instead of product b x 3 (sum of qty's)
        // and product c x 1 and product c x 1 should yield product c x 1, not product c x 2
        /** @var ItemsSet $ignorableItemsSet */
        foreach ($ignorableItemsSets->asArray() as $ignorableItemsSet) {
            (object) $itemsToExclude = $ignorableItemsSet->getItems();

            if (!isset($unionedItemsToExclude)) {
                $unionedItemsToExclude = $ignorableItemsSet;
            } else {
                $unionedItemsToExclude = $unionedItemsToExclude->unionByMaxQuantity($ignorableItemsSet);
            }
        }

        return $this->without($unionedItemsToExclude ?? new ItemsSet([]));
    }

    public function excludeItems(bool $canBeExcluded)
    {
        $this->items->forEvery(function(CartItem $cartItem) use($canBeExcluded) : void {
            $cartItem->setCanBeExcluded($canBeExcluded); 
        });
    }

    public function getUnfilteredItems() : Collection
    {
        return $this->items;   
    }

    public function withVirtualItemsRolledBackIfPossible() : ItemsSet
    {
        if ($this->hasVirtualItems()) {
            (object) $virtualItemsGroupedBySameCartItem = $this->groupVirtualItemsByCartType();
            (object) $newItemsSet = $this->createNewSet($this->items->asArray());
            foreach ($virtualItemsGroupedBySameCartItem->asArray() as $virtualCartItems) {
                (object) $virtualItemsSet = $this->createNewSet($virtualCartItems->asArray());
                (object) $RealCartItem = $virtualCartItems->first()
                                                              ->getRealCartItem();
                (boolean) $virtualItemsOfThistypeHaveTheSameQuantityAsTheRealItem = $virtualItemsSet->getTotalQuantity() === $RealCartItem->getState('quantity');

                if ($virtualItemsOfThistypeHaveTheSameQuantityAsTheRealItem) {
                    // items can be rolled back to a Real cart item
                    // so we need to:
                    // 1 remove the virtaul items
                    // 2 and replace em with the single real item
                    (object) $shouldCartItemMatchRealItem = function(bool $shouldMatch) use ($RealCartItem) : callable {
                        return function(CartItem $cartItem) use ($shouldMatch, $RealCartItem) : bool {
                            // basically, we want to remove all items with the key of the virtual items, then we'll add the real item back later
                            (boolean) $iscartItemSameAsReal = $cartItem->getState('key') === $RealCartItem->getState('key');
                            return $shouldMatch? $iscartItemSameAsReal : !$iscartItemSameAsReal; 
                        };
                    };

                    (string) $indexOfTheFirstVirtualItem = $newItemsSet->items->findKey(
                        $shouldCartItemMatchRealItem(true)
                    );
                    // keys should be preserved here at this point because we are 
                    // replacing the first item using its key
                    // in the expresion bellow this one
                    // do not add a Collection::getValues() call **yet**
                    $newItemsSet->items = $newItemsSet->items->filter(
                        $shouldCartItemMatchRealItem(false)
                    );
                    // here's were we set it using the old key
                    $newItemsSet->items->set(
                        $indexOfTheFirstVirtualItem, 
                        $RealCartItem
                    );
                }
                $newItemsSet->items = $newItemsSet->items->sortByKey();
                $newItemsSet->items = $newItemsSet->items->getValues();
            }

            return $newItemsSet->items->asArray() !== $this->items->asArray()? $newItemsSet : $this;
        }

        return $this;
    }

    public function hasVirtualItems() : bool
    {
        return (boolean) $this->items->find(function(CartItem $cartItem) : bool {
            return $cartItem instanceof VirtualCartItem;
        });
    }
    
    /**
     * Ordered by the price of a single product,
     * NOT the line item total (NOT the price x amount of items)
     * just the individual product price
     *
     *  **renamed from getOrderedByCheapestProduct()**
     */
    public function getSortedItems() : Collection
    {
        return new Collection($this->getItemsSorter()->sort($this));

        // pre 3.0
        //
/*        return  $this->getItems()->sort(function(CartItem $cartItemOne, CartItem $cartItemTwo) : int {
            (float) $firstProductPrice = (float) $cartItemOne->getProduct()->get_price($context = 'edit');
            (float) $secondProductPrice = (float) $cartItemTwo->getProduct()->get_price($context = 'edit');

            return $firstProductPrice <=> $secondProductPrice;
        });   */
    }

    /**
     * These are line items ordered by the cheapest active item price
     */
    public function withItemsOrderedByCheapest() : ItemsSet
    {
       return $this->withItemsOrderedByPrice(false);
    }

    /**
     * These are line items ordered by the most expensive active item price
     */
    public function withItemsOrderedByMostExpensive() : ItemsSet
    {
       return $this->withItemsOrderedByPrice(true);
    }

    /**
     * Helper method for ordering items by price
     */
    protected function withItemsOrderedByPrice(bool $descendingOrder = false) : ItemsSet
    {
       $orderedCollectionOfFlattenedItems = $this->splitAllItems()->getItems()->sort(function (CartItem $cartItemOne, CartItem $cartItemTwo) use ($descendingOrder) : int {
           $firstProductPrice = (float) $cartItemOne->getProduct()->get_price($context = 'edit');
           $secondProductPrice = (float) $cartItemTwo->getProduct()->get_price($context = 'edit');

           return $descendingOrder
               ? bccomp($secondProductPrice, $firstProductPrice, 2)
               : bccomp($firstProductPrice, $secondProductPrice, 2);
       });

       return $this->createNewSet($orderedCollectionOfFlattenedItems->asArray());
    }

    public function addItem(Cartitem $cartItem)
    {
        $this->items->push($cartItem);
    }

    public function addItems(/*array|Collection|ItemsSet*/ $items,  ?TestableComposite $testableCompositeSource = null)
    {
        $sourceForTheseItems = $items instanceof self ? $items->getSource() : null;
        (object) $items = $items instanceof self? $items->getItems() : new Collection($items);

        if (!$items->haveAny()){
            return;
        }

        /**
         * maybe we should unify virtual items: if they have the same key, merge them into one with the sum of their quantities
         */
        $this->items = $this->getItems()->append($items)->filter(function($item) : bool {
            return $item instanceof CartItem;
        })/*-this one is causing issues with 2 visrtual items with the same key and qty >withoutDuplicates()*/;

        // only add a source IF the current items don't have a source, if they do, use that one instead
        /**
         * DO NOT! change the priority here! we have to preserve the source from the items if any, or we'll have nasty bugs.
         * Only major groups register sources (AND, and OR), individual filters and inner groups like contexts, intersection, 
         * subset, etc. should NEVER to prevent redundancy and bugs.
         */
        $testableCompositeSourceToAdd = $sourceForTheseItems ? $sourceForTheseItems->getTestableComposite(
        ) : $testableCompositeSource;

        if ($testableCompositeSourceToAdd) {
            $this->addToSource($testableCompositeSourceToAdd);
        }
    }
    /**
     * This method ERASES ALL THE PREVIOUS STATE IN PLACE FOR THE NEW ONE
     * SOURCE PATH AND GROUPS WILL BE LOST IF PASSED AN ITEMSSET
     *
     * PRESERVES STATE IF PASSED AN ARRAY OF CART ITEMS
     *
     * probably need to pass the state (source and path)
     */
    public function setItems(/*array|Collection|ItemsSet*/ $items)
    {
        if ($items instanceof static) {
            foreach (get_object_vars($items) as $property => $value) {
                $this->{$property} = $value;
            }
        } else {
            $this->items = new Collection($items);
        }
    }

    /**
     * @alias flatten
     */
    public function splitAllItems() : ItemsSet
    {
        return $this->getSplitItems($this->items);
    }

    public function splitPublicItems() : ItemsSet
    {
        return $this->getSplitItems($this->getItems());
    }

    protected function getSplitItems(Collection $itemsToSplit) : ItemsSet
    {
        (object) $individualItemsSet = $this->createNewSet([]);

        foreach ($itemsToSplit->asArray() as $cartItem) {
            foreach ($cartItem->split()->getItems()->asArray() as $individualCartItem) {
                $individualItemsSet->addItem($individualCartItem);
            }
        }

        return $individualItemsSet;
    }


    public function isTheSameAs(/*ItemsSet*/ $itemsSetToCheck) : bool
    {
        if (!($itemsSetToCheck instanceof static)) {
            return false;
        }
        /**
         * To know:
         * Collection::areTheSameAs() uses Collection::has()
         * which uses Comparable::isTheSameAs() on Comparable objects
         * CartItem is a Comparable object,
         * so $cartItem->isTheSameAs($otherCartItem) is being called under the hood.
         *
         * Pretty cool, huh
         *
         */
        return $this->withVirtualItemsRolledBackIfPossible()
                    ->getItems()
                    ->areTheSameAs(
                        $itemsSetToCheck->withVirtualItemsRolledBackIfPossible()
                                        ->getItems()
                    );

                    /*
        return $this->items->getValues()->reduce(
            function(bool $lastCartItemIsTheSame, CartItem $cartItem, int $key) use ($itemsSetToCheck) : bool {

                // if only one of em is not the same, skip checking
                // because these sets are sure as h not the same
                if (!$lastCartItemIsTheSame) {
                    return false;
                }

                return $itemsSetToCheck->getItems()->has($cartItem);
            },
            $initial = $this->items->haveAny()? $itemsSetToCheck->getItems()->haveAny() : $itemsSetToCheck->getItems()->haveNone()
        );*/
    }

    public function getCombined(callable $callback, $initial = 0, bool $positiveOnly = true) : float
    {
        return $this->getItems()->reduce($callback, $initial = 0);
    }

    public function getCombinedField(string $field, bool $positiveOnly = true) : float
    {
        return $this->getCombined(function(float $count, CartItem $cartItem) use ($field) {
            if (!($cartItem->getState($field) > 0)) {
                return $count;
            }

            return $count + $cartItem->getState()->get($field);
        }, $initial = 0, $positiveOnly);
    }

    public function filterSum(array $options) : ItemsSet
    {
        /**
         * $this->filterSum([
         *     'numberOfItems' => [
         *         'equals' => 5
         *     ],
         *     ''
         * ])
         */
    }

    public function getPositiveItems(string $field) : Collection
    {
        return $this->getItems()->filter(function(CartItem $cartItem) use ($field) : bool {
            return $cartItem->getState($field) > 0;
        });
    }

    public function getTotalQuantity() : int
    {
        return $this->getCombinedField('quantity');
    }

    public function getTotalCost() : float
    {
        return $this->getCombined(function(float $count, CartItem $cartItem) {
            return $count + (
                    $cartItem->getProduct()->get_price($context = 'edit') * $cartItem->getState('quantity')
                   );
        }, $initial = 0);
    }

    public function updateSet(string $cart_item_key)
    {
        $this->items = $this->items->filter(function(CartItem $cartItem) use ($cart_item_key) : bool {
            return $cartItem->getState('key') !== $cart_item_key;
        });
    }

    /**
     * Might return *LESS* that the maximum amount.
     */
    public function getAMaximumOf(int $amount) : ItemsSet
    {
        (object) $itemsToReturn = $this->createNewSet([]);

        foreach ($this->getSortedItems()->asArray() as $cartItem) {
            if ($itemsToReturn->getTotalQuantity() == $amount) {
                break;
            }

            (integer) $numberOfItemsLeftToAdd = $amount - $itemsToReturn->getTotalQuantity();

            (boolean) $hasExactQuantity = $cartItem->getState('quantity') == $numberOfItemsLeftToAdd;
            (boolean) $hasLessThanRequired = $cartItem->getState('quantity') < $numberOfItemsLeftToAdd;

            if ($hasExactQuantity || $hasLessThanRequired) {
                $itemsToReturn->items->push($cartItem);
            } else {
                // non exact, needs extractin'
                (object) $virtualCartItem = $cartItem->extract($numberOfItemsLeftToAdd);

                $itemsToReturn->items->push($virtualCartItem);
            }
        }   

        return $itemsToReturn;
    }

    protected function groupVirtualItemsByCartType() : Collection
    {
        (object) $allVirtualItems = $this->getVirtualItems();
        (object) $grouppedVirtualItems = $allVirtualItems->mapWithKeys(function(CartItem $cartItem) : array {
            return [
                'key' => $cartItem->getState('key'),
                'value' => new Collection([])
            ];
        });

        foreach ($allVirtualItems->asArray() as $virtualItem) {
            $grouppedVirtualItems->get($virtualItem->getState('key'))
                                 ->push($virtualItem);
        }

        return $grouppedVirtualItems->filter();
    }

    protected function getVirtualItems() : Collection
    {
        return $this->items->filter(function(CartItem $cartItem) : bool {
            return $cartItem instanceof VirtualCartItem;
        });   
    }

    public function getPendingItemsToAddToCart() : Collection
    {
        return $this->items->filter(function(CartItem $cartItem) : bool {;
            return $cartItem instanceof PendingCartItem;
        });
    }

    /**
     * If we have two of the same spread across multiple pending cart items, we need to join them BY –SUM-
     * So that we only add them once to the cart instead of multiple times.
     * Because some offers might generate pending items for the same product multiple times.
     * Eg 1 x product a, 1 x product a should become 2 x product a so that we only add it to the cart in one call instead of multiple to prevent unnecessary database calls AND cart recalculations for each addition.
     */
    public function getPendingItemsToAddToCartJoined() : Collection
    {
        (object) $pendingItems = $this->getPendingItemsToAddToCart();
        (object) $joinedPendingItems = new Collection([]);

        foreach ($pendingItems->asArray() as $pendingCartItem) {
            (string) $key = $pendingCartItem->getState('key');

            (object) $existingPendingCartItem = $joinedPendingItems->find(function(CartItem $cartItem) use ($key) : bool {
                return $cartItem->getState('key') === $key;
            });

            if ($existingPendingCartItem) {
                // already have one, sum em up
                $existingPendingCartItem->setQuantity(
                    $existingPendingCartItem->getQuantity() + $pendingCartItem->getQuantity()
                );
            } else {
                // new one, just add it
                $joinedPendingItems->push($pendingCartItem);
            }
        }

        return $joinedPendingItems;
    }


    public function setItemsSorter(ItemsSetSorterInterface $getItemsSorter)
    {
        $this->itemsSorter = $getItemsSorter;
    }

    public function getItemsSorter() : ItemsSetSorterInterface
    {
        $contextualSorter = useItemsSetSorter();

        return $contextualSorter ?? $this->itemsSorter;
    }

    /** New experimental algorithm start **/

    /**
     * Efficiently get items by quantity with optional sorting.
     * Use this when items are already sorted or sorting is not needed.
     * Optimized to avoid unnecessary splitting and sorting operations.
     *
     * @param string $type Type from AmountValidator: 'equals', 'minimum', 'maximum', 'range'
     * @param int|array $amount Quantity amount or range [min, max]
     * @return ItemsSet New ItemsSet with the requested quantity, or empty set if not achievable
     */
    public function selectQuantity(string $type, $amount): ItemsSet
    {
        return $this->getQuantityItemsInternal($type, $amount, false);
    }

    /**
     * Efficiently get items by quantity WITH sorting and limiting.
     * Use this when you need both selection and ordering in one operation.
     * Optimized to avoid unnecessary splitting and full sorting operations.
     *
     * -No ordering on:
     *    -exact match (equals 5 and there are 5 items)
     *    -minimum
     *
     * @param string $type Type from AmountValidator: 'equals', 'minimum', 'maximum', 'range'
     * @param int|array $amount Quantity amount or range [min, max]
     * @return ItemsSet New ItemsSet with the requested quantity, or empty set if not achievable
     */
    public function selectQuantityOrdered(string $type, $amount): ItemsSet
    {
        return $this->getQuantityItemsInternal($type, $amount, true);
    }

    /**
     * Internal method that handles both sorted and unsorted quantity selection
     */
    private function getQuantityItemsInternal(string $type, $amount, bool $withSorting): ItemsSet
    {
        // Handle different quantity types
        switch ($type) {
            case 'minimum':
                return $this->getMinimumQuantityItems($amount);
            case 'equals':
                return $this->getExactQuantityItems($amount, $withSorting);
            case 'maximum':
                return $this->getMaximumQuantityItems($amount, $withSorting);
            case 'range':
                return $this->getRangeQuantityItems($amount[0], $amount[1], $withSorting);
            default:
                return $this->createNewSet([]);
        }
    }

    /**
     * Get minimum quantity items - no sorting needed, just validate total
     */
    private function getMinimumQuantityItems(int $minimumAmount): ItemsSet
    {
        $totalQuantity = $this->getTotalQuantity();

        if ($totalQuantity >= $minimumAmount) {
            return $this; // All items meet minimum requirement
        }

        return $this->createNewSet([]); // Not enough items
    }

    /**
     * Get exact quantity items with optional sorting and early termination
     */
    private function getExactQuantityItems(int $exactAmount, bool $withSorting = false): ItemsSet
    {
        $totalQuantity = $this->getTotalQuantity();

        // Early returns for edge cases
        if ($totalQuantity < $exactAmount) {
            return $this->createNewSet([]); // Not enough items
        }

        /* still order them when we have an exact amount because some offers expect a sorted set eg (BOGOS)
         * if ($totalQuantity === $exactAmount) {
            return $this; // Perfect match, return all items
        }
        */

        // Need to select exactly the required amount
        return $this->collectItemsWithQuantity($exactAmount, $exactAmount, $withSorting);
    }

    /**
     * Get maximum quantity items with optional sorting and early termination
     */
    private function getMaximumQuantityItems(int $maxAmount, bool $withSorting = false): ItemsSet
    {
        $totalQuantity = $this->getTotalQuantity();

        if ($totalQuantity <= $maxAmount) {
            return $this; // All items fit within maximum
        }

        // Need to select up to maximum amount
        return $this->collectItemsWithQuantity($maxAmount, $maxAmount, $withSorting);
    }

    /**
     * Get items within range with optional sorting
     */
    private function getRangeQuantityItems(int $minAmount, int $maxAmount, bool $withSorting = false): ItemsSet
    {
        $totalQuantity = $this->getTotalQuantity();

        if ($totalQuantity < $minAmount) {
            return $this->createNewSet([]); // Not enough items for minimum
        }

        if ($totalQuantity <= $maxAmount) {
            return $this; // All items fit within range
        }

        // Collect maximum possible within range
        return $this->collectItemsWithQuantity($maxAmount, $maxAmount, $withSorting);
    }

    /**
     * Core collection algorithm with optional sorting and quantity limiting.
     * Only sorts when needed and stops early when target is reached.
     */
    private function collectItemsWithQuantity(int $targetQuantity, int $maxQuantity, bool $withSorting = false): ItemsSet
    {
        $collectedItems = [];
        $currentQuantity = 0;

        // Get line items with optional sorting
        $lineItems = $withSorting ? $this->getItemsSorter()->sort($this) : $this->getItems()->asArray();

        foreach ($lineItems as $lineItem) {
            $lineQuantity = $lineItem->getState('quantity');

            // Check if we've reached our target
            if ($currentQuantity >= $targetQuantity) {
                break; // Early termination - don't process more items
            }

            $remainingNeeded = $targetQuantity - $currentQuantity;

            if ($lineQuantity <= $remainingNeeded) {
                // Take entire line item
                $collectedItems[] = $lineItem;
                $currentQuantity += $lineQuantity;
            } else {
                // Take partial line item via virtual item
                $virtualItem = $lineItem->extract($remainingNeeded);
                $collectedItems[] = $virtualItem;
                $currentQuantity += $virtualItem->getState('quantity');
                
                // Only break if we actually reached the target quantity
                if ($currentQuantity >= $targetQuantity) {
                    break; // We have exactly what we need
                }
                // If extract() returned less than expected, continue to next item
            }
        }

        // Verify we collected exactly what was requested
        if ($currentQuantity != $targetQuantity) {
            return $this->createNewSet([]); // Failed to collect exact amount
        }

        return $this->createNewSet($collectedItems);
    }

    public function getSourcePath() : ItemsSetSourcePath
    {
        return $this->itemsSetSourcePath;
    }

    public function commitSourceToPath()
    {
        if (!$this->itemsSetSource) {
            return;
        }

        $this->itemsSetSourcePath->addStep($this->itemsSetSource);
        $this->itemsSetSource = null;
    }
    public function addToSource(TestableComposite $itemsSetSource): void
    {
        if (!$this->itemsSetSource) {
            return;
        }

        $this->itemsSetSource->addTestableComposite($itemsSetSource);
    }

    public function setSource(ItemsSetSource $itemsSetSource)
    {
        $this->itemsSetSource = $itemsSetSource;
    }

    public function getSource(): ?ItemsSetSource
    {
        // this shall return a composite of sources,
        return $this->itemsSetSource;
    }

    public function setSourcePath(ItemsSetSourcePath $path)
    {
        $this->itemsSetSourcePath = $path;
    }

    /**
     * Union this set with another, taking maximum quantities for overlapping items.
     */
    public function unionByMaxQuantity(ItemsSet $itemsSetToUnion): ItemsSet
    {
        (object) $combinedItems = new Collection($this->items->asArray());

        foreach ($itemsSetToUnion->getItems()->asArray() as $itemToUnion) {
            (integer) $indexOfExistingItem = $combinedItems->findKey(function(CartItem $cartItem) use ($itemToUnion) : bool {
                return $cartItem->belongsToTheSameLineItem($itemToUnion);
            });

            if ($combinedItems->get($indexOfExistingItem)) {
                (object) $existingItem = $combinedItems->get($indexOfExistingItem);

                if ($itemToUnion->getState('quantity') > $existingItem->getState('quantity')) {
                    $combinedItems->set($indexOfExistingItem, $itemToUnion);
                }
            } else {
                $combinedItems->push(clone $itemToUnion);
            }
        }

        return $this->createNewSet($combinedItems->asArray());
    }

    public function addGroup(ItemsSet $items): void
    {
        $this->groupedItems->push($items);
    }

    public function getGroups() : Collection
    {
        return clone $this->groupedItems;
    }

    public function haveAny(): bool
    {
        return $this->getItems()->haveAny();
    }

    /**
     * Gets all items in this set that are in the cart (e.g.: no pending)
     */
    public function getExistingItems(): ItemsSet
    {
        return $this->createNewSet(
            $this->getItems()->filter(function(CartItem $cartItem) : bool {
                return !$cartItem instanceof PendingCartItem;
            })->asArray()
        );
    }

    /**
     * Get line items sorted by price using the internal sorter.
     * This avoids splitting items and works directly with line items.
     */
    private function getSortedLineItemsByPrice(): array
    {
        $lineItems = $this->getItems()->asArray();

        // Sort line items by their individual product price
        // Use the same sorting logic as the price sorters but work on line items directly
        usort($lineItems, function(CartItem $itemOne, CartItem $itemTwo) {
            $priceOne = (float) $itemOne->getProduct()->get_price('edit');
            $priceTwo = (float) $itemTwo->getProduct()->get_price('edit');

            // Check if we're using cheapest (ascending) or most expensive (descending) sorter
            $isDescendingSort = $this->isUsingDescendingSorter();

            return $isDescendingSort
                ? bccomp($priceTwo, $priceOne, 2)  // Most expensive first
                : bccomp($priceOne, $priceTwo, 2); // Cheapest first
        });

        return $lineItems;
    }

    /**
     * Detect if current sorter sorts in descending order (most expensive first)
     */
    private function isUsingDescendingSorter(): bool
    {
        // Check if sorter is MostExpensiveItemsSorter or similar
        $sorterClass = get_class($this->getItemsSorter());

        return strpos($sorterClass, 'MostExpensive') !== false ||
               strpos($sorterClass, 'Descending') !== false;
    }
    /** New experimental algorithm end **/

    public function setOfferItems(OfferItems $offerItems)
    {
        $this->offerItems = $offerItems;
    }
    /***
     * uGH, I HATE THIS ONE BUT KET ME SEE IF THIS WORKS AND IF SO ILL REFACTOR IT
     */
    public function getOfferItems()
    {
    return $this->offerItems ?? new OfferItems($this);
    }
}
