<?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\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\HostMemberAssociation;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\OfferAttachment;
use Ferienpass\CoreBundle\Entity\OfferDate;
use Ferienpass\CoreBundle\Entity\OfferMedia;
use Ferienpass\CoreBundle\Entity\OfferMemberAssociation;
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;

/**
 * Locks in the post-fix invalidation contract that the
 * "new admin account doesn't show up in /admin/accounts/eltern" bug exposed:
 *
 *   1. Per-row UUID tag is invalidated on every lifecycle event (so a page
 *      that displayed the specific user gets purged on update / remove).
 *
 *   2. List URLs are invalidated via a **regex BAN scoped to the current
 *      tenant's host** on persist / remove (so every sub-route and
 *      query-string variant flushes — `/admin/accounts/eltern`,
 *      `/admin/accounts/eltern+admins?page=2&qb_query=alice`, …).
 *
 *   3. Plain updates do NOT BAN list URLs. Editing one row would otherwise
 *      throw away cached lists of unrelated rows for no benefit.
 *
 *   4. Multi-tenant safety: the BAN carries the current host so sibling
 *      tenants on the same Varnish (alfeld / luebeck / nordhorn / …) keep
 *      their list caches.
 *
 * Why these matter: the prior implementation used `invalidatePath('/admin/accounts')`
 * which matched only the exact URL — every paginated/filtered variant
 * stayed cached with the pre-create snapshot until the 1-year s-maxage
 * naturally expired. An alternative xkey-tag fix was rejected because xkey
 * tags are GLOBAL — they'd evict sibling tenants on the shared Varnish.
 */
final class CacheInvalidationListenerCollectionTest extends TestCase
{
    private const TENANT_HOST = 'www.ferienpass-alfeld.de';

    public function testUserPersistInvalidatesUserTagAndBansAdminAccountsUrls(): void
    {
        $user = $this->makeUser();
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postPersist(new PostPersistEventArgs($user, $this->createMock(EntityManagerInterface::class)));

        // Per-row UUID tag still flies — pages that already cached this
        // user's detail view need to re-fetch.
        $flatTags = array_merge(...$tagCalls);
        self::assertContains(CacheTag::user($user), $flatTags);

        // BAN must cover the full /admin/accounts URL tree, host-scoped to
        // this tenant only.
        self::assertCount(
            1,
            $banCalls,
            'Expected exactly one BAN for the /admin/accounts subtree on User persist.'
        );
        self::assertSame('^(/[a-z]{2,3})?/admin/accounts(/[a-zA-Z0-9_\-+]+)?(\?|$)', $banCalls[0]['pattern']);
        self::assertSame(
            [self::TENANT_HOST],
            $banCalls[0]['hosts'],
            'BAN must be host-scoped — without this, creating a user in Alfeld would '
            .'evict every sibling tenant\'s admin list cache (Luebeck / Nordhorn / …) '
            .'on the shared Varnish instance.'
        );
    }

    public function testUserRemoveInvalidatesSameShapeAsPersist(): void
    {
        $user = $this->makeUser();
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postRemove(new PostRemoveEventArgs($user, $this->createMock(EntityManagerInterface::class)));

        self::assertCount(1, $banCalls, 'Remove must BAN the same list URLs as persist.');
        self::assertSame('^(/[a-z]{2,3})?/admin/accounts(/[a-zA-Z0-9_\-+]+)?(\?|$)', $banCalls[0]['pattern']);
        self::assertSame([self::TENANT_HOST], $banCalls[0]['hosts']);
    }

    public function testUserUpdateInvalidatesUserTagButDoesNotBanListUrls(): void
    {
        $user = $this->makeUser();
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate(new PostUpdateEventArgs($user, $this->createMock(EntityManagerInterface::class)));

        $flatTags = array_merge(...$tagCalls);
        self::assertContains(
            CacheTag::user($user),
            $flatTags,
            'Update must surgically invalidate the per-row tag so list/detail pages displaying this user re-render.'
        );
        self::assertSame(
            [],
            $banCalls,
            'Update MUST NOT BAN list URLs. Editing one row would otherwise throw away every '
            .'cached list of OTHER rows (different filters, pagination, etc.) for no benefit. '
            .'The per-row UUID tag already covers the rows that actually displayed the edited entity.'
        );
    }

    public function testBanIncludesPortWhenSchemeUsesNonDefaultPort(): void
    {
        // The Host header (and therefore the X-Host that Varnish stores on
        // cached objects) includes the port whenever it differs from the
        // scheme default. The BAN we issue must include the same port or
        // the regex won't match. Critical for the integration test setup
        // where Guzzle hits Varnish at http://127.0.0.1:8081.
        $user = $this->makeUser();
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(
            host: '127.0.0.1',
            tagCalls: $tagCalls,
            banCalls: $banCalls,
            scheme: 'http',
            port: 8081,
        );

        $listener->postPersist(new PostPersistEventArgs($user, $this->createMock(EntityManagerInterface::class)));

        self::assertCount(1, $banCalls);
        self::assertSame(
            ['127.0.0.1:8081'],
            $banCalls[0]['hosts'],
            'BAN must include the non-default port so the X-Host regex matches what Varnish stored.'
        );
    }

    public function testBanOmitsPortForDefaultHttpsPort(): void
    {
        // Production setup: https://www.ferienpass-alfeld.de on port 443
        // (the HTTPS default). The Host header is just the hostname; the
        // BAN regex must also be just the hostname or it won't match.
        $user = $this->makeUser();
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(
            host: 'www.ferienpass-alfeld.de',
            tagCalls: $tagCalls,
            banCalls: $banCalls,
            scheme: 'https',
            port: 443,
        );

        $listener->postPersist(new PostPersistEventArgs($user, $this->createMock(EntityManagerInterface::class)));

        self::assertSame(
            ['www.ferienpass-alfeld.de'],
            $banCalls[0]['hosts'],
            'BAN must NOT include :443 for default HTTPS — the Host header omits it.'
        );
    }

    /**
     * @return iterable<string, array{0: callable(OfferInterface): object}>
     */
    public static function offerChildEntities(): iterable
    {
        // Each factory creates a child entity bound to the given parent
        // Offer. The listener must cascade the parent's UUID tag for all of
        // these — otherwise editing media/dates/attachments leaves the
        // offer's detail and list pages serving the stale snapshot until
        // s-maxage expires.
        yield 'OfferMedia (image upload on /bearbeiten/media)' => [
            static fn (OfferInterface $offer) => new OfferMedia($offer),
        ];
        yield 'OfferAttachment (file upload on /bearbeiten/anhaenge)' => [
            static fn (OfferInterface $offer) => new OfferAttachment($offer),
        ];
        yield 'OfferDate (date added on /bearbeiten/dates)' => [
            static fn (OfferInterface $offer) => new OfferDate($offer),
        ];
        yield 'OfferMemberAssociation (host assigned on /bearbeiten)' => [
            static function (OfferInterface $offer) {
                $assoc = new OfferMemberAssociation();
                $assoc->setOffer($offer);

                return $assoc;
            },
        ];
    }

    /**
     * @param callable(OfferInterface): object $childFactory
     */
    #[DataProvider('offerChildEntities')]
    public function testOfferChildPersistCascadesToParentOfferTag(callable $childFactory): void
    {
        $offer = $this->makeOffer();
        $child = $childFactory($offer);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postPersist(new PostPersistEventArgs($child, $this->createMock(EntityManagerInterface::class)));

        $flatTags = array_merge(...$tagCalls);
        self::assertContains(
            CacheTag::offer($offer),
            $flatTags,
            'Child entity persist must invalidate the parent offer\'s UUID tag — otherwise '
            .'the offer\'s korrekturabzug / CMS detail / admin list pages stay cached with the '
            .'pre-change snapshot until s-maxage expires.'
        );
    }

    /**
     * @param callable(OfferInterface): object $childFactory
     */
    #[DataProvider('offerChildEntities')]
    public function testOfferChildUpdateCascadesToParentOfferTag(callable $childFactory): void
    {
        $offer = $this->makeOffer();
        $child = $childFactory($offer);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate(new PostUpdateEventArgs($child, $this->createMock(EntityManagerInterface::class)));

        $flatTags = array_merge(...$tagCalls);
        self::assertContains(
            CacheTag::offer($offer),
            $flatTags,
            'Editing a child entity (e.g. swapping an OfferMedia image, moving an OfferDate) '
            .'must invalidate the parent offer\'s UUID tag.'
        );
    }

    /**
     * @param callable(OfferInterface): object $childFactory
     */
    #[DataProvider('offerChildEntities')]
    public function testOfferChildRemoveCascadesToParentOfferTag(callable $childFactory): void
    {
        $offer = $this->makeOffer();
        $child = $childFactory($offer);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postRemove(new PostRemoveEventArgs($child, $this->createMock(EntityManagerInterface::class)));

        $flatTags = array_merge(...$tagCalls);
        self::assertContains(
            CacheTag::offer($offer),
            $flatTags,
            'Removing a child entity must invalidate the parent offer\'s UUID tag so the '
            .'offer\'s pages re-render without the removed media/date/attachment.'
        );
    }

    public function testOfferChildUpdateDoesNotBanAdminOfferListUrls(): void
    {
        // Editing one offer's media/date must NOT BAN /admin/angebote — that
        // would throw away every other offer's cached list entries for no
        // reason. The parent offer's UUID tag is enough; the list page that
        // rendered that offer carries its UUID and gets purged surgically.
        $offer = $this->makeOffer();
        $media = new OfferMedia($offer);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate(new PostUpdateEventArgs($media, $this->createMock(EntityManagerInterface::class)));

        self::assertSame(
            [],
            $banCalls,
            'Updating an offer-child entity MUST NOT BAN /admin/angebote — the parent offer\'s '
            .'UUID tag already invalidates every page that rendered this offer. A blanket BAN '
            .'would evict every other offer\'s cached list entries.'
        );
    }

    public function testBanIsSkippedWhenRouterHasNoHost(): void
    {
        // Defensive: an unconfigured router context (empty host) must NOT
        // produce an un-host-scoped BAN. Sending a BAN with empty `X-Host`
        // would match every cached object — cross-tenant nuke.
        $user = $this->makeUser();
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(host: '', tagCalls: $tagCalls, banCalls: $banCalls);

        $listener->postPersist(new PostPersistEventArgs($user, $this->createMock(EntityManagerInterface::class)));

        self::assertSame(
            [],
            $banCalls,
            'With an empty router host, the listener must skip the BAN rather than risk a cross-tenant eviction.'
        );
    }

    /**
     * Lock-in for the host-relationship leak.
     *
     * HostMemberAssociation links a User to a Host. Both sides display the
     * relationship — the user's edit page lists "Verbundene Veranstaltende"
     * (tagged user.{uuid}), the host's edit page lists members (tagged
     * host.{uuid}), and the CMS host detail page may render member info
     * (tagged host.{uuid}).
     *
     * Without an explicit match for HostMemberAssociation, the
     * persist/update/remove of the join row falls through to default => []
     * and no tags are invalidated. Both sides keep serving the pre-edit
     * snapshot until s-maxage expires (1 year). The fix cascades to BOTH
     * tags on every lifecycle event.
     */
    public function testHostMemberAssociationPersistCascadesToBothUserAndHostTags(): void
    {
        $user = $this->makeUser();
        $host = $this->makeHost();
        $assoc = new HostMemberAssociation($user, $host);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postPersist(new PostPersistEventArgs($assoc, $this->createMock(EntityManagerInterface::class)));

        $flatTags = array_merge(...$tagCalls);
        self::assertContains(
            CacheTag::user($user),
            $flatTags,
            'Persisting a HostMemberAssociation must invalidate the user\'s tag — otherwise '
            .'/admin/accounts/{user}/bearbeiten keeps showing the old "Verbundene Veranstaltende" list.'
        );
        self::assertContains(
            CacheTag::host($host),
            $flatTags,
            'Persisting a HostMemberAssociation must also invalidate the host\'s tag — otherwise '
            .'/admin/veranstaltende/{host}/bearbeiten and the CMS host detail page keep showing the old members.'
        );
    }

    public function testHostMemberAssociationRemoveCascadesToBothUserAndHostTags(): void
    {
        $user = $this->makeUser();
        $host = $this->makeHost();
        $assoc = new HostMemberAssociation($user, $host);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postRemove(new PostRemoveEventArgs($assoc, $this->createMock(EntityManagerInterface::class)));

        $flatTags = array_merge(...$tagCalls);
        self::assertContains(CacheTag::user($user), $flatTags, 'Remove must invalidate user side.');
        self::assertContains(CacheTag::host($host), $flatTags, 'Remove must invalidate host side.');
    }

    public function testHostMemberAssociationUpdateCascadesToBothUserAndHostTags(): void
    {
        // Updates are rare on a pure join row (it's usually persist-or-remove)
        // but locking it in is cheap and protects against future schema
        // additions like a "role" or "since" column on the association.
        $user = $this->makeUser();
        $host = $this->makeHost();
        $assoc = new HostMemberAssociation($user, $host);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate(new PostUpdateEventArgs($assoc, $this->createMock(EntityManagerInterface::class)));

        $flatTags = array_merge(...$tagCalls);
        self::assertContains(CacheTag::user($user), $flatTags);
        self::assertContains(CacheTag::host($host), $flatTags);
    }

    /**
     * Lock-in for the image-important-part / alt-text leak.
     *
     * The "important part" of an image is stored on the file entity itself
     * (DbafsMedia / DbafsAttachment, implementing DbafsWithOffer), NOT on the
     * OfferMedia / OfferAttachment join row that links the file to a specific
     * offer. When the admin saves the media tab on
     * `/admin/angebote/{uuid}/bearbeiten/media`, MediaManager::save() flushes
     * the DbafsMedia — the join rows are not touched.
     *
     * The `DbafsOfferAssociation` cascade in the listener only fires for the
     * JOIN rows. So without an explicit `DbafsWithOffer` case, a metadata edit
     * (importantPart / alt / creator) on a file used by N offers leaves all N
     * korrekturabzug / CMS detail / admin list pages serving the stale crop
     * for the full s-maxage (1 year on prod). The fix iterates `getOffers()`
     * and emits one `offer.{uuid}` tag per linked offer.
     */
    public function testDbafsWithOfferUpdateCascadesToEveryLinkedOfferTag(): void
    {
        $offerA = $this->makeOffer();
        $offerB = $this->makeOffer();
        // Same file attached to two offers via two OfferMedia join rows. This
        // is the realistic case: a logo / poster reused across offers.
        $file = $this->makeDbafsWithOffer([
            new OfferMedia($offerA),
            new OfferMedia($offerB),
        ]);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate(new PostUpdateEventArgs($file, $this->createMock(EntityManagerInterface::class)));

        $flatTags = array_merge(...$tagCalls);
        self::assertContains(
            CacheTag::offer($offerA),
            $flatTags,
            'Editing a DbafsMedia (important-part / alt / creator on the media tab) '
            .'must invalidate every offer that links to the file. Offer A was missed — '
            .'its korrekturabzug / CMS detail page will keep serving the old crop until '
            .'s-maxage expires.'
        );
        self::assertContains(
            CacheTag::offer($offerB),
            $flatTags,
            'Same file → both offers must be invalidated. Offer B was missed.'
        );
    }

    public function testDbafsWithOfferUpdateWithSingleAssociationStillFiresTag(): void
    {
        $offer = $this->makeOffer();
        $file = $this->makeDbafsWithOffer([new OfferMedia($offer)]);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate(new PostUpdateEventArgs($file, $this->createMock(EntityManagerInterface::class)));

        $flatTags = array_merge(...$tagCalls);
        self::assertContains(CacheTag::offer($offer), $flatTags);
    }

    public function testDbafsWithOfferUpdateWithNoAssociationsDoesNotFireOrCrash(): void
    {
        // Orphan file (no offers link to it yet, or all links were deleted).
        // The listener must not call invalidateTags([]) — that would be
        // wasted work — and must not crash on the empty collection.
        $orphan = $this->makeDbafsWithOffer([]);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate(new PostUpdateEventArgs($orphan, $this->createMock(EntityManagerInterface::class)));

        self::assertSame([], $tagCalls, 'Orphan DbafsMedia must produce zero tag invalidations.');
        self::assertSame([], $banCalls);
    }

    // ---------------------------------------------------------------------
    // EditionTask invalidation contract
    //
    // What we lock in here:
    //
    //   - The admin BAN regex covers list URLs (`/admin/angebote`,
    //     `/admin/angebote/herbst-2026`, `/admin/angebote/{uuid}`) but NOT
    //     multi-segment edit / korrekturabzug sub-paths. EditionTask edits
    //     used to evict the whole `/admin/angebote/*` tree even though
    //     edit forms read nothing task-derived.
    //   - CMS invalidation rides on the edition tag, not a URL BAN. The old
    //     `^/angebot/` sweep matched no real URL and guarded nothing cached
    //     (the application form is a private, no-store ESI) — see
    //     testApplicationSystemTaskUpdateCoversTheCmsViaTheEditionTag.
    //   - A change-set check on `postUpdate` skips the BANs when only
    //     non-rendering fields (title, description) changed.
    // ---------------------------------------------------------------------

    public function testEditionTaskRenderingUpdateBansNarrowedAdminUrls(): void
    {
        $task = $this->makeEditionTask('application_system');
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate($this->makePostUpdateEvent($task, ['maxApplications']));

        $patterns = array_column($banCalls, 'pattern');
        self::assertContains(
            '^(/[a-z]{2,3})?/admin/angebote(/[a-zA-Z0-9_\-+]+)?(\?|$)',
            $patterns,
            'EditionTask update must BAN the admin offers list (incl. edition-aliased variants).'
        );
        self::assertContains(
            '^(/[a-z]{2,3})?/admin/saisons(/[a-zA-Z0-9_\-+]+)?(\?|$)',
            $patterns,
            'EditionTask update must BAN the admin seasons list (incl. alias-scoped variants).'
        );

        // Regex shape check: the BAN must NOT match the offer edit / proof
        // / multi-segment sub-paths — that was the over-eviction we set
        // out to fix.
        $angebotePattern = '#^/admin/angebote(/[a-zA-Z0-9_\-+]+)?(\?|$)#';
        self::assertSame(
            0,
            preg_match($angebotePattern, '/admin/angebote/019e6908-bc4f-75d7-9c80-902a7a65807b/bearbeiten/dates'),
            'BAN regex must NOT match offer edit URLs (they don\'t render any EditionTask fields).'
        );
        self::assertSame(
            0,
            preg_match($angebotePattern, '/admin/angebote/019e6908-bc4f-75d7-9c80-902a7a65807b/korrekturabzug'),
            'BAN regex must NOT match the offer korrekturabzug page.'
        );
        self::assertSame(
            1,
            preg_match($angebotePattern, '/admin/angebote/herbst-2026'),
            'BAN regex must still match the per-edition list URL.'
        );
        self::assertSame(
            1,
            preg_match($angebotePattern, '/admin/angebote/herbst-2026+sommer-2026?page=2'),
            'BAN regex must still match the multi-edition + paginated list URL.'
        );
    }

    public function testApplicationSystemTaskUpdateCoversTheCmsViaTheEditionTag(): void
    {
        // This used to BAN `^/angebot/` on the premise that the CMS offer detail
        // page renders `applicationSystem.task.*`. It does not: the application
        // form is an ESI fragment from ApplicationFormController, which extends
        // AbstractPrivateFragmentController and answers `private, no-store`, so
        // Varnish never stores it and a task change is live on the next request.
        //
        // The one cached CMS surface that DOES render task state — the offer
        // listing's firstcome filter — is tagged CacheTag::edition() by
        // OfferListController, and the EditionTask arm of tagsFor() purges
        // exactly that. Assert the tag flies and that no CMS URL BAN does.
        $task = $this->makeEditionTask('application_system');
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate($this->makePostUpdateEvent($task, ['applicationSystem']));

        self::assertContains(
            CacheTag::edition($task->getEdition()),
            array_merge(...$tagCalls),
            'The edition tag is what carries CMS invalidation for an application-system change.'
        );

        foreach (array_column($banCalls, 'pattern') as $pattern) {
            self::assertStringNotContainsString(
                '/angebot/',
                $pattern,
                'No CMS-side URL BAN: `/angebot/` never matched a real URL, and nothing cached needs it.'
            );
        }
    }

    // testAllocationTaskUpdateDoesNotBanCmsUrls used to live here, asserting
    // that a non-application_system task issues no `^/angebot/` BAN. With that
    // BAN gone from the listener entirely, no input could make the assertion
    // fail — a test that cannot fail is worse than no test, so it's removed
    // rather than left to imply coverage it doesn't provide.

    public function testEditionTaskTitleOnlyUpdateSkipsAllBans(): void
    {
        // Renaming a task or editing its description doesn't change
        // anything anyone renders — `title` / `description` appear only
        // on the admin saisons show page (covered by the edition UUID
        // tag, which fires separately).
        $task = $this->makeEditionTask('application_system');
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate($this->makePostUpdateEvent($task, ['title', 'description']));

        self::assertSame(
            [],
            $banCalls,
            'Title/description-only task edits must not trigger any BAN — they don\'t affect rendering. '
            .'The edition UUID tag still fires via tagsFor() and covers the admin saisons show page.'
        );
    }

    public function testEditionTaskPersistAlwaysFiresBansForApplicationSystem(): void
    {
        // Persist/remove don't have a meaningful change-set — the entire
        // task is appearing or disappearing. We always evict.
        $task = $this->makeEditionTask('application_system');
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postPersist(new PostPersistEventArgs($task, $this->createMock(EntityManagerInterface::class)));

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

        // The CMS side rides on the edition tag, not a URL BAN — see
        // testApplicationSystemTaskUpdateCoversTheCmsViaTheEditionTag.
        self::assertContains(CacheTag::edition($task->getEdition()), array_merge(...$tagCalls));
    }

    // ---------------------------------------------------------------------
    // host_editing_stage task → offer EDIT URL eviction
    //
    // OfferVoter::canEdit() returns
    //   `!$edition->getActiveTasks('host_editing_stage')->isEmpty()`
    // and OfferEditController bakes that into the cached edit page as
    // `isReadonly` (lines 84 + 97). When the task's active state flips,
    // EVERY cached offer-edit page in this tenant is stale — and the
    // narrowed admin BAN regex from
    // `testEditionTaskRenderingUpdateBansNarrowedAdminUrls` deliberately
    // excludes `/bearbeiten` to avoid evicting it on every unrelated
    // EditionTask edit. The host_editing_stage carve-out here is the
    // explicit case where eviction is required.
    // ---------------------------------------------------------------------

    public function testHostEditingStageTaskUpdateBansOfferEditUrls(): void
    {
        $task = $this->makeEditionTask('host_editing_stage');
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        // `periodEnd` changing flips `isActive()` → voter result flips →
        // `isReadonly` flag in the cached edit form is wrong.
        $listener->postUpdate($this->makePostUpdateEvent($task, ['periodEnd']));

        $patterns = array_column($banCalls, 'pattern');
        self::assertContains(
            '^(/[a-z]{2,3})?/admin/angebote/[a-f0-9\-]+/bearbeiten',
            $patterns,
            'host_editing_stage active-state changes must evict offer edit pages — they cache the voter result as `isReadonly`.'
        );

        // Verify the regex shape: matches /bearbeiten URLs but doesn't
        // accidentally match the list URLs (already covered by the wider
        // EditionTask BAN).
        $editBan = '#^/admin/angebote/[a-f0-9\-]+/bearbeiten#';
        self::assertSame(1, preg_match($editBan, '/admin/angebote/019e6908-bc4f-75d7-9c80-902a7a65807b/bearbeiten/dates'));
        self::assertSame(1, preg_match($editBan, '/admin/angebote/019e6908-bc4f-75d7-9c80-902a7a65807b/bearbeiten'));
        self::assertSame(
            0,
            preg_match($editBan, '/admin/angebote/herbst-2026'),
            'Edit BAN regex must NOT match the per-edition list URL — that would defeat the narrowing.'
        );
    }

    public function testNonHostEditingStageTaskUpdateDoesNotBanEditUrls(): void
    {
        // Every other task type — application_system, allocation,
        // publish_lists, custom — does NOT gate edit access. Their changes
        // must not evict the offer edit subtree.
        foreach (['application_system', 'allocation', 'publish_lists', 'custom'] as $type) {
            $task = $this->makeEditionTask($type);
            $tagCalls = [];
            $banCalls = [];
            $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

            $listener->postUpdate($this->makePostUpdateEvent($task, ['periodEnd']));

            self::assertNotContains(
                '^(/[a-z]{2,3})?/admin/angebote/[a-f0-9\-]+/bearbeiten',
                array_column($banCalls, 'pattern'),
                \sprintf('Task type "%s" must NOT BAN /bearbeiten — only host_editing_stage does.', $type),
            );
        }
    }

    public function testHostEditingStageTaskPersistBansOfferEditUrls(): void
    {
        // Creating a new host_editing_stage task opens edit access to
        // host users → cached "denied" pages need eviction.
        $task = $this->makeEditionTask('host_editing_stage');
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postPersist(new PostPersistEventArgs($task, $this->createMock(EntityManagerInterface::class)));

        self::assertContains(
            '^(/[a-z]{2,3})?/admin/angebote/[a-f0-9\-]+/bearbeiten',
            array_column($banCalls, 'pattern'),
        );
    }

    public function testHostEditingStageTaskRemoveBansOfferEditUrls(): void
    {
        // Removing the only host_editing_stage task closes edit access →
        // cached "edit form" pages need eviction.
        $task = $this->makeEditionTask('host_editing_stage');
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postRemove(new PostRemoveEventArgs($task, $this->createMock(EntityManagerInterface::class)));

        self::assertContains(
            '^(/[a-z]{2,3})?/admin/angebote/[a-f0-9\-]+/bearbeiten',
            array_column($banCalls, 'pattern'),
        );
    }

    public function testTaskTypeChangedAwayFromHostEditingStageStillBansEditUrls(): void
    {
        // The "demote from host_editing_stage to custom" path: by the time
        // postUpdate fires, `$task->getType()` is already `custom`. But
        // the cached `isReadonly=false` pages were rendered when the task
        // WAS host_editing_stage and need eviction.
        //
        // The change-set check picks up the old value: changeSet['type']
        // = ['host_editing_stage', 'custom'] → old value matches → BAN
        // fires.
        $task = $this->makeEditionTask('custom');
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate(
            $this->makePostUpdateEventWithValues($task, ['type' => ['host_editing_stage', 'custom']])
        );

        self::assertContains(
            '^(/[a-z]{2,3})?/admin/angebote/[a-f0-9\-]+/bearbeiten',
            array_column($banCalls, 'pattern'),
            'Demoting away from host_editing_stage must still BAN the edit subtree — the cached pages reflect the OLD voter result.'
        );
    }

    public function testTaskTypeChangedTowardsHostEditingStageBansEditUrls(): void
    {
        // The opposite direction: promoting a `custom` task to
        // `host_editing_stage`. The current type IS host_editing_stage,
        // so the regular type check catches it without needing the
        // change-set old-value fallback.
        $task = $this->makeEditionTask('host_editing_stage');
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate(
            $this->makePostUpdateEventWithValues($task, ['type' => ['custom', 'host_editing_stage']])
        );

        self::assertContains(
            '^(/[a-z]{2,3})?/admin/angebote/[a-f0-9\-]+/bearbeiten',
            array_column($banCalls, 'pattern'),
        );
    }

    // ---------------------------------------------------------------------
    // Offer collection tag → CMS listing visibility
    //
    // The CMS offer list / "offers" / "highlighted offers" listings tag
    // themselves with the edition-scoped `offer.collection.<edition>` tag
    // (on top of one `offer.<uuid>` per rendered card). The per-offer xkey
    // cannot make an offer APPEAR on a listing — while the offer was hidden
    // (draft, non-public) the listing never carried its tag. So when an
    // offer enters or leaves the published/public set, the listener must
    // purge the collection tag. Scoped to the edition UUID (globally
    // unique) so it never evicts a sibling tenant's listings.
    // ---------------------------------------------------------------------

    public function testOfferPersistInvalidatesEditionScopedCollectionTag(): void
    {
        $edition = $this->makeEdition();
        $offer = $this->makeOffer($edition);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postPersist(new PostPersistEventArgs($offer, $this->createMock(EntityManagerInterface::class)));

        $flatTags = array_merge(...$tagCalls);
        self::assertContains(
            CacheTag::offerCollection($edition),
            $flatTags,
            'A newly-created offer may enter its edition\'s published listing — the edition '
            .'collection tag must be purged so the (cached) list picks up the new card.'
        );
    }

    public function testOfferRemoveInvalidatesEditionScopedCollectionTag(): void
    {
        $edition = $this->makeEdition();
        $offer = $this->makeOffer($edition);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postRemove(new PostRemoveEventArgs($offer, $this->createMock(EntityManagerInterface::class)));

        $flatTags = array_merge(...$tagCalls);
        self::assertContains(
            CacheTag::offerCollection($edition),
            $flatTags,
            'A removed offer must drop out of its edition\'s cached listing.'
        );
    }

    public function testOfferStatusChangeInvalidatesEditionScopedCollectionTag(): void
    {
        // The headline case: draft → published. The listing never carried
        // this offer's per-offer tag (it was filtered out while draft), so
        // only the collection tag makes the new card appear.
        $edition = $this->makeEdition();
        $offer = $this->makeOffer($edition);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate($this->makeOfferPostUpdateEvent($offer, ['status' => [['draft' => 1], ['published' => 1]]]));

        $flatTags = array_merge(...$tagCalls);
        self::assertContains(
            CacheTag::offerCollection($edition),
            $flatTags,
            'A status transition (draft → published, cancel, complete) changes listing membership '
            .'and must purge the edition collection tag.'
        );
    }

    public function testOfferPublishFlagChangeInvalidatesEditionScopedCollectionTag(): void
    {
        // The `publish` (public/private) flag also gates the listing query
        // (`o.publish = 'public'`), so flipping it changes membership too.
        $edition = $this->makeEdition();
        $offer = $this->makeOffer($edition);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate($this->makeOfferPostUpdateEvent($offer, ['publish' => ['private', 'public']]));

        $flatTags = array_merge(...$tagCalls);
        self::assertContains(CacheTag::offerCollection($edition), $flatTags);
    }

    public function testOfferPlainContentUpdateDoesNotInvalidateCollectionTag(): void
    {
        // Editing name/fee/description doesn't change listing membership —
        // the per-offer xkey already purges the listings that rendered this
        // card. Busting the whole edition collection here would needlessly
        // evict every other offer's cached listing.
        $edition = $this->makeEdition();
        $offer = $this->makeOffer($edition);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate($this->makeOfferPostUpdateEvent($offer, ['name' => ['Alt', 'Neu']]));

        $flatTags = $tagCalls ? array_merge(...$tagCalls) : [];
        self::assertContains(CacheTag::offer($offer), $flatTags, 'The per-offer tag still fires for content edits.');
        self::assertNotContains(
            CacheTag::offerCollection($edition),
            $flatTags,
            'A plain content edit must NOT bust the edition collection tag — wasted eviction of every other offer\'s listing.'
        );
    }

    public function testOfferEditionMoveInvalidatesBothSourceAndDestinationCollections(): void
    {
        // Rare, but correct: moving an offer between editions removes its
        // card from the source listing and adds it to the destination. Both
        // edition collections must be purged.
        $oldEdition = $this->makeEdition();
        $newEdition = $this->makeEdition();
        $offer = $this->makeOffer($newEdition);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postUpdate($this->makeOfferPostUpdateEvent($offer, ['edition' => [$oldEdition, $newEdition]]));

        $flatTags = array_merge(...$tagCalls);
        self::assertContains(CacheTag::offerCollection($newEdition), $flatTags, 'Destination edition listing must be purged.');
        self::assertContains(CacheTag::offerCollection($oldEdition), $flatTags, 'Source edition listing must be purged too.');
    }

    public function testEditionlessOfferUsesBareCollectionBucket(): void
    {
        // A tenant without editions: offers carry no edition, so the listing
        // tag (and its invalidation) falls back to the bare `offer.collection`.
        $offer = $this->makeOffer(null);
        $tagCalls = [];
        $banCalls = [];
        $listener = $this->makeListener(self::TENANT_HOST, $tagCalls, $banCalls);

        $listener->postPersist(new PostPersistEventArgs($offer, $this->createMock(EntityManagerInterface::class)));

        $flatTags = array_merge(...$tagCalls);
        self::assertContains(CacheTag::offerCollection(null), $flatTags);
        self::assertSame('offer.collection', CacheTag::offerCollection(null));
    }

    /**
     * @param list<DbafsOfferAssociation> $associations
     */
    private function makeDbafsWithOffer(array $associations): DbafsWithOffer
    {
        return new class($associations) implements DbafsWithOffer {
            public function __construct(private readonly array $associations)
            {
            }

            public function getOffers(): \Doctrine\Common\Collections\Collection
            {
                return new ArrayCollection($this->associations);
            }
        };
    }

    /**
     * @param list<list<string>>                          $tagCalls out-param: captures invalidateTags(...) arguments
     * @param list<array{pattern: string, hosts: ?array}> $banCalls out-param: captures invalidateRegex(...) arguments
     */
    private function makeListener(
        string $host,
        array &$tagCalls,
        array &$banCalls,
        string $scheme = 'https',
        ?int $port = null,
    ): CacheInvalidationListener {
        $manager = $this->createMock(CacheManager::class);
        $manager->method('invalidateTags')->willReturnCallback(
            static function (array $tags) use (&$tagCalls, $manager) {
                $tagCalls[] = array_values($tags);

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

                return $manager;
            }
        );

        $context = new RequestContext();
        $context->setHost($host);
        $context->setScheme($scheme);
        if (null !== $port) {
            if ('https' === $scheme) {
                $context->setHttpsPort($port);
            } else {
                $context->setHttpPort($port);
            }
        }
        $router = $this->createMock(RouterInterface::class);
        $router->method('getContext')->willReturn($context);

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

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

        return $user;
    }

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

        return $offer;
    }

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

        return $edition;
    }

    /**
     * Build a PostUpdateEventArgs for an Offer whose change-set carries the
     * given `[oldValue, newValue]` tuples (the membership check reads the keys;
     * the edition-move branch reads the old `edition` value).
     *
     * @param array<string, array{0: mixed, 1: mixed}> $changeSet
     */
    private function makeOfferPostUpdateEvent(OfferInterface $offer, array $changeSet): PostUpdateEventArgs
    {
        $uow = $this->createMock(UnitOfWork::class);
        $uow->method('getEntityChangeSet')->with($offer)->willReturn($changeSet);

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

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

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

        return $host;
    }

    /**
     * Variant of {@see self::makePostUpdateEvent()} that lets the caller
     * pass full `[oldValue, newValue]` tuples for change-set entries —
     * needed for the type-transition tests because
     * `banOfferEditUrlsForHostEditingStageTask()` reads the OLD type
     * value for the demote-away-from-host_editing_stage case.
     *
     * @param array<string, array{0: mixed, 1: mixed}> $changeSet
     */
    private function makePostUpdateEventWithValues(EditionTask $task, array $changeSet): PostUpdateEventArgs
    {
        $uow = $this->createMock(UnitOfWork::class);
        $uow->method('getEntityChangeSet')->with($task)->willReturn($changeSet);

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

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

    private function makeEditionTask(string $type): EditionTask
    {
        $task = $this->createMock(EditionTask::class);
        $task->method('getEdition')->willReturn($this->createMock(Edition::class));
        $task->method('getType')->willReturn($type);
        $task->method('isAnApplicationSystem')->willReturn('application_system' === $type);

        return $task;
    }

    /**
     * Build a PostUpdateEventArgs whose UnitOfWork->getEntityChangeSet()
     * returns the requested field names — the change-set check in
     * `editionTaskRenderingFieldsChanged()` reads only the keys, so the
     * values are arbitrary.
     *
     * @param list<string> $changedFields
     */
    private function makePostUpdateEvent(EditionTask $task, array $changedFields): PostUpdateEventArgs
    {
        $changeSet = array_combine($changedFields, array_fill(0, \count($changedFields), [null, 'changed']));

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

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

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