<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CoreBundle\Tests\Cron;

use Contao\CoreBundle\Cron\Cron;
use Doctrine\Persistence\ManagerRegistry;
use Ferienpass\CoreBundle\Cron\SendRemindersListener;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\MessengerLog;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Enum\OfferStatus;
use Ferienpass\CoreBundle\Fixtures\Doctrine\OrmTestCase;
use Ferienpass\CoreBundle\Fixtures\Messenger\CollectingMessageBus;
use Ferienpass\CoreBundle\Message\RemindAttendance;
use Ferienpass\CoreBundle\Repository\AttendanceRepository;
use Ferienpass\CoreBundle\Settings\SystemSettingsManager;

class SendRemindersListenerTest extends OrmTestCase
{
    private CollectingMessageBus $bus;

    protected function setUp(): void
    {
        parent::setUp();

        $this->bus = new CollectingMessageBus();
    }

    public function testWebScopeReturnsEarly(): void
    {
        $this->eligibleAttendance();
        $this->em->flush();

        $this->cron()(Cron::SCOPE_WEB);

        self::assertSame([], $this->bus->messages);
    }

    public function testDoesNotRemindWhenTheSettingIsOff(): void
    {
        $this->eligibleAttendance();
        $this->em->flush();

        $this->cron(remind: false)(Cron::SCOPE_CLI);

        self::assertSame([], $this->bus->messages);
    }

    public function testRemindsAConfirmedAttendanceInsideTheNoticePeriod(): void
    {
        $attendance = $this->eligibleAttendance();
        $this->em->flush();

        $this->cron()(Cron::SCOPE_CLI);

        self::assertCount(1, $this->bus->messages);
        self::assertInstanceOf(RemindAttendance::class, $this->bus->messages[0]);
        self::assertSame([(string) $attendance->getUuid()], $this->bus->messages[0]->getAttendanceIds());
    }

    /**
     * The whole point of the messenger-log guard: the cron runs hourly and the offer
     * stays inside the notice period for hours, so without a working guard every run
     * reminds the same family again.
     */
    public function testDoesNotRemindAgainOnceLogged(): void
    {
        $attendance = $this->eligibleAttendance();
        $this->em->flush();

        $this->cron()(Cron::SCOPE_CLI);
        self::assertCount(1, $this->bus->messages);

        $this->log(RemindAttendance::class, $attendance);
        $this->bus->reset();

        $this->cron()(Cron::SCOPE_CLI);

        self::assertSame([], $this->bus->messages);
    }

    public function testAnUnrelatedLogDoesNotSuppressTheReminder(): void
    {
        $attendance = $this->eligibleAttendance();
        $this->em->flush();

        $this->log('Some\Other\Message', $attendance);

        $this->cron()(Cron::SCOPE_CLI);

        self::assertCount(1, $this->bus->messages);
    }

    public function testAnotherAttendancesLogDoesNotSuppressTheReminder(): void
    {
        $this->eligibleAttendance();
        $other = $this->eligibleAttendance();
        $this->em->flush();

        $this->log(RemindAttendance::class, $other);

        $this->cron()(Cron::SCOPE_CLI);

        self::assertCount(1, $this->bus->messages);
    }

    /**
     * Every confirmed seat gets reminded, not one arbitrary attendee per offer. These
     * two seats have no resolvable recipient in common (both e-mail-less), so they are
     * not combined and each gets its own message.
     */
    public function testRemindsEveryConfirmedAttendanceOfAnOffer(): void
    {
        $first = $this->eligibleAttendance();
        $second = $this->attendanceOn($first->getOffer());
        $this->em->flush();

        $this->cron()(Cron::SCOPE_CLI);

        self::assertCount(2, $this->bus->messages);
        self::assertSame(
            [[(string) $first->getUuid()], [(string) $second->getUuid()]],
            array_map(static fn (RemindAttendance $m): array => $m->getAttendanceIds(), $this->bus->messages),
        );
    }

    /**
     * The actual point of grouping: a parent with two children (two attendances that
     * resolve to the same recipient e-mail) gets ONE message carrying both, so they
     * receive a single combined reminder instead of one mail per child.
     */
    public function testCombinesRemindersForTheSameRecipient(): void
    {
        $mum = 'mum@example.com';
        $first = $this->eligibleAttendance(participant: $this->createParticipant('Lena', email: $mum));
        $second = $this->eligibleAttendance(participant: $this->createParticipant('Max', email: $mum));
        $this->em->flush();

        $this->cron()(Cron::SCOPE_CLI);

        self::assertCount(1, $this->bus->messages);
        self::assertEqualsCanonicalizing(
            [(string) $first->getUuid(), (string) $second->getUuid()],
            $this->bus->messages[0]->getAttendanceIds(),
        );
    }

    /**
     * Same recipient, but the two attendances are for the same child in two offers:
     * still one combined reminder.
     */
    public function testCombinesSeveralOffersOfOneChild(): void
    {
        $child = $this->createParticipant('Lena', email: 'mum@example.com');
        $first = $this->eligibleAttendance(participant: $child);
        $second = $this->eligibleAttendance(participant: $child);
        $this->em->flush();

        $this->cron()(Cron::SCOPE_CLI);

        self::assertCount(1, $this->bus->messages);
        self::assertEqualsCanonicalizing(
            [(string) $first->getUuid(), (string) $second->getUuid()],
            $this->bus->messages[0]->getAttendanceIds(),
        );
    }

    /**
     * A not-yet-due sibling is simply not selected this run, so it is neither combined
     * nor marked as reminded — it will get its own reminder once its offer enters the
     * notice period.
     */
    public function testDoesNotCombineASiblingThatIsNotDueYet(): void
    {
        $mum = 'mum@example.com';
        $due = $this->eligibleAttendance(participant: $this->createParticipant('Lena', email: $mum));
        $this->eligibleAttendance(begin: '+3 days', participant: $this->createParticipant('Max', email: $mum));
        $this->em->flush();

        $this->cron()(Cron::SCOPE_CLI);

        self::assertCount(1, $this->bus->messages);
        self::assertSame([(string) $due->getUuid()], $this->bus->messages[0]->getAttendanceIds());
    }

    /**
     * The combined message logs against every attendance it carries, so once logged the
     * whole family is suppressed on the next hourly run — not re-reminded child by child.
     */
    public function testDoesNotRemindAgainOnceACombinedReminderIsLogged(): void
    {
        $mum = 'mum@example.com';
        $first = $this->eligibleAttendance(participant: $this->createParticipant('Lena', email: $mum));
        $second = $this->eligibleAttendance(participant: $this->createParticipant('Max', email: $mum));
        $this->em->flush();

        $this->em->persist(new MessengerLog(RemindAttendance::class, [$first, $second]));
        $this->em->flush();
        $this->em->clear();

        $this->cron()(Cron::SCOPE_CLI);

        self::assertSame([], $this->bus->messages);
    }

    public function testDoesNotRemindBeforeTheNoticePeriodBegins(): void
    {
        $this->eligibleAttendance(begin: '+3 days');
        $this->em->flush();

        $this->cron()(Cron::SCOPE_CLI);

        self::assertSame([], $this->bus->messages);
    }

    public function testHonoursAConfiguredNoticePeriod(): void
    {
        $this->eligibleAttendance(begin: '+3 days');
        $this->em->flush();

        $this->cron(period: '+4 days')(Cron::SCOPE_CLI);

        self::assertCount(1, $this->bus->messages);
    }

    public function testDoesNotRemindForAnOfferThatHasStarted(): void
    {
        $this->eligibleAttendance(begin: '-1 hour');
        $this->em->flush();

        $this->cron()(Cron::SCOPE_CLI);

        self::assertSame([], $this->bus->messages);
    }

    public function testDoesNotRemindForAnUnconfirmedAttendance(): void
    {
        $this->eligibleAttendance(status: AttendanceStatus::WAITLISTED);
        $this->em->flush();

        $this->cron()(Cron::SCOPE_CLI);

        self::assertSame([], $this->bus->messages);
    }

    public function testDoesNotRemindForACancelledOffer(): void
    {
        $this->eligibleAttendance(offerStatus: [OfferStatus::PUBLISHED, OfferStatus::CANCELLED]);
        $this->em->flush();

        $this->cron()(Cron::SCOPE_CLI);

        self::assertSame([], $this->bus->messages);
    }

    private function cron(bool $remind = true, ?string $period = null): SendRemindersListener
    {
        $settings = $this->createStub(SystemSettingsManager::class);
        $settings->method('isRemindAttendance')->willReturn($remind);
        $settings->method('getAttendanceRemindPeriod')->willReturn($period);

        $registry = $this->createStub(ManagerRegistry::class);
        $registry->method('getManagerForClass')->willReturn($this->em);

        return new SendRemindersListener(new AttendanceRepository($registry), $this->bus, $settings);
    }

    /**
     * A confirmed attendance on a published offer that starts within the default
     * notice period of 36 hours.
     *
     * @param list<string> $offerStatus
     */
    private function eligibleAttendance(string $status = AttendanceStatus::CONFIRMED, string $begin = '+12 hours', array $offerStatus = [OfferStatus::PUBLISHED], ?ParticipantInterface $participant = null): Attendance
    {
        $begin = new \DateTimeImmutable($begin);

        $offer = $this->createOffer(
            status: $offerStatus,
            dates: [[$begin, $begin->modify('+2 hours')]],
        );

        return $this->attendanceOn($offer, $status, $participant);
    }

    private function attendanceOn(OfferInterface $offer, string $status = AttendanceStatus::CONFIRMED, ?ParticipantInterface $participant = null): Attendance
    {
        $attendance = new Attendance($offer, $participant ?? $this->createParticipant());
        $attendance->setStatus($status);

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

        return $attendance;
    }

    private function log(string $message, Attendance $attendance): void
    {
        $this->em->persist(new MessengerLog($message, [$attendance]));
        $this->em->flush();
        $this->em->clear();
    }
}
