<?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\Tests\Service;

use Ferienpass\AdminBundle\Service\MailingRecipients;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Host;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Fixtures\Doctrine\OrmTestCase;
use Ferienpass\CoreBundle\Repository\ParticipantRepositoryInterface;
use Symfony\Component\Yaml\Yaml;

/**
 * A Veranstalterin mailed "the participants" of her offer and every applicant
 * received it — the waiting list and the children who had been turned down
 * included. The status filter was optional: with no status selected the query
 * simply dropped the condition, so the widest possible audience was also the
 * default one, and nothing in the UI said so.
 *
 * These tests pin the two halves of the fix: an empty selection reaches nobody,
 * and no attendance status can go missing from the filter unnoticed.
 */
class MailingRecipientsTest extends OrmTestCase
{
    private const ROOT = __DIR__.'/../../..';

    public function testResolvesOnlyTheSelectedStatus(): void
    {
        $offer = $this->createOffer();
        $this->attend($offer, 'zugesagt@example.org', AttendanceStatus::CONFIRMED);
        $this->attend($offer, 'warteliste@example.org', AttendanceStatus::WAITLISTED);
        $this->attend($offer, 'abgelehnt@example.org', AttendanceStatus::REJECTED);
        $this->attend($offer, 'leer-ausgegangen@example.org', AttendanceStatus::UNFULFILLED);
        $this->em->flush();

        self::assertSame(
            ['zugesagt@example.org'],
            $this->emails([AttendanceStatus::CONFIRMED], [$offer->getId()]),
        );
    }

    /**
     * The regression itself: no status selected used to mean "every status".
     */
    public function testEmptyStatusSelectionReachesNobody(): void
    {
        $offer = $this->createOffer();
        $this->attend($offer, 'zugesagt@example.org', AttendanceStatus::CONFIRMED);
        $this->attend($offer, 'abgelehnt@example.org', AttendanceStatus::REJECTED);
        $this->em->flush();

        self::assertSame([], $this->emails([], [$offer->getId()]));
    }

    /**
     * A hand-edited URL or a stale bookmark must not widen the audience either.
     */
    public function testUnknownStatusIsDroppedRatherThanIgnored(): void
    {
        $offer = $this->createOffer();
        $this->attend($offer, 'zugesagt@example.org', AttendanceStatus::CONFIRMED);
        $this->attend($offer, 'abgelehnt@example.org', AttendanceStatus::REJECTED);
        $this->em->flush();

        self::assertSame([], $this->emails(['error'], [$offer->getId()]));
        self::assertSame(
            ['zugesagt@example.org'],
            $this->emails(['error', AttendanceStatus::CONFIRMED], [$offer->getId()]),
        );
    }

    public function testSelectingSeveralStatusesReachesAllOfThem(): void
    {
        $offer = $this->createOffer();
        $this->attend($offer, 'zugesagt@example.org', AttendanceStatus::CONFIRMED);
        $this->attend($offer, 'warteliste@example.org', AttendanceStatus::WAITLISTED);
        $this->attend($offer, 'abgemeldet@example.org', AttendanceStatus::WITHDRAWN);
        $this->em->flush();

        $emails = $this->emails([AttendanceStatus::CONFIRMED, AttendanceStatus::WAITLISTED], [$offer->getId()]);
        sort($emails);

        self::assertSame(['warteliste@example.org', 'zugesagt@example.org'], $emails);
    }

    public function testOtherOffersAreNotReached(): void
    {
        $offer = $this->createOffer();
        $other = $this->createOffer();
        $this->attend($offer, 'ponyfuehrerschein@example.org', AttendanceStatus::CONFIRMED);
        $this->attend($other, 'anderes-angebot@example.org', AttendanceStatus::CONFIRMED);
        $this->em->flush();

        self::assertSame(
            ['ponyfuehrerschein@example.org'],
            $this->emails([AttendanceStatus::CONFIRMED], [$offer->getId()]),
        );
    }

    /**
     * Veranstaltende only ever reach the participants of their own offers, and the
     * status filter still applies on top of that.
     */
    public function testHostRestrictionAppliesOnTopOfTheStatus(): void
    {
        $host = new Host();
        $host->setName('Reiterhof');
        $host->setAlias('reiterhof');
        $this->em->persist($host);

        $mine = $this->createOffer();
        $mine->addHost($host);
        $theirs = $this->createOffer();

        $this->attend($mine, 'meins-zugesagt@example.org', AttendanceStatus::CONFIRMED);
        $this->attend($mine, 'meins-warteliste@example.org', AttendanceStatus::WAITLISTED);
        $this->attend($theirs, 'fremd@example.org', AttendanceStatus::CONFIRMED);
        $this->em->flush();

        self::assertSame(
            ['meins-zugesagt@example.org'],
            $this->emails([AttendanceStatus::CONFIRMED], hosts: [$host]),
        );

        // No host at all (a principal that is not a User) reaches nobody.
        self::assertSame([], $this->emails([AttendanceStatus::CONFIRMED], hosts: []));
    }

    public function testEveryAttendanceStatusIsOfferedByTheFilter(): void
    {
        foreach ($this->attendanceStatuses() as $name => $status) {
            self::assertContains(
                $status,
                MailingRecipients::STATUSES,
                \sprintf('AttendanceStatus::%s is not offered by the Rundmail filter, so that group can neither be reached nor be excluded on purpose. Add it to MailingRecipients::STATUSES.', $name),
            );
        }
    }

    public function testTheFilterOffersNoStatusThatDoesNotExist(): void
    {
        $statuses = array_values($this->attendanceStatuses());

        foreach (MailingRecipients::STATUSES as $status) {
            self::assertContains(
                $status,
                $statuses,
                \sprintf('The Rundmail filter offers "%s", which is not an AttendanceStatus — the checkbox matches no attendance at all.', $status),
            );
        }
    }

    public function testEveryOfferedStatusHasAGermanLabel(): void
    {
        $labels = Yaml::parseFile(self::ROOT.'/admin-bundle/translations/admin+intl-icu.de.yml')['mailing']['attendanceStatus'] ?? [];

        foreach (MailingRecipients::STATUSES as $status) {
            self::assertArrayHasKey(
                $status,
                $labels,
                \sprintf('The Rundmail filter offers "%s" but has no label for it, so the checkbox shows the raw translation key. Add it under mailing.attendanceStatus.', $status),
            );
        }

        foreach (array_keys($labels) as $status) {
            self::assertContains(
                $status,
                MailingRecipients::STATUSES,
                \sprintf('mailing.attendanceStatus labels "%s", which the filter does not offer.', $status),
            );
        }
    }

    /**
     * The initial selection covers both groups a Rundmail about an offer normally
     * addresses — and because "nicht berücksichtigt" has no seat, it must trip the
     * warning rather than pass unremarked.
     */
    public function testTheDefaultSelectionIsSpelledOutAsHavingApplicantsWithoutASeat(): void
    {
        self::assertSame(
            [AttendanceStatus::CONFIRMED, AttendanceStatus::UNFULFILLED],
            MailingRecipients::DEFAULT_STATUSES,
        );

        self::assertSame(
            [AttendanceStatus::UNFULFILLED],
            MailingRecipients::withoutSeat(MailingRecipients::DEFAULT_STATUSES),
        );
    }

    public function testTheDefaultSelectionLeavesOutTheWaitingListAndTheRejected(): void
    {
        $offer = $this->createOffer();
        $this->attend($offer, 'zugesagt@example.org', AttendanceStatus::CONFIRMED);
        $this->attend($offer, 'nicht-beruecksichtigt@example.org', AttendanceStatus::UNFULFILLED);
        $this->attend($offer, 'warteliste@example.org', AttendanceStatus::WAITLISTED);
        $this->attend($offer, 'abgelehnt@example.org', AttendanceStatus::REJECTED);
        $this->attend($offer, 'abgemeldet@example.org', AttendanceStatus::WITHDRAWN);
        $this->em->flush();

        $emails = $this->emails(MailingRecipients::DEFAULT_STATUSES, [$offer->getId()]);
        sort($emails);

        self::assertSame(['nicht-beruecksichtigt@example.org', 'zugesagt@example.org'], $emails);
    }

    public function testWithoutSeatNamesTheStatusesThatHaveNoPlace(): void
    {
        self::assertSame([], MailingRecipients::withoutSeat([AttendanceStatus::CONFIRMED, AttendanceStatus::PARTICIPATED]));
        self::assertSame(
            [AttendanceStatus::WAITLISTED, AttendanceStatus::REJECTED],
            MailingRecipients::withoutSeat([AttendanceStatus::REJECTED, AttendanceStatus::CONFIRMED, AttendanceStatus::WAITLISTED]),
        );
        // Order follows the filter, not the input, and unknown values are dropped.
        self::assertSame([], MailingRecipients::withoutSeat(['error']));
    }

    /**
     * @param list<string>     $statuses
     * @param list<int|string> $offerIds
     * @param list<Host>|null  $hosts
     *
     * @return list<string>
     */
    private function emails(array $statuses, array $offerIds = [], ?array $hosts = null): array
    {
        /** @var ParticipantRepositoryInterface $repository */
        $repository = $this->em->getRepository(Participant::class);

        $participants = new MailingRecipients($repository)->resolve($statuses, $offerIds, hosts: $hosts);

        return array_map(static fn ($participant) => $participant->getEmail(), $participants);
    }

    private function attend(OfferInterface $offer, string $email, string $status): Attendance
    {
        $participant = $this->createParticipant(email: $email);

        $attendance = new Attendance($offer, $participant);
        $attendance->setStatus($status);

        $this->em->persist($attendance);

        return $attendance;
    }

    /**
     * @return array<string, string>
     */
    private function attendanceStatuses(): array
    {
        return new \ReflectionClass(AttendanceStatus::class)->getConstants();
    }
}
