<?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\Facade;

use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Order;
use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\CoreBundle\ApplicationSystem\ApplicationSystems;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Debtor;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Enum\AttendanceTransition;
use Ferienpass\CoreBundle\Message\ReorderParticipantList;
use Ferienpass\CoreBundle\Repository\AttendanceRepository;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Workflow\WorkflowInterface;

class AttendanceFacade
{
    public function __construct(private readonly MessageBusInterface $commandBus, private readonly EntityManagerInterface $entityManager, private readonly ApplicationSystems $applicationSystems, private readonly AttendanceRepository $attendances, private readonly WorkflowInterface $attendanceStateMachine, private readonly LockFactory $lockFactory)
    {
    }

    /**
     * Preview an attendance status without persisting the attendance.
     */
    public function preview(OfferInterface $offer, ParticipantInterface $participant): Attendance
    {
        $attendance = new Attendance($offer, $participant);

        $applicationSystem = $this->applicationSystems->findApplicationSystem($offer);
        $transition = $applicationSystem?->suggestTransition($attendance);

        // Project the suggested transition onto the attendance so callers can read the
        // status the applicant *would* get (e.g. to pre-select it in the admin dialog).
        // suggestTransition() only computes the transition name; it never mutates the
        // status. Map it directly instead of running the state machine, so none of the
        // transition side effects (notifications, stash, payment items) fire on a preview.
        // A null transition (lottery / no application system) leaves it WAITING.
        $projectedStatus = match ($transition) {
            AttendanceTransition::CONFIRM => AttendanceStatus::CONFIRMED,
            AttendanceTransition::WAITLIST => AttendanceStatus::WAITLISTED,
            AttendanceTransition::UNFULFILL => AttendanceStatus::UNFULFILLED,
            default => null,
        };

        if (null !== $projectedStatus) {
            $attendance->setStatus($projectedStatus);
        }

        return $attendance;
    }

    /**
     * Create an attendance (or update an existing record) for a given participant and offer.
     *
     * If an explicit status is given, no application procedure is used.
     * Setting an explicit attendance status shall only be possible for admins.
     *
     * @throws \RuntimeException in case no unambiguous application system is applicable
     */
    public function create(OfferInterface $offer, ParticipantInterface $participant, ?string $status = null, bool $notify = true, bool $allAlternateDates = true, bool $consentOutstanding = false, bool $commit = true, ?Uuid $group = null): ?Attendance
    {
        if ($notify && !$commit) {
            throw new \RuntimeException('Notification cannot be sent without committing the changes');
        }

        $attendance = $this->findOrCreateAttendance($offer, $participant, $allAlternateDates);

        $attendance->setConsentOutstanding($consentOutstanding);
        if ($consentOutstanding && !$attendance->getConsentToken()) {
            $attendance->setConsentToken((string) Uuid::v7());
        }

        // Create debtor for given user
        if (($debtor = $participant->getUser()?->getDebtor()) instanceof Debtor) {
            $this->entityManager->persist($debtor);
        }

        // Reset status to allow re-assignment from current application system
        if (null !== $status) {
            $attendance->resetStatus();
        }

        // We need the entity persisted because we will dispatch messages
        if (!$attendance->getId()) {
            $this->entityManager->flush();
        }

        if (null === $attendance->getStatus() || $attendance->isHidden()) {
            $attendance->setStatus(AttendanceStatus::WAITING);
        }

        $initialTransition = match ($status) {
            AttendanceStatus::CONFIRMED => AttendanceTransition::CONFIRM,
            AttendanceStatus::WAITLISTED => AttendanceTransition::WAITLIST,
            default => null,
        };

        $context = [
            'initial' => true,
            'notify' => $notify,
            'commit' => $commit,
        ];

        if ($group) {
            $context['group'] = (string) $group;
        }

        if (null !== $initialTransition) {
            // Explicit (admin) status — no capacity decision is made, so no lock is needed.
            $this->attendanceStateMachine->apply($attendance, $initialTransition, $context);
            $this->entityManager->flush();
        } elseif (AttendanceStatus::WAITING === $status) {
            // Explicit "neu" (waiting): the admin wants the attendance parked in the
            // waiting column, NOT allocated — so the application system must not run,
            // otherwise FirstCome would immediately CONFIRM it whenever a seat is free.
            // resetStatus() above already put it in WAITING, so we only persist it.
            // The prePersist/preUpdate ParticipantLog listener still records the
            // CREATE/RESET activity; no AttendanceCreated event fires, exactly as for a
            // lottery hold (LotApplicationSystem::suggestTransition() also returns null).
            $this->entityManager->flush();
        } else {
            // No explicit status (public application): application-system-driven
            // allocation. The FirstCome system compares the confirmed/waitlisted count
            // against the offer's capacity. Guard the whole count → decide → persist
            // sequence with a per-offer blocking lock so that concurrent applications
            // cannot each read the same pre-write count and all CONFIRM beyond capacity
            // (TOCTOU).
            $this->allocateUnderLock($attendance, $context);
        }

        $this->commandBus->dispatch(new ReorderParticipantList((string) $offer->getUuid()));

        return $attendance;
    }

    /**
     * Create an attendance for an anonymous (no-account) applicant and hold it in
     * the EMAIL_UNCONFIRMED state. It does not occupy a spot and emits no
     * application notification; the confirmation e-mail is sent by the caller.
     * {@see self::confirmEmail()} releases it into the normal allocation.
     */
    public function createAwaitingEmailConfirmation(OfferInterface $offer, ParticipantInterface $participant, bool $allAlternateDates = true, bool $consentOutstanding = false): Attendance
    {
        $attendance = $this->findOrCreateAttendance($offer, $participant, $allAlternateDates);

        // Already held for this participant+offer — nothing to do.
        if ($attendance->isEmailUnconfirmed()) {
            return $attendance;
        }

        $attendance->setConsentOutstanding($consentOutstanding);
        if ($consentOutstanding && !$attendance->getConsentToken()) {
            $attendance->setConsentToken((string) Uuid::v7());
        }

        // We need the entity persisted because the workflow reads/writes status.
        if (!$attendance->getId()) {
            $this->entityManager->flush();
        }

        if (null === $attendance->getStatus() || $attendance->isHidden()) {
            $attendance->setStatus(AttendanceStatus::WAITING);
        }

        // Only a fresh/waiting application can be put on hold (a previously
        // allocated one is returned unchanged).
        if ($attendance->isWaiting()) {
            $this->attendanceStateMachine->apply($attendance, AttendanceTransition::AWAIT_EMAIL, ['notify' => false]);
            $this->entityManager->flush();
        }

        return $attendance;
    }

    /**
     * Release a held anonymous application once its e-mail has been confirmed:
     * reset it to WAITING and run the normal allocation, exactly as if it had just
     * been created (so the standard "application received/confirmed" notification
     * is sent and the spot is granted based on availability at this moment).
     */
    public function confirmEmail(Attendance $attendance): void
    {
        if (!$attendance->isEmailUnconfirmed()) {
            return;
        }

        // Direct status reset (no event) — mirrors create(); the subsequent
        // allocation transition carries the 'initial' context so the regular
        // AttendanceCreated notification fires.
        $attendance->setStatus(AttendanceStatus::WAITING);
        $this->entityManager->flush();

        // A minor applicant postponed the consents to their legal guardian: the
        // application is NOT allocated yet but held until the guardian signed
        // via the shared consent link ({@see self::confirmConsents()}).
        if ($attendance->isConsentOutstanding()) {
            $this->attendanceStateMachine->apply($attendance, AttendanceTransition::AWAIT_CONSENTS, ['notify' => false]);
            $this->entityManager->flush();

            return;
        }

        // Same capacity-sensitive allocation as create(): guard count → decide →
        // persist under the per-offer lock with a fresh in-lock count.
        $this->allocateUnderLock($attendance, ['initial' => true, 'notify' => true]);

        $this->commandBus->dispatch(new ReorderParticipantList((string) $attendance->getOffer()->getUuid()));
    }

    /**
     * Release an application held for outstanding consents once the legal
     * guardian signed them: reset to WAITING and run the normal allocation,
     * exactly like {@see self::confirmEmail()} does for the e-mail hold.
     *
     * For attendances that are NOT in the held state (e.g. an admin-shared
     * consent link for an already-allocated application) only the
     * consent-outstanding flag is cleared.
     */
    public function confirmConsents(Attendance $attendance): void
    {
        $attendance->setConsentOutstanding(false);

        if (!$attendance->isAwaitingConsents()) {
            $this->entityManager->flush();

            return;
        }

        $attendance->setStatus(AttendanceStatus::WAITING);
        $this->entityManager->flush();

        $this->allocateUnderLock($attendance, ['initial' => true, 'notify' => true]);

        $this->commandBus->dispatch(new ReorderParticipantList((string) $attendance->getOffer()->getUuid()));
    }

    public function increasePriority(Attendance $attendance): void
    {
        // The priority only applies for non-assigned spots
        if (!$attendance->isWaiting()) {
            return;
        }

        $attendances = $attendance->getParticipant()
            ?->getAttendancesWaiting()
            ?->matching(Criteria::create()->orderBy(['userPriority' => Order::Ascending]))
        ;

        if (null === $attendances || $attendances->isEmpty()) {
            $attendance->setUserPriority(1);
            $this->entityManager->flush();

            return;
        }

        foreach ($attendances as $currentAttendance) {
            if ($attendance === $currentAttendance) {
                $attendance->setUserPriority(min(1, $attendance->getUserPriority() - 1));
                continue;
            }

            $currentAttendance->setUserPriority($currentAttendance->getUserPriority() + 1);
        }

        $this->entityManager->flush();
    }

    /**
     * Run the application-system suggest-transition and persist atomically under a
     * per-offer lock. The offer's attendances are refreshed from the database inside
     * the lock so the capacity decision is based on committed data, not a stale
     * in-memory/identity-map count.
     */
    private function allocateUnderLock(Attendance $attendance, array $context): void
    {
        $offer = $attendance->getOffer();

        $lock = $this->lockFactory->createLock('attendance-allocation.'.$offer->getUuid(), ttl: 30);
        $lock->acquire(true);

        try {
            // Re-read the offer (and its attendances) so the count used for the
            // CONFIRM-vs-WAITLIST decision reflects what other transactions have
            // already committed. The attendance under work is already persisted above.
            if ($this->entityManager->contains($offer)) {
                $this->entityManager->refresh($offer);
            }

            $applicationSystem = $this->applicationSystems->findApplicationSystem($offer);
            $transition = $applicationSystem?->suggestTransition($attendance);

            if ($transition) {
                $this->attendanceStateMachine->apply($attendance, $transition, $context);
            }

            $this->entityManager->flush();
        } finally {
            $lock->release();
        }
    }

    private function findOrCreateAttendance(OfferInterface $offer, ParticipantInterface $participant, bool $allAlternateDates): Attendance
    {
        $attendance = $this->attendances->findExisting($offer, $participant);
        if (!$attendance instanceof Attendance) {
            $attendance = new Attendance($offer, $participant);
            $attendance->setAllAlternateDates($allAlternateDates);
            $offer->addAttendance($attendance);

            return $attendance;
        }

        //        // When an attendance already exists for this user, this action is immutable
        //        // But if existing attendance was withdrawn from the user, allow re-sign-ups
        //        if (!$attendance->isHidden()) {
        //            return null;
        //        }

        $attendance->setAllAlternateDates($allAlternateDates);

        return $attendance;
    }
}
