<?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 Ferienpass\AdminBundle\Service\MailingRecipients;
use Ferienpass\CoreBundle\ConsentManager\ConsentManager;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Entity\MessengerLog;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Message\DispatchMailing;
use Ferienpass\CoreBundle\Message\MailingCopyMode;
use Ferienpass\CoreBundle\Messenger\UuidStamp;
use Ferienpass\CoreBundle\Payments\Provider\PaymentProviderInterface;
use Ferienpass\CoreBundle\Repository\EditionRepository;
use Ferienpass\CoreBundle\Repository\HostRepository;
use Ferienpass\CoreBundle\Repository\MessengerLogRepository;
use Ferienpass\CoreBundle\Repository\OfferRepositoryInterface;
use Ferienpass\CoreBundle\Repository\ParticipantRepositoryInterface;
use Ferienpass\CoreBundle\Repository\SentEmailRepository;
use Ferienpass\CoreBundle\Repository\UserRepository;
use Ferienpass\CoreBundle\Twig\SandboxedTextRenderer;
use League\CommonMark\CommonMarkConverter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Validator\ValidatorInterface;
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\ComponentToolsTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\Metadata\UrlMapping;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
use Twig\Environment;
use Twig\Error\Error;

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

    /**
     * Neutral sample recipient used to resolve the {{ recipientEmail }} token in
     * the previews and in the (non-personalized) copy-to-self.
     */
    private const EXAMPLE_RECIPIENT = 'empfaenger@beispiel.de';

    #[LiveProp(writable: true, url: new UrlMapping(as: 'gruppe'))]
    public ?string $group = null;

    #[LiveProp]
    public ?Edition $edition = null;

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

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

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

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

    /**
     * How the sender is copied in on the mailing — the raw dropdown value
     * (empty = none), resolved to a {@see MailingCopyMode} at dispatch.
     */
    #[LiveProp(writable: true)]
    public string $selfCopyMode = '';

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

    #[LiveProp(writable: true, url: new UrlMapping(as: 'saison'))]
    public array $selectedEditions = [];

    #[LiveProp(writable: true, onUpdated: 'onOffersUpdated', url: new UrlMapping(as: 'angebote'))]
    public array $selectedOffers = [];

    #[LiveProp(writable: true, url: new UrlMapping(as: 'veranstaltende'))]
    public array $selectedHosts = [];

    #[LiveProp(writable: true)]
    #[Assert\NotBlank()]
    public string $emailSubject = '';

    #[LiveProp(writable: true)]
    #[Assert\NotBlank()]
    public string $emailText = '';

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

    /**
     * Which attendance statuses the mailing reaches. Seeded with
     * {@see MailingRecipients::DEFAULT_STATUSES} rather than left open: an empty
     * selection resolves to nobody rather than to everybody, so the waiting list
     * can no longer ride along unnoticed.
     *
     * URL-mapped so a link can preselect it — the participant list links here with
     * `confirmed` set, which is the whole point of that entry: a Veranstalter
     * calling out names must not reach the waiting list by accident.
     */
    #[LiveProp(writable: true, url: new UrlMapping(as: 'status'))]
    public array $attendanceStatus = MailingRecipients::DEFAULT_STATUSES;

    #[LiveProp]
    public bool $showConfirm = false;

    /**
     * Wizard step: 1 = compose (recipients + message), 2 = review/preview.
     * Sending redirects to the dispatch page (the implicit step 3).
     */
    #[LiveProp]
    public int $step = 1;

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

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

    #[LiveProp]
    public array $removedEmails = [];

    public ?TranslatableMessage $confirm = null;

    public function __construct(private readonly EditionRepository $editions, private readonly ConsentManager $consentManager, private readonly ParticipantRepositoryInterface $participants, private readonly MailingRecipients $mailingRecipients, private readonly UserRepository $users, private readonly HostRepository $hosts, private readonly OfferRepositoryInterface $offers, private readonly Environment $twig, private readonly RequestStack $requestStack, private readonly NormalizerInterface $normalizer, private readonly MessageBusInterface $commandBus, private readonly SluggerInterface $slugger, private readonly PaymentProviderInterface $paymentProvider, private readonly SentEmailRepository $sentEmails, private readonly SandboxedTextRenderer $sandboxedTextRenderer, #[Autowire(service: 'mailer.envelope_listener')] private readonly object $envelopeListener, #[Autowire(param: 'ferienpass.reply_address')] private readonly ?string $replyAddress, #[Autowire('%env(ADMIN_EMAIL)%')] private readonly ?string $fallbackFromAddress = null, private readonly ?MessengerLogRepository $messengerLogs = null)
    {
    }

    public function mount(?Edition $edition = null): void
    {
        $this->edition = $edition;
        if ($this->edition) {
            $this->selectedEditions = [$this->edition->getId()];
            $this->hostsWithOffer = true;
        }

        if (!$this->isGranted('ROLE_ADMIN')) {
            $this->group = 'veranstaltende';
        }

        if ('veranstaltende' === $this->group) {
            $this->hostsWithOffer = true;
        }
    }

    #[LiveListener('group')]
    public function changeGroup(#[LiveArg] string $group): void
    {
        $this->group = $group;

        if ('veranstaltende' === $this->group) {
            $this->hostsWithOffer = true;
        }
    }

    public function onOffersUpdated($previous): void
    {
        if ($this->isGranted('ROLE_ADMIN')) {
            return;
        }

        $this->selectedOffers = $previous;
    }

    #[ExposeInTemplate]
    public function countAllHosts()
    {
        $qb = $this->users->createQueryBuilder('u')
            ->innerJoin('u.hostAssociations', 'ha')
            ->innerJoin('ha.host', 'host')
        ;

        if ($this->hostsNotDisabled) {
            $qb->andWhere('u.disable = :disabled')->setParameter('disabled', 0);
        }

        if ($this->selectedEditions) {
            $qb->innerJoin('host.offers', 'offers');
            $qb->leftJoin('offers.edition', 'edition');
            $qb->andWhere('edition IN (:editions)')->setParameter('editions', $this->selectedEditions);
        }

        $qb->select('COUNT(DISTINCT u.email)');

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

    #[ExposeInTemplate]
    public function countAllParticipants()
    {
        $qb = $this->participants->createQueryBuilder('participant')
            ->innerJoin('participant.attendances', 'attendance')
            ->innerJoin('attendance.offer', 'offer')
            ->leftJoin('offer.edition', 'edition')
            ->leftJoin('participant.user', 'u')
        ;

        if ($this->selectedEditions) {
            $qb->andWhere('edition IN (:editions)')->setParameter('editions', $this->selectedEditions);
        }

        $qb->select('COUNT(DISTINCT COALESCE(participant.email, u.email))');

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

    #[ExposeInTemplate]
    public function context(): array
    {
        $context = [];

        $context['baseUrl'] = $this->requestStack->getCurrentRequest()?->getSchemeAndHttpHost().$this->requestStack->getCurrentRequest()?->getBaseUrl();

        if (1 === \count($this->selectedEditions)) {
            $context['edition'] = $this->editions->find(array_values($this->selectedEditions)[0]);
        }
        if (1 === \count($this->selectedOffers)) {
            $context['offer'] = $this->offers->find(array_values($this->selectedOffers)[0]);
        }
        if (1 === \count($this->selectedHosts)) {
            $context['host'] = $this->hosts->find(array_values($this->selectedHosts)[0]);
        }

        return $context;
    }

    /**
     * Editions in scope of the current selection (a selected season, or the
     * edition of a selected offer) whose host confidentiality declaration is
     * active but not yet signed by the current user. While this is non-empty
     * the mailing is gated — same rule as the participant-list gate
     * ({@see \Ferienpass\AdminBundle\Controller\Page\OfferParticipantsController}),
     * but scoped to the actual selection because this tool spans all seasons
     * and is reachable without an edition context.
     *
     * @return list<Edition>
     */
    #[ExposeInTemplate]
    public function unconsentedEditions(): array
    {
        $user = $this->getUser();
        if (!$user instanceof User) {
            return [];
        }

        $candidates = [];
        if ([] !== $this->selectedEditions) {
            foreach ($this->editions->findBy(['id' => $this->selectedEditions]) as $edition) {
                $candidates[$edition->getId()] = $edition;
            }
        }
        if ([] !== $this->selectedOffers) {
            foreach ($this->offers->findBy(['id' => $this->selectedOffers]) as $offer) {
                $edition = $offer->getEdition();
                if ($edition instanceof Edition) {
                    $candidates[$edition->getId()] = $edition;
                }
            }
        }

        return array_values(array_filter(
            $candidates,
            fn (Edition $edition): bool => $edition->isHostAgreementActive()
                && !$this->consentManager->findState($edition->getHostConsentForm(), $user)->isSigned(),
        ));
    }

    #[ExposeInTemplate]
    public function editionOptions()
    {
        return $this->editions->findBy(['archived' => 0]);
    }

    #[ExposeInTemplate]
    public function offerOptions()
    {
        $qb = $this->offers->createQueryBuilder('o')
            ->where('o.id IN (:ids)')
            ->setParameter('ids', $this->selectedOffers);

        if (!$this->isGranted('ROLE_ADMIN')) {
            $user = $this->getUser();
            $qb->innerJoin('o.hosts', 'hosts')->andWhere('hosts IN (:hosts)')->setParameter('hosts', $user instanceof User ? $user->getHosts() : []);
        }

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

    #[ExposeInTemplate]
    public function hostOptions()
    {
        $qb = $this->hosts->createQueryBuilder('h')
            ->where('h.id IN (:ids)')
            ->setParameter('ids', $this->selectedHosts)
        ;

        if (!$this->isGranted('ROLE_ADMIN')) {
            $user = $this->getUser();
            $qb->andWhere('h.id IN (:hosts)')->setParameter('hosts', $user instanceof User ? $user->getHosts() : []);
        }

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

    #[ExposeInTemplate]
    public function recipients(): array
    {
        $return = [];

        if ('veranstaltende' === $this->group) {
            foreach ($this->queryHostAccounts() as $item) {
                $return[$item->getEmail()][] = $item;
            }
        } elseif ('teilnehmende' === $this->group) {
            foreach ($this->queryParticipants() as $item) {
                $return[$item->getEmail()][] = $item;
            }
        }

        return array_diff_key($return, array_flip($this->removedEmails));
    }

    /**
     * The attendance statuses the filter offers, in display order.
     *
     * @return list<string>
     */
    #[ExposeInTemplate]
    public function statusOptions(): array
    {
        return MailingRecipients::STATUSES;
    }

    /**
     * The selected statuses that do not mean "has a seat" — non-empty means this
     * mailing also reaches the waiting list, the turned-down or the withdrawn,
     * which is worth spelling out before it goes out.
     *
     * @return list<string>
     */
    #[ExposeInTemplate]
    public function statusesWithoutSeat(): array
    {
        return MailingRecipients::withoutSeat($this->attendanceStatus);
    }

    /**
     * The narrowest sensible audience — the seat holders alone. Named after what
     * the button promises ("Nur zugesagte"), so it stays independent of whatever
     * the initial selection happens to be.
     */
    #[LiveAction]
    public function selectConfirmedOnly(): void
    {
        $this->attendanceStatus = [AttendanceStatus::CONFIRMED];
    }

    #[LiveAction]
    public function selectAllStatuses(): void
    {
        $this->attendanceStatus = MailingRecipients::STATUSES;
    }

    #[ExposeInTemplate]
    public function preview(): string|false
    {
        try {
            return $this->sandboxedTextRenderer->render($this->parsedEmailText(), $this->previewContext());
        } catch (Error) {
            return false;
        }
    }

    #[ExposeInTemplate]
    public function subjectPreview(): string
    {
        if ('' === trim($this->emailSubject)) {
            return '';
        }

        try {
            return trim(strip_tags($this->sandboxedTextRenderer->render($this->emailSubject, $this->previewContext())));
        } catch (Error) {
            return $this->emailSubject;
        }
    }

    /**
     * The sender address the mailing is actually sent from, i.e. the
     * configured Symfony mailer envelope sender (`framework.mailer.envelope.sender`,
     * overridden per tenant). Falls back to the ADMIN_EMAIL env value.
     */
    #[ExposeInTemplate]
    public function fromAddress(): ?string
    {
        try {
            $sender = new \ReflectionProperty($this->envelopeListener, 'sender')->getValue($this->envelopeListener);
            if ($sender instanceof Address) {
                return $sender->getAddress();
            }
        } catch (\Throwable) {
            // Mailer internals changed — fall back to the configured env value.
        }

        return $this->fallbackFromAddress ?: null;
    }

    /**
     * The Reply-To applied to the mailing, read from the `ferienpass.reply_address`
     * configuration (set per tenant). Null when not configured.
     */
    #[ExposeInTemplate]
    public function replyToAddress(): ?string
    {
        return $this->replyAddress ?: null;
    }

    #[ExposeInTemplate]
    public function canCreatePaymentLinks(): bool
    {
        return $this->paymentProvider->isConfigured();
    }

    /**
     * The current user's own email — the target offered for the "send a copy to
     * myself" option. Null for principals that are not a {@see User}.
     */
    #[ExposeInTemplate]
    public function currentUserEmail(): ?string
    {
        $user = $this->getUser();

        return $user instanceof User ? $user->getEmail() : null;
    }

    #[ExposeInTemplate]
    public function availableTokens(): array
    {
        $availableTokens = [];

        foreach ($this->context() as $token => $object) {
            switch ($token) {
                case 'baseUrl':
                    $availableTokens[$token] = $object;
                    break;
                case 'edition':
                case 'offer':
                case 'host':
                    $tokens = $this->normalizer->normalize($object, context: ['groups' => 'notification']);
                    foreach (array_keys((array) $tokens) as $property) {
                        $availableTokens["$token.$property"] = $this->container->get('twig')->createTemplate(\sprintf('{{ %s }}', "$token.$property"))->render([$token => $tokens]);
                    }
                    break;
            }
        }

        return $availableTokens + ['recipientEmail' => self::EXAMPLE_RECIPIENT];
    }

    /**
     * Record the current user's signature for a gating edition's confidentiality
     * declaration, then re-render (which lifts the gate for that season).
     */
    #[LiveAction]
    public function signConsent(#[LiveArg] int $edition): void
    {
        $user = $this->getUser();
        if (!$user instanceof User) {
            return;
        }

        // Only sign an edition that is actually gating this mailing — never an
        // arbitrary id posted by the client.
        foreach ($this->unconsentedEditions() as $candidate) {
            if ($candidate->getId() === $edition && null !== $form = $candidate->getHostConsentForm()) {
                $this->consentManager->sign($form, $user);

                return;
            }
        }
    }

    /**
     * Step 1 → step 2: validate the message, capture the (optional) attachment
     * and advance to the review/preview step.
     */
    #[LiveAction]
    public function toReview(): void
    {
        // Do not advance (nor reveal the recipient list) while the selection
        // still includes a season whose confidentiality declaration is unsigned.
        if ([] !== $this->unconsentedEditions()) {
            return;
        }

        $this->validate();

        $this->step = 2;
    }

    /**
     * Upload the attachment the moment it is selected, so the file is consumed
     * in a single request and stored in {@see $emailAttachment}. This avoids
     * re-sending a file input on "Vorschau ansehen" (the browser clears file
     * inputs on every live re-render, so it could not survive that round-trip).
     *
     * Validation errors are surfaced via a LiveProp ({@see $attachmentError})
     * rather than a flash: live components do not reload the page, so flashes
     * read from the session/cookie would never be delivered here.
     */
    #[LiveAction]
    public function uploadAttachment(Request $request, ValidatorInterface $validator): void
    {
        $this->attachmentError = null;

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

        // This LiveAction bypasses the Symfony form (and therefore its File
        // constraints), so the uploaded attachment MUST be validated explicitly
        // before it is moved to disk. The allow-list mirrors the offer
        // AttachmentManager: PDF only, max 6 MB.
        $violations = $validator->validate($file, new Assert\File(
            maxSize: '6M',
            mimeTypes: ['application/pdf'],
            mimeTypesMessage: 'Erlaubte Dateitypen sind: PDF',
        ));

        if ($violations->count() > 0) {
            $this->attachmentError = (string) $violations->get(0)->getMessage();

            return;
        }

        $this->emailAttachment = $this->upload($file);
    }

    /**
     * Step 2 → step 1: go back to editing.
     */
    #[LiveAction]
    public function back(): void
    {
        $this->step = 1;
    }

    /**
     * Step 2: open the final confirmation modal.
     */
    #[LiveAction]
    public function submit(): void
    {
        if ([] !== $this->unconsentedEditions()) {
            return;
        }

        $this->validate();

        $this->showConfirm = true;
    }

    #[LiveAction]
    public function removeAttachment(): void
    {
        $this->emailAttachment = null;
        $this->attachmentError = null;
    }

    #[LiveListener('sendMailing')]
    public function send(): void
    {
        // Final safety net: never dispatch to recipients of a season whose
        // confidentiality declaration the sender has not signed.
        if ([] !== $this->unconsentedEditions()) {
            return;
        }

        $uuid = Uuid::v7();

        $copyMode = MailingCopyMode::tryFrom($this->selfCopyMode) ?? MailingCopyMode::None;

        $context = $this->context();
        if (MailingCopyMode::Copy === $copyMode) {
            // Render the copy like the preview: the neutral sample recipient.
            // Real recipients override this with their own address downstream.
            $context += ['recipientEmail' => self::EXAMPLE_RECIPIENT];
        }

        $command = new DispatchMailing(
            $this->getUser()?->getUuid()?->toRfc4122(),
            array_keys($this->recipients()),
            $this->emailSubject,
            $this->parsedEmailText(),
            $context,
            array_filter([$this->emailAttachment]),
            withPaymentLink: $this->onlyParticipantsUnacknowledged && $this->addPaymentLink,
            offerIds: $this->selectedOffers,
            editionIds: $this->selectedEditions,
            copyMode: $copyMode,
        );
        $this->commandBus->dispatch(
            new Envelope($command)->with(new UuidStamp($uuid))
        );

        // Move to step 3, which embeds the dispatch tracker (SentMessages) for
        // this mailing instead of redirecting to the standalone /versand page.
        $this->dispatchUuid = $uuid->toBase58();
        $this->step = 3;
        $this->showConfirm = false;
        $this->resetValidation();
    }

    #[ExposeInTemplate]
    public function dispatchLog(): ?MessengerLog
    {
        if (null === $this->dispatchUuid || null === $this->messengerLogs) {
            return null;
        }

        return $this->messengerLogs->findOneBy(['uuid' => Uuid::fromBase58($this->dispatchUuid)]);
    }

    #[LiveAction]
    public function remove(#[LiveArg] string $email): void
    {
        $this->removedEmails[] = $email;
    }

    #[ExposeInTemplate]
    public function recentMailings(): array
    {
        $qb0 = $this->sentEmails->createQueryBuilder('e');

        $qb = $this->sentEmails->createQueryBuilder('email')
            ->innerJoin('email.logEntry', 'logEntry')
            ->innerJoin('logEntry.group', 'groupLog')
            ->where($qb0->expr()->isNotNull('logEntry.group'))
            ->andWhere($qb0->expr()->eq('logEntry.message', ':messageType'))
            ->setParameter('messageType', \Ferienpass\CoreBundle\Message\SendMailing::class);

        if ($this->edition) {
            $qb->leftJoin('groupLog.offers', 'offer')
                ->leftJoin('groupLog.editions', 'edition')
                ->andWhere($qb->expr()->orX(
                    'offer.edition = :edition',
                    'edition = :edition'
                ))
                ->setParameter('edition', $this->edition);
        }

        $list = $qb->groupBy('logEntry.group')
            ->orderBy('logEntry.createdAt', 'DESC')
            ->setMaxResults(10)
            ->getQuery()
            ->getResult();

        if ($this->isGranted('ROLE_ADMIN')) {
            return $list;
        }

        return array_values(array_filter($list, function ($message) {
            if (!$message instanceof \Ferienpass\CoreBundle\Entity\SentMessage) {
                return false;
            }
            $log = $message->getLogEntry();
            if (null === $log) {
                return false;
            }
            $groupLog = $log->getGroup();
            if (null !== $groupLog) {
                foreach ($groupLog->getEditions() as $edition) {
                    if ($this->isGranted('view', $edition)) {
                        return true;
                    }
                }
                foreach ($groupLog->getOffers() as $offer) {
                    $edition = $offer->getEdition();
                    if ($edition && $this->isGranted('view', $edition)) {
                        return true;
                    }
                }
                foreach ($groupLog->getAttendances() as $attendance) {
                    $offer = $attendance->getOffer();
                    $edition = $offer?->getEdition();
                    if ($edition && $this->isGranted('view', $edition)) {
                        return true;
                    }
                }
            }

            return false;
        }));
    }

    /**
     * Context used to render the previews — like {@see context()} but with an
     * example recipient address so the {{ recipientEmail }} token is replaced.
     */
    private function previewContext(): array
    {
        return $this->context() + ['recipientEmail' => self::EXAMPLE_RECIPIENT];
    }

    private function upload(UploadedFile $file): string
    {
        $originalFilename = pathinfo($file->getClientOriginalName(), \PATHINFO_FILENAME);
        $safeFilename = $this->slugger->slug($originalFilename);
        $fileName = $safeFilename.'-'.uniqid().'.'.$file->guessExtension();

        try {
            $file->move(sys_get_temp_dir(), $fileName);
        } catch (FileException) {
            // ... handle exception if something happens during file upload
        }

        return sys_get_temp_dir().'/'.$fileName;
    }

    private function queryHostAccounts()
    {
        $qb = $this->users
            ->createQueryBuilder('u')
            ->innerJoin('u.hostAssociations', 'ha')
            ->innerJoin('ha.host', 'host')
        ;

        if ($this->hostsNotDisabled) {
            $qb->andWhere('u.disable = :disabled')->setParameter('disabled', 0);
        }

        if ($this->hostsWithOffer) {
            $qb->innerJoin('host.offers', 'offers');
            $qb->leftJoin('offers.edition', 'edition');

            if ($this->selectedEditions) {
                $qb->andWhere('edition IN (:editions)')->setParameter('editions', $this->selectedEditions);
            }
        }

        if ($this->selectedHosts) {
            $qb->andWhere('host IN (:hosts)')->setParameter('hosts', $this->selectedHosts);
        }

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

    private function parsedEmailText(): string
    {
        $converter = new CommonMarkConverter([
            'html_input' => 'strip',
            'allow_unsafe_links' => false,
            'renderer' => [
                'soft_break' => "<br />\n",
            ],
        ]);

        return (string) $converter->convert($this->emailText);
    }

    private function queryParticipants()
    {
        $user = $this->getUser();

        return $this->mailingRecipients->resolve(
            $this->attendanceStatus,
            $this->selectedOffers,
            $this->selectedEditions,
            $this->isGranted('ROLE_ADMIN') ? null : ($user instanceof User ? $user->getHosts() : []),
            $this->onlyParticipantsUnacknowledged,
            $this->onlyParticipantsUnpaid,
        );
    }
}
