<?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\Cron;

use Contao\CoreBundle\Cron\Cron;
use Contao\CoreBundle\DependencyInjection\Attribute\AsCronJob;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Ferienpass\CoreBundle\Entity\MessengerLog;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Enum\OfferStatus;
use Ferienpass\CoreBundle\Message\SendAttendanceChanges as SendAttendanceChangesMessage;
use Ferienpass\CoreBundle\Message\SendParticipantList;
use Ferienpass\CoreBundle\Notification\RosterChangeCollector;
use Ferienpass\CoreBundle\Repository\OfferRepositoryInterface;
use Symfony\Component\Messenger\MessageBusInterface;

/**
 * Mails a digest of roster changes to the hosts that receive the participant list.
 *
 * Starts only once {@see SendParticipantLists} has actually sent that list: before
 * then the host has nothing to reconcile against, and the list itself carries the
 * news. Runs until the event is over, because a seat given back the night before
 * is exactly the case that used to go unnoticed.
 *
 * Reads the messenger log through {@see MessengerLog::$offers} rather than the inverse
 * side on the offer, because it needs the newest log per offer and message rather than
 * a mere "was there one". `Offer::$messengerLogs` would do now that it is mapped
 * correctly (it named the attendance join table until then, and saw nothing at all).
 */
#[AsCronJob('daily')]
class SendAttendanceChanges
{
    public function __construct(
        private readonly OfferRepositoryInterface $offers,
        private readonly MessageBusInterface $commandBus,
        private readonly RosterChangeCollector $changeCollector,
        private readonly EntityManagerInterface $em,
    ) {
    }

    public function __invoke(string $scope): void
    {
        if (Cron::SCOPE_WEB === $scope) {
            return;
        }

        foreach ($this->candidates() as $offer) {
            $since = $this->newestLog($offer, SendAttendanceChangesMessage::class)
                ?? $this->newestLog($offer, SendParticipantList::class);

            // No participant list sent yet — nothing to reconcile against.
            if (!$since instanceof \DateTimeInterface) {
                continue;
            }

            // Ask before dispatching: an empty digest would still write a messenger
            // log, and that log is the watermark — advancing it on a silent run is
            // harmless but pointless, and it would bury the real sends in the outbox.
            if ([] === $this->changeCollector->collect($offer, $since)) {
                continue;
            }

            $this->commandBus->dispatch(new SendAttendanceChangesMessage((string) $offer->getUuid(), $since->format(\DATE_ATOM)));
        }
    }

    /**
     * Offers with opted-in recipients whose event has not ended yet. Bounded to what
     * is currently running or imminent, so resolving the watermark per offer stays cheap.
     *
     * @return iterable<OfferInterface>
     */
    private function candidates(): iterable
    {
        $published = OfferStatus::PUBLISHED;
        $cancelled = OfferStatus::CANCELLED;

        /** @var QueryBuilder $qb */
        $qb = $this->offers->createQueryBuilder('o');

        return $qb
            ->innerJoin('o.participantListRecipients', 'r')
            ->innerJoin('o.dates', 'd')
            ->groupBy('o.id')

            ->andWhere('o.onlineApplication = 1')
            ->andWhere("JSON_CONTAINS_PATH(o.status, 'one', '$.$published') = 1")
            ->andWhere("JSON_CONTAINS_PATH(o.status, 'one', '$.$cancelled') = 0")

            // Still upcoming or running (COALESCE guards dates without an explicit end).
            ->andHaving('MAX(COALESCE(d.end, d.begin)) > CURRENT_TIMESTAMP()')

            ->getQuery()
            ->execute()
        ;
    }

    /**
     * When this message was last logged for the offer, or null if it never was.
     */
    private function newestLog(OfferInterface $offer, string $message): ?\DateTimeInterface
    {
        $result = $this->em->createQueryBuilder()
            ->select('MAX(ml.createdAt)')
            ->from(MessengerLog::class, 'ml')
            ->innerJoin('ml.offers', 'o')
            ->where('o = :offer')
            ->andWhere('ml.message = :message')
            ->setParameter('offer', $offer)
            ->setParameter('message', $message)
            ->getQuery()
            ->getSingleScalarResult()
        ;

        return null === $result ? null : new \DateTimeImmutable((string) $result);
    }
}
