<?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\DbafsOfferAssociation;
use Ferienpass\CoreBundle\Entity\DbafsWithOffer;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Entity\EditionTask;
use Ferienpass\CoreBundle\Entity\Host;
use Ferienpass\CoreBundle\Entity\Notification;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Entity\Payment;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\EventListener\HttpCache\CacheInvalidationListener;
use Ferienpass\CoreBundle\HttpCache\CacheTag;
use FOS\HttpCacheBundle\CacheManager;
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 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, BookEntry,
        // 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(
            ['^/admin/angebote(/|\?|$)'],
            array_column($calls['bans'], 'pattern'),
        );
    }

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

        self::assertSame(
            ['^/admin/veranstaltende(/|\?|$)'],
            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('^/admin/teilnehmende(/|\?|$)', $patterns);
        self::assertContains(
            '^/admin/accounts(/|\?|$)',
            $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('^/admin/teilnehmende(/|\?|$)', $patterns);
        self::assertNotContains(
            '^/admin/accounts(/|\?|$)',
            $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('^/admin/teilnehmende(/|\?|$)', $patterns);
        self::assertContains('^/admin/zahlungen(/|\?|$)', $patterns);
        self::assertContains('^/admin/accounts(/|\?|$)', $patterns);
        self::assertContains('^/admin/angebote(/|\?|$)', $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(
            '^/admin/accounts(/|\?|$)',
            $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(
            ['^/admin/saisons(/|\?|$)'],
            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(
            ['^/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(
            '^/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(
            '^/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(
            '^/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(
            '^/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('^/admin/teilnehmende.*(\[|%5B)status(\]|%5D)=', $patterns);
        self::assertContains('^/admin/zahlungen.*(\[|%5B)status(\]|%5D)=', $patterns);
        self::assertContains('^/admin/angebote.*(\[|%5B)status(\]|%5D)=', $patterns);
    }

    public function testPaymentStatusChangeBansZahlungenStatusFilters(): void
    {
        $payment = $this->createMock(Payment::class);
        $payment->method('getUuid')->willReturn(Uuid::v7());

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

        $patterns = array_column($calls['bans'], 'pattern');
        self::assertContains('^/admin/zahlungen.*(\[|%5B)status(\]|%5D)=', $patterns);
    }

    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 / Payment only).'
        );
    }

    // ====================================================================
    // Dashboard aggregate tag — the /admin dashboard is one cached entry
    // whose heatmap / next-steps / activity-feed aggregates move on every
    // attendance lifecycle event but carry no per-attendance 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 testNonAttendanceEntityDoesNotInvalidateDashboard(): void
    {
        // Only attendances feed the dashboard's aggregates through this
        // listener — an offer create must not drag the dashboard tag along.
        $calls = $this->capture();
        $this->listener($calls)->postPersist(new PostPersistEventArgs($this->makeOffer(), $this->emptyEm()));

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

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

    public function testOfferPersistPurgesCmsDetailAlias(): void
    {
        $offer = $this->makeOffer();
        $offer->method('getAlias')->willReturn('rugby-camp');

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

        self::assertContains(
            '/angebot/rugby-camp',
            $calls['paths'],
            'Offer persist must PURGE the alias path — earlier 404 responses to that URL are stuck in cache.'
        );
    }

    public function testOfferUpdateAlsoPurgesCmsDetailAlias(): void
    {
        // Unpublish → publish flips an offer from 404 to 200 on the alias
        // page. Without the postUpdate purge, the cached 404 keeps
        // shadowing the now-published page until s-maxage.
        $offer = $this->makeOffer();
        $offer->method('getAlias')->willReturn('rugby-camp');

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

        self::assertContains('/angebot/rugby-camp', $calls['paths']);
    }

    public function testOfferWithoutAliasPurgesNothing(): void
    {
        $offer = $this->makeOffer();
        $offer->method('getAlias')->willReturn(null);

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

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

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

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

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

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

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

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

    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()));

        self::assertContains('/admin/teilnehmende/'.$participantUuid->toRfc4122().'/anmeldungen', $calls['paths']);
        self::assertContains('/account', $calls['paths']);
    }

    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']);
    }

    // ====================================================================
    // 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(
            ['^/admin/accounts(/|\?|$)'],
            $patterns,
            'User persist BAN must be exactly `^/admin/accounts(/|\?|$)` — 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('^/admin/angebote(/|\?|$)', $patterns);
        self::assertNotContains('^/admin/teilnehmende(/|\?|$)', $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(['^/admin/saisons(/|\?|$)'], $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(['^/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 testOfferUpdateDoesNotPurgeHostAliasPath(): void
    {
        // Offers and hosts have separate CMS detail URL prefixes:
        // /angebot/{alias} vs /v/{alias}. Cross-prefix purges would
        // wipe an unrelated tenant page tree.
        $offer = $this->makeOffer();
        $offer->method('getAlias')->willReturn('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.');
        }
    }

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

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

        foreach ($calls['paths'] as $path) {
            self::assertStringStartsNotWith('/angebot/', $path);
        }
    }

    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']) : [];
    }

    /**
     * @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;
    }
}
