<?php
/**
 * BookiFlex Cart Extension
 *
 * @package    BookiFlex
 * @subpackage Cart
 * @author     BookiFlex
 * @copyright  Copyright (c) 2025 BookiFlex
 * @license    GPL-2.0-or-later
 *
 * @link       https://bookiflex.com
 */


declare(strict_types=1);
namespace BookiFlex\Extensions\Cart\Service;

use BookiFlex\Core\Contract\AvailabilityChecker\QuotaAvailabilityInterface;
use BookiFlex\Core\Contract\Fee\FeeCalculatorInterface;
use BookiFlex\Core\Contract\Taxation\TaxCalculatorInterface;
use BookiFlex\Core\Exception\RuntimeException;
use BookiFlex\Extensions\Cart\Contract\CartExtraProviderInterface;
use BookiFlex\Extensions\Cart\Exception\AccommodationTypeNotFoundException;
use BookiFlex\Extensions\Cart\Exception\CartNotFoundException;
use BookiFlex\Extensions\Cart\Exception\CartValidationException;
use BookiFlex\Extensions\Cart\Exception\InsufficientAvailabilityException;
use BookiFlex\Extensions\Cart\Exception\InvalidPaymentTypeException;
use BookiFlex\Extensions\Cart\Exception\ItemNotFoundException;
use BookiFlex\Extensions\Cart\Exception\RatePlanNotFoundException;
use BookiFlex\Extensions\Cart\Exception\SessionRequiredException;
use BookiFlex\Extensions\Cart\Contract\CartItemInterface;
use BookiFlex\Extensions\Cart\Contract\CartItemProcessorInterface;
use BookiFlex\Extensions\Cart\Model\AccommodationCartItem;
use BookiFlex\Extensions\Cart\Model\ExtraCartItem;
use BookiFlex\Extensions\Cart\Model\Cart;
use BookiFlex\Extensions\Cart\Repository\CartRepository;
use BookiFlex\Extensions\Cart\Service\DTO\CheckoutPreparationDTO;
use BookiFlex\Core\Model\Customer\Customer;
use BookiFlex\Core\Model\PaymentType\PaymentType;
use BookiFlex\Core\Model\RatePlan\RatePlan;
use BookiFlex\Core\Repository\AccommodationTypeRepository;
use BookiFlex\Core\Repository\CustomerRepository;
use BookiFlex\Core\Repository\RatePlanRepository;
use BookiFlex\Core\Service\AvailabilityChecker\AvailabilityChecker;
use BookiFlex\Core\Service\ConfigurationService;
use BookiFlex\Core\Service\StayCostCalculator\StayCostCalculator;
use BookiFlex\Core\Util\DateTimeUtils;
use BookiFlex\Core\Util\LocaleUtils;
use Cycle\ORM\EntityManagerInterface;
use Throwable;

defined( 'ABSPATH' ) || exit;

/**
 * Cart service acts as an orchestrator for cart operations
 */
class CartService
{
    private ?string $sessionId = null;
    private ?Cart $cart = null;

    // Registered item processors
    private array $itemProcessors = [];

    public function __construct(
        private readonly CartRepository $cartRepository,
        private readonly RatePlanRepository $ratePlanRepository,
        private readonly AccommodationTypeRepository $accommodationTypeRepository,
        private readonly CustomerRepository $customerRepository,
        private readonly AvailabilityChecker $availabilityChecker,
        private readonly StayCostCalculator $stayCostCalculator,
        private readonly ConfigurationService $configurationService,
        private readonly EntityManagerInterface $entityManager,
        private readonly ?TaxCalculatorInterface $taxCalculator = null,
        private readonly ?FeeCalculatorInterface $feeCalculator = null,
        private readonly ?CartExtraProviderInterface $extraProvider = null,
    ) {
        // Register default processor for accommodations
        $this->registerItemProcessor('accommodation', new AccommodationItemProcessor(
            $this->ratePlanRepository,
            $this->accommodationTypeRepository,
            $this->availabilityChecker,
            $this->stayCostCalculator,
            $this->taxCalculator,
            $this->feeCalculator,
            $this->configurationService->getCheckInTime()
        ));

        // Register extras processor only if a provider was supplied (Extras extension active)
        if ($this->extraProvider !== null) {
            $this->registerItemProcessor(ExtraCartItem::TYPE, new ExtraItemProcessor($this->extraProvider));
        }
    }

    /**
     * Register a processor for a specific item type
     */
    public function registerItemProcessor(string $type, CartItemProcessorInterface $processor): void
    {
        $this->itemProcessors[$type] = $processor;
    }

    /**
     * Whether the given cart-item type can be added (i.e. has a registered processor).
     */
    public function supportsType(string $type): bool
    {
        return isset($this->itemProcessors[$type]);
    }

    /**
     * Set session ID for cart operations
     */
    public function setSessionId(?string $sessionId): void
    {
        if (!$sessionId) {
            throw new SessionRequiredException('Session ID is required');
        }

        $this->sessionId = $sessionId;
        $this->cart = null;
    }

    /**
     * Get or create cart
     */
    public function getCart(bool $createIfNotExists = false): ?Cart
    {
        if (!$this->sessionId) {
            throw new SessionRequiredException('Session ID not set');
        }

        if (!$this->cart) {
            $this->cart = $this->cartRepository->findBySessionId($this->sessionId);

            if ($this->cart) {
                $currentCurrency = $this->configurationService->getDefaultCurrency();
                if ($this->cart->getCurrency() !== $currentCurrency) {
                    // Site currency changed since this cart was created (typically right
                    // after install, while configuring). Its items are priced in a now-stale
                    // currency, so reset the cart and adopt the current one before anything
                    // recomputes/aggregates prices. Rare, one-time cost — safe to drop items.
                    $this->cart->resetCurrency($currentCurrency);
                    $this->save();
                }
            }

            if (!$this->cart && $createIfNotExists) {
                $this->cart = Cart::create(
                    $this->sessionId,
                    $this->configurationService->getDefaultCurrency(),
                    $this->configurationService->getCheckInTime(),
                    $this->configurationService->getCheckOutTime()
                );
                $this->save();
            }
        }

        return $this->cart;
    }

    /**
     * Add item to cart
     *
     * @throws RuntimeException
     */
    public function addItem(array $data): CartItemInterface
    {
        $cart = $this->getCart(true);

        // Determine item type
        $type = $data['type'] ?? 'accommodation';
        $quantity = (isset($data['quantity']) && is_numeric($data['quantity']))
            ? (int) $data['quantity']
            : 1;

        if (!isset($this->itemProcessors[$type])) {
            throw new RuntimeException("No processor registered for item type: $type");
        }

        /** @var CartItemProcessorInterface $processor */
        $processor = $this->itemProcessors[$type];

        // Create and validate item
        $item = $processor->createItem($data);

        // Cross-item rule: a self-selected extra cannot be added if the same extra is already
        // present as included on the same parent accommodation.
        if ($item instanceof ExtraCartItem && !$item->isIncluded()) {
            $this->assertNotAlreadyIncluded($cart, $item);
        }

        if (!$processor->validate($item, $quantity)) {
            $errors = $processor->getValidationErrors();
            throw new CartValidationException(
                'Validation errors occurred while adding item to cart',
                400,
                null,
                ['errors' => $errors]
            );
        }

        // Process item in widget locale so i18n strings (e.g. cancellation rules) are generated correctly
        $item = LocaleUtils::withLocale(
            $this->configurationService->getWidgetLanguage(),
            fn() => $processor->process($item)
        );

        // Add to cart
        $cart->addItem($item, $quantity);
        $this->save();

        // Auto-attach extras that the rate plan bundles into accommodation price.
        if ($item instanceof AccommodationCartItem) {
            $this->addIncludedExtrasFor($item);
        }

        return $item;
    }

    private function assertNotAlreadyIncluded(Cart $cart, ExtraCartItem $candidate): void
    {
        foreach ($cart->getItems() as $existing) {
            $existingDomain = $existing->toDomainObject();
            if ($existingDomain instanceof ExtraCartItem
                && $existingDomain->isIncluded()
                && $existingDomain->getExtraId() === $candidate->getExtraId()
                && $existingDomain->getParentHash() === $candidate->getParentHash()
            ) {
                throw new CartValidationException(
                    'Extra is already included in the rate plan',
                    422,
                    null,
                    ['errors' => [
                        'already_included' => 'This extra is part of the rate plan and cannot be added separately',
                    ]]
                );
            }
        }
    }

    private function addIncludedExtrasFor(AccommodationCartItem $accommodation): void
    {
        if ($this->extraProvider === null) {
            return;
        }
        if (!$this->supportsType(ExtraCartItem::TYPE)) {
            return;
        }

        $ids = $this->extraProvider->getIncludedExtraIds(
            $accommodation->getAccommodationSelection()->getRatePlanId(),
            $accommodation->getAccommodationSelection()->getAccommodationTypeId(),
        );
        if (empty($ids)) {
            return;
        }

        $cart = $this->cart;
        if ($cart === null) {
            return;
        }

        $stay = $accommodation->getStayPeriod();
        $nights = max(
            1,
            (int) $stay->getCheckInDate()->diff($stay->getCheckOutDate())->days
        );
        $guests = $accommodation->getGuestConfiguration()->getAdults()
            + count($accommodation->getGuestConfiguration()->getChildren());
        $currency = $cart->getCurrency();
        $parentHash = $accommodation->getHash();

        /** @var ExtraItemProcessor $processor */
        $processor = $this->itemProcessors[ExtraCartItem::TYPE];

        $added = false;
        foreach ($ids as $extraId) {
            try {
                $extra = $processor->createItem([
                    'extraId'    => $extraId,
                    'parentHash' => $parentHash,
                    'quantity'   => 1,
                    'guests'     => $guests,
                    'nights'     => $nights,
                    'currency'   => $currency,
                    'included'   => true,
                ]);
                $extra = $processor->process($extra);
                $cart->addItem($extra, 1);
                $added = true;
            } catch (Throwable $e) {
                // Failure to attach a single included extra must not break accommodation add.
            }
        }

        if ($added) {
            $this->save();
        }
    }

    /**
     * Remove item from cart.
     *
     * - Refuses to remove included extras (they belong to the rate plan).
     * - When removing an accommodation, cascades to all extras attached to it
     *   (both included and self-selected).
     *
     * @throws RuntimeException
     */
    public function removeItem(string $hash): bool
    {
        $cart = $this->getCart();
        if (!$cart) {
            return false;
        }

        $cartItem = null;
        foreach ($cart->getItems() as $item) {
            if ($item->getHash() === $hash) {
                $cartItem = $item;
                break;
            }
        }
        if ($cartItem === null) {
            return false;
        }

        $domainItem = $cartItem->toDomainObject();

        if ($domainItem instanceof ExtraCartItem && $domainItem->isIncluded()) {
            throw new CartValidationException(
                'Included extras cannot be removed manually',
                422,
                null,
                ['errors' => [
                    'cannot_remove_included' => 'This extra is part of the rate plan',
                ]]
            );
        }

        $result = $cart->removeItem($hash);

        if ($result && $domainItem instanceof AccommodationCartItem) {
            // Remove dependent extras directly via the cart (bypass the public guard so
            // included extras follow their parent accommodation).
            foreach ($cart->getItems() as $sibling) {
                $siblingDomain = $sibling->toDomainObject();
                if ($siblingDomain instanceof ExtraCartItem
                    && $siblingDomain->getParentHash() === $hash
                ) {
                    $cart->removeItem($sibling->getHash());
                }
            }
        }

        if ($result) {
            $this->save();
        }

        return $result;
    }

    /**
     * Update item quantity
     * @throws RuntimeException
     */
    public function updateItemQuantity(string $hash, int $quantity): bool
    {
        $cart = $this->getCart();
        if (!$cart) {
            return false;
        }

        $cartItem = null;
        foreach ($cart->getItems() as $item) {
            if ($item->getHash() === $hash) {
                $cartItem = $item;
                break;
            }
        }

        if (!$cartItem) {
            return false;
        }

        /** @var CartItemProcessorInterface $processor */
        $processor = $this->itemProcessors[$cartItem->getType()];

        if (!$processor->checkAvailability($cartItem->toDomainObject(), $quantity)) {
            throw new InsufficientAvailabilityException(
                'Not enough available accommodations',
                400,
                null,
                ['field' => 'quantity', 'requested' => $quantity]
            );
        }

        $result = $cart->updateItemQuantity($hash, $quantity);
        if ($result) {
            $this->save();
        }

        return $result;
    }

    /**
     * Change payment type for accommodation
     * @throws RuntimeException
     * @throws \Exception
     */
    public function changePaymentType(string $hash, int $paymentTypeId): void
    {
        $cart = $this->getCart();
        if (!$cart) {
            throw new CartNotFoundException('Cart not found');
        }

        foreach ($cart->getItems() as $cartItem) {
            if ($cartItem->getHash() === $hash) {
                $domainItem = $cartItem->toDomainObject();

                if ($domainItem instanceof AccommodationCartItem) {
                    // Validate payment type
                    /** @var RatePlan $ratePlan */
                    $ratePlan = $this->ratePlanRepository->findByPK($domainItem->getAccommodationSelection()->getRatePlanId());

                    $paymentTypes = $ratePlan->getAvailablePaymentTypes(
                        DateTimeUtils::createDateTimeImmutable($domainItem->getStayPeriod()->getCheckInDate()->format('Y-m-d'), $cart->getCheckInTime()),
                    );
                    $validPaymentType = null;
                    foreach ($paymentTypes as $type) {
                        if ($type->getId() === $paymentTypeId) {
                            $validPaymentType = $type;
                            break;
                        }
                    }

                    if (!$validPaymentType) {
                        throw new InvalidPaymentTypeException(
                            'Invalid payment type',
                            400,
                            null,
                            ['field' => 'paymentTypeId', 'value' => $paymentTypeId]
                        );
                    }

                    $domainItem->setPaymentType($validPaymentType->getId(), $validPaymentType->getName());
                    $cartItem->setData($domainItem->toArray());
                    $this->save();
                    return;
                }
            }
        }

        throw new ItemNotFoundException(
            'Item not found',
            404,
            null,
            ['field' => 'hash', 'value' => $hash]
        );
    }

    /**
     * Set customer information
     */
    public function setCustomer(string $firstName, string $lastName, string $email, string $phone = ''): void
    {
        $cart = $this->getCart();
        if (!$cart) {
            throw new CartNotFoundException('Cart not found');
        }

        // add cart to exists customer
        if (!$customer = $this->customerRepository->findByEmail($email)) {
            $customer = new Customer($firstName, $lastName, $email, $phone);
        }

        $cart->setCustomer($customer);
        $this->save();
    }

    /**
     * Set special request
     * @throws RuntimeException
     */
    public function setSpecialRequest(string $request): void
    {
        $cart = $this->getCart();
        if (!$cart) {
            throw new CartNotFoundException('Cart not found');
        }

        $cart->setSpecialRequest($request);
        $this->save();
    }

    /**
     * Set arrival time
     * @throws RuntimeException
     */
    public function setArrivalTime(string $time): void
    {
        $cart = $this->getCart();
        if (!$cart) {
            throw new CartNotFoundException('Cart not found');
        }

        $cart->setArrivalTime($time);
        $this->save();
    }

    /**
     * Prepare cart for checkout with all required information
     */
    public function prepareForCheckout(CheckoutPreparationDTO $data): void
    {
        $cart = $this->getCart();
        if (!$cart) {
            throw new CartNotFoundException('Cart not found');
        }

        // Set customer
        $this->setCustomer(
            $data->firstName,
            $data->lastName,
            $data->email,
            $data->phone
        );

        // Set optional fields
        if ($data->specialRequest) {
            $this->setSpecialRequest($data->specialRequest);
        }

        if ($data->arrivalTime) {
            $this->setArrivalTime($data->arrivalTime);
        }
    }

    /**
     * Set promo code
     * @throws RuntimeException
     */
    public function setPromoCode(string $code): void
    {
        $cart = $this->getCart();
        if (!$cart) {
            throw new CartNotFoundException('Cart not found');
        }

        $cart->setPromoCode($code);
        $this->save();
    }

    /**
     * Calculate total cart price
     * @throws RuntimeException
     */
    public function calculateTotal(): array
    {
        $cart = $this->getCart();
        if (!$cart || $cart->isEmpty()) {
            return [
                'subtotal' => 0,
                'taxes' => 0,
                'total' => 0,
                'items' => []
            ];
        }

        $summaries = [];
        foreach ($cart->getItems() as $cartItem) {

            $type = $cartItem->getType();
            if (!isset($this->itemProcessors[$type])) {
                throw new RuntimeException("No processor registered for item type: $type");
            }

            /** @var CartItemProcessorInterface $processor */
            $processor = $this->itemProcessors[$type];

            $domainItem = $processor->calculateItemTotal($cartItem->toDomainObject());
            ['accommodation' => $accommodation, 'daily' => $daily] = $domainItem->getSummary();

            $dailyBreakdown = $domainItem instanceof AccommodationCartItem
                ? $this->buildDailyBreakdown($domainItem)
                : [];

            for ($i = 0; $i < $cartItem->getQuantity(); $i++) {
                $summaries[$type][] = [
                    'accommodation' => [
                        'cashier' => $accommodation,
                        'context' => [
                            'id' => $cartItem->getHash(),
                        ]
                    ],
                    'daily'          => $daily,
                    'hash'           => $cartItem->getHash(),
                    'dailyBreakdown' => $dailyBreakdown,
                ];
            }
        }

        return $summaries;
    }

    /**
     * Build per-day placement breakdown for an accommodation cart item.
     * Returns an array of [date, basePrice, placements] entries — placements
     * carries the placementType/variant/quantity/price line items from the
     * stay-cost calculator.
     *
     * @return array<int, array<string, mixed>>
     */
    private function buildDailyBreakdown(AccommodationCartItem $item): array
    {
        $result = $item->getStayCostResult();
        if ($result === null) {
            return [];
        }

        $breakdown = [];
        foreach ($result->getDailyCosts() as $daily) {
            $breakdown[] = [
                'date'       => $daily->getDateString(),
                'basePrice'  => $daily->getBasePrice()->getMinorAmount()->toInt(),
                'placements' => $daily->getPriceDetails(),
            ];
        }

        return $breakdown;
    }


    /**
     * Clear cart
     * @throws RuntimeException
     */
    public function clear(): void
    {
        $cart = $this->getCart();
        if ($cart) {
            $cart->clear();
            $this->save();
        }
    }

    /**
     * Clean up expired carts
     */
    public function cleanupExpiredCarts(): int
    {
        $expiredCarts = $this->cartRepository->findExpired();
        $count = 0;

        foreach ($expiredCarts as $cart) {
            $this->entityManager->delete($cart);
            $count++;
        }

        if ($count > 0) {
            $this->entityManager->run();
        }

        return $count;
    }

    /**
     * Save cart to database
     *
     * @throws RuntimeException
     */
    private function save(): void
    {
        if (!$this->cart) {
            return;
        }

        try {
            $this->entityManager->persist($this->cart);
            $this->entityManager->run();
        } catch (Throwable $e) {
            throw new RuntimeException('Failed to save cart: ' . $e->getMessage(), 500);
        }
    }
}
