<?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\OfferStatus;
use Ferienpass\CoreBundle\Message\LotEdition as LotEditionCommand;
use Ferienpass\CoreBundle\Repository\AttendanceStashRepository;
use Ferienpass\CoreBundle\Repository\OfferRepositoryInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Lock\LockFactory;
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 LockFactory $lockFactory, private readonly AttendanceStashRepository $stashRepository, ?LoggerInterface $logger = null)
    {
        $this->logger = $logger ?? new NullLogger();
    }

    public function __invoke(LotEditionCommand $command): void
    {
        $lock = $this->lockFactory->createLock('lotting.'.$command->getEditionId(), ttl: 300, autoRelease: false);
        if (!$lock->acquire()) {
            return;
        }

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

        try {
            $cancelled = OfferStatus::CANCELLED;

            /** @var array<array> $result */
            $result = $this->offers->createQueryBuilder('o')
                ->innerJoin('o.edition', 'e')
                ->andWhere('e.uuid = :edition_uuid')
                ->setParameter('edition_uuid', $command->getEditionId())
                ->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')
                // Stable tie-break so the (order-dependent) draw is deterministic
                // for equally-crowded offers, instead of relying on DB row order.
                ->addOrderBy('o.id', 'ASC')
                ->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) {
                $lock->refresh();

                /** @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->setStatus(AttendanceStatus::WITHDRAWN);
                        $attendance->setStash($stash);

                        continue;
                    }

                    $attendance->setStatus(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) {
                $lock->refresh();

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