<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CoreBundle\MessageHandler;

use Doctrine\ORM\EntityRepository;
use Ferienpass\CoreBundle\ApplicationSystem\Lotting\Assignment;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Enum\DecisionReason;
use Ferienpass\CoreBundle\Enum\OfferStatus;
use Ferienpass\CoreBundle\Message\LotEdition as LotEditionCommand;
use Ferienpass\CoreBundle\Repository\AttendanceStashRepository;
use Ferienpass\CoreBundle\Repository\EditionRepository;
use Ferienpass\CoreBundle\Repository\OfferRepositoryInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler(bus: 'command.bus')]
class LotEdition
{
    private readonly LoggerInterface $logger;

    /**
     * @param OfferRepositoryInterface&EntityRepository $offers
     */
    public function __construct(private readonly OfferRepositoryInterface $offers, private readonly Assignment $assignment, private readonly AttendanceStashRepository $stashRepository, private readonly EditionRepository $editions, ?LoggerInterface $logger = null)
    {
        $this->logger = $logger ?? new NullLogger();
    }

    public function __invoke(LotEditionCommand $command): void
    {
        $edition = $this->editions->findOneBy(['uuid' => $command->getEditionId()]);
        if (null === $edition) {
            return;
        }

        $stash = $this->stashRepository->findOrCreateDefault();

        $cancelled = OfferStatus::CANCELLED;

        /** @var array<array> $result */
        $result = $this->offers->createQueryBuilder('o')
            ->andWhere('o.edition = :edition')
            ->setParameter('edition', $edition)
            ->andWhere('o.requiresApplication = 1')
            ->andWhere('o.onlineApplication = 1')
            ->andWhere("JSON_CONTAINS_PATH(o.status, 'one', '$.$cancelled') = 0")
            ->innerJoin('o.attendances', 'a')
            ->addSelect('sum(CASE WHEN a.status = :status_confirmed THEN 1 ELSE 0 END) AS countConfirmed')
            ->addSelect('sum(CASE WHEN a.status = :status_waiting THEN 1 ELSE 0 END) AS countWaiting')
            ->addSelect('sum(CASE WHEN a.status = :status_waiting OR a.status = :status_confirmed THEN 1 ELSE 0 END) AS countConfirmedOrWaiting')
            ->setParameter('status_confirmed', AttendanceStatus::CONFIRMED)
            ->setParameter('status_waiting', AttendanceStatus::WAITING)
            ->groupBy('o')
            ->orderBy('countWaiting', 'DESC')
            ->getQuery()
            ->getResult()
        ;

        // Process all offers with no waitlist
        foreach (array_filter($result, static fn ($r) => null === $r[0]->getMaxParticipants() || $r['countConfirmedOrWaiting'] <= $r[0]->getMaxParticipants()) as $r) {
            /** @var OfferInterface $offer */
            $offer = $r[0];

            if (!$command->shouldIgnoreMinParticipants() && $r['countConfirmedOrWaiting'] < $offer->getMinParticipants()) {
                // $offer->setStateManually(OfferStatus::CANCELLED);
                continue;
            }

            foreach ($offer->getAttendancesWaiting() as $attendance) {
                if (!$attendance->isAllAlternateDates()
                    && !$attendance
                        ->getParticipant()
                        ->getAttendances()
                        ->filter(static fn (Attendance $a) => $a->getOffer()->isVariantOf($attendance->getOffer()) && $a->isConfirmed())
                        ->isEmpty()) {
                    $attendance->lottingDecision(AttendanceStatus::WITHDRAWN, DecisionReason::SIBLING_CONFLICT);
                    $attendance->setStash($stash);

                    continue;
                }

                $attendance->lottingDecision(AttendanceStatus::CONFIRMED);
                $attendance->setStash($stash);
            }
        }

        // Start assigning participants to offers
        // Begin with the most crowded offers (see @orderBy)
        // Luck and priority win for the crowded offers
        // After that, the algorithm aligns for "bad luck"
        $withOverhang = array_filter($result, static fn ($r) => null !== $r[0]->getMaxParticipants() && $r['countConfirmedOrWaiting'] > $r[0]->getMaxParticipants() && $r['countWaiting'] > 0);
        if (empty($withOverhang)) {
            return;
        }

        $maxWaitlist = 0;
        if ($command->shouldUseWaitlist()) {
            /** @var OfferInterface $firstOffer */
            $firstOffer = reset($withOverhang)[0];
            $maxWaitlist = $firstOffer->getEdition()?->findUpcomingTask('application_system', 'firstcome')?->getMaxWaitlist();
        }

        // One seed per run, logged so the draw can be reproduced and audited.
        // Each offer gets a distinct derived seed so its shuffle is independent
        // yet still deterministic.
        $seed = random_int(0, \PHP_INT_MAX);
        $this->logger->info('Lotting edition {edition}: drawing {count} oversubscribed offer(s) with seed {seed}', [
            'edition' => $command->getEditionId(),
            'count' => \count($withOverhang),
            'seed' => $seed,
        ]);

        foreach (array_values($withOverhang) as $i => $r) {
            /** @var OfferInterface $offer */
            $offer = $r[0];
            $this->assignment->drawForOffer($offer, $maxWaitlist, $stash, ($seed + $i) % \PHP_INT_MAX);
        }
    }
}
