<?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\EventListener\HttpCache;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Event\PostRemoveEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs;
use Doctrine\ORM\UnitOfWork;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\BookEntry;
use Ferienpass\CoreBundle\Entity\Consent;
use Ferienpass\CoreBundle\Entity\ConsentForm;
use Ferienpass\CoreBundle\Entity\ConversationItem;
use Ferienpass\CoreBundle\Entity\DbafsOfferAssociation;
use Ferienpass\CoreBundle\Entity\DbafsWithOffer;
use Ferienpass\CoreBundle\Entity\Debtor;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Entity\EditionTask;
use Ferienpass\CoreBundle\Entity\Host;
use Ferienpass\CoreBundle\Entity\IntegrationConfig;
use Ferienpass\CoreBundle\Entity\Notification;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\PageConfig;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Entity\Payment;
use Ferienpass\CoreBundle\Entity\SentEmail;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\EventListener\HttpCache\CacheInvalidationListener;
use Ferienpass\CoreBundle\HttpCache\CacheTag;
use FOS\HttpCacheBundle\CacheManager;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Uid\Uuid;

/**
 * Per-entity branch matrix for {@see CacheInvalidationListener}.
 *
 * The sibling file `CacheInvalidationListenerCollectionTest` covers the
 * collection contracts (User list-BAN shape, offer-child cascades,
 * HostMemberAssociation, EditionTask gating). This file walks the listener's
 * three internal dispatch tables in turn — `tagsFor()`, `banListUrlsFor()`,
 * `pathsForCreatedOrRemoved()` — plus the postUpdate special cases
 * (Notification single-type BAN, `statusChanged()`, Offer/Host 404 purge),
 * so any new branch added to those `match (true)` blocks is caught by a
 * direct test rather than only by side-effect through a collection.
 */
final class CacheInvalidationListenerTest extends TestCase
{
    private const string TENANT_HOST = 'www.ferienpass-alfeld.de';

    // ====================================================================
    // tagsFor() — the per-entity tag invalidation table
    // ====================================================================

    public function testOfferUpdateInvalidatesOfferTag(): void
    {
        $offer = $this->makeOffer();
        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($offer, $this->emptyEm()));

        self::assertContains(
            CacheTag::offer($offer),
            $this->flatTags($calls),
            'Offer update must fire its own UUID tag — admin lists and CMS detail pages tag with it.'
        );
    }

    public function testHostUpdateInvalidatesHostTag(): void
    {
        $host = $this->makeHost();
        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($host, $this->emptyEm()));

        self::assertContains(CacheTag::host($host), $this->flatTags($calls));
    }

    public function testEditionUpdateInvalidatesEditionTag(): void
    {
        $edition = $this->makeEdition();
        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($edition, $this->emptyEm()));

        self::assertContains(CacheTag::edition($edition), $this->flatTags($calls));
    }

    public function testConversationItemInvalidatesSubjectAndOutboxTags(): void
    {
        // A comment posted on a participant must refresh the participant show
        // page (which renders the inline thread, tagged participant.<uuid>) and
        // the Postausgang (new thread item + reply badge).
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getUuid')->willReturn(Uuid::v7());
        $comment = new ConversationItem($participant, null, 'Bitte prüfen.');

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($comment, $this->emptyEm()));

        $tags = $this->flatTags($calls);
        self::assertContains(CacheTag::participant($participant), $tags, 'The subject show page renders the thread and carries its UUID tag.');
        self::assertContains(CacheTag::outbox('test-app'), $tags, 'The Postausgang lists the thread; it is a host-less app.id-keyed tag.');
    }

    public function testSentEmailInvalidatesOutboxTag(): void
    {
        // A logged outbound email is a new Postausgang row. Persisted from the
        // async worker (no host), so only the host-less outbox xkey reaches it.
        $email = $this->createMock(SentEmail::class);

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($email, $this->emptyEm()));

        self::assertContains(CacheTag::outbox('test-app'), $this->flatTags($calls));
    }

    public function testEditionTaskUpdateCascadesToEditionTag(): void
    {
        // EditionTask doesn't have its own list, so the listener cascades
        // to the edition tag — admin Saisons show page renders task title
        // and is tagged with `CacheTag::edition`, so it needs the purge.
        $edition = $this->makeEdition();
        $task = $this->createMock(EditionTask::class);
        $task->method('getEdition')->willReturn($edition);
        $task->method('isAnApplicationSystem')->willReturn(false);

        $calls = $this->capture();
        // Even title-only edit cascades the tag — change-set gate only
        // affects the BAN, not the tag.
        $this->listener($calls)->postUpdate($this->postUpdateEventWithChangeSet($task, ['title']));

        self::assertContains(
            CacheTag::edition($edition),
            $this->flatTags($calls),
            'EditionTask must cascade to its edition tag so admin Saisons pages refresh even on title-only edits.'
        );
    }

    public function testParticipantWithUserInvalidatesBothTags(): void
    {
        $user = $this->makeUser();
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getUuid')->willReturn(Uuid::v7());
        $participant->method('getUser')->willReturn($user);

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($participant, $this->emptyEm()));

        $tags = $this->flatTags($calls);
        self::assertContains(CacheTag::participant($participant), $tags);
        self::assertContains(
            CacheTag::user($user),
            $tags,
            'Participant with a parent user must also fire the user tag — the user\'s account page shows children counts.'
        );
    }

    public function testParticipantWithoutUserOnlyInvalidatesParticipantTag(): void
    {
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getUuid')->willReturn(Uuid::v7());
        $participant->method('getUser')->willReturn(null);

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($participant, $this->emptyEm()));

        $tags = $this->flatTags($calls);
        self::assertContains(CacheTag::participant($participant), $tags);
        // No user → no user tag. Filtered out by `array_filter` in the match arm.
        self::assertNotContains(null, $tags, 'A null user must not produce a null tag.');
    }

    public function testAttendanceCascadesToParticipantUserAndOfferTags(): void
    {
        $offer = $this->makeOffer();
        $user = $this->makeUser();
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getUuid')->willReturn(Uuid::v7());
        $participant->method('getUser')->willReturn($user);

        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getUuid')->willReturn(Uuid::v7());
        $attendance->method('getParticipant')->willReturn($participant);
        $attendance->method('getOffer')->willReturn($offer);

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($attendance, $this->emptyEm()));

        $tags = $this->flatTags($calls);
        self::assertContains(CacheTag::attendance($attendance), $tags);
        self::assertContains(CacheTag::participant($participant), $tags);
        self::assertContains(CacheTag::user($user), $tags);
        self::assertContains(
            CacheTag::offer($offer),
            $tags,
            'Attendance must cascade to the offer — the offer\'s admin participant list / korrekturabzug / CMS detail show attendance counts.'
        );
    }

    public function testAttendanceWithParticipantButNoUserSkipsUserTag(): void
    {
        $offer = $this->makeOffer();
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getUuid')->willReturn(Uuid::v7());
        $participant->method('getUser')->willReturn(null);

        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getUuid')->willReturn(Uuid::v7());
        $attendance->method('getParticipant')->willReturn($participant);
        $attendance->method('getOffer')->willReturn($offer);

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($attendance, $this->emptyEm()));

        $tags = $this->flatTags($calls);
        self::assertContains(CacheTag::participant($participant), $tags);
        self::assertContains(CacheTag::offer($offer), $tags);
        // No null leaks — array_filter strips the missing user tag.
        self::assertNotContains(null, $tags);
    }

    public function testAttendanceWithNullParticipantStillTagsOffer(): void
    {
        // Anonymous attendances exist (the application_form's anonymous
        // path can create them). Listener must not blow up on null
        // participant.
        $offer = $this->makeOffer();
        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getUuid')->willReturn(Uuid::v7());
        $attendance->method('getParticipant')->willReturn(null);
        $attendance->method('getOffer')->willReturn($offer);

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($attendance, $this->emptyEm()));

        $tags = $this->flatTags($calls);
        self::assertContains(CacheTag::attendance($attendance), $tags);
        self::assertContains(CacheTag::offer($offer), $tags);
    }

    public function testDbafsWithOfferCascadesToAllLinkedOfferTags(): void
    {
        // Editing image metadata (alt text, important part) on a DbafsMedia
        // entity must invalidate EVERY offer that uses the file — the join
        // rows (OfferMedia) aren't touched on a metadata edit, so the
        // DbafsOfferAssociation branch doesn't fire.
        $offerA = $this->makeOffer();
        $offerB = $this->makeOffer();

        $assocA = $this->createMock(DbafsOfferAssociation::class);
        $assocA->method('getOffer')->willReturn($offerA);
        $assocB = $this->createMock(DbafsOfferAssociation::class);
        $assocB->method('getOffer')->willReturn($offerB);

        $media = $this->createMock(DbafsWithOffer::class);
        $media->method('getOffers')->willReturn(new ArrayCollection([$assocA, $assocB]));

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($media, $this->emptyEm()));

        $tags = $this->flatTags($calls);
        self::assertContains(CacheTag::offer($offerA), $tags);
        self::assertContains(CacheTag::offer($offerB), $tags);
    }

    public function testDbafsWithNoLinkedOffersFiresNoTags(): void
    {
        $media = $this->createMock(DbafsWithOffer::class);
        $media->method('getOffers')->willReturn(new ArrayCollection([]));

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($media, $this->emptyEm()));

        self::assertSame([], $calls['tags'], 'An orphaned file shouldn\'t fire any tags.');
    }

    public function testDefaultEntityIsNoOp(): void
    {
        // A plain object that doesn't match any branch (Debtor,
        // ParticipantDuplicate, or any future entity) must round-trip the
        // listener without invalidating anything.
        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs(new \stdClass(), $this->emptyEm()));

        self::assertSame([], $calls['tags']);
        self::assertSame([], $calls['bans']);
        self::assertSame([], $calls['paths']);
    }

    // ====================================================================
    // banListUrlsFor() — per-entity BAN patterns
    //
    // For each entity we assert the EXACT pattern set, so a future
    // accidental BAN removal or addition fails the test.
    // ====================================================================

    public function testOfferPersistBansAdminOffersList(): void
    {
        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($this->makeOffer(), $this->emptyEm()));

        self::assertSame(
            ['^(/[a-z]{2,3})?/admin/angebote(/[a-zA-Z0-9_\-+]+)?(\?|$)'],
            array_column($calls['bans'], 'pattern'),
        );
    }

    public function testHostPersistBansAdminVeranstaltendeList(): void
    {
        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($this->makeHost(), $this->emptyEm()));

        self::assertSame(
            ['^(/[a-z]{2,3})?/admin/veranstaltende(/[a-zA-Z0-9_\-+]+)?(\?|$)'],
            array_column($calls['bans'], 'pattern'),
        );
    }

    public function testParticipantWithUserBansBothTeilnehmendeAndAccounts(): void
    {
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getUuid')->willReturn(Uuid::v7());
        $participant->method('getUser')->willReturn($this->makeUser());

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($participant, $this->emptyEm()));

        $patterns = array_column($calls['bans'], 'pattern');
        self::assertContains('^(/[a-z]{2,3})?/admin/teilnehmende(/[a-zA-Z0-9_\-+]+)?(\?|$)', $patterns);
        self::assertContains(
            '^(/[a-z]{2,3})?/admin/accounts(/[a-zA-Z0-9_\-+]+)?(\?|$)',
            $patterns,
            'A new child must BAN /admin/accounts — the user\'s row shows child count.'
        );
    }

    public function testParticipantWithoutUserSkipsAccountsBan(): void
    {
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getUuid')->willReturn(Uuid::v7());
        $participant->method('getUser')->willReturn(null);

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($participant, $this->emptyEm()));

        $patterns = array_column($calls['bans'], 'pattern');
        self::assertContains('^(/[a-z]{2,3})?/admin/teilnehmende(/[a-zA-Z0-9_\-+]+)?(\?|$)', $patterns);
        self::assertNotContains(
            '^(/[a-z]{2,3})?/admin/accounts(/[a-zA-Z0-9_\-+]+)?(\?|$)',
            $patterns,
            'No parent user → no accounts BAN. Filtered by `null !== getUser()` arm.'
        );
    }

    public function testAttendancePersistBansAllAffectedAdminLists(): void
    {
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getUuid')->willReturn(Uuid::v7());
        $participant->method('getUser')->willReturn($this->makeUser());

        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getUuid')->willReturn(Uuid::v7());
        $attendance->method('getParticipant')->willReturn($participant);
        $attendance->method('getOffer')->willReturn($this->makeOffer());

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($attendance, $this->emptyEm()));

        $patterns = array_column($calls['bans'], 'pattern');
        self::assertContains('^(/[a-z]{2,3})?/admin/teilnehmende(/[a-zA-Z0-9_\-+]+)?(\?|$)', $patterns);
        self::assertContains('^(/[a-z]{2,3})?/admin/zahlungen(/|\?|$)', $patterns);
        self::assertContains('^(/[a-z]{2,3})?/admin/accounts(/[a-zA-Z0-9_\-+]+)?(\?|$)', $patterns);
        self::assertContains('^(/[a-z]{2,3})?/admin/angebote(/[a-zA-Z0-9_\-+]+)?(\?|$)', $patterns);
    }

    public function testAttendanceWithoutParticipantSkipsAccountsBan(): void
    {
        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getUuid')->willReturn(Uuid::v7());
        $attendance->method('getParticipant')->willReturn(null);
        $attendance->method('getOffer')->willReturn($this->makeOffer());

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($attendance, $this->emptyEm()));

        $patterns = array_column($calls['bans'], 'pattern');
        self::assertNotContains(
            '^(/[a-z]{2,3})?/admin/accounts(/[a-zA-Z0-9_\-+]+)?(\?|$)',
            $patterns,
            'No participant → no accounts BAN. Filtered by `null !== getParticipant()` arm.'
        );
    }

    public function testEditionPersistBansAdminSaisonsList(): void
    {
        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($this->makeEdition(), $this->emptyEm()));

        self::assertSame(
            ['^(/[a-z]{2,3})?/admin/saisons(/[a-zA-Z0-9_\-+]+)?(\?|$)'],
            array_column($calls['bans'], 'pattern'),
        );
    }

    public function testNotificationPersistBansBenachrichtigungenBroad(): void
    {
        // Persist flips the left-nav status dot for the type — must BAN
        // the whole notifications area.
        $notification = $this->createMock(Notification::class);
        $notification->method('getType')->willReturn('account.confirm_signup');

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($notification, $this->emptyEm()));

        self::assertSame(
            ['^(/[a-z]{2,3})?/admin/benachrichtigungen(/|\?|$)'],
            array_column($calls['bans'], 'pattern'),
        );
    }

    public function testEveryBanIncludesTheTenantHost(): void
    {
        // Defensive: every BAN must carry the canonical host. Without it,
        // an event in Alfeld would evict every other tenant's caches on
        // the shared Varnish.
        $offer = $this->makeOffer();

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($offer, $this->emptyEm()));

        foreach ($calls['bans'] as $ban) {
            self::assertSame([self::TENANT_HOST], $ban['hosts']);
        }
    }

    public function testNonMatchingEntitySkipsBan(): void
    {
        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs(new \stdClass(), $this->emptyEm()));

        self::assertSame([], $calls['bans']);
    }

    // ====================================================================
    // Notification: postUpdate single-type BAN
    // ====================================================================

    public function testNotificationUpdateBansOnlyItsType(): void
    {
        // Editing a notification template (subject, body, email-to) only
        // changes the preview pages for THAT type. The left-nav dot
        // doesn't move on a plain edit — so we BAN narrowly and don't
        // throw away cached pages for unrelated notification types.
        $notification = $this->createMock(Notification::class);
        $notification->method('getType')->willReturn('account.confirm_signup');

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($notification, $this->emptyEm()));

        $patterns = array_column($calls['bans'], 'pattern');
        self::assertContains(
            '^(/[a-z]{2,3})?/admin/benachrichtigungen/account\.confirm_signup(/|\?|$)',
            $patterns,
            'Notification update must BAN its own type sub-path only — broad BAN is reserved for persist/remove.'
        );
        self::assertNotContains(
            '^(/[a-z]{2,3})?/admin/benachrichtigungen(/|\?|$)',
            $patterns,
            'Notification update must NOT broad-BAN /admin/benachrichtigungen — that would evict every other type\'s cached preview.'
        );
    }

    public function testNotificationUpdateRegexEscapesTypeSpecials(): void
    {
        // `Notification::getType()` returns strings like "user.welcome";
        // unescaped dots in the regex would match any character. Guard
        // against that by checking a contrived value with regex metachars
        // round-trips literal.
        $notification = $this->createMock(Notification::class);
        $notification->method('getType')->willReturn('foo.bar+baz');

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($notification, $this->emptyEm()));

        $patterns = array_column($calls['bans'], 'pattern');
        // preg_quote escapes . and + → \. and \+
        self::assertContains(
            '^(/[a-z]{2,3})?/admin/benachrichtigungen/foo\.bar\+baz(/|\?|$)',
            $patterns,
            'Notification type must be passed through preg_quote — otherwise a "." in the type matches any char.'
        );
    }

    // ====================================================================
    // statusChanged() — workflow transition BANs
    // ====================================================================

    public function testOfferStatusChangeBansStatusFilteredOffersList(): void
    {
        $offer = $this->makeOffer();
        $calls = $this->capture();
        $this->listener($calls)
            ->postUpdate($this->postUpdateEventWithChangeSet($offer, ['status']));

        // The status BAN pattern is wide (`status=<anything>`) and matches
        // both encoded and literal bracket forms.
        $patterns = array_column($calls['bans'], 'pattern');
        self::assertContains(
            '^(/[a-z]{2,3})?/admin/angebote.*(\[|%5B)status(\]|%5D)=',
            $patterns,
            'Offer status transition must BAN status-filtered offer lists.'
        );
    }

    public function testAttendanceStatusChangeBansThreeAdminAreas(): void
    {
        $offer = $this->makeOffer();
        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getUuid')->willReturn(Uuid::v7());
        $attendance->method('getParticipant')->willReturn(null);
        $attendance->method('getOffer')->willReturn($offer);

        $calls = $this->capture();
        $this->listener($calls)->postUpdate($this->postUpdateEventWithChangeSet($attendance, ['status']));

        $patterns = array_column($calls['bans'], 'pattern');
        // Attendance status surfaces in three different list areas.
        self::assertContains('^(/[a-z]{2,3})?/admin/teilnehmende.*(\[|%5B)status(\]|%5D)=', $patterns);
        self::assertContains('^(/[a-z]{2,3})?/admin/zahlungen.*(\[|%5B)status(\]|%5D)=', $patterns);
        self::assertContains('^(/[a-z]{2,3})?/admin/angebote.*(\[|%5B)status(\]|%5D)=', $patterns);
    }

    public function testPaymentUpdateTagsPaymentAndDebtor(): void
    {
        // A payment content edit — status transition (incl. the daily
        // pmpayment:fetch-status cron's pay/fail) or billing — purges the
        // payment's own UUID tag (the receipts list rows + receipt detail carry
        // it) and its debtor's tag (the debtor balance pages). These xkey purges
        // need no host, so they fire from the request-less CLI cron; and there's
        // no list BAN on update — the receipts list has no status filter, so a
        // status change is a pure content edit on an already-listed row.
        $debtor = $this->createMock(Debtor::class);
        $debtor->method('getUuid')->willReturn($debtorUuid = Uuid::v7());
        $payment = $this->createMock(Payment::class);
        $payment->method('getUuid')->willReturn($paymentUuid = Uuid::v7());
        $payment->method('getDebtor')->willReturn($debtor);

        $calls = $this->capture();
        $this->listener($calls)->postUpdate($this->postUpdateEventWithChangeSet($payment, ['status']));

        $tags = $this->flatTags($calls);
        self::assertContains(CacheTag::payment($paymentUuid), $tags);
        self::assertContains(CacheTag::debtor($debtorUuid), $tags);
        self::assertSame(
            [],
            $calls['bans'],
            'Payment update is tag-based; the list BAN only fires on persist/remove.'
        );
    }

    public function testPaymentPersistBansListTreesAndTagsPayment(): void
    {
        // A brand-new receipt (CMS/admin payment, reversal) has no cached list
        // carrying its UUID yet, so the receipts + debtors LIST trees are BANned
        // (host-scoped) to make the row appear — the one thing the UUID tag can't.
        $payment = $this->createMock(Payment::class);
        $payment->method('getUuid')->willReturn(Uuid::v7());
        $payment->method('getDebtor')->willReturn(null);

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($payment, $this->emptyEm()));

        $patterns = array_column($calls['bans'], 'pattern');
        self::assertContains('^(/[a-z]{2,3})?/admin/zahlungen/belege(/|\?|$)', $patterns);
        self::assertContains('^(/[a-z]{2,3})?/admin/zahlungen/debitoren(/|\?|$)', $patterns);
        self::assertContains(CacheTag::payment($payment), $this->flatTags($calls));
    }

    public function testStandaloneBookEntryBansWholeTreeAndTagsDebtor(): void
    {
        // A manual "freie Buchung" (no Payment) is a new debtor-balance row → BAN
        // the whole /admin/zahlungen tree, and tag the debtor it moved.
        $debtor = $this->createMock(Debtor::class);
        $debtor->method('getUuid')->willReturn(Uuid::v7());
        $bookEntry = $this->createMock(BookEntry::class);
        $bookEntry->method('getPayment')->willReturn(null);
        $bookEntry->method('getDebtor')->willReturn($debtor);

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($bookEntry, $this->emptyEm()));

        self::assertContains('^(/[a-z]{2,3})?/admin/zahlungen(/|\?|$)', array_column($calls['bans'], 'pattern'));
        self::assertContains(CacheTag::debtor($debtor), $this->flatTags($calls));
    }

    public function testPayLinkedBookEntryTagsDebtorAndPaymentWithoutBan(): void
    {
        // The pay workflow (incl. the cron) creates a Payment-linked BookEntry. It
        // carries a Payment, so it's not a new list row → no BAN; the debtor +
        // payment UUID tags refresh the balances/receipt host-lessly, which is the
        // path that makes the cron's pay actually evict the debtor pages.
        $debtor = $this->createMock(Debtor::class);
        $debtor->method('getUuid')->willReturn(Uuid::v7());
        $payment = $this->createMock(Payment::class);
        $payment->method('getUuid')->willReturn(Uuid::v7());
        $bookEntry = $this->createMock(BookEntry::class);
        $bookEntry->method('getPayment')->willReturn($payment);
        $bookEntry->method('getDebtor')->willReturn($debtor);

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($bookEntry, $this->emptyEm()));

        self::assertSame([], $calls['bans'], 'A pay-linked book entry is not a new list row → no BAN.');
        $tags = $this->flatTags($calls);
        self::assertContains(CacheTag::debtor($debtor), $tags);
        self::assertContains(CacheTag::payment($payment), $tags);
    }

    public function testConsentUpdateTagsConsent(): void
    {
        // A form-less consent (no linked ConsentForm — e.g. a migrated legacy
        // signature) revoke/edit purges only its own UUID tag, refreshing the
        // /admin/einwilligungen list rows + the consent detail page. No edition /
        // host-consent / offer cascade (getConsentForm/getEdition/getOffer are all
        // null), and no list BAN on update.
        $consent = $this->createMock(Consent::class);
        $consent->method('getUuid')->willReturn($uuid = Uuid::v7());

        $calls = $this->capture();
        $this->listener($calls)->postUpdate($this->postUpdateEventWithChangeSet($consent, ['revoked']));

        self::assertSame([CacheTag::consent($uuid)], $this->flatTags($calls));
        self::assertSame([], $calls['bans'], 'Consent update is tag-based; no list BAN.');
    }

    public function testHostConsentUpdateCascadesToEditionAndHostConsentTags(): void
    {
        // Revoking a host's confidentiality signature (subject=host) must refresh
        // both surfaces keyed by the edition: the signee count on the season's
        // hostConsent editor (edition tag) and the participant-list gate on every
        // offer in the edition (host-consent tag). This is the reported bug — a
        // revoke via /admin/einwilligungen left both serving the pre-revoke state.
        $edition = $this->makeEdition();
        $form = $this->createMock(ConsentForm::class);
        $form->method('isHost')->willReturn(true);

        $consent = $this->createMock(Consent::class);
        $consent->method('getUuid')->willReturn($uuid = Uuid::v7());
        $consent->method('getConsentForm')->willReturn($form);
        $consent->method('getEdition')->willReturn($edition);
        $consent->method('getOffer')->willReturn(null);

        $calls = $this->capture();
        $this->listener($calls)->postUpdate($this->postUpdateEventWithChangeSet($consent, ['revoked']));

        $tags = $this->flatTags($calls);
        self::assertContains(CacheTag::consent($uuid), $tags);
        self::assertContains(
            CacheTag::edition($edition),
            $tags,
            'Host signature change must purge the edition tag — the season hostConsent editor (signee count) carries it.'
        );
        self::assertContains(
            CacheTag::hostConsent($edition),
            $tags,
            'Host signature change must purge the host-consent tag — every offer\'s participant-list gate carries it.'
        );
    }

    public function testHostConsentRemoveCascadesToEditionAndHostConsentTags(): void
    {
        // Deleting a host signature (postRemove) is the same cascade as revoke,
        // plus the einwilligungen list BAN that every consent create/remove fires.
        $edition = $this->makeEdition();
        $form = $this->createMock(ConsentForm::class);
        $form->method('isHost')->willReturn(true);

        $consent = $this->createMock(Consent::class);
        $consent->method('getUuid')->willReturn($uuid = Uuid::v7());
        $consent->method('getConsentForm')->willReturn($form);
        $consent->method('getEdition')->willReturn($edition);
        $consent->method('getOffer')->willReturn(null);

        $calls = $this->capture();
        $this->listener($calls)->postRemove(new PostRemoveEventArgs($consent, $this->emptyEm()));

        $tags = $this->flatTags($calls);
        self::assertContains(CacheTag::consent($uuid), $tags);
        self::assertContains(CacheTag::edition($edition), $tags);
        self::assertContains(CacheTag::hostConsent($edition), $tags);
        self::assertContains(
            '^(/[a-z]{2,3})?/admin/einwilligungen(/[a-zA-Z0-9_\-+]+)?(\?|$)',
            array_column($calls['bans'], 'pattern'),
        );
    }

    public function testHostConsentSignCascadesToEditionAndHostConsentTags(): void
    {
        // Signing (ConsentManager::sign persists a Consent) must lift the gate: the
        // freshly-cached gate page for that host carries the host-consent tag, and
        // the editor's signee count (edition tag) ticks up.
        $edition = $this->makeEdition();
        $form = $this->createMock(ConsentForm::class);
        $form->method('isHost')->willReturn(true);

        $consent = $this->createMock(Consent::class);
        $consent->method('getUuid')->willReturn(Uuid::v7());
        $consent->method('getConsentForm')->willReturn($form);
        $consent->method('getEdition')->willReturn($edition);
        $consent->method('getOffer')->willReturn(null);

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($consent, $this->emptyEm()));

        $tags = $this->flatTags($calls);
        self::assertContains(CacheTag::edition($edition), $tags);
        self::assertContains(CacheTag::hostConsent($edition), $tags);
    }

    public function testOfferScopedConsentCascadesToOfferTag(): void
    {
        // An offer-scoped participant consent surfaces in that one offer's admin
        // participant list (the consent column / hasConsents). Revoking it purges
        // the offer tag so the column refreshes — but NOT the edition/host-consent
        // tags (it has nothing to do with the host gate).
        $offer = $this->makeOffer();
        $form = $this->createMock(ConsentForm::class);
        $form->method('isHost')->willReturn(false);

        $consent = $this->createMock(Consent::class);
        $consent->method('getUuid')->willReturn($uuid = Uuid::v7());
        $consent->method('getConsentForm')->willReturn($form);
        $consent->method('getEdition')->willReturn(null);
        $consent->method('getOffer')->willReturn($offer);

        $calls = $this->capture();
        $this->listener($calls)->postUpdate($this->postUpdateEventWithChangeSet($consent, ['revoked']));

        $tags = $this->flatTags($calls);
        self::assertContains(CacheTag::consent($uuid), $tags);
        self::assertContains(CacheTag::offer($offer), $tags);
        foreach ($tags as $tag) {
            self::assertStringStartsNotWith('host-consent.', $tag, 'A non-host consent must not touch the host gate.');
            self::assertStringStartsNotWith('edition.', $tag);
        }
    }

    public function testEditionScopedNonHostConsentDoesNotCascadeToHostGate(): void
    {
        // An edition-scoped PARTICIPANT consent has a non-null getEdition() too,
        // but it is not the host declaration — the isHost() gate keeps it from
        // evicting the hostConsent editor / participant-list gates.
        $edition = $this->makeEdition();
        $form = $this->createMock(ConsentForm::class);
        $form->method('isHost')->willReturn(false);

        $consent = $this->createMock(Consent::class);
        $consent->method('getUuid')->willReturn($uuid = Uuid::v7());
        $consent->method('getConsentForm')->willReturn($form);
        $consent->method('getEdition')->willReturn($edition);
        $consent->method('getOffer')->willReturn(null);

        $calls = $this->capture();
        $this->listener($calls)->postUpdate($this->postUpdateEventWithChangeSet($consent, ['revoked']));

        self::assertSame(
            [CacheTag::consent($uuid)],
            $this->flatTags($calls),
            'A non-host edition-scoped consent must fire only its own tag — the isHost() gate blocks the edition/host-consent cascade.'
        );
    }

    public function testHostConsentFormUpdateCascadesToHostConsentTag(): void
    {
        // Editing the host declaration text (HostConsent::save) swaps the
        // ConsentForm's ConsentText, dirtying the form row → postUpdate. That
        // invalidates every host signature, so the participant-list gates
        // (host-consent tag) and the editor (edition tag) must both refresh.
        $edition = $this->makeEdition();
        $form = $this->createMock(ConsentForm::class);
        $form->method('isHost')->willReturn(true);
        $form->method('getOffer')->willReturn(null);
        $form->method('getEdition')->willReturn($edition);

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($form, $this->emptyEm()));

        $tags = $this->flatTags($calls);
        self::assertContains(CacheTag::edition($edition), $tags);
        self::assertContains(CacheTag::hostConsent($edition), $tags);
    }

    public function testOfferConsentFormDoesNotCascadeToHostConsentTag(): void
    {
        // The agreement-letter ConsentForm on an OFFER (subject!=host) refreshes
        // only that offer's pages — never the host gate.
        $offer = $this->makeOffer();
        $form = $this->createMock(ConsentForm::class);
        $form->method('isHost')->willReturn(false);
        $form->method('getOffer')->willReturn($offer);
        $form->method('getEdition')->willReturn(null);

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($form, $this->emptyEm()));

        $tags = $this->flatTags($calls);
        self::assertContains(CacheTag::offer($offer), $tags);
        foreach ($tags as $tag) {
            self::assertStringStartsNotWith('host-consent.', $tag);
        }
    }

    public function testConsentPersistBansEinwilligungenList(): void
    {
        // A brand-new consent must appear in the /admin/einwilligungen list — the
        // per-row consent.<uuid> tag can't add a row that was never cached.
        $consent = $this->createMock(Consent::class);
        $consent->method('getUuid')->willReturn(Uuid::v7());

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($consent, $this->emptyEm()));

        self::assertContains('^(/[a-z]{2,3})?/admin/einwilligungen(/[a-zA-Z0-9_\-+]+)?(\?|$)', array_column($calls['bans'], 'pattern'));
        self::assertContains(CacheTag::consent($consent), $this->flatTags($calls));
    }

    public function testOfferUpdateWithoutStatusChangeSkipsStatusBan(): void
    {
        // Renaming an offer changes its detail page (covered by the UUID
        // tag) but NOT the status-filtered list pages.
        $offer = $this->makeOffer();
        $calls = $this->capture();
        $this->listener($calls)->postUpdate($this->postUpdateEventWithChangeSet($offer, ['name']));

        $patterns = array_column($calls['bans'], 'pattern');
        foreach ($patterns as $p) {
            self::assertStringNotContainsString(
                'status',
                $p,
                'A non-status field change must not fire any status-filtered BAN. Pattern was: '.$p
            );
        }
    }

    public function testNonWorkflowEntitySkipsStatusBan(): void
    {
        // User has no `status` column the admin filters on; even if the
        // change-set happened to mention "status", the entity-type check
        // gates statusChanged() and skips the BAN. Since `postUpdate`
        // doesn't BAN list URLs for User (that's a persist/remove
        // concern), the BAN list comes back empty — assert that directly
        // rather than looping a zero-element list.
        $user = $this->makeUser();
        $calls = $this->capture();
        $this->listener($calls)->postUpdate($this->postUpdateEventWithChangeSet($user, ['status']));

        self::assertSame(
            [],
            $calls['bans'],
            'User update with a "status" change-set must not fire any BAN — '
            .'`statusChanged()` gates on entity type (Offer / Attendance only).'
        );
    }

    // ====================================================================
    // Dashboard aggregate tag — the /admin dashboard (admin ESI widgets plus
    // the host's inline next-steps) is one cached entry whose heatmap /
    // next-steps / activity-feed / finalized-offer aggregates move on
    // attendance AND offer lifecycle events but carry no per-entity UUID tag.
    // ====================================================================

    public function testAttendancePersistInvalidatesDashboardTag(): void
    {
        // The reported bug: a CMS application creates an attendance, which
        // adds a heatmap point, a next-steps row and an activity-feed entry,
        // but the /admin dashboard kept serving its year-old snapshot.
        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getUuid')->willReturn(Uuid::v7());
        $attendance->method('getParticipant')->willReturn(null);
        $attendance->method('getOffer')->willReturn($this->makeOffer());

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($attendance, $this->emptyEm()));

        self::assertContains(
            CacheTag::dashboard('test-app'),
            $this->flatTags($calls),
            'A new attendance must purge the dashboard tag — heatmap / next-steps / activity feed all move.'
        );
    }

    public function testAttendanceRemoveInvalidatesDashboardTag(): void
    {
        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getUuid')->willReturn(Uuid::v7());
        $attendance->method('getParticipant')->willReturn(null);
        $attendance->method('getOffer')->willReturn($this->makeOffer());

        $calls = $this->capture();
        $this->listener($calls)->postRemove(new PostRemoveEventArgs($attendance, $this->emptyEm()));

        self::assertContains(CacheTag::dashboard('test-app'), $this->flatTags($calls));
    }

    public function testAttendanceStatusChangeInvalidatesDashboardTag(): void
    {
        // Committing / waitlisting moves an attendance between the
        // next-steps widget's uncommitted / waiting buckets.
        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getUuid')->willReturn(Uuid::v7());
        $attendance->method('getParticipant')->willReturn(null);
        $attendance->method('getOffer')->willReturn($this->makeOffer());

        $calls = $this->capture();
        $this->listener($calls)->postUpdate($this->postUpdateEventWithChangeSet($attendance, ['status']));

        self::assertContains(CacheTag::dashboard('test-app'), $this->flatTags($calls));
    }

    public function testAttendanceNonStatusUpdateDoesNotInvalidateDashboard(): void
    {
        // A plain edit (e.g. a note) doesn't move any dashboard aggregate —
        // the heatmap keys off the immutable createdAt and next-steps off
        // status — so the year-long page cache must survive it.
        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getUuid')->willReturn(Uuid::v7());
        $attendance->method('getParticipant')->willReturn(null);
        $attendance->method('getOffer')->willReturn($this->makeOffer());

        $calls = $this->capture();
        $this->listener($calls)->postUpdate($this->postUpdateEventWithChangeSet($attendance, ['note']));

        self::assertNotContains(CacheTag::dashboard('test-app'), $this->flatTags($calls));
    }

    public function testOfferPersistInvalidatesDashboardTag(): void
    {
        // A new offer adds a draft "Angebot abschließen" row to its host's
        // inline dashboard next-steps (and an activity-feed entry for admins).
        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($this->makeOffer(), $this->emptyEm()));

        self::assertContains(
            CacheTag::dashboard('test-app'),
            $this->flatTags($calls),
            'A new offer must purge the dashboard tag — the host next-steps list a draft row for it.'
        );
    }

    public function testOfferStatusChangeInvalidatesDashboardTag(): void
    {
        // draft → reviewed → published moves the offer between the host
        // next-steps buckets ("abschließen" vs "Teilnahmeliste") and the
        // admin's "Angebote freischalten" (finalized) row.
        $calls = $this->capture();
        $this->listener($calls)->postUpdate($this->postUpdateEventWithChangeSet($this->makeOffer(), ['status']));

        self::assertContains(CacheTag::dashboard('test-app'), $this->flatTags($calls));
    }

    public function testOfferRemoveInvalidatesDashboardTag(): void
    {
        $calls = $this->capture();
        $this->listener($calls)->postRemove(new PostRemoveEventArgs($this->makeOffer(), $this->emptyEm()));

        self::assertContains(CacheTag::dashboard('test-app'), $this->flatTags($calls));
    }

    public function testOfferNonStatusUpdateDoesNotInvalidateDashboard(): void
    {
        // A plain offer edit (name, fee, …) doesn't change which next-steps
        // bucket the offer sits in, so the year-long page cache survives it —
        // the same status-gated rule the attendance path uses above.
        $calls = $this->capture();
        $this->listener($calls)->postUpdate($this->postUpdateEventWithChangeSet($this->makeOffer(), ['name']));

        self::assertNotContains(CacheTag::dashboard('test-app'), $this->flatTags($calls));
    }

    public function testNonDashboardEntityDoesNotInvalidateDashboard(): void
    {
        // Only attendances and offers feed the dashboard through this listener —
        // a host create must not drag the dashboard tag along.
        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($this->makeHost(), $this->emptyEm()));

        self::assertNotContains(CacheTag::dashboard('test-app'), $this->flatTags($calls));
    }

    // ====================================================================
    // pathsForCreatedOrRemoved() + Offer/Host postUpdate 404 purge
    // ====================================================================

    public function testOfferPersistBansCmsReaderUrl(): void
    {
        // EditionLandingController::detail() throws PageNotFoundException for an
        // offer that isn't publicly visible, and fos_http_cache stores 404s for
        // a year. The 404 is thrown BEFORE tagResponse(), so it carries no xkey
        // and CacheTag::offer can never reach it — only a URL invalidation can.
        [$offer] = $this->makeOfferInEdition('sommerferien', 'rugby-camp');

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($offer, $this->emptyEm()));

        self::assertNotEmpty(
            array_filter(array_column($calls['bans'], 'pattern'), static fn (string $p): bool => str_contains($p, '/o/')),
            'Offer persist must invalidate the canonical reader URL — an earlier 404 for it is stuck in cache.'
        );
    }

    public function testOfferUpdateAlsoBansCmsReaderUrl(): void
    {
        // Unpublish → publish flips the reader from 404 to 200. Without the
        // postUpdate invalidation the cached 404 shadows the now-published page
        // until s-maxage.
        [$offer] = $this->makeOfferInEdition('sommerferien', 'rugby-camp');

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($offer, $this->emptyEm()));

        self::assertNotEmpty(
            array_filter(array_column($calls['bans'], 'pattern'), static fn (string $p): bool => str_contains($p, '/o/'))
        );
    }

    public function testOfferReaderBanCoversEveryCanonicalUrlShape(): void
    {
        // /o/{edition}/{base58}, …-{slug}, the .ics variants, the ?k= private
        // link, and every language mount of the #[Route('/o')] controller.
        [$offer, $uuid] = $this->makeOfferInEdition('sommerferien', 'rugby-camp');

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($offer, $this->emptyEm()));

        $base58 = $uuid->toBase58();
        $pattern = '#'.$this->banMatching($calls, '/o/').'#';

        self::assertMatchesRegularExpression($pattern, '/o/sommerferien/'.$base58);
        self::assertMatchesRegularExpression($pattern, '/o/sommerferien/'.$base58.'-rugby-camp');
        self::assertMatchesRegularExpression($pattern, '/o/sommerferien/'.$base58.'.ics');
        self::assertMatchesRegularExpression($pattern, '/o/sommerferien/'.$base58.'-rugby-camp.ics');
        self::assertMatchesRegularExpression($pattern, '/o/sommerferien/'.$base58.'?k=abc');
        self::assertMatchesRegularExpression($pattern, '/nl/o/sommerferien/'.$base58.'-rugby-camp');
    }

    public function testOfferReaderBanStaysScopedToItsOwnOfferAndEdition(): void
    {
        // The base58 uuid is the authoritative segment; terminating the pattern
        // keeps a sibling offer whose id merely shares a prefix out of it.
        [$offer, $uuid] = $this->makeOfferInEdition('sommerferien', 'rugby-camp');

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($offer, $this->emptyEm()));

        $pattern = '#'.$this->banMatching($calls, '/o/').'#';

        self::assertDoesNotMatchRegularExpression($pattern, '/o/sommerferien/'.$uuid->toBase58().'X');
        self::assertDoesNotMatchRegularExpression($pattern, '/o/osterferien/'.$uuid->toBase58());
        self::assertDoesNotMatchRegularExpression($pattern, '/o/sommerferien/'.Uuid::v7()->toBase58());
    }

    public function testOfferWithoutEditionInvalidatesNoReaderUrl(): void
    {
        // `/o/{edition}/…` cannot be built without an edition, and an offer
        // outside one has no canonical reader URL to purge.
        $offer = $this->makeOffer();
        $offer->method('getEdition')->willReturn(null);

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($offer, $this->emptyEm()));

        self::assertSame([], $calls['paths']);
        self::assertEmpty(
            array_filter(array_column($calls['bans'], 'pattern'), static fn (string $p): bool => str_contains($p, '/o/'))
        );
    }

    public function testHostPersistBansCmsVeranstaltendePath(): void
    {
        // `/v/{alias}` is a Symfony route in CmsBundle\Controller\Page, mounted
        // once per locale — so it's a host-scoped BAN, not an exact-URL PURGE
        // that would only ever hit the German variant.
        $host = $this->makeHost();
        $host->method('getAlias')->willReturn('sportverein');

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($host, $this->emptyEm()));

        self::assertContains(
            '^(/[a-z]{2,3})?/v/sportverein(\?|$)',
            array_column($calls['bans'], 'pattern')
        );
    }

    public function testHostUpdateAlsoBansCmsPath(): void
    {
        $host = $this->makeHost();
        $host->method('getAlias')->willReturn('sportverein');

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($host, $this->emptyEm()));

        self::assertContains(
            '^(/[a-z]{2,3})?/v/sportverein(\?|$)',
            array_column($calls['bans'], 'pattern')
        );
    }

    public function testHostBanPatternMatchesEveryLocaleVariant(): void
    {
        $host = $this->makeHost();
        $host->method('getAlias')->willReturn('sportverein');

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($host, $this->emptyEm()));

        $pattern = '#'.$this->banMatching($calls, '/v/sportverein').'#';
        self::assertMatchesRegularExpression($pattern, '/v/sportverein');
        self::assertMatchesRegularExpression($pattern, '/nl/v/sportverein');
        self::assertMatchesRegularExpression($pattern, '/prs/v/sportverein');
        // Must not leak into a longer alias that merely starts the same.
        self::assertDoesNotMatchRegularExpression($pattern, '/v/sportverein-nord');
    }

    public function testHostAliasIsQuotedInBanPattern(): void
    {
        // An alias is slugged, but the pattern is interpolated into a regex —
        // a stray metacharacter must not widen the BAN.
        $host = $this->makeHost();
        $host->method('getAlias')->willReturn('a.b');

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($host, $this->emptyEm()));

        $pattern = '#'.$this->banMatching($calls, '/v/a').'#';
        self::assertMatchesRegularExpression($pattern, '/v/a.b');
        self::assertDoesNotMatchRegularExpression($pattern, '/v/axb');
    }

    public function testParticipantPersistPurgesAccountPage(): void
    {
        // A new participant changes /account (lists children there). The
        // page is cached per-user via Vary: Cookie, but the URL is shared
        // so a path PURGE evicts the whole entry.
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getUuid')->willReturn(Uuid::v7());
        $participant->method('getUser')->willReturn(null);

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($participant, $this->emptyEm()));

        self::assertContains('/account', $calls['paths']);
    }

    public function testAttendancePersistPurgesParticipantAttendancesAndAccount(): void
    {
        $participantUuid = Uuid::v7();
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getUuid')->willReturn($participantUuid);
        $participant->method('getUser')->willReturn(null);

        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getUuid')->willReturn(Uuid::v7());
        $attendance->method('getParticipant')->willReturn($participant);
        $attendance->method('getOffer')->willReturn($this->makeOffer());

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($attendance, $this->emptyEm()));

        // The attendance sub-list is an admin route (locale-prefixed) and is
        // linked by base58, so it's a BAN covering both uuid spellings rather
        // than a PURGE of the legacy RFC4122 URL alone. /account is a Contao
        // page-tree URL and stays an exact PURGE.
        $pattern = '#'.$this->banMatching($calls, '/admin/teilnehmende/').'#';
        self::assertMatchesRegularExpression($pattern, '/admin/teilnehmende/'.$participantUuid->toBase58().'/anmeldungen');
        self::assertMatchesRegularExpression($pattern, '/admin/teilnehmende/'.$participantUuid->toRfc4122().'/anmeldungen');
        self::assertMatchesRegularExpression($pattern, '/nl/admin/teilnehmende/'.$participantUuid->toBase58().'/anmeldungen');
        self::assertContains('/account', $calls['paths']);
    }

    public function testAttendanceSubListBanStaysScopedToItsOwnParticipant(): void
    {
        // Widening this to `[^/]+` would evict every other participant's
        // attendance page on every application.
        $participantUuid = Uuid::v7();
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getUuid')->willReturn($participantUuid);
        $participant->method('getUser')->willReturn(null);

        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getUuid')->willReturn(Uuid::v7());
        $attendance->method('getParticipant')->willReturn($participant);
        $attendance->method('getOffer')->willReturn($this->makeOffer());

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($attendance, $this->emptyEm()));

        $pattern = '#'.$this->banMatching($calls, '/admin/teilnehmende/').'#';
        self::assertMatchesRegularExpression(
            $pattern,
            '/admin/teilnehmende/'.$participantUuid->toBase58().'/anmeldungen'
        );
        self::assertDoesNotMatchRegularExpression(
            $pattern,
            '/admin/teilnehmende/'.Uuid::v7()->toBase58().'/anmeldungen'
        );
    }

    public function testAttendanceWithoutParticipantOnlyPurgesAccount(): void
    {
        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getUuid')->willReturn(Uuid::v7());
        $attendance->method('getParticipant')->willReturn(null);
        $attendance->method('getOffer')->willReturn($this->makeOffer());

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($attendance, $this->emptyEm()));

        self::assertSame(['/account'], $calls['paths']);
    }

    // ====================================================================
    // Locale prefixes — admin routes are mounted once per language
    //
    // admin-bundle/config/routes.yaml mounts the controllers under de: ''
    // plus /nl, /en, /ar, /ru, /uk, /tr, /pl, /sq, /ku, /es, /fr, /pt, /sw,
    // /prs, /zh, /sr, /hr, /bs. Each is a separate Varnish object, so a
    // pattern anchored at `^/admin/…` silently leaves every non-German admin
    // stuck with a stale page for the full s-maxage=1y.
    // ====================================================================

    /**
     * The structural invariant, and the one that actually prevents this
     * regressing: no matter which event fires, an admin BAN must never be
     * anchored straight at `^/admin`. A new `match` arm that hardcodes the
     * German path trips this without anyone having to remember to add a
     * per-URL test for it.
     */
    public function testNoAdminBanIsAnchoredWithoutALocalePrefix(): void
    {
        $calls = $this->capture();
        $listener = $this->listener($calls);

        // One representative event per method that issues an admin BAN.
        $listener->postPersist(new PostPersistEventArgs($this->makeUser(), $this->emptyEm()));           // banListUrlsFor
        $listener->postUpdate($this->postUpdateEventWithChangeSet($this->makeOffer(), ['status']));      // banStatusFilteredUrlsFor
        $listener->postPersist(new PostPersistEventArgs($this->makePayment(), $this->emptyEm()));        // invalidatePaymentArea
        $listener->postUpdate(new PostUpdateEventArgs($this->makeNotification('x.y'), $this->emptyEm())); // Notification single-type
        $listener->postPersist(new PostPersistEventArgs($this->makeHostEditingStageTask(), $this->emptyEm())); // offer edit subtree
        $listener->postUpdate($this->postUpdateEventWithChangeSet(new PageConfig(), ['remindAttendance'])); // invalidateSettings

        $adminPatterns = array_values(array_filter(
            array_column($calls['bans'], 'pattern'),
            static fn (string $p): bool => str_contains($p, '/admin/'),
        ));

        self::assertNotEmpty($adminPatterns, 'Sanity: the representative events must issue admin BANs at all.');

        foreach ($adminPatterns as $pattern) {
            self::assertStringStartsWith(
                '^(/[a-z]{2,3})?/admin/',
                $pattern,
                \sprintf('BAN "%s" is anchored at the German path and will never evict /nl/admin/…', $pattern),
            );
        }
    }

    /**
     * @param list<string> $urls
     */
    #[DataProvider('localisedAdminBanCases')]
    public function testAdminBanPatternsMatchEveryLocaleVariant(string $needle, array $urls, string $nonMatch): void
    {
        $calls = $this->capture();
        $listener = $this->listener($calls);

        $listener->postPersist(new PostPersistEventArgs($this->makeUser(), $this->emptyEm()));
        $listener->postUpdate($this->postUpdateEventWithChangeSet($this->makeOffer(), ['status']));
        $listener->postPersist(new PostPersistEventArgs($this->makePayment(), $this->emptyEm()));
        $listener->postUpdate(new PostUpdateEventArgs($this->makeNotification('account.confirm_signup'), $this->emptyEm()));
        $listener->postPersist(new PostPersistEventArgs($this->makeHostEditingStageTask(), $this->emptyEm()));

        $pattern = '#'.$this->banMatching($calls, $needle).'#';

        foreach ($urls as $url) {
            self::assertMatchesRegularExpression($pattern, $url);
        }

        self::assertDoesNotMatchRegularExpression($pattern, $nonMatch);
    }

    /**
     * @return iterable<string, array{string, list<string>, string}>
     */
    public static function localisedAdminBanCases(): iterable
    {
        yield 'entity list (banListUrlsFor)' => [
            '/admin/accounts',
            ['/admin/accounts', '/admin/accounts/eltern', '/nl/admin/accounts', '/prs/admin/accounts?page=2'],
            '/admin/accountsummary',
        ];

        yield 'status-filtered list (banStatusFilteredUrlsFor)' => [
            'status',
            ['/admin/angebote?filter%5Bstatus%5D=draft', '/nl/admin/angebote?filter[status]=draft'],
            '/admin/angebote?page=2',
        ];

        yield 'receipts list (invalidatePaymentArea)' => [
            '/admin/zahlungen/belege',
            ['/admin/zahlungen/belege', '/nl/admin/zahlungen/belege?page=3'],
            '/admin/zahlungen/debitoren',
        ];

        yield 'single notification type (postUpdate)' => [
            'benachrichtigungen/account',
            ['/admin/benachrichtigungen/account.confirm_signup', '/uk/admin/benachrichtigungen/account.confirm_signup/sommer'],
            '/admin/benachrichtigungen/account.other',
        ];

        yield 'offer edit subtree (banOfferEditUrlsForHostEditingStageTask)' => [
            'bearbeiten',
            ['/admin/angebote/0192bd3c-1a2b-7c3d-8e4f-5a6b7c8d9e0f/bearbeiten', '/hr/admin/angebote/0192bd3c-1a2b-7c3d-8e4f-5a6b7c8d9e0f/bearbeiten/medien'],
            '/admin/angebote/0192bd3c-1a2b-7c3d-8e4f-5a6b7c8d9e0f/korrekturabzug',
        ];
    }

    // ====================================================================
    // invalidateSettings() — the tenant-global settings singletons
    //
    // PageConfig / IntegrationConfig / IntegrationCredential carry no UUID
    // that any page tags itself with, and `/admin` is stamped
    // `public, s-maxage=1y`, so without a BAN the settings screen serves its
    // pre-save snapshot for a year. Breadth is change-set gated: a field that
    // only renders on the settings screen narrows to /admin/system, anything
    // else takes the whole host.
    // ====================================================================

    public function testPageConfigUpdateOfPageLocalFieldBansOnlySettingsTree(): void
    {
        // Flipping the attendance-reminder toggle changes a cron's behaviour
        // and the settings screen's own rendering — nothing else. Evicting
        // every cached offer listing for it would be pure churn.
        $calls = $this->capture();
        $this->listener($calls)->postUpdate(
            $this->postUpdateEventWithChangeSet(new PageConfig(), ['remindAttendance'])
        );

        self::assertSame(
            [['pattern' => '^(/[a-z]{2,3})?/admin/system(/|\?|$)', 'hosts' => [self::TENANT_HOST]]],
            $calls['bans'],
            'A settings-screen-only field must evict the /admin/system tree only — and stay host-scoped.'
        );
    }

    public function testPageConfigUpdateOfPageLocalFieldMatchesLocalePrefixedSettingsUrl(): void
    {
        // Admin routes are locale-prefixed (admin-bundle/config/routes.yaml);
        // German is unprefixed. Both variants are separate cache objects.
        $calls = $this->capture();
        $this->listener($calls)->postUpdate(
            $this->postUpdateEventWithChangeSet(new PageConfig(), ['eraseDataPeriod'])
        );

        $pattern = '#'.$calls['bans'][0]['pattern'].'#';
        self::assertMatchesRegularExpression($pattern, '/admin/system/allgemein');
        self::assertMatchesRegularExpression($pattern, '/nl/admin/system/allgemein');
        self::assertMatchesRegularExpression($pattern, '/admin/system/integrationen/brevo_sms/konfigurieren');
        self::assertDoesNotMatchRegularExpression($pattern, '/admin/systemcheck');
    }

    public function testPageConfigUpdateOfRenderedFieldBansWholeHost(): void
    {
        // pageTitle lands in the `<title>` of EVERY admin page and colorPrimary
        // in the brand CSS of the admin layout AND the CMS fe_page — there is
        // no narrower pattern that covers them.
        $calls = $this->capture();
        $this->listener($calls)->postUpdate(
            $this->postUpdateEventWithChangeSet(new PageConfig(), ['pageTitle'])
        );

        self::assertSame(
            [['pattern' => '^/', 'hosts' => [self::TENANT_HOST]]],
            $calls['bans']
        );
    }

    public function testPageConfigUpdateMixingPageLocalAndRenderedFieldsBansWholeHost(): void
    {
        // One rendered field in the change-set is enough to widen the BAN.
        $calls = $this->capture();
        $this->listener($calls)->postUpdate(
            $this->postUpdateEventWithChangeSet(new PageConfig(), ['remindAttendance', 'friendsBooking'])
        );

        self::assertSame('^/', $calls['bans'][0]['pattern']);
    }

    public function testUnclassifiedPageConfigFieldFallsBackToWholeHostBan(): void
    {
        // The allow-list is fail-safe by design: a column nobody classified
        // must over-invalidate, never under-invalidate.
        $calls = $this->capture();
        $this->listener($calls)->postUpdate(
            $this->postUpdateEventWithChangeSet(new PageConfig(), ['someFieldAddedNextYear'])
        );

        self::assertSame('^/', $calls['bans'][0]['pattern']);
    }

    public function testPageConfigPersistBansWholeHost(): void
    {
        // First save materialises the singleton row — the entire default
        // config becomes live at once, so nothing is safe to keep.
        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs(new PageConfig(), $this->emptyEm()));

        self::assertSame('^/', $calls['bans'][0]['pattern']);
    }

    public function testIntegrationConfigSaveBansWholeHost(): void
    {
        // `isIntegrationConfigured('PmPaymentProvider')` gates the payment
        // block of the CMS application form and the pay button in offer
        // emails, not just the admin integration cards.
        $calls = $this->capture();
        $this->listener($calls)->postUpdate(
            $this->postUpdateEventWithChangeSet(new IntegrationConfig('PmPaymentProvider'), ['config'])
        );

        self::assertSame(
            [['pattern' => '^/', 'hosts' => [self::TENANT_HOST]]],
            $calls['bans']
        );
    }

    public function testSettingsBanSkippedWhenHostEmpty(): void
    {
        // Same cross-tenant guard as every other BAN in the listener.
        $calls = $this->capture();
        $this->listener($calls, host: '')->postUpdate(
            $this->postUpdateEventWithChangeSet(new PageConfig(), ['pageTitle'])
        );

        self::assertSame([], $calls['bans']);
    }

    // ====================================================================
    // canonicalHost() — port + scheme handling, empty-host BAN guard
    // ====================================================================

    public function testEmptyHostContextSkipsAllBans(): void
    {
        // Sending a BAN without a host filter would match every cached
        // object — cross-tenant nuke. The listener must skip.
        $offer = $this->makeOffer();
        $calls = $this->capture();
        $this->listener($calls, host: '')
            ->postPersist(new PostPersistEventArgs($offer, $this->emptyEm()));

        self::assertSame(
            [],
            $calls['bans'],
            'No host context → no BAN. Path purges and tag invalidations still fire because they\'re host-keyed implicitly.'
        );
    }

    public function testStatusBanAlsoSkippedWhenHostEmpty(): void
    {
        // Independent of the main banListUrlsFor guard.
        $offer = $this->makeOffer();
        $calls = $this->capture();
        $this->listener($calls, host: '')
            ->postUpdate($this->postUpdateEventWithChangeSet($offer, ['status']));

        self::assertSame([], $calls['bans']);
    }

    // ====================================================================
    // Over-invalidation — what we MUST NOT evict
    //
    // The dispatch tables are easy to widen by accident: someone adds a
    // new BAN prefix to the Attendance arm, someone adds a new tag to the
    // Participant cascade, and unrelated cache buckets get torched on
    // every edit. The assertions in this section pin the negative space
    // so a future widening trips a test.
    // ====================================================================

    public function testOfferUpdateDoesNotInvalidateUnrelatedTags(): void
    {
        // An offer edit shouldn't cascade to user, host, or edition tags.
        // The offer's own pages carry only the offer UUID; the edition tag
        // is reserved for EditionTask cascades (which affect status badges
        // for the whole edition, not a single offer's content).
        $offer = $this->makeOffer();
        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($offer, $this->emptyEm()));

        $tags = $this->flatTags($calls);
        self::assertSame(
            [CacheTag::offer($offer)],
            $tags,
            'Offer update tag set must be exactly `[offer.<uuid>]` — no user/host/edition cascades.'
        );
    }

    public function testHostUpdateDoesNotInvalidateUnrelatedTags(): void
    {
        $host = $this->makeHost();
        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($host, $this->emptyEm()));

        self::assertSame([CacheTag::host($host)], $this->flatTags($calls));
    }

    public function testEditionUpdateDoesNotInvalidateUnrelatedTags(): void
    {
        $edition = $this->makeEdition();
        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($edition, $this->emptyEm()));

        self::assertSame([CacheTag::edition($edition)], $this->flatTags($calls));
    }

    public function testParticipantUpdateDoesNotTagOffer(): void
    {
        // A participant has no parent offer (only attendances do). The
        // participant cascade reaches into user but must NOT walk further
        // into attendances → offers, even if the participant has many.
        $user = $this->makeUser();
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getUuid')->willReturn(Uuid::v7());
        $participant->method('getUser')->willReturn($user);

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($participant, $this->emptyEm()));

        $tags = $this->flatTags($calls);
        foreach ($tags as $tag) {
            self::assertStringStartsNotWith(
                'offer.',
                $tag,
                'Participant cascade must NOT reach offer tags — that would evict every offer the child ever attended.'
            );
        }
    }

    public function testAttendanceWithNullParticipantDoesNotTagParticipantOrUser(): void
    {
        // Defensive: the array_filter chain in the Attendance arm must
        // drop both participant and user tags cleanly when participant is
        // null, not synthesise empty/null entries.
        $offer = $this->makeOffer();
        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getUuid')->willReturn(Uuid::v7());
        $attendance->method('getParticipant')->willReturn(null);
        $attendance->method('getOffer')->willReturn($offer);

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($attendance, $this->emptyEm()));

        $tags = $this->flatTags($calls);
        foreach ($tags as $tag) {
            self::assertStringStartsNotWith('participant.', $tag);
            self::assertStringStartsNotWith('user.', $tag);
            self::assertNotEmpty($tag);
        }
    }

    public function testDbafsWithOfferDoesNotTagUserOrHost(): void
    {
        // Editing image metadata cascades to offers only — not to the
        // file's uploadedBy user or anything else.
        $offer = $this->makeOffer();
        $assoc = $this->createMock(DbafsOfferAssociation::class);
        $assoc->method('getOffer')->willReturn($offer);

        $media = $this->createMock(DbafsWithOffer::class);
        $media->method('getOffers')->willReturn(new ArrayCollection([$assoc]));

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($media, $this->emptyEm()));

        $tags = $this->flatTags($calls);
        foreach ($tags as $tag) {
            self::assertStringStartsNotWith('user.', $tag);
            self::assertStringStartsNotWith('host.', $tag);
        }
    }

    public function testOfferPersistDoesNotBanUnrelatedAdminAreas(): void
    {
        // Offer creation BANs `/admin/angebote` only. It does NOT BAN
        // participants/payments/accounts/seasons/notifications — those
        // are independent collection lists with their own dispatch arms.
        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($this->makeOffer(), $this->emptyEm()));

        $patterns = array_column($calls['bans'], 'pattern');
        foreach (['teilnehmende', 'zahlungen', 'accounts', 'saisons', 'veranstaltende', 'benachrichtigungen'] as $area) {
            foreach ($patterns as $p) {
                self::assertStringNotContainsString(
                    $area,
                    $p,
                    \sprintf('Offer persist must NOT BAN /admin/%s — different aggregate.', $area)
                );
            }
        }
    }

    public function testUserPersistDoesNotBanUnrelatedAdminAreas(): void
    {
        // A user creation only affects /admin/accounts. The user might
        // eventually own participants/attendances/payments, but those
        // lists are unchanged by a bare account creation — those would
        // come with their own lifecycle events when the children appear.
        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($this->makeUser(), $this->emptyEm()));

        $patterns = array_column($calls['bans'], 'pattern');
        self::assertSame(
            ['^(/[a-z]{2,3})?/admin/accounts(/[a-zA-Z0-9_\-+]+)?(\?|$)'],
            $patterns,
            'User persist BAN must be exactly `^/admin/accounts(/[a-zA-Z0-9_\-+]+)?(\?|$)` — nothing else.'
        );
    }

    public function testHostPersistDoesNotBanOffersList(): void
    {
        // Hosts and offers are independent aggregates — creating a host
        // doesn't change any offer's data. The cross-cascade only goes
        // the other way (HostMemberAssociation cascades to host tag).
        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($this->makeHost(), $this->emptyEm()));

        $patterns = array_column($calls['bans'], 'pattern');
        self::assertNotContains('^(/[a-z]{2,3})?/admin/angebote(/[a-zA-Z0-9_\-+]+)?(\?|$)', $patterns);
        self::assertNotContains('^(/[a-z]{2,3})?/admin/teilnehmende(/[a-zA-Z0-9_\-+]+)?(\?|$)', $patterns);
    }

    public function testEditionPersistDoesNotBanOffersList(): void
    {
        // A bare edition with no offers yet only touches /admin/saisons.
        // Offers acquire their edition assignment separately, with their
        // own lifecycle events.
        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($this->makeEdition(), $this->emptyEm()));

        $patterns = array_column($calls['bans'], 'pattern');
        self::assertSame(['^(/[a-z]{2,3})?/admin/saisons(/[a-zA-Z0-9_\-+]+)?(\?|$)'], $patterns);
    }

    public function testNotificationPersistBanScopedToBenachrichtigungenOnly(): void
    {
        $notification = $this->createMock(Notification::class);
        $notification->method('getType')->willReturn('account.confirm_signup');

        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($notification, $this->emptyEm()));

        $patterns = array_column($calls['bans'], 'pattern');
        self::assertSame(['^(/[a-z]{2,3})?/admin/benachrichtigungen(/|\?|$)'], $patterns);
    }

    public function testNotificationUpdateDoesNotBanOtherTypes(): void
    {
        // Editing the `account.confirm_signup` body must NOT evict
        // cached preview pages for `account.welcome`, `attendance.invite`,
        // etc. The narrow regex with the type in it is the contract.
        $notification = $this->createMock(Notification::class);
        $notification->method('getType')->willReturn('account.confirm_signup');

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($notification, $this->emptyEm()));

        $banPattern = $calls['bans'][0]['pattern'] ?? '';
        $regex = '#'.$banPattern.'#';
        self::assertSame(
            1,
            preg_match($regex, '/admin/benachrichtigungen/account.confirm_signup/preview'),
            'Sanity: pattern must match its own type.'
        );
        self::assertSame(
            0,
            preg_match($regex, '/admin/benachrichtigungen/account.welcome/preview'),
            'Pattern must NOT match a sibling notification type. The cached pages for unrelated types stay live.'
        );
        self::assertSame(
            0,
            preg_match($regex, '/admin/benachrichtigungen/attendance.invite'),
            'Pattern must NOT match a sibling notification type.'
        );
    }

    public function testStatusBanDoesNotEvictNonStatusFilteredUrls(): void
    {
        // The status BAN regex is `^/admin/<area>.*status=` — it must
        // NOT match URLs without the `status=` query param. A cached
        // `/admin/angebote?filter[name]=foo` page should survive a
        // status transition on some other offer.
        $offer = $this->makeOffer();
        $calls = $this->capture();
        $this->listener($calls)->postUpdate($this->postUpdateEventWithChangeSet($offer, ['status']));

        $statusBan = '#^/admin/angebote.*(\[|%5B)status(\]|%5D)=#';
        self::assertSame(0, preg_match($statusBan, '/admin/angebote'));
        self::assertSame(0, preg_match($statusBan, '/admin/angebote?page=2'));
        self::assertSame(0, preg_match($statusBan, '/admin/angebote?filter%5Bname%5D=foo'));
        self::assertSame(1, preg_match($statusBan, '/admin/angebote?filter%5Bstatus%5D=draft'));
        self::assertSame(1, preg_match($statusBan, '/admin/angebote?filter[status]=draft'));
    }

    public function testOfferUpdateDoesNotInvalidateHostReaderUrls(): void
    {
        // Offers and hosts have separate CMS detail URL prefixes: /o/… vs
        // /v/{alias}. A cross-prefix eviction would wipe an unrelated part of
        // the tenant's page tree.
        [$offer] = $this->makeOfferInEdition('sommerferien', 'rugby-camp');

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($offer, $this->emptyEm()));

        foreach ($calls['paths'] as $path) {
            self::assertStringStartsNotWith('/v/', $path, 'Offer update must not touch host CMS paths.');
        }
        foreach (array_column($calls['bans'], 'pattern') as $pattern) {
            self::assertStringNotContainsString('/v/', $pattern, 'Offer update must not BAN host CMS URLs.');
        }
    }

    public function testHostUpdateDoesNotPurgeOfferAliasPath(): void
    {
        $host = $this->makeHost();
        $host->method('getAlias')->willReturn('sportverein');

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($host, $this->emptyEm()));

        // Checks both channels: a host's own CMS page moved from the path
        // purges to the locale-aware BANs, so asserting over `paths` alone
        // would now pass vacuously.
        self::assertSame([], $calls['paths']);
        foreach (array_column($calls['bans'], 'pattern') as $pattern) {
            self::assertStringNotContainsString('/angebot/', $pattern);
        }
    }

    public function testParticipantUpdateDoesNotPurgeAdminAttendancePath(): void
    {
        // The /admin/teilnehmende/{uuid}/anmeldungen purge is reserved
        // for Attendance lifecycle events (the page lists attendances).
        // A participant edit (name change, postal address) shouldn't
        // evict it. Participant `postUpdate` doesn't call
        // `pathsForCreatedOrRemoved` at all (only persist/remove do), so
        // the path list comes back empty — assert that directly.
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getUuid')->willReturn(Uuid::v7());
        $participant->method('getUser')->willReturn(null);

        $calls = $this->capture();
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs($participant, $this->emptyEm()));

        self::assertSame(
            [],
            $calls['paths'],
            'Participant updates must not purge any paths — `pathsForCreatedOrRemoved` is reserved for create/delete events.'
        );
    }

    public function testEditionTaskTitleOnlyEditDoesNotInvalidateAnything(): void
    {
        // The change-set gate (see CacheInvalidationListenerCollectionTest)
        // should keep title-only edits BAN-free. The edition UUID tag
        // still fires (intentional — admin Saisons page shows title), but
        // NO BAN at all. This is the strongest negative assertion in the
        // suite — locks in the optimisation.
        $task = $this->createMock(EditionTask::class);
        $task->method('getEdition')->willReturn($this->makeEdition());
        $task->method('isAnApplicationSystem')->willReturn(false);
        $task->method('getType')->willReturn('custom');

        $calls = $this->capture();
        $this->listener($calls)->postUpdate($this->postUpdateEventWithChangeSet($task, ['title', 'description']));

        self::assertSame(
            [],
            $calls['bans'],
            'Title/description-only EditionTask edits must NOT fire any BAN. '
            .'The edition tag cascade still covers the admin Saisons page.'
        );
    }

    public function testDefaultEntityHasZeroSideEffects(): void
    {
        // Across all three lifecycle events, a non-matched entity (a
        // future entity class, a stdClass, etc.) must round-trip without
        // any side effect. This catches a future `match` arm widened to
        // accidentally cover everything.
        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs(new \stdClass(), $this->emptyEm()));
        $this->listener($calls)->postUpdate(new PostUpdateEventArgs(new \stdClass(), $this->emptyEm()));
        $this->listener($calls)->postRemove(new PostRemoveEventArgs(new \stdClass(), $this->emptyEm()));

        self::assertSame([], $calls['tags']);
        self::assertSame([], $calls['bans']);
        self::assertSame([], $calls['paths']);
    }

    // ====================================================================
    // Capture / mock helpers
    // ====================================================================

    /**
     * @return array{tags: list<list<string>>, bans: list<array{pattern: string, hosts: ?array}>, paths: list<string>}
     */
    private function capture(): array
    {
        // Pre-seed the three arrays so a no-action test asserts an empty
        // array rather than an undefined offset.
        return ['tags' => [], 'bans' => [], 'paths' => []];
    }

    /**
     * @param array{tags: list<list<string>>, bans: list<array{pattern: string, hosts: ?array}>, paths: list<string>} $calls
     */
    private function listener(array &$calls, string $host = self::TENANT_HOST): CacheInvalidationListener
    {
        $manager = $this->createMock(CacheManager::class);
        $manager->method('invalidateTags')->willReturnCallback(
            static function (array $tags) use (&$calls, $manager) {
                $calls['tags'][] = array_values($tags);

                return $manager;
            }
        );
        $manager->method('invalidatePath')->willReturnCallback(
            static function (string $path) use (&$calls, $manager) {
                $calls['paths'][] = $path;

                return $manager;
            }
        );
        $manager->method('invalidateRegex')->willReturnCallback(
            static function (string $pattern, ?string $_contentType = null, $hosts = null) use (&$calls, $manager) {
                $calls['bans'][] = ['pattern' => $pattern, 'hosts' => $hosts];

                return $manager;
            }
        );

        $context = new RequestContext();
        $context->setHost($host);
        $context->setScheme('https');

        $router = $this->createMock(RouterInterface::class);
        $router->method('getContext')->willReturn($context);

        return new CacheInvalidationListener($manager, $router, 'test-app');
    }

    /**
     * @param array{tags: list<list<string>>, bans: list<array{pattern: string, hosts: ?array}>, paths: list<string>} $calls
     *
     * @return list<string>
     */
    private function flatTags(array $calls): array
    {
        return [] !== $calls['tags'] ? array_merge(...$calls['tags']) : [];
    }

    /**
     * The one issued BAN pattern containing `$needle`, for tests that then
     * exercise it as a regex against concrete URLs. Fails loudly rather than
     * returning null, so a pattern that stopped being issued surfaces as a
     * named failure instead of a preg_match warning.
     *
     * @param array{tags: list<list<string>>, bans: list<array{pattern: string, hosts: ?array}>, paths: list<string>} $calls
     */
    private function banMatching(array $calls, string $needle): string
    {
        $patterns = array_values(array_filter(
            array_column($calls['bans'], 'pattern'),
            static fn (string $p): bool => str_contains($p, $needle),
        ));

        self::assertCount(1, $patterns, \sprintf('Expected exactly one BAN pattern containing "%s".', $needle));

        return $patterns[0];
    }

    /**
     * @param list<string> $changedFields
     */
    private function postUpdateEventWithChangeSet(object $entity, array $changedFields): PostUpdateEventArgs
    {
        $changeSet = array_combine($changedFields, array_fill(0, \count($changedFields), [null, 'new']));

        $uow = $this->createMock(UnitOfWork::class);
        $uow->method('getEntityChangeSet')->with($entity)->willReturn($changeSet);

        $em = $this->createMock(EntityManagerInterface::class);
        $em->method('getUnitOfWork')->willReturn($uow);

        return new PostUpdateEventArgs($entity, $em);
    }

    private function emptyEm(): EntityManagerInterface
    {
        // For event paths that never touch the UoW (path purges, tag-only
        // dispatches) a bare EM mock is enough.
        return $this->createMock(EntityManagerInterface::class);
    }

    private function makeUser(): User
    {
        $user = $this->createMock(User::class);
        $user->method('getUuid')->willReturn(Uuid::v7());

        return $user;
    }

    private function makeOffer(): OfferInterface
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getUuid')->willReturn(Uuid::v7());

        return $offer;
    }

    private function makeHost(): Host
    {
        $host = $this->createMock(Host::class);
        $host->method('getUuid')->willReturn(Uuid::v7());

        return $host;
    }

    private function makeEdition(): Edition
    {
        $edition = $this->createMock(Edition::class);
        $edition->method('getUuid')->willReturn(Uuid::v7());

        return $edition;
    }

    /**
     * An offer that has a canonical reader URL — i.e. one that belongs to an
     * edition, since `/o/{edition}/{base58}` needs the edition's alias.
     *
     * @return array{OfferInterface, Uuid}
     */
    private function makeOfferInEdition(string $editionAlias, string $offerAlias): array
    {
        $edition = $this->createMock(Edition::class);
        $edition->method('getUuid')->willReturn(Uuid::v7());
        $edition->method('getAlias')->willReturn($editionAlias);

        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getUuid')->willReturn($uuid = Uuid::v7());
        $offer->method('getAlias')->willReturn($offerAlias);
        $offer->method('getEdition')->willReturn($edition);

        return [$offer, $uuid];
    }

    private function makePayment(): Payment
    {
        $payment = $this->createMock(Payment::class);
        $payment->method('getUuid')->willReturn(Uuid::v7());
        $payment->method('getDebtor')->willReturn(null);

        return $payment;
    }

    private function makeNotification(string $type): Notification
    {
        $notification = $this->createMock(Notification::class);
        $notification->method('getType')->willReturn($type);

        return $notification;
    }

    private function makeHostEditingStageTask(): EditionTask
    {
        $task = $this->createMock(EditionTask::class);
        $task->method('getEdition')->willReturn($this->makeEdition());
        $task->method('getType')->willReturn('host_editing_stage');
        $task->method('isAnApplicationSystem')->willReturn(false);

        return $task;
    }
}
