<?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\CmsBundle\Components;

use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Order;
use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\CmsBundle\Application\AbstractParticipantExclusionReason;
use Ferienpass\CmsBundle\Application\ExclusionReasonEvent;
use Ferienpass\CmsBundle\Application\ExclusionReasons\AgeInvalid;
use Ferienpass\CmsBundle\Application\FriendCodeManager;
use Ferienpass\CmsBundle\Application\ParticipantExclusionReason;
use Ferienpass\CmsBundle\Dto\AccessCodeDto;
use Ferienpass\CmsBundle\Dto\FriendCodeDto;
use Ferienpass\CoreBundle\Application\ApplicationFormBuilder;
use Ferienpass\CoreBundle\Application\ApplicationFormSpec;
use Ferienpass\CoreBundle\Application\ExtraFileManager;
use Ferienpass\CoreBundle\Application\ExtraFileRejected;
use Ferienpass\CoreBundle\Application\ExtraQuestion;
use Ferienpass\CoreBundle\ApplicationSystem\ApplicationSystemInterface;
use Ferienpass\CoreBundle\ApplicationSystem\ApplicationSystems;
use Ferienpass\CoreBundle\ApplicationSystem\FirstComeApplicationSystem;
use Ferienpass\CoreBundle\ApplicationSystem\LotApplicationSystem;
use Ferienpass\CoreBundle\ConsentManager\ConsentManager;
use Ferienpass\CoreBundle\ConsentManager\ConsentRequirementChecker;
use Ferienpass\CoreBundle\ConsentManager\ConsentState;
use Ferienpass\CoreBundle\Entity\AccessCodeStrategy;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\AttendanceExtraFile;
use Ferienpass\CoreBundle\Entity\EditionTask;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Entity\PostalAddress;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Enum\AttendanceTransition;
use Ferienpass\CoreBundle\Facade\AttendanceFacade;
use Ferienpass\CoreBundle\Facade\PaymentsFacade;
use Ferienpass\CoreBundle\Message\AnonymousAttendanceUnconfirmed;
use Ferienpass\CoreBundle\Message\DeleteAttendance;
use Ferienpass\CoreBundle\Repository\AttendanceExtraFileRepository;
use Ferienpass\CoreBundle\Repository\AttendanceRepository;
use Ferienpass\CoreBundle\Repository\EditionTaskRepository;
use Ferienpass\CoreBundle\Repository\ParticipantRepositoryInterface;
use Ferienpass\CoreBundle\Settings\SystemSettingsManager;
use Ferienpass\CoreBundle\Validator\ValidAccessCode;
use Ferienpass\CoreBundle\Validator\ValidFriendCode;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Workflow\WorkflowInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveListener;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\Attribute\PostHydrate;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;

#[AsLiveComponent(template: '@Contao/components/application_form.html.twig', route: 'live_component_cms')]
class ApplicationForm extends AbstractController
{
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    #[LiveProp]
    public OfferInterface $offer;

    #[LiveProp]
    public ?ParticipantInterface $participant = null;

    #[LiveProp(writable: true)]
    public bool $doubleBooking = false;

    #[LiveProp]
    public bool $hasNewParticipant = false;

    #[Assert\Valid]
    #[LiveProp(writable: ['firstname', 'lastname', 'email', 'phone', 'mobile'])]
    public ?ParticipantInterface $newParticipant = null;

    /**
     * Top-level mirror of {@see ParticipantInterface::getDateOfBirth()} for the
     * inline DOB input. Pulled out of the nested writable list on
     * `$newParticipant` because nested writables can't declare a
     * `hydrateWith`/`dehydrateWith` pair, and the default Serializer
     * dehydration emits an ISO-8601 string with time-and-tz (e.g.
     * `2015-06-14T00:00:00+02:00`) that `<input type="date">` silently
     * rejects on Live's post-render value-sync — clearing the field
     * whenever any other prop on the form triggers a re-render.
     *
     * The hydrate/dehydrate methods round-trip the value as the `Y-m-d` string
     * the date input actually accepts; `onUpdated` mirrors it back onto the
     * participant entity. Same pattern as `OfferDates::$applicationDeadline`.
     */
    #[LiveProp(writable: true, hydrateWith: 'hydrateNewParticipantDateOfBirth', dehydrateWith: 'dehydrateNewParticipantDateOfBirth', onUpdated: 'onNewParticipantDateOfBirthUpdated')]
    public ?\DateTimeImmutable $newParticipantDateOfBirth = null;

    /**
     * Postal address collected inline on the anonymous flow when the system
     * requires an address ({@see ApplicationFormSpec::$requiresPostalAddress}).
     * Attached to the {@see Attendance} on submit so the address belongs to
     * this specific application — accounts use User.postalAddresses instead.
     */
    #[LiveProp(writable: ['street', 'postalCode', 'city', 'country'])]
    public ?PostalAddress $newPostalAddress = null;

    #[ValidAccessCode]
    #[LiveProp(writable: ['code'])]
    public ?AccessCodeDto $accessCode = null;

    #[ValidFriendCode]
    #[LiveProp(writable: ['code'], onUpdated: ['code' => 'onFriendCodeUpdated'])]
    public ?FriendCodeDto $friendCode = null;

    #[LiveProp(writable: true)]
    public bool $useFriendsBooking = false;

    #[LiveProp(writable: true)]
    public string $useFriendCode = 'on-hold';

    #[LiveProp(writable: true)]
    public array $extra = [];

    /**
     * File answers picked so far: question uuid => {@see AttendanceExtraFile} uuid.
     *
     * A file is stored the moment it is picked — the application does not exist
     * yet, so the row is staged (no attendance) and adopted on submit. That is
     * also the only way a file can survive a live re-render: browsers clear file
     * inputs on every DOM update, so the bytes must be consumed in the request
     * that carries them (same reasoning as {@see \Ferienpass\AdminBundle\Components\Mailing::uploadAttachment()}).
     *
     * NOT writable: the client must not be able to point an answer at someone
     * else's upload.
     *
     * @var array<string, string>
     */
    #[LiveProp]
    public array $extraFiles = [];

    /**
     * Question uuids whose required answer is still missing, surfaced inline next
     * to the input after a rejected submit.
     *
     * @var list<string>
     */
    public array $extraErrors = [];

    /**
     * Per-question upload errors, `{key, params}` for the `cms` domain. A plain
     * public property (reset on every request) rather than a flash: live
     * components never reload the page, so a flash would not be delivered.
     *
     * @var array<string, array{key: string, params: array}>
     */
    public array $extraFileErrors = [];

    #[LiveProp(writable: true)]
    public array $participantDecisions = [];

    #[LiveProp(writable: true, url: true)]
    public ?string $consentToken = null;

    #[LiveProp(writable: true)]
    public bool $isPostponeConsents = false;

    /**
     * Anonymous flow: whether the applicant is of age and the legal guardian
     * ('1'), or a minor applying for themselves ('0'). Minors do not decide the
     * consents in the form — the application is held after the e-mail
     * confirmation until the legal guardian signs them via the shared consent
     * link. Null until answered.
     */
    #[LiveProp(writable: true)]
    public ?string $isLegalGuardian = null;

    #[LiveProp(writable: true)]
    public bool $proceedFromConsents = false;

    #[LiveProp]
    public bool $gateCompleted = false;

    /**
     * UUIDs of anonymous participants this browser created. Held client-side in
     * localStorage and synced to this prop by the cms-anon-participants Stimulus
     * controller — keeps the server-side session unstarted so Varnish can cache
     * the offer-detail response.
     *
     * @var string[]
     */
    #[LiveProp(writable: true)]
    public array $participantIds = [];

    public ?TranslatableMessage $confirm = null;

    /**
     * The status the just-submitted application actually ended up in, so the
     * success screen can tell a granted spot from a waiting-list place or a
     * refusal. Without it the screen claimed "Anmeldung erfolgreich — die
     * Bestätigung ist unterwegs" for every outcome, while no e-mail is sent for
     * anything but a confirmed attendance ({@see AttendanceCreated} handler) —
     * families turned up at an event they never had a spot for.
     */
    public ?string $appliedStatus = null;

    public ?string $anonymousError = null;

    public function __construct(
        private readonly ApplicationSystems $applicationSystems,
        private readonly AttendanceRepository $attendances,
        private readonly ParticipantRepositoryInterface $participantRepository,
        private readonly RequestStack $requestStack,
        private readonly EventDispatcherInterface $dispatcher,
        private readonly SystemSettingsManager $systemSettingsManager,
        private readonly PaymentsFacade $paymentsFacade,
        private readonly EditionTaskRepository $periods,
        private readonly ConsentManager $consentManager,
        private readonly ConsentRequirementChecker $consentRequirementChecker,
        private readonly ApplicationFormBuilder $applicationFormBuilder,
        private readonly FriendCodeManager $friendCodeManager,
        private readonly RateLimiterFactoryInterface $anonymousApplicationLimiter,
        private readonly RateLimiterFactoryInterface $applicationFileUploadLimiter,
        private readonly ExtraFileManager $extraFileManager,
        private readonly AttendanceExtraFileRepository $extraFileRepository,
    ) {
    }

    /**
     * Single source of truth for what this application requires (fields, consents,
     * extras, age range). Used by both `apply()` and `applyAnonymous()` so both
     * flows stay aligned.
     */
    #[ExposeInTemplate]
    public function applicationSpec(): ApplicationFormSpec
    {
        return $this->applicationFormBuilder->build(
            $this->offer,
            $this->participant instanceof ParticipantInterface ? $this->participant : null,
            $this->currentUser(),
        );
    }

    public function mount(OfferInterface $offer, ?AccessCodeStrategy $accessCodeStrategy = null): void
    {
        if ($accessCodeStrategy instanceof AccessCodeStrategy) {
            $this->accessCode = new AccessCodeDto($accessCodeStrategy, $this->accessCode?->getCode());
        }

        $this->offer = $offer;
        $this->friendCode = new FriendCodeDto($offer, $this->friendCode?->getCode());

        foreach ($offer->getApplicationExtra() ?? [] as $extra) {
            $this->extra[$extra['uuid']] ??= '';
        }

        foreach ($this->requiredConsents() as $state) {
            $this->participantDecisions[(string) $state->getConsentForm()->getId()] ??= null;
        }
    }

    #[ExposeInTemplate]
    public function attendancesDue(): array
    {
        $user = $this->getUser();

        // Guest visitor
        if (!$user instanceof User) {
            return [];
        }

        return $this->paymentsFacade->attendancesDue($user);
    }

    public function onFriendCodeUpdated(): void
    {
        if ($this->friendCode?->getCode()) {
            $this->useFriendCode = 'redeem';
        }
    }

    public function hydrateNewParticipantDateOfBirth(?string $value): ?\DateTimeImmutable
    {
        if (!$value) {
            return null;
        }

        try {
            return new \DateTimeImmutable($value);
        } catch (\DateMalformedStringException) {
            return null;
        }
    }

    public function dehydrateNewParticipantDateOfBirth(?\DateTimeImmutable $value): ?string
    {
        return $value?->format('Y-m-d');
    }

    /**
     * Mirror the standalone DOB LiveProp onto the participant entity so the
     * downstream apply/validation paths that read `$newParticipant->getDateOfBirth()`
     * see the up-to-date value.
     */
    public function onNewParticipantDateOfBirthUpdated(): void
    {
        $this->newParticipant?->setDateOfBirth($this->newParticipantDateOfBirth);
    }

    /**
     * Mirror the standalone DOB onto the participant on *every* hydration —
     * `onUpdated` only fires for actual user-driven changes, but every Live
     * action (e.g. `applyAnonymous`) re-hydrates the props fresh from the
     * wire. Without this hook the participant ends up with `dateOfBirth=null`
     * during validation and the rejection message reads "Pflichtfeld" even
     * though the user filled the input.
     */
    #[PostHydrate]
    public function syncDateOfBirthToParticipantAfterHydration(): void
    {
        if (!$this->newParticipant instanceof ParticipantInterface) {
            return;
        }
        if ($this->newParticipantDateOfBirth instanceof \DateTimeImmutable) {
            $this->newParticipant->setDateOfBirth($this->newParticipantDateOfBirth);
        }
    }

    #[ExposeInTemplate]
    public function participants(): array
    {
        $user = $this->getUser();
        if ($user instanceof \Symfony\Component\Security\Core\User\UserInterface) {
            return $this->participantRepository->createQueryBuilder('p')
                ->andWhere('p.user = :user')
                ->setParameter('user', $user)
                ->getQuery()
                ->getResult()
            ;
        }

        if (empty($this->participantIds)) {
            return [];
        }

        // Anonymous-only — never expose participants already linked to another
        // account just because their UUID happens to be in localStorage.
        return $this->participantRepository->findBy(['uuid' => $this->participantIds, 'user' => null]);
    }

    #[ExposeInTemplate]
    public function mustAddPostalAddress(): bool
    {
        $user = $this->currentUser();
        if (!$user instanceof User) {
            return false;
        }

        if (!$this->systemSettingsManager->isRequirePostalAddress()) {
            return false;
        }

        /** @var PostalAddress|false $postalAddress */
        $postalAddress = $user->getPostalAddresses()->first();

        // Same "is this address complete?" rule the anonymous flow uses.
        return !$this->applicationFormBuilder->postalAddressComplete(false === $postalAddress ? null : $postalAddress);
    }

    #[ExposeInTemplate]
    public function newPriority(): ?int
    {
        if (!$this->participant instanceof ParticipantInterface) {
            return null;
        }

        /** @var Attendance|false $lastAttendanceParticipant */
        $lastAttendanceParticipant = $this->participant
            ?->getAttendancesWaiting()
            ?->matching(Criteria::create()->orderBy(['userPriority' => Order::Ascending]))
            ?->last()
        ;

        $task = $this->applicationSystem()->getTask();
        $priority = $lastAttendanceParticipant ? $lastAttendanceParticipant->getUserPriority() + 1 : 1;
        if ($maxApplications = $task?->getMaxApplications()) {
            $priority = min($maxApplications + 1, $priority);
        }

        return $priority;
    }

    #[ExposeInTemplate]
    public function inGracePeriod(): bool
    {
        $gracePeriodStr = $this->systemSettingsManager->getWithdrawGracePeriod();
        $gracePeriod = $gracePeriodStr ? new \DateTimeImmutable($gracePeriodStr) : null;

        return !$gracePeriod instanceof \DateTimeImmutable || ($this->offer->getDates()->isEmpty() || $this->offer->getDates()->first()->getBegin() > $gracePeriod);
    }

    #[ExposeInTemplate]
    public function isRegistered(): bool
    {
        $user = $this->getUser();

        return $user instanceof User && $this->offer->getAttendancesNotHidden()
                ->filter(static fn (Attendance $a) => $a->getParticipant()?->getUser() === $user)
                ->count() > 0;
    }

    public function attendance(?ParticipantInterface $participant): ?Attendance
    {
        if (!$participant instanceof ParticipantInterface) {
            return null;
        }

        return $this->offer
            ->getAttendancesNotHidden()
            ->findFirst(static fn (int $i, Attendance $a) => $a->getParticipant()->getId() === $participant->getId())
        ;
    }

    public function exclusionReason(ParticipantInterface $participant): ?ParticipantExclusionReason
    {
        $event = new ExclusionReasonEvent($this->offer, $participant, $this->applicationSystem());

        try {
            $this->dispatcher->dispatch($event);
        } catch (AbstractParticipantExclusionReason $reason) {
            return $reason;
        }

        return null;
    }

    #[ExposeInTemplate]
    public function applicationSystem(): ?ApplicationSystemInterface
    {
        return $this->applicationSystems->findApplicationSystem($this->offer);
    }

    #[ExposeInTemplate]
    public function futureApplicationSystemTask(): ?EditionTask
    {
        $edition = $this->offer->getEdition();
        if (null === $edition) {
            return null;
        }

        $now = new \DateTimeImmutable();
        $future = null;
        foreach ($edition->getTasks() as $task) {
            if (!$task->isAnApplicationSystem()) {
                continue;
            }
            $begin = $task->getPeriodBegin();
            if (!$begin instanceof \DateTimeInterface || $begin <= $now) {
                continue;
            }
            if (null === $future || $begin < $future->getPeriodBegin()) {
                $future = $task;
            }
        }

        return $future;
    }

    #[ExposeInTemplate]
    public function vacant(): ?int
    {
        // One definition of "how full is this offer", shared with the offer
        // card's chip and the `sofortbuchung` filter. It lived here in a second
        // copy until the two drifted and the list started advertising sold-out
        // offers as instantly bookable.
        return $this->offer->getVacancies();
    }

    /**
     * How a fresh first-come application for this offer would be received right
     * now:
     *   'open'         — a spot, or a place on the waiting list, is available
     *   'waitlistFull' — full, the season keeps a waiting list but it is exhausted
     *   'noWaitlist'   — full and the season works without a waiting list at all.
     *
     * This mirrors {@see FirstComeApplicationSystem::doGetTransitionName()} —
     * the two MUST agree. When they drifted apart, the form kept inviting
     * applications that the allocation then parked on a waiting list the season
     * does not work with, and the availability banner even advertised it.
     * A null maxWaitlist means "unlimited waiting list".
     */
    #[ExposeInTemplate]
    public function firstComeAvailability(): string
    {
        $applicationSystem = $this->applicationSystem();
        if (!$applicationSystem instanceof FirstComeApplicationSystem) {
            return 'open';
        }

        // No participant limit, or spots still left → nobody gets queued.
        if (0 !== ($this->vacant() ?? 1)) {
            return 'open';
        }

        $maxWaitlist = $applicationSystem->getTask()?->getMaxWaitlist();
        if (null === $maxWaitlist) {
            return 'open';
        }

        if (0 === $maxWaitlist) {
            return 'noWaitlist';
        }

        return $this->attendances->count(['status' => AttendanceStatus::WAITLISTED, 'offer' => $this->offer]) >= $maxWaitlist
            ? 'waitlistFull'
            : 'open';
    }

    #[ExposeInTemplate]
    public function yourFriendCode(): string
    {
        return $this->friendCodeManager->tokenFor($this->offer, $this->participant);
    }

    /**
     * @return ParticipantInterface[]
     */
    public function friendCodeParticipants(): array
    {
        return $this->friendCodeManager->participantsFor($this->friendCode->getOffer(), $this->friendCode->getCode());
    }

    /**
     * @return \Ferienpass\CoreBundle\Entity\FriendCode[]
     */
    public function siblingCodes(): array
    {
        return $this->friendCodeManager->siblingCodes($this->offer, $this->currentUser());
    }

    /**
     * @return Collection<int, ParticipantInterface>
     */
    public function siblingsWithoutCode(): Collection
    {
        return $this->friendCodeManager->siblingsWithoutCode($this->offer, $this->currentUser(), $this->participant);
    }

    /**
     * @return Collection<Attendance>
     */
    public function variantApplications(): Collection
    {
        $offerIds = $this->offer->getVariants(include: true)->map(static fn (OfferInterface $o) => $o->getId())->toArray();

        return $this->participant->getAttendancesNotHidden(includedCancelled: false)
            ->filter(fn (Attendance $a) => $a->getOffer() !== $this->offer && \in_array($a->getOffer()->getId(), $offerIds, true))
        ;
    }

    #[LiveAction]
    public function postponeConsents(): void
    {
        $this->isPostponeConsents = true;
    }

    #[ExposeInTemplate]
    public function isAnonymousAllowed(): bool
    {
        if ($this->getUser() instanceof User) {
            return false;
        }

        $task = $this->applicationSystem()?->getTask();

        return $task instanceof EditionTask && $task->isAllowAnonymous() && !$this->offer->getFee();
    }

    #[ExposeInTemplate]
    public function hasUnsignedConsents(): bool
    {
        return $this->consentRequirementChecker->hasUnsignedConsents($this->offer, $this->participants(), $this->currentUser());
    }

    #[LiveAction]
    public function confirmConsents(): void
    {
        $this->proceedFromConsents = true;
    }

    /**
     * All account-level consent states for this offer — signed and outstanding
     * alike. The gate renders the full list so already-given consents show as a
     * green "Erteilt am …" line while open ones carry their inline control.
     *
     * @return ConsentState[]
     */
    #[ExposeInTemplate]
    public function accountConsentStates(): array
    {
        return array_values(array_filter(
            $this->requiredConsents(),
            static fn (ConsentState $state) => true === $state->getConsentForm()?->isUserLevel(),
        ));
    }

    /**
     * Whether to render the account-consent gate screen instead of the form.
     */
    #[ExposeInTemplate]
    public function showConsentGate(): bool
    {
        // No online application means there is no form to gate — never show it.
        if (!$this->offer->isOnlineApplication()) {
            return false;
        }

        return $this->mustSignFirst() || ($this->gateCompleted && !$this->proceedFromConsents);
    }

    #[LiveAction]
    public function decideAccountConsent(#[LiveArg] string $form, #[LiveArg] string $decision, #[LiveArg] ?int $participant = null): void
    {
        $user = $this->currentUser();
        if (!$user instanceof User) {
            return;
        }

        foreach ($this->consentManager->getConsentsForOffer($this->offer) as $consentForm) {
            if (!$consentForm->isUserLevel() || (string) $consentForm->getId() !== $form) {
                continue;
            }

            // Edition-scoped participant consents live in the gate but are signed
            // once PER participant; account-subject ones are signed for the user.
            $subject = null;
            if ($consentForm->isPerParticipant()) {
                $subject = null !== $participant ? $this->resolveOwnedParticipant($participant) : null;
                if (!$subject instanceof ParticipantInterface) {
                    return;
                }
            }

            if ('1' === $decision) {
                $this->consentManager->sign($consentForm, $user, $subject);
            } else {
                $this->consentManager->decline($consentForm, $user, $subject);
            }

            break;
        }

        $this->gateCompleted = !$this->hasUnsignedConsents();
    }

    #[LiveListener('consentSigned')]
    public function onConsentSigned(): void
    {
        // Triggers a re-render to update the list status icons
    }

    /**
     * @return ConsentState[]
     */
    #[ExposeInTemplate]
    public function requiredConsents(): array
    {
        return $this->consentManager->forOfferAndParticipants($this->offer, $this->participants(), $this->currentUser());
    }

    /**
     * The offer's definition of one extra question, or null when the uuid is not
     * one of them — never trust a question id off the wire.
     *
     * @return array{uuid: string, question: string, type: string, required: bool, fileTypes: list<string>}|null
     */
    public function extraQuestion(string $uuid): ?array
    {
        foreach ($this->offer->getApplicationExtra() ?? [] as $question) {
            if (($question['uuid'] ?? null) === $uuid) {
                return $question;
            }
        }

        return null;
    }

    /**
     * The file staged for one question, for rendering its name/size and the
     * download link. Re-checks that the row is still staged and belongs to this
     * question, so a stale prop (e.g. after the prune cron ran) renders as "no
     * file" instead of pointing at nothing.
     */
    public function extraFile(string $question): ?AttendanceExtraFile
    {
        $id = $this->extraFiles[$question] ?? null;
        if (null === $id) {
            return null;
        }

        try {
            $file = $this->extraFileRepository->find(Uuid::fromString($id));
        } catch (\InvalidArgumentException) {
            return null;
        }

        if (!$file instanceof AttendanceExtraFile || $file->getQuestion() !== $question || null !== $file->getAttendance()) {
            return null;
        }

        return $file;
    }

    /**
     * Store the file picked for a `file` question right away (see {@see $extraFiles}).
     * The bytes are untrusted input from an unauthenticated visitor, so the whole
     * validation happens server-side in {@see ExtraFileManager}; the `accept`
     * attribute on the input is convenience only.
     */
    #[LiveAction]
    public function uploadExtraFile(Request $request, #[LiveArg] string $question): void
    {
        $definition = $this->extraQuestion($question);
        if (null === $definition || ExtraQuestion::TYPE_FILE !== ExtraQuestion::type($definition['type'] ?? null)) {
            return;
        }

        $file = $request->files->get('extraFile');
        if (!$file instanceof UploadedFile) {
            return;
        }

        // The endpoint writes to object storage for anyone who can open an offer
        // page, so throttle per client IP before the bytes are touched.
        if (!$this->applicationFileUploadLimiter->create($request->getClientIp())->consume()->isAccepted()) {
            $this->extraFileErrors[$question] = ['key' => 'applicationForm.extra.file.error.rateLimited', 'params' => []];

            return;
        }

        $previous = $this->extraFile($question);

        try {
            $stored = $this->extraFileManager->store($file, $question, $definition['fileTypes'] ?? []);
        } catch (ExtraFileRejected $e) {
            $this->extraFileErrors[$question] = ['key' => $e->getTranslationKey(), 'params' => $e->getParameters()];

            return;
        }

        // One answer, one file: picking another one replaces the staged upload
        // instead of leaving it behind for the prune cron.
        if (null !== $previous) {
            $this->extraFileManager->delete($previous);
        }

        $this->extraFiles[$question] = $stored->getUuid()->toRfc4122();
        // Mirror the filename into the scalar answer so the submit gate and the
        // exports see the question as answered.
        $this->extra[$question] = $stored->getName();
    }

    #[LiveAction]
    public function removeExtraFile(#[LiveArg] string $question): void
    {
        $file = $this->extraFile($question);
        if (null !== $file) {
            $this->extraFileManager->delete($file);
        }

        unset($this->extraFiles[$question]);
        $this->extra[$question] = '';
    }

    /**
     * Extra questions the admin marked as required and that carry no answer yet.
     * The client-side gate mirrors this (see the cms-application-form Stimulus
     * controller), but the submit actions re-check it: a live action is a plain
     * HTTP endpoint.
     *
     * @return list<string> the question uuids
     */
    public function missingRequiredExtras(): array
    {
        $missing = [];
        foreach ($this->offer->getApplicationExtra() ?? [] as $question) {
            if (!($question['required'] ?? false)) {
                continue;
            }

            $uuid = (string) ($question['uuid'] ?? '');

            // A file question is answered by an actual staged upload — never by the
            // scalar, which the client can write and which still holds the previous
            // applicant's filename right after a submit.
            $answered = ExtraQuestion::TYPE_FILE === ExtraQuestion::type($question['type'] ?? null)
                ? null !== $this->extraFile($uuid)
                : '' !== trim((string) ($this->extra[$uuid] ?? ''));

            if (!$answered) {
                $missing[] = $uuid;
            }
        }

        return $missing;
    }

    #[LiveAction]
    public function proceed(#[LiveArg] int $participant): void
    {
        if ($participant === $this->participant?->getId()) {
            $this->participant = null;

            return;
        }

        // Ownership gate: resolve only a participant the current actor is
        // allowed to act on (their own, for a logged-in member; one created in
        // this anonymous browser session otherwise). Without this, an
        // authenticated parent could enroll another family's child by passing
        // an arbitrary id.
        $this->participant = $this->resolveOwnedParticipant($participant);
    }

    #[LiveAction]
    public function new(): void
    {
        $this->newParticipant ??= $this->participantRepository->createNew();
        // Seed a blank address container so the writable LiveProp paths exist
        // when the spec requires it; harmless when it doesn't.
        $this->newPostalAddress ??= new PostalAddress();
        $this->hasNewParticipant = !$this->hasNewParticipant;
    }

    #[LiveAction]
    public function apply(AttendanceFacade $attendanceFacade, EntityManagerInterface $entityManager): void
    {
        if (!$this->participant instanceof ParticipantInterface) {
            return;
        }

        // Ownership gate (defense in depth): `$participant` is a non-writable,
        // checksum-protected LiveProp so the client cannot point it at a foreign
        // id, but re-assert the same ownership rule `proceed()` enforces so this
        // entry point is safe on its own and a parent can never enroll another
        // family's child.
        if (null === $this->resolveOwnedParticipant((int) $this->participant->getId())) {
            return;
        }

        // Age preflight — same gate the anonymous flow now enforces. The exclusion
        // listener (cms-bundle/Application/ExclusionReasons/AgeInvalidListener) reads
        // dateOfBirth + offer.minAge/maxAge.
        if (($exclusion = $this->exclusionReason($this->participant)) instanceof ParticipantExclusionReason) {
            $this->confirm = $exclusion->getUserMessage();

            return;
        }

        // Required extra questions. The client gate already blocks the button, but
        // a live action is a plain HTTP endpoint — re-check it here.
        $this->extraErrors = $this->missingRequiredExtras();
        if ([] !== $this->extraErrors) {
            return;
        }

        $user = $this->currentUser();

        // Same persistence + "outstanding?" calc as the anonymous flow now uses,
        // so the builder is the single source of truth for both paths.
        $this->persistParticipantDecisions($this->participantOfferConsents($this->participant), $this->participant, $user);
        $consentOutstanding = $this->hasUnsignedConsents()
            || !$this->applicationFormBuilder->consentsResolved($this->offer, $this->participant, $user, $this->participantDecisions);

        if ($this->useFriendsBooking) {
            // `friendCode` is only consulted on the redeem branch; validate it
            // here so any inline error surfaces before the manager runs.
            if ('redeem' === $this->useFriendCode) {
                $this->validateField('friendCode');
            }
            $this->friendCodeManager->attachFriendBooking($this->participant, $this->offer, $this->useFriendCode, $this->friendCode, $user);
        }

        $attendance = $attendanceFacade->create($this->offer, $this->participant, allAlternateDates: $this->variantApplications()->isEmpty() || $this->doubleBooking, consentOutstanding: $consentOutstanding);

        if (\count($this->extra)) {
            $attendance->setExtra($this->extra);
            $entityManager->flush();
        }

        // Adopt the staged file answers — this also rewrites their scalar answers
        // to the verified filenames, overriding anything the client sent.
        $this->attachExtraFiles($attendance);

        $this->confirm = new TranslatableMessage('applicationForm.confirm.applied', [], 'cms');
        $this->appliedStatus = $attendance->getStatus();

        $this->participant = null;
        $this->reset();
    }

    #[LiveAction]
    public function withdraw(MessageBusInterface $commandBus, WorkflowInterface $attendanceStateMachine): void
    {
        $attendance = $this->attendance($this->participant);
        if (!$attendance instanceof Attendance) {
            return;
        }

        if (!$attendanceStateMachine->can($attendance, AttendanceTransition::WITHDRAW)) {
            // Todo, warning; or maybe disallow action in the first place
            return;
        }

        if ($this->applicationSystem() instanceof LotApplicationSystem) {
            $commandBus->dispatch(new DeleteAttendance((string) $attendance->getUuid()));
        } else {
            $attendanceStateMachine->apply($attendance, AttendanceTransition::WITHDRAW, ['notify' => false]);
        }

        $this->confirm = new TranslatableMessage('applicationForm.confirm.withdrawn', [], 'cms');

        $this->participant = null;
        $this->reset();
    }

    /**
     * Logged-in members add a new participant inline from the offer page (without
     * yet applying). Anonymous applicants use {@see self::applyAnonymous()} which
     * creates the participant + held attendance + e-mail confirmation in one step.
     */
    #[LiveAction]
    public function saveParticipant(EntityManagerInterface $entityManager): void
    {
        $user = $this->currentUser();
        if (!$user instanceof User) {
            return;
        }

        $this->validateField('newParticipant');

        $participant = $this->newParticipant;
        $entityManager->persist($participant);

        if ($this->accessCode instanceof AccessCodeDto) {
            $participant->addAccessCode($this->accessCode->toEntity());
        }

        $participant->setUser($user);

        $entityManager->flush();

        $this->confirm = new TranslatableMessage('applicationForm.newParticipant.savedAskApply', [], 'cms');

        $this->reset();
    }

    #[LiveAction]
    public function applyAnonymous(Request $request, EntityManagerInterface $entityManager, AttendanceFacade $attendanceFacade, MessageBusInterface $eventBus, TranslatorInterface $translator): void
    {
        $this->anonymousError = null;

        if (!$this->isAnonymousAllowed() || !$this->newParticipant instanceof ParticipantInterface) {
            return;
        }

        // Rate-limit the anonymous application submit: it persists entities and
        // sends a confirmation e-mail to an attacker-supplied address, so an
        // unthrottled #[LiveAction] is a mail-bombing vector. Scoped by client
        // IP and (when present) target e-mail. Mirrors the friend_code limiter
        // wiring; the `anonymous_application` limiter is configured in the
        // core bundle's rate_limiter prepend.
        $clientIp = $request->getClientIp() ?? '';
        $targetEmail = strtolower(trim((string) $this->newParticipant->getEmail()));
        $limiter = $this->anonymousApplicationLimiter->create(\sprintf('%s-%s', $clientIp, $targetEmail));
        if (false === $limiter->consume(1)->isAccepted()) {
            $this->anonymousError = 'applicationForm.anonymous.rateLimited';

            return;
        }

        $this->validateField('newParticipant');

        $participant = $this->newParticipant;

        // Field-level validations (firstname, lastname, dob, email, phone-or-mobile) —
        // identical to what the signed-in flow expects on a logged-in member.
        $errors = $this->applicationFormBuilder->validateParticipantData($participant);
        if ([] !== $errors) {
            $this->markInvalidParticipantFields($errors, $translator);
            // The per-field paragraphs carry the SHORT "Pflichtfeld" label.
            // The banner gets the matching long-form summary: required-fields
            // wins if anything other than phone/mobile failed; otherwise the
            // phone-or-mobile copy stands on its own.
            $missingRequired = array_diff_key($errors, ['phone' => true, 'mobile' => true]);
            $this->anonymousError = [] !== $missingRequired
                ? 'applicationForm.anonymous.requiredFields'
                : 'applicationForm.anonymous.phoneOrMobile';

            return;
        }

        // Exclusion preflight — same machinery as the signed-in path. Handles
        // AgeInvalid (offer min/max-age), Unconfirmed (a previous anonymous
        // attempt for this email is still pending its confirmation link),
        // LimitReached, OverlappingDates, etc.
        if (($exclusion = $this->exclusionReason($participant)) instanceof ParticipantExclusionReason) {
            $exclusionMessage = $exclusion->getUserMessage();
            // Pre-translate so the banner renders the actual rejection (e.g.
            // "Teilnehmer ist zu jung") instead of the underlying translation
            // key — the template runs `|trans({}, 'cms')` over $anonymousError
            // unconditionally, which for an already-localized string falls
            // back to the input as-is.
            $this->anonymousError = $exclusionMessage->trans($translator);
            // Only anchor under the DOB input when the rejection is genuinely
            // dob-bound (AgeInvalid). Other exclusions (Unconfirmed previous
            // email, LimitReached, OverlappingDates…) have nothing to do with
            // the date of birth and reading "Bitte bestätigen zuerst die
            // E-Mail…" under the dob input is confusing — those stay
            // banner-only.
            if ($exclusion instanceof AgeInvalid) {
                $this->getErrorsObject()->set('newParticipant.dateOfBirth', [
                    new ConstraintViolation(
                        $this->anonymousError,
                        $exclusionMessage->getMessage(),
                        $exclusionMessage->getParameters(),
                        null,
                        'newParticipant.dateOfBirth',
                        null,
                    ),
                ]);
                $this->validatedFields[] = 'newParticipant.dateOfBirth';
                $this->validatedFields = array_values(array_unique($this->validatedFields));
            }

            return;
        }

        // Required extra questions — same server-side re-check as the signed-in
        // path, before any entity is written.
        $this->extraErrors = $this->missingRequiredExtras();
        if ([] !== $this->extraErrors) {
            $this->anonymousError = 'applicationForm.extra.required';

            return;
        }

        // Postal address: required when the system says so. Validate before we
        // start writing entities so an incomplete address rejects cleanly.
        $spec = $this->applicationSpec();
        if ($spec->requiresPostalAddress && !$this->applicationFormBuilder->postalAddressComplete($this->newPostalAddress)) {
            $this->anonymousError = 'applicationForm.anonymous.postalAddressRequired';

            return;
        }

        // When the offer requires consents, the applicant must state whether they
        // are of age and the legal guardian. A minor postpones the consents: they
        // are not rendered in the form, and the application is held after the
        // e-mail confirmation until the guardian signs via the shared link. Both
        // account-level (step-1) and per-participant consents are collected
        // inline here and follow the same guardian gate.
        $needsGuardianAnswer = $spec->hasConsents();
        if ($needsGuardianAnswer && !\in_array($this->isLegalGuardian, ['1', '0'], true)) {
            $this->anonymousError = 'applicationForm.anonymous.guardianRequired';

            return;
        }
        $postponeConsents = $needsGuardianAnswer && '0' === $this->isLegalGuardian;

        // Guardians decide the consents right in the form — enforce completeness
        // server-side (compulsory must be agreed, force-decision must be decided).
        // `$participant` is not persisted yet; `null` makes the builder evaluate
        // the pristine (signature-less) states, which is exactly this situation.
        if ($needsGuardianAnswer && !$postponeConsents
            && !$this->applicationFormBuilder->consentsResolved($this->offer, null, null, $this->participantDecisions)) {
            $this->anonymousError = 'applicationForm.anonymous.consentsRequired';

            return;
        }

        $entityManager->persist($participant);

        if ($this->accessCode instanceof AccessCodeDto) {
            $participant->addAccessCode($this->accessCode->toEntity());
        }

        $entityManager->flush();

        $this->rememberAnonymousParticipant($participant);

        // Persist every consent decision against (form, participant) with no User —
        // both per-participant AND account-level (step-1) consents, since an
        // anonymous applicant has no account to bind the latter to yet. They are
        // recorded against the participant and rebound to the account on later
        // registration ({@see Registration::adoptStoredParticipants}). A minor's
        // application carries no decisions (the guardian signs later via the
        // consent link), so the attendance is flagged consent-outstanding instead.
        if (!$postponeConsents) {
            $this->persistAnonymousDecisions($participant);
        }

        $attendance = $attendanceFacade->createAwaitingEmailConfirmation($this->offer, $participant, consentOutstanding: $postponeConsents);

        if ($spec->requiresPostalAddress && $this->newPostalAddress instanceof PostalAddress) {
            $attendance->setPostalAddress($this->newPostalAddress);
        }

        if (\count($this->extra)) {
            $attendance->setExtra($this->extra);
        }

        // Single flush picks up both setPostalAddress and setExtra in one batch.
        $entityManager->flush();

        $this->attachExtraFiles($attendance);

        // Do NOT pass $request->getHost() here: it is attacker-controllable
        // (Host header) and the confirmation URL is signed against it. The
        // handler builds the absolute URL from the configured canonical base
        // (router default_uri) instead.
        $eventBus->dispatch(new AnonymousAttendanceUnconfirmed((string) $attendance->getUuid()));

        $this->confirm = new TranslatableMessage('applicationForm.anonymous.emailSent', ['email' => (string) $participant->getEmail()], 'cms');
        $this->participant = null;
        $this->reset();
    }

    public function reset(): void
    {
        $this->resetValidation();

        $this->anonymousError = null;
        $this->hasNewParticipant = false;
        $this->newParticipant = null;
        $this->newParticipantDateOfBirth = null;
        $this->newPostalAddress = null;
        $this->isLegalGuardian = null;

        $this->useFriendsBooking = false;
        $this->friendCode = new FriendCodeDto($this->offer);
        $this->useFriendCode = 'on-hold';

        $this->participantDecisions = [];
    }

    /**
     * @return ConsentState[]
     */
    public function participantOfferConsents(ParticipantInterface $participant): array
    {
        $states = [];
        foreach ($this->requiredConsents() as $state) {
            $form = $state->getConsentForm();

            if ($state->getParticipant()?->getId() === $participant->getId()
                && !$form->isUserLevel()
                && $state->isOutstanding()) {
                $states[] = $state;
            }
        }

        return $states;
    }

    /**
     * Adopt the staged file answers once the attendance exists, and make sure the
     * scalar answers hold the verified filenames rather than whatever the client
     * sent for those questions.
     */
    private function attachExtraFiles(Attendance $attendance): void
    {
        foreach (array_keys($this->extraFiles) as $question) {
            $file = $this->extraFile((string) $question);
            if (null === $file) {
                continue;
            }

            $this->extraFileManager->attach($file, $attendance);
        }

        // The files now belong to the attendance, so the form holds none. Blank the
        // scalar answers too: applying for a second child must not inherit the first
        // one's filename as an answer with no file behind it.
        $this->extraFiles = [];
        foreach ($this->offer->getApplicationExtra() ?? [] as $question) {
            if (ExtraQuestion::TYPE_FILE === ExtraQuestion::type($question['type'] ?? null)) {
                $this->extra[(string) ($question['uuid'] ?? '')] = '';
            }
        }
    }

    /**
     * @return ConsentState[]
     */
    private function mustSignFirstConsents(): array
    {
        return $this->consentRequirementChecker->getMustSignFirstConsents($this->offer, $this->participants(), $this->currentUser());
    }

    /**
     * Whether outstanding account-level consents must be decided before the
     * form — feeds {@see self::showConsentGate()}, which the template reads.
     */
    private function mustSignFirst(): bool
    {
        if ($this->isPostponeConsents || $this->proceedFromConsents) {
            return false;
        }

        if ($this->isAnonymousAllowed()) {
            return false;
        }

        if (!$this->hasUnsignedConsents()) {
            return false;
        }

        return !empty($this->mustSignFirstConsents());
    }

    /**
     * Resolve a participant by id only if it belongs to the current actor.
     *
     * - Logged-in member: the participant must be theirs ({@see ParticipantVoter}
     *   `edit`, i.e. `participant.user === currentUser`).
     * - Anonymous visitor: the participant must be account-less and one this
     *   browser session created (its UUID is in {@see self::$participantIds},
     *   the localStorage-backed allow-list the rest of the form already trusts).
     *
     * Returns null when no owned participant matches, which the callers treat
     * as "deselect / do nothing".
     */
    private function resolveOwnedParticipant(int $id): ?ParticipantInterface
    {
        $participant = $this->participantRepository->find($id);
        if (!$participant instanceof ParticipantInterface) {
            return null;
        }

        if ($this->currentUser() instanceof User) {
            return $this->isGranted('edit', $participant) ? $participant : null;
        }

        // Anonymous flow: only account-less participants tracked by this session.
        if (null !== $participant->getUser()) {
            return null;
        }

        return \in_array((string) $participant->getUuid(), $this->participantIds, true) ? $participant : null;
    }

    /**
     * Narrow Symfony's `UserInterface|null` to our concrete User entity (or null).
     * Replaces the verbose `$this->getUser() instanceof User ? $this->getUser() : null`
     * pattern that previously appeared in every consent/spec call site.
     */
    private function currentUser(): ?User
    {
        $user = $this->getUser();

        return $user instanceof User ? $user : null;
    }

    /**
     * Push synthetic ConstraintViolations into the LiveComponent's `_errors`
     * store so the per-field red rings light up under the same `newParticipant.*`
     * property paths the template already reads (firstname, lastname,
     * dateOfBirth, email, phone, mobile). The accompanying banner copy is
     * still set by the caller via {@see $anonymousError}.
     *
     * Used by the anonymous flow because the manual `validateParticipantData()`
     * checks live outside the Symfony Validator that `validateField()` runs —
     * without this bridge, the framework wouldn't know which inputs failed
     * the soft rules and they'd stay visually neutral.
     *
     * @param array<string, string> $errors property name (relative to
     *                                      `newParticipant`) → translation key
     */
    private function markInvalidParticipantFields(array $errors, TranslatorInterface $translator): void
    {
        foreach ($errors as $field => $translationKey) {
            $path = 'newParticipant.'.$field;
            $message = $translator->trans($translationKey, [], 'cms');
            $this->validatedFields[] = $path;
            $this->getErrorsObject()->set($path, [
                new ConstraintViolation($message, $translationKey, [], null, $path, null),
            ]);
        }

        $this->validatedFields = array_values(array_unique($this->validatedFields));
    }

    /**
     * Track a freshly created anonymous participant by UUID. The updated
     * {@see self::$participantIds} prop is mirrored back to the browser's
     * localStorage by the cms-anon-participants Stimulus controller on
     * re-render — no server-side session is touched.
     */
    private function rememberAnonymousParticipant(ParticipantInterface $participant): void
    {
        $uuid = (string) $participant->getUuid();
        if ('' === $uuid || \in_array($uuid, $this->participantIds, true)) {
            return;
        }

        $this->participantIds = array_values(array_merge($this->participantIds, [$uuid]));
    }

    /**
     * Persist the current `$participantDecisions` against `$participant` for the
     * given per-participant consent states — shared between `apply()` (logged-in)
     * and `applyAnonymous()` (anonymous, `$user = null`). Account-level forms are
     * skipped; null decisions leave the existing row untouched.
     *
     * @param iterable<ConsentState> $states
     */
    private function persistParticipantDecisions(iterable $states, ParticipantInterface $participant, ?User $user): void
    {
        foreach ($states as $state) {
            $form = $state->getConsentForm();
            if ($form->isUserLevel()) {
                continue;
            }

            $decision = $this->participantDecisions[(string) $form->getId()] ?? null;
            if (null === $decision) {
                continue;
            }

            if ('1' === (string) $decision) {
                $this->consentManager->sign($form, $user, $participant);
            } else {
                $this->consentManager->decline($form, $user, $participant);
            }
        }
    }

    /**
     * Persist all of the offer's consent decisions (account-level + per-participant)
     * for an anonymous application against the participant, with no User. Unlike
     * {@see self::persistParticipantDecisions} this does NOT skip account-level
     * forms: an anonymous applicant has no account, so even the step-1 consents
     * are recorded against the participant (user=null) and get rebound to the
     * account on later registration. Null decisions leave the row untouched.
     */
    private function persistAnonymousDecisions(ParticipantInterface $participant): void
    {
        foreach ($this->consentManager->getConsentsForOffer($this->offer) as $form) {
            $decision = $this->participantDecisions[(string) $form->getId()] ?? null;
            if (null === $decision) {
                continue;
            }

            if ('1' === (string) $decision) {
                $this->consentManager->sign($form, null, $participant);
            } else {
                $this->consentManager->decline($form, null, $participant);
            }
        }
    }
}
