<?php

namespace CouponsPlus\App\Coupon;

use Throwable;
use Exception;
use JsonException;
use CouponsPlus\App\Offers\OffersSet;
use CouponsPlus\App\Conditions\ItemsSet;
use CouponsPlus\App\Coupon\Tree\Data\AppData;
use CouponsPlus\App\Coupon\Tree\TestableGroup;
use CouponsPlus\App\Offers\OffersSetCollection;
use CouponsPlus\App\Coupon\Abilities\CouponModel;
use CouponsPlus\App\Coupon\Tree\Data\AppDataLoader;
use CouponsPlus\App\Coupon\Tree\Data\DataWithSource;
use CouponsPlus\App\Coupon\Tree\PassThroughTestableGroup;
use CouponsPlus\App\Coupon\Tree\Finders\GroveOffersDataFinder;
use CouponsPlus\App\Coupon\Tree\Factories\OfferSetsFromDataFactory;
use CouponsPlus\App\Coupon\Tree\Finders\ProductionGroveOffersDataFinderFactory;
use CouponsPlus\App\Coupon\Tree\Factories\ConditionFilterOrOfferFromDataFactory;
use CouponsPlus\App\Coupon\Tree\Factories\StandaloneProductionTestableGroupFactory;

use const JSON_THROW_ON_ERROR;

class TreeCouponModel implements CouponModel
{
    const TYPE = 'tree';
    protected AppData $appData;

    private $coupon;
    private $groveSourceJson;
    /** @var GroveOffersDataFinder */
    private $groveOffersDataFinder;
    /** @var StandaloneProductionTestableGroupFactory */
    protected $testableGroupFactory;
    /** @var TestableGroup */
    protected $preconditionsTestableGroup;
    /** @var TestableGroup */
    protected $predatesTestableGroup;

    /** @var OffersSetCollection|OffersSet|null */
    private $cachedOffers = null;
    /** @var string */
    private string $cachedOffersFingerprint = '';
    /** @var bool|null */
    private ?bool $cachedPreconditionsResult = null;
    /** @var string */
    private string $cachedPreconditionsFingerprint = '';

    /**
     * @throws JsonException
     */
    public function __construct(Coupon $coupon)
    {
        $appDataLoader = new AppDataLoader($coupon->getClassic());
        $this->appData = $appDataLoader->load();

        $this->coupon = $coupon;
        $this->groveSourceJson = $this->appData->grove->getSource();

        (object) $groveOffersDataFinderFactory = new ProductionGroveOffersDataFinderFactory($coupon->getClassic());
        try {
            $this->groveOffersDataFinder = $groveOffersDataFinderFactory->create($this->groveSourceJson, $this->appData->partialRelations->getSource(), $this->appData->version);
        } catch (Throwable $e) {
            throw new Exception(
                esc_html('Grove source JSON is not valid: ' . $e->getMessage() . '. Source: ' . $this->groveSourceJson)
            );
        }
        $testableGroupFactory = new StandaloneProductionTestableGroupFactory($coupon->getClassic());

        $this->preconditionsTestableGroup = $this->appData->preconditions->getData()?
            $testableGroupFactory->create($this->getOrExclusiveData($this->appData->preconditions)->getSource())
            :
            new TestableGroup(new PassThroughTestableGroup);

        $this->predatesTestableGroup = $this->appData->predates->getData()?
            $testableGroupFactory->create($this->getOrExclusiveData($this->appData->predates)->getSource())
            :
            new TestableGroup(new PassThroughTestableGroup);
    }

    public function getModelType(): string
    {
        return static::TYPE;
    }

    public function hasConfigurationContent(): bool
    {
        return !empty($this->getRawGroveData());
    }

    public function isValid(): bool
    {
        /**
         * if we are adding products, the engine is shut off until we have added all products,
         * so pass this **IF** this was already added to the cart so as to 'freeze' the state until the engine is re-run after the final product is added
         */
        if ($this->isFrozen()) {
            return true;
        }

        /** @var OffersSetCollection */
        (object) $offers = $this->getOffers();

        return $offers->canBeApplied();
    }

    /**
     * @return OffersSetCollection|OffersSet
     */
    public function getOffers()
    {
        if ($this->isFrozen()) {
            return new OffersSet([]);
        }

        if (!$this->precondtionsPassed()) {
            return new OffersSet([]);
        }

        $currentFingerprint = CartContentFingerprint::generate();

        if ($this->cachedOffers !== null && $this->cachedOffersFingerprint === $currentFingerprint) {
            return $this->cachedOffers;
        }

        (object) $offerSetsFromDataFactory = new OfferSetsFromDataFactory(
            new ConditionFilterOrOfferFromDataFactory($this->coupon->getClassic())
        );

        $this->cachedOffers = $offerSetsFromDataFactory->create($this->findOffersData());
        $this->cachedOffersFingerprint = $currentFingerprint;

        return $this->cachedOffers;
    }

    protected function getPreconditions() : TestableGroup
    {
        return $this->preconditionsTestableGroup;
    }

    public function precondtionsPassed() : bool
    {
        $currentFingerprint = CartContentFingerprint::generate();

        if ($this->cachedPreconditionsResult !== null && $this->cachedPreconditionsFingerprint === $currentFingerprint) {
            return $this->cachedPreconditionsResult;
        }

        $allCartItems = ItemsSet::createFromAllCartItems();

        $this->cachedPreconditionsResult = ($this->preconditionsTestableGroup->filterSet($allCartItems)->isValid())
            &&
            ($this->predatesTestableGroup->filterSet($allCartItems)->isValid());

        $this->cachedPreconditionsFingerprint = $currentFingerprint;

        return $this->cachedPreconditionsResult;
    }

    protected function findOffersData() : OffersSetCollection
    {
        return $this->groveOffersDataFinder->findOffers(ItemsSet::createFromAllCartItems());
    }

    /**
     * Get offers evaluated against order items instead of cart items.
     * Used when applying coupons in admin order editing context.
     */
    public function getOffersForOrder(\WC_Order $order): \CouponsPlus\App\Offers\Abilities\Offers
    {
        $orderItemsSet = ItemsSet::createFromOrderItems($order);

        // Skip preconditions for order context - the coupon was already validated when originally applied
        (object) $offerSetsFromDataFactory = new OfferSetsFromDataFactory(
            new ConditionFilterOrOfferFromDataFactory($this->coupon->getClassic())
        );

        return $offerSetsFromDataFactory->create(
            $this->groveOffersDataFinder->findOffers($orderItemsSet)
        );
    }

    protected function getRawGroveData() : array
    {
        return json_decode($this->getGroveSource()) ?: [];
    }

    protected function getGroveSource() : string
    {
        return $this->groveSourceJson;
    }

    /**
     * @return bool
     */
    private function isFrozen(): bool
    {
        return GroveOffersDataFinder::isShutOff() && $this->coupon->isInCart();
    }

    /**
     * Clears all cached results. Useful for testing.
     */
    public function clearCache(): void
    {
        $this->cachedOffers = null;
        $this->cachedOffersFingerprint = '';
        $this->cachedPreconditionsResult = null;
        $this->cachedPreconditionsFingerprint = '';
    }

    /**
     * Legacy this used to be "or" but we've changed it to or inclusive and exclusive. By default, "or" was inclusive, which is the more popular option, so let's change it here explicitly to or exclusive
     * @param DataWithSource $preconditions
     * @return DataWithSource
     * @throws JsonException
     */
    private function getOrExclusiveData(DataWithSource $preconditions)
    {
        (object) $newData = clone $preconditions->getData();

        $newData->type = 'OR_EXCLUSIVE';

        return new DataWithSource(
            json_encode($newData, JSON_THROW_ON_ERROR),
            $newData
        );
    }
}