<?php

namespace KupShop\BonusProgramBundle\Utils;

use Doctrine\DBAL\ParameterType;
use KupShop\BonusProgramBundle\Actions\BonusProgramAction;
use KupShop\BonusProgramBundle\Email\BonusPointsActivatedEmail;
use KupShop\BonusProgramBundle\Event\UserBonusPointsUpdatedEvent;
use KupShop\CatalogBundle\ProductList\ProductCollection;
use KupShop\KupShopBundle\Context\ContextManager;
use KupShop\KupShopBundle\Context\UserContext;
use KupShop\KupShopBundle\Util\Contexts;
use KupShop\OrderDiscountBundle\Util\DiscountManager;
use KupShop\OrderingBundle\Entity\Purchase\PurchaseState;
use KupShop\OrderingBundle\Util\Order\OrderInfo;
use KupShop\OrderingBundle\Util\Purchase\PurchaseUtil;
use Query\Operator;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

class BonusComputer
{
    public function __construct(
        private EventDispatcherInterface $eventDispatcher,
        private ContextManager $contextManager,
        private DiscountManager $discountManager,
        private PurchaseUtil $purchaseUtil,
        protected BonusPointsActivatedEmail $emailService,
    ) {
    }

    public function updateBonusPoints(\Order $order): void
    {
        if (!$order->id_user) {
            return;
        }

        // updateBonusPoints se vola primarne z administrace, takze potrebuju mit spravne aktivni contexty
        // vznikal napr. bug, kdy B2B spravne nedostalo body, ale jakmile nekdo v adminu preulozil objednavku, tak body
        // dodatecne dostal, protoze nebyly spravne aktivni contexty
        $this->contextManager->activateOrder($order, function () use ($order) {
            /** @var PurchaseState $pstate */
            $pstate = $order->getPurchaseState();
            $this->discountManager->setActionsFromUsedDiscounts($pstate);
            $this->applyBonusPointsActions($pstate);

            $qb = sqlQueryBuilder()
                ->select('bp.*')
                ->from('bonus_points', 'bp')
                ->where(Operator::equals(['bp.id_order' => $order->id]))
                ->execute();

            if ($qb->fetch()) {
                $points = $this->countBonusPoints($pstate);

                if ($points->isNegative()
                    && ($order->source == OrderInfo::ORDER_SOURCE_RETURNS)
                    && ($replacement_id = $order->getData('replacement'))) {
                    // v pripade vratky odecist body pouze pokud za puvodni objednavku byly pricteny body
                    if (!$this->getBonusPointsReceivedForOrder($replacement_id)) {
                        $points = \DecimalConstants::zero();
                    }
                }

                $qb = sqlQueryBuilder()
                    ->update('bonus_points')
                    ->set('points', $points)
                    ->where(Operator::equals(['id_order' => $order->id]));

                // v pripade vratkove objednavky chci provest update bodu v kazdem pripade, aby se napr. v pripade pridani zbozi
                // spravne zaktualizovaly body. A jelikoz za vratkovou objednavku muzou byt body minusove, tak tu podminku nechci
                if ($order->source !== OrderInfo::ORDER_SOURCE_RETURNS) {
                    $qb->andWhere('points >= 0');
                }

                $result = $qb->execute();
                if ($result) {
                    $order->logHistory(str_replace('{POINTS}', $points->printFloatValue(-4), translate_shop('points_added', 'bonus_program')));
                }

                sqlQueryBuilder()
                    ->update('users')
                    ->set('date_updated', 'NOW()')
                    ->where(Operator::equals(['id' => $order->id_user]))
                    ->execute();

                $this->sendUserBonusPointsUpdatedEvent($order->id_user);
            } else {
                $this->createBonusPoints($order);
            }

            if ($data = $pstate->getCustomData()) {
                $order->setData('discounts', $data);
            }
            foreach ($pstate->getProducts() as $item) {
                $note = json_encode($item->getNote());
                sqlQueryBuilder()->update('order_items')
                    ->directValues(['note' => (empty($note) ? '' : $note)])
                    ->where(Operator::equals(['id' => $item->getId()]))
                    ->execute();
            }
        });
    }

    public function stornoBonusPoints(\Order $order): void
    {
        $qbToTest = sqlQueryBuilder()->select('id_user')->from('bonus_points')
            ->andWhere(Operator::equals(['id_order' => $order->id]));

        sqlQueryBuilder()
            ->update('bonus_points')
            ->directValues(['status' => 'deleted'])
            ->andWhere(Operator::equals(['id_order' => $order->id]))
            ->andWhere(Operator::exists($qbToTest))
            ->execute();

        sqlQueryBuilder()
            ->update('users')
            ->set('date_updated', 'NOW()')
            ->andWhere(Operator::inIntArray($qbToTest->execute()->fetchFirstColumn(), 'id'))
            ->execute();
    }

    public function checkBonusPointsEarningDiscount(\Order $order): void
    {
        // nove objednavce z adminu, importu atd. (krome obj. z e-shopu a vratek)
        // pridat aktualne platne slevy typu 'Zisk bodů' a 'Přidat body'

        if (!$order->id_user) {
            return;
        }

        if ($order->source == OrderInfo::ORDER_SOURCE_RETURNS) {
            // vratkove obj. budou pridane bonus points slevy z puvodni objednavky,
            // see \KupShop\ReturnsBundle\EventListener\ReturnsEventListener::updateReturnOrderBonusPoints
            return;
        }

        /** @var PurchaseState $pstate */
        $pstate = $order->getPurchaseState();
        if (!empty($pstate->getActions('bonus_points_earning'))) {
            return;
        }

        $this->discountManager->setActionsFromUsedDiscounts($pstate);
        if (empty($pstate->getActions('bonus_points_earning'))) {
            $this->discountManager->setPurchaseState($pstate);
            $bp_actions = $this->contextManager->activateOrder($order, function () {
                return $this->discountManager->getCurrentActions(['bonus_points_earning', 'bonus_points']);
            });
            foreach ($bp_actions as $type => $actions) {
                foreach ($actions as $action) {
                    $id_discount = $action['id_order_discount'];
                    $pstate->addUsedDiscount($id_discount);
                    sqlQueryBuilder()->insert('order_discounts_orders')
                        ->directValues(['id_order' => $order->id, 'id_order_discount' => $id_discount])
                        ->execute();
                }
            }
        }
    }

    public function createBonusPoints(\Order $order, $date = 'NOW()'): void
    {
        if ($order->id_user) {
            /** @var PurchaseState $pstate */
            $pstate = $order->getPurchaseState();
            $points = $this->countBonusPoints($pstate);

            if (!$points->isZero()) {
                if ($points->isNegative()
                    && ($order->source == OrderInfo::ORDER_SOURCE_RETURNS)
                    && ($replacement_id = $order->getData('replacement'))) {
                    // v pripade vratky odecist body pouze pokud za puvodni objednavku byly pricteny body
                    if (!$this->getBonusPointsReceivedForOrder($replacement_id)) {
                        return;
                    }
                }

                $result = $this->insertBonusPoints($date, $order, $order->id_user, $points, 'note_order_points_added');

                if ($result) {
                    $order->logHistory(str_replace('{POINTS}', $points->printFloatValue(-4), translate_shop('points_added', 'bonus_program')));
                }
            }

            // Remove (insert negative points into DB) points that have been used as discount on order (if any).
            $usedPoints = $this->getBonusPointsUsedOnOrder($order);
            if ($usedPoints->isPositive()) {
                $this->usePoints(
                    amount: $usedPoints->asInteger(),
                    order: $order,
                    date: $date,
                );
            }

            sqlQueryBuilder()
                ->update('users')
                ->set('date_updated', $date)
                ->where(Operator::equals(['id' => $order->id_user]))
                ->execute();

            $this->sendUserBonusPointsUpdatedEvent($order->id_user);
        }
    }

    /**
     * @return \Decimal amount of bonus program points used as discount on order
     */
    private function getBonusPointsUsedOnOrder(\Order $order): \Decimal
    {
        $points = [];

        // bonus points as a Discount, but with `divide_discount_price` = true
        $discounts = $order->getPurchaseState()->getDiscounts();
        foreach ($discounts as $discountItem) {
            $discountType = $discountItem->note['discount_type'] ?? 'UNKNOWN';
            $divideDiscountPrice = (bool) ($discountItem->note['divide_discount_price'] ?? false);

            if ($discountType !== BonusProgramAction::getType() || !$divideDiscountPrice) {
                continue;
            }

            $pointsUsed = toDecimal($discountItem->note['bonus_points'] ?? 0);
            if ($pointsUsed->isZero()) {
                continue;
            }

            $points[$discountItem->note['id_discount']] = $pointsUsed;
        }

        // bonus points as a separate DiscountItem
        foreach ($order->fetchItems() as $item) {
            if (!empty($item['note']) && empty($item['id_product'])) {
                if (!empty($item['note']['bonus_points'])) {
                    $pointsUsed = toDecimal($item['note']['bonus_points']);

                    $points[$item['note']['id_discount']] = $pointsUsed;
                }
            }
        }

        $pointsTotal = \DecimalConstants::zero();
        foreach ($points as $idDiscount => $pointsUsed) {
            $pointsTotal = $pointsTotal->add($pointsUsed);
        }

        return $pointsTotal;
    }

    /**
     * Insert negative points to the user's bonus_points account.
     * This removes (uses) the points previously present.
     */
    private function usePoints(int $amount, \Order $order, string $date): void
    {
        $query = sqlQueryBuilder()
            ->insert('bonus_points')
            ->values(['date_created' => $date])
            ->directValues([
                'id_user' => $order->id_user,
                'points' => -$amount,
                'note' => sprintf(translate_shop('note_order_points_redeem', 'bonus_program'), $order->order_no),
                'status' => 'active',
                'id_order' => $order->id,
            ]);

        $query->execute();
    }

    public function getBonusPointsReceivedForOrder(int $id_order)
    {
        $qb = sqlQueryBuilder()
            ->select('bp.points')
            ->from('bonus_points', 'bp')
            ->andWhere(Operator::equals(['bp.id_order' => $id_order]))
            ->andWhere('bp.points > 0')
            ->execute();

        return $qb->fetchOne();
    }

    public function createBonusPointsForOtherReceiverUser(\Order $order, $date = 'NOW()'): void
    {
        $pstate = $order->getPurchaseState();

        $pointsForOtherReceivers = $pstate->getCustomData('points_for_other_receivers');

        if (!$pointsForOtherReceivers) {
            return;
        }

        foreach ($pointsForOtherReceivers as $item) {
            // kdyz se id prijemce rovna id uzivetele ktery realizuje objednavku => neprideluju body
            if ($item['user_id'] == $order->id_user) {
                continue;
            }

            $result = $this->insertBonusPoints($date, $order, $item['user_id'], toDecimal($item['points']), 'note_order_points_added_other_receiver');

            if ($result) {
                $comment = str_replace('{POINTS}',
                    round($item['points']),
                    translate('points_added_other_receiver', 'bonus_program'));
                $comment = str_replace('{USER}', '<a href="javascript:nw(\'users\','.$item['user_id'].')">'.$item['user_id'].'</a>', $comment);
                $order->logHistory($comment);
            }

            sqlQueryBuilder()
                ->update('users')
                ->set('date_updated', $date)
                ->where(Operator::equals(['id' => $item['user_id']]))
                ->execute();

            $this->sendUserBonusPointsUpdatedEvent($item['user_id']);
        }
    }

    public function activatePoints()
    {
        $dbcfg = \Settings::getDefault();

        $activation_days = $dbcfg->bonus_program['activate_time'] ?? 0;
        $qb = sqlQueryBuilder()
            ->select('DISTINCT bp.id_user, bp.id_order, bp.points')
            ->from('bonus_points', 'bp')
            ->join('bp', 'users', 'u', 'u.id = bp.id_user')
            ->leftJoin('bp', 'orders', 'o', 'bp.id_order = o.id')
            ->where('(CURDATE() - INTERVAL :activation_days DAY) >= IF(bp.id_order, o.date_handle, bp.date_created) OR bp.points < 0')
            ->andWhere(Operator::equals(['bp.status' => 'inactive']))
            ->setParameter('activation_days', $activation_days)
            ->execute();

        $ids = $qb->fetchAll();

        sqlQueryBuilder()
            ->update('bonus_points', 'bp')
            ->leftJoin('bp', 'orders', 'o', 'bp.id_order = o.id')
            ->set('bp.status', "'active'")
            ->where('(CURDATE() - INTERVAL :activation_days DAY) >= IF(bp.id_order, o.date_handle, bp.date_created) OR bp.points < 0')
            ->andWhere(Operator::equals(['bp.status' => 'inactive']))
            ->setParameter('activation_days', $activation_days)
            ->execute();

        if ($ids) {
            sqlQueryBuilder()
                ->update('users')
                ->set('date_updated', 'NOW()')
                ->where(Operator::inIntArray(array_column($ids, 'id_user'), 'id'))
                ->execute();

            $this->sendBonusPointsActivatedEmails($ids);
        }
    }

    public function deleteExpiredPoints()
    {
        $dbcfg = \Settings::getDefault();

        $removeTime = $dbcfg->bonus_program['remove_time'];

        if ($removeTime) {
            // neaktivni uzivatele - ktere neudelali zadnou objednavku za poslednich $removeTime days
            // a maji aktivni body, starsi nez $removeTime days
            $users = $this->getUsersWithoutOrdersInLast(days: (int) $removeTime);

            if ($users) {
                $user_ids = $users;

                // aktivni body, starsi nez $removeTime days - odebrat
                // muze ale mit rucne pridane body - ty neodebirame pokud byly pridany za poslednich $removeTime days
                $res = sqlQuery(
                    "INSERT INTO bonus_points (id_user, points, date_created, note, status, id_order)
                  SELECT
                    bp.id_user AS id_user,
                    SUM(bp.points)*(-1) AS points,
                    NOW() AS date_created,
                    :points_lost AS note,
                    'deleted' AS status,
                    NULL AS id_order
                  FROM bonus_points bp
                  WHERE bp.id_user IN (".implode(',', $user_ids).") AND bp.status = 'active' 
                        AND DATE(bp.date_created) <= DATE(NOW() - INTERVAL :days DAY)
                  GROUP BY bp.id_user;",
                    [
                        'days' => $removeTime,
                        'points_lost' => translate('points_lost', 'bonus_program'),
                    ]
                );

                sqlQueryBuilder()
                    ->update('bonus_points')
                    ->set('status', "'deleted'")
                    ->where(Operator::inIntArray($user_ids, 'id_user'))
                    ->andWhere(Operator::equals(['status' => 'active']))
                    ->andWhere('DATE(date_created) <= DATE(NOW() - INTERVAL :days DAY)')
                    ->addParameters(['days' => $removeTime])
                    ->execute();

                sqlQueryBuilder()
                    ->update('users')
                    ->set('date_updated', 'NOW()')
                    ->where(Operator::inIntArray($user_ids, 'id'))
                    ->execute();

                foreach ($user_ids as $userId) {
                    $this->sendUserBonusPointsUpdatedEvent($userId);
                }
            }
        }
    }

    /**
     * @return int[] ids of users without orders in the last $days that have active points
     *
     * @throws \Doctrine\DBAL\Driver\Exception|\Doctrine\DBAL\Exception
     */
    protected function getUsersWithoutOrdersInLast(int $days): array
    {
        $usersWithoutOrdersInRemoveTime = sqlQueryBuilder()
            ->select('id_user')
            ->from('orders', 'o')
            ->groupBy('o.id_user')
            ->having('DATE(MAX(date_created) + INTERVAL :days DAY) <= DATE(NOW())')
            ->addParameters(['days' => $days], ['days' => ParameterType::INTEGER]);

        $qb = sqlQueryBuilder()
            ->select('bp.id_user')
            ->from('bonus_points', 'bp')
            ->join('bp', 'users', 'u', 'bp.id_user = u.id')
            ->andWhere(Operator::inSubQuery('bp.id_user', $usersWithoutOrdersInRemoveTime))
            ->andWhere("bp.status = 'active'")
            ->andWhere('DATE(bp.date_created) <= DATE((NOW() - INTERVAL :days DAY))')
            ->addParameters(['days' => $days], ['days' => ParameterType::INTEGER]);

        return $qb->execute()->fetchFirstColumn();
    }

    /**
     * @return array{"id_user": int, "points": int}[] users whose points will expire at *exactly* $dateExpiry
     *                                                (precise to a day)
     *
     * @throws \Doctrine\DBAL\Driver\Exception|\Doctrine\DBAL\Exception
     */
    public function getPointsThatExpireIn(int $dateExpiryOffset, int $removeTime): array
    {
        $alreadyPassedRemoveTime = $removeTime - $dateExpiryOffset;
        $nonActiveUsers = $this->getUsersWithoutOrdersInLast($alreadyPassedRemoveTime);

        $qb = sqlQueryBuilder()
            ->select('bp.id_user', 'SUM(bp.points) AS points')
            ->from('bonus_points', 'bp')
            ->andWhere("bp.status = 'active'")
            // ignore manually added points in the period between the date of last points rewarded from order and today
            ->andWhere('DATE(bp.date_created) <= DATE(NOW() - INTERVAL :passed_remove_time DAY)')
            ->andWhere(Operator::inIntArray($nonActiveUsers, 'bp.id_user'))
            // the points will expire in the exact day of TODAY + expiry_email_time
            ->andWhere('DATE(bp.date_created + INTERVAL :remove_time DAY) = DATE(NOW() + INTERVAL :date_expiry DAY)')
            ->groupBy('bp.id_user')
            ->addParameters([
                'date_expiry' => $dateExpiryOffset,
                'remove_time' => $removeTime,
                'passed_remove_time' => $alreadyPassedRemoveTime,
            ], [
                'date_expiry' => ParameterType::STRING,
                'remove_time' => ParameterType::INTEGER,
                'remaining_remove_time' => ParameterType::INTEGER,
            ]);

        return $qb->execute()->fetchAllAssociative();
    }

    protected function getNewPointsStatus(): string
    {
        $dbcfg = \Settings::getDefault();

        return empty($dbcfg->bonus_program['activate_time']) ? 'active' : 'inactive';
    }

    public function insertBonusPoints(string $date, \Order $order, int $userId, \Decimal $points, string $noteTextKey)
    {
        return sqlQueryBuilder()
            ->insert('bonus_points')
            ->values(
                [
                    'date_created' => $date,
                ]
            )
            ->directValues(
                [
                    'id_user' => $userId,
                    'points' => $points,
                    'note' => sprintf(translate_shop($noteTextKey, 'bonus_program'), $order->order_no),
                    'status' => $this->getNewPointsStatus(),
                    'id_order' => $order->id,
                ]
            )
            ->execute();
    }

    public function applyBonusPointsActions(PurchaseState $purchaseState): PurchaseState
    {
        // akce 'Zisk bodů'
        $actions = $purchaseState->getActions('bonus_points_earning') ?? [];
        $this->discountManager->applyActions($purchaseState, $actions);

        // akce 'Přidat body'
        if ($actions = $purchaseState->getActions('bonus_points')) {
            $purchaseState->setCustomData(['extra_bonus_points' => null]);
            $this->discountManager->applyActions($purchaseState, $actions);
        }

        return $purchaseState;
    }

    protected function getBonusPointsActions()
    {
        $purchaseState = $this->purchaseUtil->getEmptyPurchaseState();
        $actions = $purchaseState->getActions();
        if (is_null($actions)) {
            $this->discountManager->setPurchaseState($purchaseState);
            $actions = $this->discountManager->getCurrentActions();
            $purchaseState->setActions($actions);
            $this->purchaseUtil->persistPurchaseState($purchaseState, true);
        }

        return $actions['bonus_points_earning'] ?? [];
    }

    public function getProductsBonusPoints($product, $id_variation = null): \Decimal
    {
        $bp_actions = $this->getBonusPointsActions();
        if (!$bp_actions) {
            return \DecimalConstants::zero();
        }

        $purchaseState = $this->purchaseUtil->getEmptyPurchaseState();
        $productItem = $this->purchaseUtil->createProductPurchaseItem($product->id, $id_variation, 1);
        $purchaseState->addProduct($productItem);
        $this->discountManager->applyActions($purchaseState, $bp_actions);

        return $productItem->bonus_points ?? \DecimalConstants::zero();
    }

    public function multiFetchProductsBonusPoints(ProductCollection $products): void
    {
        if ($products->isEmpty()) {
            return;
        }

        $bp_actions = $this->getBonusPointsActions();
        if (!$bp_actions) {
            // 0 points for each product and variation
            foreach ($products as $product) {
                $product->bonus_points = \DecimalConstants::zero();
                foreach ($product->variations['variations'] ?? [] as $key => $value) {
                    $product->variations['variations'][$key]['bonus_points'] = \DecimalConstants::zero();
                }
            }

            return;
        }

        $purchaseState = $this->purchaseUtil->getEmptyPurchaseState();
        foreach ($products as $product) {
            if (empty($product->variations['variations'])) {
                $productItem = $this->purchaseUtil->createProductPurchaseItem($product->id, $product->matched_id_variation ?: null, 1);
                $purchaseState->addProduct($productItem);
            } else {
                foreach ($product->variations['variations'] as $variation) {
                    $productItem = $this->purchaseUtil->createProductPurchaseItem($product->id, $variation['id'], 1);
                    $purchaseState->addProduct($productItem);
                }
            }
        }

        $this->discountManager->applyActions($purchaseState, $bp_actions);

        foreach ($purchaseState->getProducts() as $purchaseItem) {
            $points = $purchaseItem->bonus_points ?? \DecimalConstants::zero();

            // pokud tam je rounding az celkove castky, tak to tady potrebuju zavolat, protoze tohle je pro detail produktu
            // a ono to na detailu jinak neni zaorkouhleny
            if ($roundingData = $purchaseState->getCustomData('bonus_points_rounding')) {
                $points = $this->roundPoints(
                    $purchaseItem->bonus_points ?? \DecimalConstants::zero(),
                    $roundingData['points_round_direction'] ?? 'round',
                    (int) ($roundingData['points_precision'] ?? 0)
                );
            }

            $product = $products[$purchaseItem->id_product];
            if ($purchaseItem->id_variation && !empty($product->variations['variations'])) {
                $product->variations['variations'][$purchaseItem->id_variation]['bonus_points'] = $points;
            } else {
                $product->bonus_points = $points;
            }
        }
    }

    public function countBonusPoints(PurchaseState $purchaseState): \Decimal
    {
        $points = \DecimalConstants::zero();
        foreach ($purchaseState->getProducts() as $item) {
            if ($item->bonus_points ?? null) {
                $points = $points->add($item->bonus_points);
            }
        }

        if ($extra_bonus_points = $purchaseState->getCustomData('extra_bonus_points')) {
            $points = $points->add(toDecimal($extra_bonus_points));
        }

        if ($roundingData = $purchaseState->getCustomData('bonus_points_rounding')) {
            $points = $this->roundPoints(
                $points,
                $roundingData['points_round_direction'] ?? 'round',
                (int) ($roundingData['points_precision'] ?? 0)
            );
        }

        return $points;
    }

    public function isBonusProgramActive(): bool
    {
        $user = Contexts::get(UserContext::class)->getActive();
        if ($user) {
            if ($this->getBonusPointsActions()) {
                return true;
            }
        }

        return false;
    }

    public function roundPoints(\Decimal $points, string $method, int $precision): \Decimal
    {
        return $points->$method($precision);
    }

    /**
     * @param array{id: int, id_user: int, id_order: int, points: int} $data
     */
    protected function sendBonusPointsActivatedEmails(array $data): void
    {
        if (!$this->emailService->isEnabled()) {
            return;
        }

        foreach ($data as $item) {
            $this->sendUserBonusPointsUpdatedEvent($item['id_user']);

            if ($id_order = $item['id_order'] ?? null) {
                $order = new \Order($id_order);
                $order->createFromDB($id_order);
                $this->contextManager->activateOrder($order, function () use ($order, $item) {
                    $this->emailService->setOrder($order)->setBonusPoints($item['points']);
                    if ($item['points'] > 0) {
                        $message = $this->emailService->getEmail();
                        $this->emailService->sendEmail($message);
                    }
                });
            }
        }
    }

    private function sendUserBonusPointsUpdatedEvent(int $userId): void
    {
        $this->eventDispatcher->dispatch(
            new UserBonusPointsUpdatedEvent($userId)
        );
    }
}
