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

declare(strict_types=1);

namespace BookiFlex\Core\Service\OfferFinder;

use BookiFlex\Core\Contract\Fee\FeeCalculatorInterface;
use BookiFlex\Core\Contract\Taxation\TaxCalculatorInterface;
use BookiFlex\Core\Model\AccommodationType\Guest\AdultGuest;
use BookiFlex\Core\Model\AccommodationType\Scheme\Allocation;
use BookiFlex\Core\Model\AccommodationType\Scheme\AllocationScheme;
use BookiFlex\Core\Model\DateUnit\RestrictionRulesConfigurator;
use BookiFlex\Core\Model\RatePlan\RatePlan;
use BookiFlex\Core\ValueObject\PlacementLocation;
use BookiFlex\Core\Service\AccommodationType\ScenarioGeneratorService;
use BookiFlex\Core\Service\OfferFinder\DTO\OfferRequest;
use BookiFlex\Core\Service\OfferFinder\DTO\OfferResult;
use BookiFlex\Core\Service\StayCostCalculator\StayCostCalculator;
use BookiFlex\Core\Service\SuitableAccommodationType\DTO\SuitableAccommodationTypeResult;
use BookiFlex\Core\Service\SuitableAccommodationType\SuitableAccommodationType;
use BookiFlex\Core\Service\SuitableRatePlan\SuitableRatePlan;
use BookiFlex\Core\ValueObject\AccommodationSelection;
use BookiFlex\Core\ValueObject\GuestConfiguration;
use BookiFlex\Core\ValueObject\StayPeriod;
use DateTimeImmutable;

defined( 'ABSPATH' ) || exit;

/**
 * Finds available offers for the stay.
 * Given the search parameters, check the availability of the accommodation types and rate plans.
 */
class OfferFinder
{
    public function __construct(
        private readonly StayCostCalculator        $stayCostCalculator,
        private readonly SuitableRatePlan          $suitableRatePlan,
        private readonly SuitableAccommodationType $suitableAccommodationType,
        private readonly ScenarioGeneratorService          $scenarioGeneratorService,
        private readonly ?TaxCalculatorInterface   $taxCalculator = null,
        private readonly ?FeeCalculatorInterface   $feeCalculator = null
    ) {
    }

    /**
     * @param OfferRequest $query
     *
     * @return OfferResult[]
     */
    public function find(OfferRequest $query): array
    {
        $suitableAccommodationTypes = $this->suitableAccommodationType->search(
            adults: $query->getAdults(),
            childrenAges: $query->getChildren(),
            accommodationTypeIds: $query->getAccommodationTypes(),
            context: $query
        );

        $availableAccommodationTypes = array_values(array_filter(
            $suitableAccommodationTypes,
            fn (SuitableAccommodationTypeResult $item) => $item->getQuota() > 0
        ));

        $ratePlans = $this->suitableRatePlan->search(
            $query->getRatePlans(),
            array_map(fn (SuitableAccommodationTypeResult $item) => $item->getAccommodationType()->getId(), $availableAccommodationTypes),
            $query
        );

        $offerResults = ($query->getAdults() && !empty($availableAccommodationTypes) && !empty($ratePlans))
            ? $this->processSpecificAccommodation(
                $query->getCheckInDate(),
                $query->getCheckOutDate(),
                $availableAccommodationTypes,
                $ratePlans,
                $query->getAdults(),
                $query->getChildren(),
                $query->getRestrictionRules()
            )
            : $this->processAbstractAccommodations(
                $query->getCheckInDate(),
                $query->getCheckOutDate(),
                $availableAccommodationTypes,
                $ratePlans,
                $query->getRestrictionRules()
            );

        $this->calculateTaxesAndFees($offerResults);

        $notAvailableOfferResults = $this->notAvailableOfferResults($suitableAccommodationTypes, $availableAccommodationTypes, $offerResults);

        return [...$offerResults, ...$notAvailableOfferResults];
    }

    /**
     * Returns the list of accommodation types that are not available for booking:
     * either sold out (no quota left), or priced with no valid offer (e.g. no price
     * configured for the requested dates). Both cases are surfaced the same way so
     * the accommodation type is never silently dropped from the response.
     *
     * @param SuitableAccommodationTypeResult[] $suitableAccommodationTypes
     * @param SuitableAccommodationTypeResult[] $availableAccommodationTypes
     * @param OfferResult[] $offerResults
     */
    private function notAvailableOfferResults(array $suitableAccommodationTypes, array $availableAccommodationTypes, array $offerResults): array
    {
        $result = [];

        foreach ($suitableAccommodationTypes as $item) {
            if ($item->getQuota() > 0) {
                continue;
            }

            $result[] = new OfferResult(
                null,
                $item->getAccommodationType(),
                $item->getQuota()
            );
        }

        $accommodationTypeIdsWithOffer = [];
        foreach ($offerResults as $offerResult) {
            $accommodationTypeIdsWithOffer[$offerResult->getAccommodationType()->getId()] = true;
        }

        foreach ($availableAccommodationTypes as $item) {
            if (isset($accommodationTypeIdsWithOffer[$item->getAccommodationType()->getId()])) {
                continue;
            }

            $result[] = new OfferResult(
                null,
                $item->getAccommodationType(),
                $item->getQuota()
            );
        }

        return $result;
    }

    /**
     * @param DateTimeImmutable $checkInDate
     * @param DateTimeImmutable $checkOutDate
     * @param SuitableAccommodationTypeResult[] $suitableAccommodationTypes
     * @param RatePlan[] $ratePlans
     * @param int $adults
     * @param array $children
     * @param RestrictionRulesConfigurator|null $restrictionRules
     * @return OfferResult[]
     */
    private function processSpecificAccommodation(
        DateTimeImmutable             $checkInDate,
        DateTimeImmutable             $checkOutDate,
        array                         $suitableAccommodationTypes,
        array                         $ratePlans,
        int                           $adults,
        array                         $children,
        ?RestrictionRulesConfigurator $restrictionRules
    ): array {
        $result = [];

        $stayPeriod = new StayPeriod($checkInDate, $checkOutDate);
        $guestConfiguration = new GuestConfiguration($adults, $children);

        foreach ($suitableAccommodationTypes as $suitableAccommodationType) {
            $accommodationType = $suitableAccommodationType->getAccommodationType();
            $allocationScheme  = $suitableAccommodationType->getAllocationScheme();

            // Physical occupancy (beds actually taken) derived from the concrete allocation.
            $occupancy = $allocationScheme !== null
                ? $this->buildPhysicalOccupancy($allocationScheme)
                : null;

            foreach ($ratePlans as $ratePlan) {
                if (!$ratePlan->supportAccommodationType($accommodationType->getId())) {
                    continue;
                }

                $stayCostResult = $this->stayCostCalculator->calculate(
                    $stayPeriod,
                    new AccommodationSelection($accommodationType->getId(), $ratePlan->getId()),
                    $guestConfiguration,
                    $restrictionRules
                );

                if (!$stayCostResult->hasError()
                    && count($stayCostResult->getDailyCosts()) > 0
                    && count($stayCostResult->getRestrictions()) === 0
                ) {
                    $result[] = new OfferResult(
                        $ratePlan,
                        $accommodationType,
                        $suitableAccommodationType->getQuota(),
                        $stayCostResult,
                        $occupancy,
                        $allocationScheme,
                    );
                }
            }
        }

        return $result;
    }

    /**
     * Project a concrete allocation scheme onto physical occupancy (bed footprint).
     *
     * Only main/extra places are counted — "without place" guests (e.g. infants sharing
     * a parent's bed) occupy no physical place and are intentionally excluded here.
     * The people-level breakdown is carried separately via the allocation scheme itself.
     *
     * @return array{kind: string, main: int, extra: int}
     */
    private function buildPhysicalOccupancy(AllocationScheme $allocationScheme): array
    {
        $main = 0;
        $extra = 0;

        foreach ($allocationScheme->getAllocations() as $allocation) {
            $location = $allocation->getLocation();
            if ($location->isMain()) {
                $main += $allocation->getQuantity();
            } elseif ($location->isExtra()) {
                $extra += $allocation->getQuantity();
            }
        }

        return ['kind' => 'adults', 'main' => $main, 'extra' => $extra];
    }

    /**
     * Build a concrete allocation for an adults-only scenario.
     *
     * In the abstract flow every priced scenario is adults-only, so the physical
     * bed footprint equals the guest count and the placement is unambiguous. This
     * lets offers expose the same people-level `allocation` as the guest-refined flow.
     */
    private function buildAdultsAllocation(int $main, int $extra): AllocationScheme
    {
        $scheme = new AllocationScheme();

        if ($main > 0) {
            $scheme->addAllocation(new Allocation(new AdultGuest($main), PlacementLocation::main(), $main));
        }

        if ($extra > 0) {
            $scheme->addAllocation(new Allocation(new AdultGuest($extra), PlacementLocation::extra(), $extra));
        }

        return $scheme;
    }

    /**
     * @param DateTimeImmutable $checkInDate
     * @param DateTimeImmutable $checkOutDate
     * @param SuitableAccommodationTypeResult[] $suitableAccommodationTypes
     * @param RatePlan[] $ratePlans
     * @param RestrictionRulesConfigurator|null $restrictionRules
     * @return OfferResult[]
     */
    private function processAbstractAccommodations(
        DateTimeImmutable             $checkInDate,
        DateTimeImmutable             $checkOutDate,
        array                         $suitableAccommodationTypes,
        array                         $ratePlans,
        ?RestrictionRulesConfigurator $restrictionRules
    ): array {
        $result = [];

        $stayPeriod = new StayPeriod($checkInDate, $checkOutDate);

        foreach ($suitableAccommodationTypes as $suitableAccommodationType) {
            $accommodationType  = $suitableAccommodationType->getAccommodationType();
            $placementScenarios = $this->scenarioGeneratorService->generateScenarios($accommodationType);
//            $placementScenarios = $accommodationType->getAllPlacementScenarios();

            foreach ($ratePlans as $ratePlan) {
                if (!$ratePlan->supportAccommodationType($accommodationType->getId())) {
                    continue;
                }

                $accommodationSelection = new AccommodationSelection($accommodationType->getId(), $ratePlan->getId());
                $stayCostResults = [];
                foreach ($placementScenarios->toArray() as $scenario) {
                    $adults = $scenario['main'] + $scenario['extra'];

                    // Since 'children' scenario is last in the list of scenarios
                    // we are checking the existence of 'adult' scenarios
                    if ($scenario['kind'] === 'children' && !empty($stayCostResults)) {
                        $stayCostResults[] = new OfferResult(
                            $ratePlan,
                            $accommodationType,
                            $suitableAccommodationType->getQuota(),
                            null,
                            $scenario
                        );
                    }

                    if ($scenario['kind'] === 'adults') {
                        $stayCostResult = $this->stayCostCalculator->calculate(
                            $stayPeriod,
                            $accommodationSelection,
                            new GuestConfiguration($adults, []),
                            $restrictionRules
                        );

                        $stayCostResults[] = new OfferResult(
                            $ratePlan,
                            $accommodationType,
                            $suitableAccommodationType->getQuota(),
                            $stayCostResult,
                            $scenario,
                            $this->buildAdultsAllocation($scenario['main'], $scenario['extra'])
                        );
                    }
                }

                $result = array_merge($result, $stayCostResults);
            }
        }

        return $result;
    }

    /**
     * @param OfferResult[] $stayFinderResults
     */
    private function calculateTaxesAndFees(array $stayFinderResults): void
    {
        if ($this->taxCalculator === null && $this->feeCalculator === null) {
            return;
        }

        $taxableObjects = [];
        foreach ($stayFinderResults as $stayFinderResult) {
            if ($stayFinderResult->getStayCost() !== null) {
                $taxableObjects[] = $stayFinderResult->getStayCost();
                foreach ($stayFinderResult->getStayCost()->getDailyCosts() as $stayCostByDate) {
                    $taxableObjects[] = $stayCostByDate;
                }
            }
        }

        $this->taxCalculator?->applyTaxes($taxableObjects);
        $this->feeCalculator?->applyFees($taxableObjects);
    }
}
