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

use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\CoreBundle\ConsentManager\ConsentManager;
use Ferienpass\CoreBundle\ConsentManager\ConsentState;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\MessengerLog;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant\BaseParticipant;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Facade\AttendanceFacade;
use Ferienpass\CoreBundle\Message\AttendanceCompletionRequested;
use Ferienpass\CoreBundle\Messenger\UuidStamp;
use Ferienpass\CoreBundle\Notification\AttendanceNotificationChecker;
use Ferienpass\CoreBundle\Repository\OfferRepositoryInterface;
use Ferienpass\CoreBundle\Repository\ParticipantRepositoryInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;

#[AsLiveComponent(route: 'live_component_admin')]
class AddAttendance extends AbstractController
{
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    #[LiveProp]
    #[Assert\NotNull(message: 'Bitte wählen Sie ein Angebot aus.')]
    public ?OfferInterface $offer = null;

    #[LiveProp]
    #[Assert\NotNull(message: 'Bitte wählen Sie einen Teilnehmer aus.')]
    public ?ParticipantInterface $participant = null;

    #[LiveProp]
    public bool $isNewParticipant = false;

    #[LiveProp(writable: true)]
    public string $status = AttendanceStatus::WAITING;

    #[LiveProp(writable: true)]
    #[Assert\Expression(
        'value == false or this.commit == true',
        message: 'attendances.error.notifyWithoutCommit'
    )]
    public bool $notify = false;

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

    #[LiveProp(writable: true)]
    public string $offerSearch = '';

    #[LiveProp(writable: true)]
    public string $participantSearch = '';

    /**
     * Per-consent decision keyed by consent-form id: 'grant' (the team signs it in
     * the name of the legal guardian, right now) or 'open' (left for the
     * participant to sign via the confirmation link). Defaults to 'open'.
     *
     * @var array<string, string>
     */
    #[LiveProp(writable: true)]
    public array $consentDecisions = [];

    /**
     * Answers to the offer's extra questions, keyed by question uuid. Same shape
     * the frontend application form uses ('y'/'n' for yes-no, free text/number
     * otherwise). Delegated questions are left blank here.
     *
     * @var array<string, string>
     */
    #[LiveProp(writable: true)]
    public array $extra = [];

    /**
     * Which extra questions the team hands back to the participant, keyed by
     * question uuid → bool. A delegated required question keeps the registration
     * pending until the participant answers it via the confirmation link.
     *
     * @var array<string, bool>
     */
    #[LiveProp(writable: true)]
    public array $delegatedQuestions = [];

    #[LiveProp(writable: true)]
    public string $overrideEmail = '';

    #[LiveProp(writable: true)]
    public string $overridePhone = '';

    #[LiveProp]
    public ?string $notificationUuid = null;

    public bool $notificationSent = false;

    public ?Attendance $success = null;

    public function __construct(
        private readonly OfferRepositoryInterface $offerRepository,
        private readonly ParticipantRepositoryInterface $participantRepository,
        private readonly AttendanceFacade $attendanceFacade,
        private readonly AttendanceNotificationChecker $notificationChecker,
        private readonly ConsentManager $consentManager,
    ) {
    }

    #[ExposeInTemplate]
    public function isNotifiable(): bool
    {
        return $this->notificationChecker->shouldNotify($this->status, true);
    }

    #[ExposeInTemplate]
    public function isParticipantNotifiable(): bool
    {
        if (!$this->participant instanceof ParticipantInterface) {
            return true;
        }

        return (bool) ($this->participant->getEmail() || $this->participant->getMobile());
    }

    /**
     * The outstanding consents the selected offer/season requires for the selected
     * participant. Already-signed ones (e.g. an account-level consent the family
     * gave before) drop out, so the list only shows what still needs a decision.
     *
     * @return ConsentState[]
     */
    #[ExposeInTemplate]
    public function consentStates(): array
    {
        if (!$this->offer instanceof OfferInterface || !$this->participant instanceof ParticipantInterface) {
            return [];
        }

        return array_values(array_filter(
            $this->consentManager->forOfferAndParticipants($this->offer, [$this->participant]),
            static fn (ConsentState $state): bool => $state->isOutstanding(),
        ));
    }

    /**
     * The offer's extra application questions in a fully-populated shape: uuid,
     * question, type, required. The entity only normalises these on save, so a
     * legacy row read straight from the database can be missing keys (notably
     * `required`, added later) — normalise here so the template can rely on all
     * four. Legacy rows without `required` default to optional.
     *
     * @return list<array{uuid: string, question: string, type: string, required: bool}>
     */
    #[ExposeInTemplate]
    public function questions(): array
    {
        if (!$this->offer instanceof OfferInterface) {
            return [];
        }

        $questions = [];
        foreach ($this->offer->getApplicationExtra() ?? [] as $q) {
            if ('' === trim((string) ($q['question'] ?? ''))) {
                continue;
            }

            $questions[] = [
                'uuid' => (string) ($q['uuid'] ?? ''),
                'question' => (string) $q['question'],
                'type' => \in_array($q['type'] ?? null, ['yn', 'int', 'txt'], true) ? $q['type'] : 'txt',
                'required' => (bool) ($q['required'] ?? false),
            ];
        }

        return $questions;
    }

    /**
     * Compulsory consents the team left open — the participant must sign them via
     * the confirmation link, so the registration is not complete on its own.
     */
    #[ExposeInTemplate]
    public function pendingConsents(): int
    {
        $count = 0;
        foreach ($this->consentStates() as $state) {
            $id = (string) $state->getConsentForm()?->getId();
            if ($state->isCompulsory() && 'grant' !== ($this->consentDecisions[$id] ?? 'open')) {
                ++$count;
            }
        }

        return $count;
    }

    /**
     * Required questions delegated back to the participant.
     */
    #[ExposeInTemplate]
    public function pendingQuestions(): int
    {
        $count = 0;
        foreach ($this->questions() as $q) {
            if (($q['required'] ?? false) && ($this->delegatedQuestions[$q['uuid']] ?? false)) {
                ++$count;
            }
        }

        return $count;
    }

    /**
     * Whether the registration cannot be completed by the team alone: an open
     * compulsory consent or a delegated required question is still outstanding, so
     * the participant has to finish it via the confirmation link.
     */
    #[ExposeInTemplate]
    public function isPending(): bool
    {
        return $this->pendingConsents() + $this->pendingQuestions() > 0;
    }

    /**
     * Whether we can reach the participant with the confirmation link — their own
     * or their account's e-mail/mobile, or an address the team entered here.
     */
    #[ExposeInTemplate]
    public function hasContact(): bool
    {
        if ('' !== trim($this->overrideEmail) || '' !== trim($this->overridePhone)) {
            return true;
        }

        if (!$this->participant instanceof ParticipantInterface) {
            return false;
        }

        return (bool) ($this->participant->getEmail() || $this->participant->getMobile());
    }

    public function updatedStatus(): void
    {
        if (!$this->isNotifiable()) {
            $this->notify = false;
        }
    }

    public function getOffers(): array
    {
        if (\strlen($this->offerSearch) < 2) {
            return [];
        }

        $qb = $this->offerRepository->createQueryBuilder('o')
            ->innerJoin('o.edition', 'e')
            ->leftJoin('o.dates', 'd');

        $qb->andWhere($qb->expr()->orX(
            $qb->expr()->like('o.name', ':query'),
            $qb->expr()->eq('o.id', ':id'),
        ))
            ->andWhere('e.archived = :archived')
            ->setParameter('archived', false)
            ->setParameter('query', '%'.$this->offerSearch.'%')
            ->setParameter('id', $this->offerSearch)
            ->addSelect('MIN(d.begin) AS HIDDEN minBegin')
            ->groupBy('o.id')
            ->orderBy('minBegin', 'DESC')
            ->setMaxResults(20);

        if (!$this->isGranted('ROLE_ADMIN')) {
            $qb->innerJoin('o.hosts', 'h')
                ->innerJoin('h.memberAssociations', 'ma')
                ->andWhere('ma.user = :user')
                ->setParameter('user', $this->getUser());
        }

        return $qb->getQuery()->getResult();
    }

    public function getParticipants(): array
    {
        if (\strlen($this->participantSearch) < 2) {
            return [];
        }

        $qb = $this->participantRepository->createQueryBuilder('p');

        $qb->andWhere($qb->expr()->orX(
            $qb->expr()->like('p.firstname', ':query'),
            $qb->expr()->like('p.lastname', ':query'),
        ))
            ->setParameter('query', '%'.$this->participantSearch.'%')
            ->setMaxResults(10);

        if (!$this->isGranted('ROLE_ADMIN')) {
            $qb->innerJoin('p.attendances', 'a')
                ->innerJoin('a.offer', 'o')
                ->innerJoin('o.hosts', 'h')
                ->innerJoin('h.memberAssociations', 'ma')
                ->andWhere('ma.user = :user')
                ->setParameter('user', $this->getUser())
                ->groupBy('p.id');
        }

        return $qb->getQuery()->getResult();
    }

    #[LiveAction]
    public function selectOffer(#[LiveArg('id')] int $id): void
    {
        $offer = $this->offerRepository->find($id);
        if (!$offer instanceof OfferInterface) {
            return;
        }

        $this->denyAccessUnlessGranted('participants.add', $offer);

        $this->offer = $offer;
        $this->offerSearch = '';
        $this->preview();
    }

    #[LiveAction]
    public function selectParticipant(#[LiveArg('id')] int $id): void
    {
        $participant = $this->participantRepository->find($id);
        if (!$participant instanceof ParticipantInterface) {
            return;
        }

        $this->participant = $participant;
        $this->participantSearch = '';

        $this->preview();
    }

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

    /**
     * Toggle whether a question is answered by the participant via the confirmation
     * link. Driven by an explicit action rather than a `data-model` checkbox: a
     * value-less checkbox bound to a nested array path (delegatedQuestions.<uuid>)
     * does not reliably flip a boolean in Live, so the row would not re-render to
     * swap the answer control for the "wird vom Teilnehmer beantwortet" note.
     */
    #[LiveAction]
    public function toggleDelegate(#[LiveArg] string $uuid): void
    {
        $this->delegatedQuestions[$uuid] = !($this->delegatedQuestions[$uuid] ?? false);
    }

    #[LiveAction]
    public function unselectOffer(): void
    {
        $this->offer = null;
        $this->resetRequirements();
    }

    #[LiveAction]
    public function unselectParticipant(): void
    {
        $this->participant = null;
        $this->resetRequirements();
    }

    #[LiveAction]
    public function preview(): void
    {
        $this->success = null;

        if (!$this->offer || !$this->participant) {
            return;
        }

        $preview = $this->attendanceFacade->preview($this->offer, $this->participant);
        $this->status = $preview->getStatus();

        $this->seedRequirements();
    }

    #[LiveAction]
    public function submit(EntityManagerInterface $entityManager, UrlGeneratorInterface $urlGenerator, MessageBusInterface $eventBus, #[LiveArg] string $redirect = 'new'): ?Response
    {
        // A pending registration sends the confirmation link instead of the status
        // notification; "benachrichtigen" is implied and its checkbox is not
        // rendered while pending, so neutralise the notify prop before validation
        // (otherwise a stale notify=true + commit=false would trip its constraint).
        if ($this->isPending()) {
            $this->notify = false;
        }

        $this->validate();

        if (!$this->offer instanceof OfferInterface || !$this->participant instanceof ParticipantInterface) {
            throw $this->createAccessDeniedException();
        }

        $this->denyAccessUnlessGranted('participants.add', $this->offer);

        $isPending = $this->isPending();
        $consentOutstanding = $this->pendingConsents() > 0;

        // A pending registration cannot be completed by the team: it has to send
        // the confirmation link, so a reachable contact is required. Re-render with
        // the "Kontakt fehlt" banner instead of creating a dead-end attendance.
        if ($isPending && !$this->hasContact()) {
            return null;
        }

        // Apply the contact override to the participant so the link (and any later
        // status mail) reaches the entered address. setOwnEmail/-Mobile only touch
        // the participant's own field; the account e-mail stays as a fallback. The
        // setters live on the base entity (not the read-only interface).
        if ($this->participant instanceof BaseParticipant) {
            if ('' !== $overrideEmail = trim($this->overrideEmail)) {
                $this->participant->setOwnEmail($overrideEmail);
            }
            if ('' !== $overridePhone = trim($this->overridePhone)) {
                $this->participant->setOwnMobile($overridePhone);
            }
        }

        // The link is intrinsic to a pending registration (the design forces
        // "benachrichtigen" on and relabels the button "Anlegen & Link senden").
        // A non-pending registration only mails the status notification when the
        // team opted in and the status is notifiable.
        $sendStatusMail = $this->notify && !$isPending;
        $sendLink = $isPending; // contact already ensured above

        $this->notificationUuid = (string) Uuid::v7();
        if ($sendStatusMail || $sendLink) {
            $masterLog = new MessengerLog(static::class, uuid: Uuid::fromString($this->notificationUuid));
            $entityManager->persist($masterLog);
        }

        if ($this->isNewParticipant && $this->participant instanceof ParticipantInterface) {
            $entityManager->persist($this->participant);
        }

        $this->success = $this->attendanceFacade->create(
            offer: $this->offer,
            participant: $this->participant,
            status: $this->status,
            notify: $sendStatusMail,
            commit: $this->commit,
            consentOutstanding: $consentOutstanding,
            group: Uuid::fromString($this->notificationUuid),
        );
        $this->isNewParticipant = false;

        // Record the consents the team grants in the name of the legal guardian.
        // The still-open ones stay outstanding and travel with the confirmation
        // link (which shows only those, see AttendanceShowController).
        $this->grantConsents();

        // Persist the answers given here; delegated questions are left blank.
        $answers = $this->collectAnswers();
        if ([] !== $answers) {
            $this->success->setExtra($answers);
        }

        $entityManager->flush();

        if ($sendLink) {
            $eventBus->dispatch(
                new Envelope(new AttendanceCompletionRequested((string) $this->success->getUuid()))
                    ->with(new UuidStamp($this->notificationUuid))
            );
        }

        $this->notificationSent = $sendStatusMail || $sendLink;

        if ('participant' === $redirect) {
            $this->addSentFlash($urlGenerator);

            return $this->redirectToRoute('admin_participants_attendances', ['uuid' => $this->success->getParticipant()->getUuid()->toBase58()]);
        }

        if ('offer' === $redirect) {
            $this->addSentFlash($urlGenerator);

            return $this->redirectToRoute('admin_offers_participants', ['uuid' => $this->success->getOffer()->getUuid()->toBase58()]);
        }

        // Reset simple fields for a fresh entry while keeping the current offer
        // selection; clear the participant and their per-participant requirements.
        $this->status = AttendanceStatus::WAITING;
        $this->notify = false;
        $this->commit = true;
        $this->participant = null;
        $this->resetRequirements();

        return null;
    }

    /**
     * Seed the per-participant requirement props with their defaults once an offer
     * and participant are both selected: consents default to "leave open", extra
     * answers to empty.
     */
    private function seedRequirements(): void
    {
        foreach ($this->consentStates() as $state) {
            $id = (string) $state->getConsentForm()?->getId();
            $this->consentDecisions[$id] ??= 'open';
        }

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

    private function resetRequirements(): void
    {
        $this->consentDecisions = [];
        $this->extra = [];
        $this->delegatedQuestions = [];
        $this->overrideEmail = '';
        $this->overridePhone = '';
    }

    private function grantConsents(): void
    {
        if (!$this->participant instanceof ParticipantInterface) {
            return;
        }

        $adminUser = $this->getUser();
        $signedBy = $adminUser instanceof User ? $adminUser->getName() : '';
        $account = $this->participant->getUser();

        foreach ($this->consentStates() as $state) {
            $form = $state->getConsentForm();
            if (null === $form || 'grant' !== ($this->consentDecisions[(string) $form->getId()] ?? 'open')) {
                continue;
            }

            // Mirror the frontend's binding rules so a granted consent is found again
            // when the participant opens the confirmation page:
            //  - account holder: per-participant forms bind to the participant,
            //    account-level ones to the account (null participant);
            //  - accountless (team-registered): bind everything to the participant
            //    with no user (like the anonymous flow), so it rebinds to the
            //    account on a later registration.
            $subject = (null === $account || $form->isPerParticipant()) ? $this->participant : null;
            $signState = $this->consentManager->sign($form, $account, $subject);
            if ('' !== $signedBy) {
                $signState->getSignature()?->setSignedBy($signedBy);
            }
        }
    }

    /**
     * @return array<string, string>
     */
    private function collectAnswers(): array
    {
        $answers = [];
        foreach ($this->questions() as $q) {
            $uuid = $q['uuid'];
            if ($this->delegatedQuestions[$uuid] ?? false) {
                continue;
            }

            $value = trim((string) ($this->extra[$uuid] ?? ''));
            if ('' !== $value) {
                $answers[$uuid] = $value;
            }
        }

        return $answers;
    }

    private function addSentFlash(UrlGeneratorInterface $urlGenerator): void
    {
        if (!$this->notificationSent) {
            return;
        }

        $this->addFlash('mail_sent', [
            'type' => 'mail_sent',
            'title' => 'Teilnehmer:in wurde benachrichtigt',
            'message' => $this->participant->getName().' wurde per E-Mail benachrichtigt',
            'dismissAfter' => 0,
            'primaryActionLink' => $urlGenerator->generate('admin_outbox', ['group' => $this->notificationUuid]),
            'primaryActionTitle' => 'Nachricht anzeigen',
        ]);
    }
}
