<?php

namespace KupShop\GraphQLBundle\ApiPublic\Util;

use KupShop\CatalogBundle\ProductList\ProductList;
use KupShop\CatalogBundle\Util\Product\ProductDiscountCalculator;
use KupShop\ContentBundle\Exception\InvalidCartItemException;
use KupShop\GraphQLBundle\ApiPublic\Types\Cart\CartItemInput;
use KupShop\GraphQLBundle\ApiShared\ApiUtil;
use KupShop\GraphQLBundle\ApiShared\Util\ProductListUtil;
use KupShop\GraphQLBundle\Exception\CartPreconditionFailedException;
use KupShop\KupShopBundle\Util\Database\QueryHint;
use KupShop\OrderingBundle\Cart;
use KupShop\OrderingBundle\Util\Purchase\PurchaseUtil;
use Query\Operator;
use Symfony\Contracts\Service\Attribute\Required;

class CartUtil
{
    protected $cart;

    protected $purchaseUtil;

    protected $productListUtil;

    protected $productList;

    #[Required]
    public ProductDiscountCalculator $productDiscountCalculator;

    public function __construct(Cart $cart, PurchaseUtil $purchaseUtil, ProductListUtil $productListUtil, ProductList $productList)
    {
        $this->cart = $cart;
        $this->purchaseUtil = $purchaseUtil;
        $this->productListUtil = $productListUtil;
        $this->productList = $productList;
    }

    public function getCart(bool $invalidatePurchaseState = false): \KupShop\GraphQLBundle\ApiPublic\Types\Cart\Cart
    {
        if ($invalidatePurchaseState) {
            $this->cart->invalidatePurchaseState();
        }

        $this->purchaseUtil->getCart()->load();
        $purchaseState = $this->purchaseUtil->getPurchaseState();

        if (!empty($purchaseState->getProducts())) {
            $collection = $purchaseState->createProductCollection();

            $this->productDiscountCalculator->prefetchPriceTypes($collection);
        }

        return new \KupShop\GraphQLBundle\ApiPublic\Types\Cart\Cart(
            $this->purchaseUtil, $purchaseState
        );
    }

    /**
     * @param CartItemInput[] $items
     *
     * @throws \Throwable
     */
    public function updateCart(array $items, ?\DateTimeInterface $lastUpdated): void
    {
        $actualItems = QueryHint::withRouteToMaster(fn () => sqlFetchAll(
            sqlQueryBuilder()->select('id id_cart, id_product, id_variation, note, pieces, date')->from('cart')
                ->where(Operator::equals($this->cart->getSelectParams()))
                ->execute(),
            'id_cart'));

        if ($lastUpdated && $this->hasCartChangedSince($lastUpdated, $actualItems, $items)) {
            throw new CartPreconditionFailedException();
        }

        $this->filterOutInvalidItems($actualItems, $items);

        $newItems = [];
        foreach ($items as $item) {
            $id = $item->getCartId();
            $newNote = $item->getNote();
            $newItems[] = [
                'id_cart' => $item->getCartId(),
                'id_product' => $item->getProductId(),
                'id_variation' => $item->getVariationId(),
                'pieces' => $item->getPieces(),
                'note' => ($actualItems[$id] ?? false) ? $actualItems[$id]['note'] : ($newNote ? json_encode($newNote) : ''),
            ];
        }

        $filteredItems = $this->filterItemsForUpdate($actualItems, $newItems);

        foreach ($filteredItems as $filteredItem) {
            $item = $filteredItem['new'];
            $update = [
                'id_cart' => $item['id_cart'],
                'id_product' => $item['id_product'],
                'id_variation' => $item['id_variation'],
                'pieces' => $item['pieces'] - ($filteredItem['actual']['pieces'] ?? 0),
                'note' => empty($item['note']) ? '' : json_decode($item['note'], true),
            ];

            try {
                $this->cart->addItem($update);
            } catch (InvalidCartItemException $e) {
                // the product does not exist. It could be in the cart, but has been deleted by the administrator in the meantime
            }
        }

        $this->cart->invalidatePurchaseState();
    }

    protected function filterOutInvalidItems(array &$oldItems, array &$newItems): void
    {
        $newItemsToClear = [];

        $oldItems = array_filter($oldItems, function ($item) use (&$newItemsToClear) {
            // Remove items that have negative amount or have invalid JSON in note.
            if (
                $item['pieces'] < 0
                || ($item['note'] && json_decode($item['note']) === null)
            ) {
                sqlQueryBuilder()->delete('cart')->where(Operator::equals(['id' => $item['id_cart']]))->execute();
                $newItemsToClear[] = $item['id_cart'];

                return false;
            }

            return true;
        });

        $newItems = array_filter($newItems, function ($item) use ($newItemsToClear) {
            return !$item->getCartId() || !in_array($item->getCartId(), $newItemsToClear);
        });
    }

    /**
     * Finds differences between $actualItems and $newItems. Tries to match items by cart_id first, the rest is matched by id_p, id_v, note.
     * Ensures that items are matched even if $newItems are supplied with existing items without cart_id. So duplicate rows cannot be created into the cart table.
     */
    protected function filterItemsForUpdate($actualItems, $newItems): array
    {
        $getKey = function ($item) {
            $idP = $item['id_product'];
            $idV = $item['id_variation'] ?? 'empty';
            $note = $item['note'] ?: 'empty';

            return $idP.'-'.($idV ?? '').'-'.(md5($note) ?? '');
        };

        $newItemsIndex = [];
        foreach ($newItems as $newItem) { // deduplicate new items and create index
            $key = $getKey($newItem);

            if ($newItemsIndex[$key] ?? false) {
                $newItemsIndex[$key]['pieces'] += $newItem['pieces'] ?? 0;
                if ($newItemsIndex[$key]['id_cart'] ?? false) {
                    $newItemsIndex[$key]['id_cart'] = $newItem['id_cart'];
                }
            } else {
                $newItemsIndex[$key] = $newItem;
            }
        }
        unset($newItems);

        $differences = [];

        // phase 1 - match items by cart_id and generate differences
        foreach ($newItemsIndex as $nKey => $newItem) {
            $actualItem = $actualItems[$newItem['id_cart']] ?? false;
            if (empty($actualItem)) {
                continue;
            }

            if (abs($actualItem['pieces'] - $newItem['pieces']) > PHP_FLOAT_EPSILON) {
                $differences[] = ['new' => $newItem, 'actual' => $actualItem];
            }

            $actualItems[$newItem['id_cart']] = false;
            $newItemsIndex[$nKey] = false;
        }

        $actualItems = array_filter($actualItems);
        $newItemsIndex = array_filter($newItemsIndex);

        // phase 2 - try to match remaining items by id_p, id_v, note
        $actualItemsIndex = [];
        foreach ($actualItems as $actualItem) {
            $key = $getKey($actualItem);
            $actualItemsIndex[$key] = $actualItem;
        }
        unset($actualItems);

        foreach ($newItemsIndex as $key => $newItem) {
            $actualItem = $actualItemsIndex[$key] ?? false;

            if (!$actualItem) {
                $differences[] = ['new' => $newItem];
                continue;
            }

            unset($actualItemsIndex[$key]);

            if (abs($actualItem['pieces'] - $newItem['pieces']) < PHP_FLOAT_EPSILON) {
                continue;
            }
            $differences[] = ['new' => $newItem, 'actual' => $actualItem];
        }

        $deletedItems = array_map(function ($item) {
            $item['pieces'] = -$item['pieces'];

            return ['new' => $item];
        }, $actualItemsIndex);

        return array_merge(array_values($differences), array_values($deletedItems));
    }

    public function getProductListCopy($order, $limit)
    {
        $productList = clone $this->productList;
        $productList->limit($limit);
        $productList->andSpec(function ($qb) use ($order) {
            $qb->orderBySql($order);
        });

        return $productList;
    }

    public function getAlsoBought($limit = 7)
    {
        $products = $this->purchaseUtil->getPurchaseState()->getProducts();

        $productsIds = array_values(array_map(function ($prod) {
            return ['id' => $prod->getIdProduct()];
        }, $products));

        $fallback = array_merge(empty($productsIds) ? [] : [['type' => 'related_product', 'products' => $productsIds, 'in_store' => '1']],
            [['type' => 'bestselling_product', 'price_max' => 1200, 'order' => 'RAND()', 'in_store' => '1']]);

        $products = (new \InsertProductsTypes())->getProducts(['type' => 'cart_product',
            'count' => $limit,
            'products' => $productsIds,
            'image' => 2,
            'in_store' => '1',
            'fallback' => $fallback, ]);

        return $products;
    }

    public function addCoupon(string $coupon): bool
    {
        $this->cart->load(['coupons']);
        if ($this->cart->addCoupon($coupon)) {
            $this->cart->save(['coupons']);

            return true;
        }

        return false;
    }

    public function invalidatePurchaseState(): void
    {
        $this->cart->invalidatePurchaseState();
    }

    /**
     * @param CartItemInput[] $newItems
     */
    protected function hasCartChangedSince(\DateTimeInterface $lastUpdated, array $actualItems, array $newItems): bool
    {
        if (empty($actualItems)) {
            return false;
        }

        $actualIds = array_map(fn ($item) => $item['id_cart'], $actualItems);
        $newIds = array_filter(array_map(fn ($item) => $item->getCartId(), $newItems));

        foreach ($newIds as $newId) {
            if (!in_array($newId, $actualIds)) {
                return true; // cart item was deleted in the meantime
            }
        }

        $lastUpdatedCart = array_reduce($actualItems, function (?\DateTimeImmutable $gDate, $item) {
            if (empty($item['date'])) {
                return $gDate ?? null;
            }
            $itemDate = ApiUtil::prepareDateTimeFromDB($item['date']);
            if ($gDate === null) {
                return $itemDate;
            }

            return $gDate > $itemDate ? $gDate : $itemDate;
        });

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

        return $lastUpdatedCart > $lastUpdated; // somebody modified some cart item in the meantime
    }
}
