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

use Doctrine\DBAL\ArrayParameterType;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Facade\PaymentsFacade;
use Ferienpass\CoreBundle\HttpCache\CacheTag;
use Ferienpass\CoreBundle\Message\CommitAttendances as CommitAttendancesCommand;
use Ferienpass\CoreBundle\Message\DispatchDecisionLetters as DispatchDecisionLettersCommand;
use Ferienpass\CoreBundle\Messenger\UuidStamp;
use Ferienpass\CoreBundle\Repository\AttendanceRepository;
use Ferienpass\CoreBundle\Repository\EditionRepository;
use FOS\HttpCacheBundle\CacheManager;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\DispatchAfterCurrentBusStamp;
use Symfony\Component\Uid\Uuid;

#[AsMessageHandler(bus: 'command.bus')]
class CommitAttendances
{
    public function __construct(private readonly AttendanceRepository $attendances, private readonly EditionRepository $editions, private readonly MessageBusInterface $commandBus, private readonly CacheManager $cacheManager, #[Autowire(param: 'app.id')] private readonly string $appId)
    {
    }

    public function __invoke(CommitAttendancesCommand $command, Uuid $uuid): void
    {
        $edition = $this->editions->findOneBy(['uuid' => $command->getEditionId()]);
        if (!$edition instanceof Edition) {
            return;
        }

        $this->commitAllAttendances($command->getAttendanceIds());
        $this->invalidateCache($command->getHost());

        if ($command->shouldSendDecisions()) {
            $this->commandBus->dispatch(
                new Envelope(new DispatchDecisionLettersCommand($command->getEditionId(), $command->getAttendanceIds()))
                    ->with(new UuidStamp($uuid), new DispatchAfterCurrentBusStamp())
            );
        }

        // The allocation tasks ends with commit
        $allocationTask = $edition->getActiveTasks('allocation');
        if (!$allocationTask->isEmpty()) {
            foreach ($allocationTask as $task) {
                $task->setPeriodEnd(new \DateTimeImmutable());
            }
        }
    }

    /**
     * Committing mutates attendances via bulk DQL UPDATEs (see
     * {@see commitAllAttendances()}) which bypass Doctrine's UnitOfWork, so the
     * CacheInvalidationListener never fires for them. We therefore replicate the
     * relevant Attendance BANs here, host-scoped to the tenant that triggered the
     * commit (passed on the message — the worker has no request of its own).
     *
     * Beyond the listener's Attendance list URLs (teilnehmende / zahlungen /
     * accounts / angebote) we also BAN the commit page itself
     * (/admin/anmeldungen-festschreiben): committed attendances leave their
     * stash, so the page must drop them — and purge the header badge fragment's
     * xkey tag, since the staged count just dropped.
     */
    private function invalidateCache(?string $host): void
    {
        // Both tags are xkey-based (no host needed), so purge them even when the
        // host is unknown. The header badge: committed attendances left their
        // stash. The dashboard: they also leave its "uncommitted" next-steps
        // bucket. (This bulk-DQL path bypasses the CacheInvalidationListener, so
        // it replicates the listener's attendance-status invalidation here.)
        $this->cacheManager->invalidateTags([
            CacheTag::stash($this->appId),
            CacheTag::dashboard($this->appId),
        ]);

        // No host means we can't scope the URL BANs to a single tenant; skipping
        // them is safer than a cross-tenant eviction (cf. CacheInvalidationListener).
        if (null !== $host && '' !== $host) {
            $patterns = [
                '^/admin/anmeldungen-festschreiben(/|\?|$)',
                '^/admin/teilnehmende(/|\?|$)',
                '^/admin/zahlungen(/|\?|$)',
                '^/admin/accounts(/|\?|$)',
                '^/admin/angebote(/|\?|$)',
            ];

            foreach ($patterns as $pattern) {
                $this->cacheManager->invalidateRegex($pattern, hosts: [$host]);
            }
        }

        // No explicit flush(): FlushCacheManagerOnWorkerMessageListener flushes
        // the queued invalidations once this message finishes handling (FOS only
        // auto-flushes on kernel/console TERMINATE, which a long-running
        // messenger:consume worker doesn't reach per-message).
    }

    private function commitAllAttendances(array $attendanceIds): void
    {
        // `attendance.uuid` is a BINARY(16) UuidType column; a raw DQL
        // `IN (:ids)` over the RFC-string UUIDs matches nothing. Pre-convert to
        // the 16-byte form and bind it as a BINARY array (there is no "array of
        // custom type"), else the payable/stash UPDATEs would touch zero rows.
        $ids = array_map(static fn (string $id): string => Uuid::fromString($id)->toBinary(), $attendanceIds);

        // Committing bills the attendance, so it has to apply the same rule the
        // confirm listener applies when a confirmation is committed right away:
        // a free (or offline-paid) offer creates no liability. A DQL UPDATE
        // cannot join, so the exempt attendances are collected in a subquery and
        // excluded below.
        $sub = $this->attendances->createQueryBuilder('a1')->select('a1.uuid');
        PaymentsFacade::addUnbillableLogic($sub, 'a1');

        $this->attendances
            ->createQueryBuilder('attendance')
            ->update()
            ->set('attendance.payable', true)
            ->where('attendance.status = :status')
            ->setParameter('status', AttendanceStatus::CONFIRMED)
            ->andWhere('attendance.uuid IN (:ids)')
            ->andWhere(\sprintf('attendance.uuid NOT IN (%s)', $sub->getDQL()))
            ->setParameter('ids', $ids, ArrayParameterType::BINARY)
            ->getQuery()
            ->execute()
        ;

        $this->attendances
            ->createQueryBuilder('attendance')
            ->update()
            ->set('attendance.stash', ':null')
            ->where('attendance.uuid IN (:ids)')
            ->setParameter('ids', $ids, ArrayParameterType::BINARY)
            ->setParameter('null', null)
            ->getQuery()
            ->execute()
        ;
    }
}
