<?php

declare(strict_types=1);

/**
 * Central rule policy evaluation and usage tracking for promo rules.
 */

namespace Autogedal\PromoRules\Model\Rule;

use Autogedal\PromoRules\Api\Data\RuleConfigInterface;
use Autogedal\PromoRules\Api\RuleConfigRepositoryInterface;
use Autogedal\PromoRules\Api\RuleUsageRepositoryInterface;
use Autogedal\PromoRules\Model\RuleUsageFactory;
use Magento\Bundle\Model\Product\Type as BundleType;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Lock\LockManagerInterface;
use Magento\Framework\Stdlib\DateTime\TimezoneInterface;
use Magento\Framework\App\ResourceConnection;
use Magento\Quote\Model\Quote;
use Magento\Quote\Model\Quote\Address;
use Magento\Quote\Model\Quote\Item\AbstractItem;
use Magento\ConfigurableProduct\Model\Product\Type\Configurable as ConfigurableType;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\SalesRule\Model\CouponFactory;
use Magento\SalesRule\Model\Rule;
use Magento\Store\Model\StoreManagerInterface;

/**
 * Evaluates promo rule policies and persists guest usage counters.
 */
class PolicyManager
{
    /**
     * @var int
     */
    private const LOCK_TIMEOUT = 60;

    /**
     * @var array<int, RuleConfigInterface|null>
     */
    private array $ruleConfigCache = [];

    /**
     * @var array<string, array<int, float>>
     */
    private array $catalogRulePriceCache = [];

    /**
     * @param RuleConfigRepositoryInterface $ruleConfigRepository
     * @param RuleUsageRepositoryInterface $ruleUsageRepository
     * @param RuleUsageFactory $ruleUsageFactory
     * @param ResourceConnection $resourceConnection
     * @param LockManagerInterface $lockManager
     * @param TimezoneInterface $timezone
     * @param StoreManagerInterface $storeManager
     * @param CouponFactory $couponFactory
     */
    public function __construct(
        private readonly RuleConfigRepositoryInterface $ruleConfigRepository,
        private readonly RuleUsageRepositoryInterface $ruleUsageRepository,
        private readonly RuleUsageFactory $ruleUsageFactory,
        private readonly ResourceConnection $resourceConnection,
        private readonly LockManagerInterface $lockManager,
        private readonly TimezoneInterface $timezone,
        private readonly StoreManagerInterface $storeManager,
        private readonly CouponFactory $couponFactory
    ) {
    }

    /**
     * Check whether a sales rule can be applied to the current quote address.
     *
     * @param Rule $rule
     * @param Address $address
     * @return bool
     */
    public function canApplyRuleToAddress(Rule $rule, Address $address): bool
    {
        $quote = $address->getQuote();
        $ruleConfig = $this->getRuleConfig((int) $rule->getRuleId());
        if ($ruleConfig === null) {
            return true;
        }

        if ($ruleConfig->getUsesPerEmail() > 0 && !$this->canUseGuestEmail($ruleConfig, $quote)) {
            return false;
        }

        return true;
    }

    /**
     * Check whether the rule can be applied to the current quote item.
     *
     * @param Rule $rule
     * @param AbstractItem $item
     * @param Address $address
     * @return bool
     */
    public function canApplyRuleToItem(Rule $rule, AbstractItem $item, Address $address): bool
    {
        $ruleConfig = $this->getRuleConfig((int) $rule->getRuleId());
        if ($ruleConfig === null) {
            return true;
        }

        $scope = $ruleConfig->getBlockDiscountScope();
        if ($scope === DiscountScope::NONE) {
            return true;
        }

        return !$this->itemContainsBlockedDiscounts($item, $address->getQuote(), $scope);
    }

    /**
     * Validate that a guest quote email can still use the coupon rule.
     *
     * @param string $couponCode
     * @param Quote $quote
     * @return bool
     */
    public function canUseGuestCouponCode(string $couponCode, Quote $quote): bool
    {
        if (!$quote->getCustomerIsGuest() || $couponCode === '') {
            return true;
        }

        $coupon = $this->couponFactory->create()->load($couponCode, 'code');
        if (!$coupon->getId()) {
            return true;
        }

        $ruleConfig = $this->getRuleConfig((int) $coupon->getRuleId());
        if ($ruleConfig === null || $ruleConfig->getUsesPerEmail() <= 0) {
            return true;
        }

        return $this->canUseGuestEmail($ruleConfig, $quote);
    }

    /**
     * Execute a callback while holding the guest-email mutex.
     *
     * @param string $email
     * @param callable $callback
     * @return mixed
     * @throws LocalizedException
     */
    public function executeGuestEmailLock(string $email, callable $callback)
    {
        $lockName = $this->getGuestEmailLockName($email);
        if (!$this->lockManager->lock($lockName, self::LOCK_TIMEOUT)) {
            throw new LocalizedException(__('The coupon is being processed. Please try again.'));
        }

        try {
            return $callback();
        } finally {
            $this->lockManager->unlock($lockName);
        }
    }

    /**
     * Persist guest-email usage counters after order placement or rollback.
     *
     * @param OrderInterface $order
     * @param bool $increment
     * @return void
     */
    public function syncGuestEmailUsage(OrderInterface $order, bool $increment): void
    {
        if (!$order->getCustomerIsGuest() || !$order->getCustomerEmail() || !$order->getAppliedRuleIds()) {
            return;
        }

        $email = (string) $order->getCustomerEmail();
        $ruleIds = array_filter(array_map('intval', explode(',', (string) $order->getAppliedRuleIds())));
        if ($ruleIds === []) {
            return;
        }

        foreach ($ruleIds as $ruleId) {
            $ruleConfig = $this->getRuleConfig($ruleId);
            if ($ruleConfig === null || $ruleConfig->getUsesPerEmail() <= 0) {
                continue;
            }

            $this->syncUsageRow($ruleId, $email, $increment);
        }
    }

    /**
     * Retrieve a cached rule config if available.
     *
     * @param int $ruleId
     * @return RuleConfigInterface|null
     */
    private function getRuleConfig(int $ruleId): ?RuleConfigInterface
    {
        if (array_key_exists($ruleId, $this->ruleConfigCache)) {
            return $this->ruleConfigCache[$ruleId];
        }

        try {
            $this->ruleConfigCache[$ruleId] = $this->ruleConfigRepository->getByRuleId($ruleId);
        } catch (NoSuchEntityException) {
            $this->ruleConfigCache[$ruleId] = null;
        }

        return $this->ruleConfigCache[$ruleId];
    }

    /**
     * Check whether the given guest email can be reused for this rule.
     *
     * @param RuleConfigInterface $ruleConfig
     * @param Quote $quote
     * @return bool
     */
    private function canUseGuestEmail(RuleConfigInterface $ruleConfig, Quote $quote): bool
    {
        if (!$quote->getCustomerIsGuest()) {
            return true;
        }

        $email = $this->resolveGuestEmail($quote);
        if ($email === '') {
            return true;
        }

        try {
            $ruleUsage = $this->ruleUsageRepository->getByRuleIdAndEmail((int) $ruleConfig->getRuleId(), $email);
        } catch (NoSuchEntityException) {
            return true;
        }

        return $ruleUsage->getTimesUsed() < $ruleConfig->getUsesPerEmail();
    }

    /**
     * Resolve the guest email from the quote.
     *
     * @param Quote $quote
     * @return string
     */
    public function resolveGuestEmail(Quote $quote): string
    {
        $email = (string) $quote->getCustomerEmail();
        if ($email !== '') {
            return $this->normalizeEmail($email);
        }

        $billingAddress = $quote->getBillingAddress();
        if ($billingAddress && $billingAddress->getEmail()) {
            return $this->normalizeEmail((string) $billingAddress->getEmail());
        }

        return '';
    }

    /**
     * Build the mutex name for a guest email.
     *
     * @param string $email
     * @return string
     */
    private function getGuestEmailLockName(string $email): string
    {
        return 'autogedal_promo_rules_guest_email_' . sha1($this->normalizeEmail($email));
    }

    /**
     * Normalize guest email values into a stable lookup key.
     *
     * @param string $email
     * @return string
     */
    private function normalizeEmail(string $email): string
    {
        return strtolower(trim($email));
    }

    /**
     * Check whether the quote contains items blocked by the configured discount scope.
     *
     * @param Quote $quote
     * @param string $scope
     * @return bool
     */
    private function itemContainsBlockedDiscounts(AbstractItem $item, Quote $quote, string $scope): bool
    {
        if ($this->scopeBlocksFreeGifts($scope) && $this->isPromoItem($item)) {
            return true;
        }

        if (!$this->scopeBlocksCatalogDiscounts($scope)) {
            return false;
        }

        return $this->itemUsesBlockedCatalogDiscount($item, $quote);
    }

    /**
     * Determine whether the scope blocks catalog-style discounts.
     *
     * @param string $scope
     * @return bool
     */
    private function scopeBlocksCatalogDiscounts(string $scope): bool
    {
        return in_array(
            $scope,
            [
                DiscountScope::CATALOG_DISCOUNTS,
                DiscountScope::CATALOG_DISCOUNTS_AND_FREE_GIFTS,
                DiscountScope::ALL_DISCOUNTS,
            ],
            true
        );
    }

    /**
     * Determine whether the scope blocks free-gift promo items.
     *
     * @param string $scope
     * @return bool
     */
    private function scopeBlocksFreeGifts(string $scope): bool
    {
        return in_array(
            $scope,
            [
                DiscountScope::FREE_GIFTS,
                DiscountScope::CATALOG_DISCOUNTS_AND_FREE_GIFTS,
                DiscountScope::ALL_DISCOUNTS,
            ],
            true
        );
    }

    /**
     * Check whether an item is one of Amasty's promo/free-gift items.
     *
     * @param AbstractItem $item
     * @return bool
     */
    private function isPromoItem(AbstractItem $item): bool
    {
        return (int) $item->getData('ampromo_rule_id') > 0;
    }

    /**
     * Check whether a product is currently discounted by special price.
     *
     * @param AbstractItem $item
     * @return bool
     */
    private function itemUsesBlockedCatalogDiscount(AbstractItem $item, Quote $quote): bool
    {
        foreach ($this->getDiscountInspectionItems($item) as $candidate) {
            if ($this->isSpecialPriceDiscounted($candidate)) {
                return true;
            }

            if ($this->isCatalogRuleDiscounted($candidate, $quote)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Determine the quote items that should be inspected for discount-blocking.
     *
     * Composite products may carry pricing on either the child or the parent item.
     *
     * @param AbstractItem $item
     * @return AbstractItem[]
     */
    private function getDiscountInspectionItems(AbstractItem $item): array
    {
        $items = [];
        $seen = [];
        $addItem = function (AbstractItem $candidate) use (&$items, &$seen): void {
            $key = spl_object_id($candidate);
            if (isset($seen[$key])) {
                return;
            }

            $seen[$key] = true;
            $items[] = $candidate;
        };

        $addItem($item);
        foreach ($item->getChildren() ?: [] as $childItem) {
            if ($childItem instanceof AbstractItem) {
                $addItem($childItem);
            }
        }

        $parentItem = $item->getParentItem();
        if ($parentItem === null) {
            return $items;
        }

        $parentType = (string) $parentItem->getProductType();
        if (in_array($parentType, [ConfigurableType::TYPE_CODE, BundleType::TYPE_CODE], true)) {
            $addItem($parentItem);
        }

        return $items;
    }

    /**
     * Check whether a product is currently discounted by special price.
     *
     * @param AbstractItem $item
     * @return bool
     */
    private function isSpecialPriceDiscounted(AbstractItem $item): bool
    {
        $product = $item->getProduct();
        if ($product === null) {
            return false;
        }

        $originalPrice = $this->getComparableOriginalPrice($item);
        $calculatedPrice = $this->getComparableCalculationPrice($item);
        if ($originalPrice <= 0.0 || $calculatedPrice >= $originalPrice) {
            return false;
        }

        $specialPrice = $product->getSpecialPrice();
        if ($specialPrice === null || $specialPrice === '') {
            return false;
        }

        $specialPrice = (float) $specialPrice;
        if ($specialPrice <= 0.0 || $specialPrice >= $originalPrice) {
            return false;
        }

        $currentDate = $this->timezone->date()->format('Y-m-d');
        $specialFromDate = $product->getSpecialFromDate();
        if ($specialFromDate !== null && $specialFromDate !== '' && $specialFromDate > $currentDate) {
            return false;
        }

        $specialToDate = $product->getSpecialToDate();
        if ($specialToDate !== null && $specialToDate !== '' && $specialToDate < $currentDate) {
            return false;
        }

        return true;
    }

    /**
     * Check whether the item is discounted by catalog rule pricing.
     *
     * @param AbstractItem $item
     * @param Quote $quote
     * @return bool
     */
    private function isCatalogRuleDiscounted(AbstractItem $item, Quote $quote): bool
    {
        $productId = $this->getComparableProductId($item);
        if ($productId <= 0) {
            return false;
        }

        $price = $this->getCatalogRulePrice($quote, $productId);
        if ($price === null) {
            return false;
        }

        return $price < $this->getComparableOriginalPrice($item);
    }

    /**
     * Resolve the product ID that should be used for discount comparison.
     *
     * @param AbstractItem $item
     * @return int
     */
    private function getComparableProductId(AbstractItem $item): int
    {
        $productId = (int) $item->getProductId();
        if ($productId > 0) {
            return $productId;
        }

        $parentItem = $item->getParentItem();
        return $parentItem !== null ? (int) $parentItem->getProductId() : 0;
    }

    /**
     * Resolve the original price that should be used for discount comparison.
     *
     * @param AbstractItem $item
     * @return float
     */
    private function getComparableOriginalPrice(AbstractItem $item): float
    {
        $originalPrice = (float) $item->getBaseOriginalPrice();
        if ($originalPrice > 0.0) {
            return $originalPrice;
        }

        $parentItem = $item->getParentItem();
        return $parentItem !== null ? (float) $parentItem->getBaseOriginalPrice() : 0.0;
    }

    /**
     * Resolve the calculated price that should be used for discount comparison.
     *
     * @param AbstractItem $item
     * @return float
     */
    private function getComparableCalculationPrice(AbstractItem $item): float
    {
        $calculatedPrice = (float) $item->getBaseCalculationPrice();
        if ($calculatedPrice > 0.0) {
            return $calculatedPrice;
        }

        $parentItem = $item->getParentItem();
        return $parentItem !== null ? (float) $parentItem->getBaseCalculationPrice() : 0.0;
    }

    /**
     * Retrieve catalog rule price for a product from the current quote context.
     *
     * @param Quote $quote
     * @param int $productId
     * @return float|null
     */
    private function getCatalogRulePrice(Quote $quote, int $productId): ?float
    {
        $cacheKey = $this->getQuoteCacheKey($quote);
        if (!isset($this->catalogRulePriceCache[$cacheKey])) {
            $this->catalogRulePriceCache[$cacheKey] = $this->loadCatalogRulePrices($quote);
        }

        return $this->catalogRulePriceCache[$cacheKey][$productId] ?? null;
    }

    /**
     * Load catalog rule prices for the quote products in one query.
     *
     * @param Quote $quote
     * @return array<int, float>
     */
    private function loadCatalogRulePrices(Quote $quote): array
    {
        $productIds = [];
        foreach ($quote->getAllItems() as $item) {
            $productId = (int) $item->getProductId();
            if ($productId > 0) {
                $productIds[$productId] = $productId;
            }
        }

        if ($productIds === []) {
            return [];
        }

        $store = $this->storeManager->getStore((int) $quote->getStoreId());
        $connection = $this->resourceConnection->getConnection();
        $tableName = $this->resourceConnection->getTableName('catalogrule_product_price');
        $select = $connection->select()
            ->from($tableName, ['product_id', 'rule_price'])
            ->where('rule_date = ?', $this->timezone->date()->format('Y-m-d'))
            ->where('customer_group_id = ?', (int) $quote->getCustomerGroupId())
            ->where('website_id = ?', (int) $store->getWebsiteId())
            ->where('product_id IN (?)', $productIds);

        $prices = [];
        foreach ($connection->fetchAll($select) as $row) {
            $productId = (int) $row['product_id'];
            $rulePrice = (float) $row['rule_price'];
            if (!isset($prices[$productId]) || $rulePrice < $prices[$productId]) {
                $prices[$productId] = $rulePrice;
            }
        }

        return $prices;
    }

    /**
     * Sync a guest usage row for a rule/email pair.
     *
     * @param int $ruleId
     * @param string $email
     * @param bool $increment
     * @return void
     */
    private function syncUsageRow(int $ruleId, string $email, bool $increment): void
    {
        try {
            $usage = $this->ruleUsageRepository->getByRuleIdAndEmail($ruleId, $email);
        } catch (NoSuchEntityException) {
            if (!$increment) {
                return;
            }

            $usage = $this->ruleUsageFactory->create();
            $usage->setRuleId($ruleId);
            $usage->setNormalizedEmail($this->normalizeEmail($email));
            $usage->setTimesUsed(0);
        }

        $usage->setUpdatedAt($this->timezone->date()->format('Y-m-d H:i:s'));
        if ($usage->getCreatedAt() === null) {
            $usage->setCreatedAt($usage->getUpdatedAt());
        }

        $timesUsed = $usage->getTimesUsed();
        $usage->setTimesUsed($increment ? $timesUsed + 1 : max(0, $timesUsed - 1));

        if (!$increment && $usage->getTimesUsed() === 0 && $usage->getUsageId()) {
            $this->ruleUsageRepository->delete($usage);
            return;
        }

        $this->ruleUsageRepository->save($usage);
    }

    /**
     * Build a stable cache key for the current quote.
     *
     * @param Quote $quote
     * @return string
     */
    private function getQuoteCacheKey(Quote $quote): string
    {
        return $quote->getId() ? 'quote_' . (string) $quote->getId() : 'quote_object_' . spl_object_id($quote);
    }
}
