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

use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Event\PostRemoveEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs;
use Doctrine\ORM\Events;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\ConsentForm;
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\Notification;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\OfferDate;
use Ferienpass\CoreBundle\Entity\OfferMemberAssociation;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Entity\Payment;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\HttpCache\CacheTag;
use FOS\HttpCacheBundle\CacheManager;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\RouterInterface;

/**
 * Doctrine listener that invalidates Varnish on entity lifecycle events for
 * entities touched by the CMS/frontend (so admin pages stay in sync when the
 * user creates a participant or attendance on /account/*).
 *
 * ## Invalidation strategy — three layers
 *
 *   - **postUpdate** → purge the entity's UUID tag ONLY (xkey-based, via
 *     `invalidateTags`). The list and detail pages that rendered this
 *     entity got tagged with its UUID (see
 *     {@see \Ferienpass\AdminBundle\Components\SearchableQueryableList}
 *     and the various `$this->tagResponse(CacheTag::xxx(...))` calls in
 *     CMS controllers), so this surgical purge invalidates exactly those
 *     pages without touching unrelated cached lists.
 *
 *   - **postPersist / postRemove** → UUID tag + **regex BAN** for every
 *     admin list URL that displays the entity type. The BAN matches
 *     against `obj.http.X-Host` and `obj.http.X-Url` (see
 *     `varnish/config/fos_ban.vcl`), which means:
 *       - URL regex catches every sub-route and query-string variant
 *         (`/admin/accounts`, `/admin/accounts/eltern`,
 *         `/admin/accounts/eltern+admins?page=2&qb_query=…`).
 *       - Host filter scopes the BAN to THIS tenant's host only. A user
 *         created in Alfeld doesn't evict Nordhorn's caches, even though
 *         the same Varnish instance serves all tenants.
 *
 *   - **postPersist / postRemove (special cases)** → exact-URL PURGE for
 *     CMS-side alias pages (`/angebot/{alias}`, `/v/{alias}`). These may
 *     have been cached as a 404 while the entity was unpublished or not
 *     yet routed; a single PURGE per known alias wipes that 404 so the
 *     first post-publish visitor gets the rendered page.
 *
 * ## Why BAN (not just `invalidatePath`)
 *
 * `invalidatePath('/admin/accounts')` sends a PURGE matching the exact
 * `URL + Host`. The admin list routes are parameterised
 * (`/admin/accounts/{role?}`) and accept query strings (`?page=2`), so
 * every sub-route and pagination variant was a separate cache entry that
 * a single PURGE missed. Creating a user used to leave every filtered
 * /admin/accounts/* cache entry stale for the full 1-year s-maxage.
 *
 * ## Why BAN (not xkey tags)
 *
 * xkey tags are global across the Varnish instance. In our shared-Varnish
 * multi-tenant setup, `xkey.purge('user.collection')` would evict cached
 * lists in every tenant, not just the one that fired the event. BAN
 * supports a host filter (`X-Host` regex) which keeps the eviction
 * tenant-scoped.
 *
 * Entities handled directly: Offer, Host, Participant, Attendance, User,
 * Edition, EditionTask, Notification.
 *
 * Entities that cascade to a parent's tag (no own list URLs):
 *   - OfferDate, OfferMedia, OfferAttachment, OfferMemberAssociation → parent
 *     Offer's UUID tag. Image upload on the admin media tab persists an
 *     OfferMedia; without the cascade, the offer's korrekturabzug page and
 *     CMS detail page would serve the stale snapshot until s-maxage expires.
 *
 * Notification is admin-only (no frontend code path changes it) but still
 * handled here: its preview pages are parameterised by type/edition, so they
 * need the regex BAN that `#[InvalidatePath]` (exact-URL PURGE) cannot deliver.
 *
 * Remaining admin-only entities (Debtor, BookEntry, ParticipantDuplicate) are
 * handled by `#[InvalidatePath]` on the admin POST controllers instead.
 */
#[AsDoctrineListener(event: Events::postPersist)]
#[AsDoctrineListener(event: Events::postUpdate)]
#[AsDoctrineListener(event: Events::postRemove)]
class CacheInvalidationListener
{
    public function __construct(
        private readonly CacheManager $cacheManager,
        private readonly RouterInterface $router,
        #[Autowire(param: 'app.id')]
        private readonly string $appId,
    ) {
    }

    public function postPersist(PostPersistEventArgs $args): void
    {
        $entity = $args->getObject();

        // Persist: UUID tag (cascades to related entities' tags) + regex
        // BAN for admin list URLs + CMS path PURGE for alias pages.
        $this->tagsFor($entity);
        $this->banListUrlsFor($entity);
        if ($entity instanceof EditionTask) {
            $this->banCmsUrlsForApplicationSystemTask($entity);
            $this->banOfferEditUrlsForHostEditingStageTask(null, $entity);
        }
        // A new offer may enter an edition's published CMS listing.
        if ($entity instanceof OfferInterface) {
            $this->tagOfferCollection($entity);
        }
        // A newly-persisted attendance that already sits in a stash bumps the
        // header badge count (rare, but lotting can create+stash in one UoW).
        if ($entity instanceof Attendance && null !== $entity->getStash()) {
            $this->invalidateStashCount();
        }
        // A new attendance (e.g. a CMS application) adds a heatmap data point,
        // a next-steps row and an activity-feed entry on the dashboard.
        if ($entity instanceof Attendance) {
            $this->invalidateDashboard();
        }
        $this->pathsForCreatedOrRemoved($entity);
    }

    public function postUpdate(PostUpdateEventArgs $args): void
    {
        $entity = $args->getObject();

        // Updates: per-row UUID tag only. Pages that rendered this entity
        // carry its UUID as an xkey and get purged surgically. We do NOT
        // BAN every admin list URL for ordinary updates — that would throw
        // away cached lists of other unaffected rows.
        $this->tagsFor($entity);

        // EditionTask escalation: an EditionTask isn't enumerated as its
        // own list, but changes to render-affecting fields (application
        // window, max applications, application system, etc.) alter the
        // booking-status badges of EVERY offer in the edition's listing —
        // pages that don't carry the edition UUID as a tag. BAN the offer
        // + edition list URLs the same way persist/remove would.
        //
        // Gated on a change-set check (cf. `statusChanged()` below): a
        // task-title or task-description edit doesn't touch anything
        // anyone renders, so there's no reason to evict the admin list
        // tree (or the CMS application form) for those.
        if ($entity instanceof EditionTask && $this->editionTaskRenderingFieldsChanged($args, $entity)) {
            $this->banListUrlsFor($entity);
            $this->banCmsUrlsForApplicationSystemTask($entity);
            $this->banOfferEditUrlsForHostEditingStageTask($args, $entity);
        }

        // A Notification edit only changes THIS type's preview pages
        // (/benachrichtigungen/{type} and …/{type}/{edition}). The left-nav
        // status dot (missing / configured / edition-specific) is derived from
        // existence + edition — neither of which an edit changes — so we don't
        // need to evict other types' cached pages. Create/delete DO flip that
        // dot on every page and go through postPersist/postRemove → broad BAN.
        if ($entity instanceof Notification && '' !== $host = $this->canonicalHost()) {
            $this->cacheManager->invalidateRegex(
                '^/admin/benachrichtigungen/'.preg_quote($entity->getType()).'(/|\?|$)',
                hosts: [$host],
            );
        }

        // Workflow status transitions on Offer / Attendance / Payment evict
        // status-filtered list pages. A cached list at e.g.
        // `?filter[status]=draft` no longer represents reality once a row
        // transitions out of draft — neither does `?filter[status]=published`
        // for the row that just entered it. xkey only invalidates pages that
        // were rendered WITH this row visible; lists filtered to a value the
        // row will now match (but didn't before) need the regex BAN.
        if ($this->statusChanged($args, $entity)) {
            $this->banStatusFilteredUrlsFor($entity);

            // The dashboard's next-steps widget buckets attendances by status
            // (uncommitted / waiting), so a status transition restates it. The
            // heatmap (by immutable createdAt) and activity feed are unaffected
            // by an update, but the page is one cache entry — purge it whole.
            if ($entity instanceof Attendance) {
                $this->invalidateDashboard();
            }
        }

        // An attendance entering or leaving a stash changes the header badge
        // count (the ESI fragment tagged CacheTag::stash). This is the lotting /
        // manual-assign path (ORM setStash); the bulk-DQL commit clears the stash
        // without firing events, so the CommitAttendances worker purges the tag
        // itself. xkey purges need no host, so this works in the lotting worker.
        if ($entity instanceof Attendance && $this->stashMembershipChanged($args, $entity)) {
            $this->invalidateStashCount();
        }

        // An offer that enters or leaves the published/public set (status
        // transition, publish flag flip, or a move between editions) changes
        // which cards a CMS listing renders. The per-offer xkey can't fix that
        // — the listing never carried this offer's tag while it was hidden — so
        // bust the edition-scoped offer-collection tag the listings carry.
        if ($entity instanceof OfferInterface && $this->offerCollectionMembershipChanged($args, $entity)) {
            $this->tagOfferCollection($entity, $args);
        }

        // Offer / Host: a previous request may have cached a 404 for
        // /angebot/{alias} (or /v/{alias}) while the entity was unpublished
        // or not yet created; now that it's being updated (e.g. published),
        // the 404 must be purged so the first visitor gets the real page.
        if ($entity instanceof OfferInterface || $entity instanceof Host) {
            $this->pathsForCreatedOrRemoved($entity);
        }
    }

    public function postRemove(PostRemoveEventArgs $args): void
    {
        $entity = $args->getObject();

        // Removal: same shape as persist.
        $this->tagsFor($entity);
        $this->banListUrlsFor($entity);
        if ($entity instanceof EditionTask) {
            $this->banCmsUrlsForApplicationSystemTask($entity);
            $this->banOfferEditUrlsForHostEditingStageTask(null, $entity);
        }
        // A removed offer drops out of its edition's published CMS listing.
        if ($entity instanceof OfferInterface) {
            $this->tagOfferCollection($entity);
        }
        // Deleting an attendance that sat in a stash lowers the badge count.
        if ($entity instanceof Attendance && null !== $entity->getStash()) {
            $this->invalidateStashCount();
        }
        // A removed attendance drops out of the dashboard's aggregates.
        if ($entity instanceof Attendance) {
            $this->invalidateDashboard();
        }
        $this->pathsForCreatedOrRemoved($entity);
    }

    /**
     * True if the Doctrine UoW changeset shows the `status` column changed
     * on an entity type whose status drives admin list filters.
     */
    private function statusChanged(PostUpdateEventArgs $args, object $entity): bool
    {
        if (!($entity instanceof OfferInterface || $entity instanceof Attendance || $entity instanceof Payment)) {
            return false;
        }

        return \array_key_exists(
            'status',
            $args->getObjectManager()->getUnitOfWork()->getEntityChangeSet($entity),
        );
    }

    /**
     * True if the Doctrine change-set shows the attendance's `stash` association
     * changed (entered or left a stash) — the only field that moves the header
     * badge count.
     */
    private function stashMembershipChanged(PostUpdateEventArgs $args, Attendance $entity): bool
    {
        return \array_key_exists(
            'stash',
            $args->getObjectManager()->getUnitOfWork()->getEntityChangeSet($entity),
        );
    }

    /**
     * Purge the tenant's staged-attendances badge fragment (see
     * {@see CacheTag::stash()}). xkey purge — no host needed, so it fires
     * correctly from request and worker contexts alike.
     */
    private function invalidateStashCount(): void
    {
        $this->cacheManager->invalidateTags([CacheTag::stash($this->appId)]);
    }

    /**
     * Purge the admin dashboard fragment (see {@see CacheTag::dashboard()}).
     *
     * The dashboard renders attendance-derived aggregates — heatmap counts,
     * uncommitted/waiting next-steps, and the recent-activity feed — but carries
     * no per-attendance UUID tag, so the surgical `attendance.<uuid>` purge can't
     * reach it and the `/admin` BAN list deliberately stops at the entity list
     * URLs. An xkey purge (no host needed) busts it from request and worker
     * contexts alike, the same way {@see invalidateStashCount()} handles the
     * staged-count badge.
     */
    private function invalidateDashboard(): void
    {
        $this->cacheManager->invalidateTags([CacheTag::dashboard($this->appId)]);
    }

    /**
     * True if an offer update changed its membership in a CMS listing's
     * published/public set: the workflow `status`, the `publish` visibility
     * flag, or the `edition` it belongs to. Plain content edits (name, fee,
     * dates, …) are already covered by the per-offer xkey and don't need the
     * collection tag.
     */
    private function offerCollectionMembershipChanged(PostUpdateEventArgs $args, OfferInterface $entity): bool
    {
        $changeSet = $args->getObjectManager()->getUnitOfWork()->getEntityChangeSet($entity);

        return [] !== array_intersect(['status', 'publish', 'edition'], array_keys($changeSet));
    }

    /**
     * Invalidate the edition-scoped offer-collection tag(s) that CMS listings
     * carry (see {@see CacheTag::offerCollection()}). On an edition move
     * (postUpdate change-set carries the old edition) both the source and the
     * destination listing are busted; persist/remove pass no `$args` and only
     * the current edition is affected.
     */
    private function tagOfferCollection(OfferInterface $offer, ?PostUpdateEventArgs $args = null): void
    {
        $tags = [CacheTag::offerCollection($offer->getEdition())];

        if (null !== $args) {
            $changeSet = $args->getObjectManager()->getUnitOfWork()->getEntityChangeSet($offer);
            $previousEdition = $changeSet['edition'][0] ?? null;
            if (null !== $previousEdition) {
                $tags[] = CacheTag::offerCollection($previousEdition);
            }
        }

        $this->cacheManager->invalidateTags($tags);
    }

    /**
     * True if the Doctrine UoW changeset for an EditionTask touched a field
     * that drives anything anyone renders:
     *
     *   - `type`, `applicationSystem` — switch firstcome ↔ lot, gates the
     *     CMS application form layout and the admin booking-status badge
     *   - `periodBegin`, `periodEnd`, `alwaysActive` — `isActive()` /
     *     `isUpcoming()` / `isCompleted()` flip → badge text changes
     *   - `maxApplications`, `maxApplicationsDay`, `maxWaitlist` —
     *     CMS application form max-selection loops + waitlist gating
     *   - `allowAnonymous`, `allowAnonymousFee`, `skipMaxApplications` —
     *     anonymous application gating in CMS
     *   - `accessCodeStrategy` — admin signup tooling
     *
     * `title`, `description`, `createdAt`, `uuid` don't appear anywhere
     * cached — editing them shouldn't evict any pages.
     */
    private function editionTaskRenderingFieldsChanged(PostUpdateEventArgs $args, EditionTask $entity): bool
    {
        $changeSet = $args->getObjectManager()->getUnitOfWork()->getEntityChangeSet($entity);

        return [] !== array_intersect(
            ['type', 'applicationSystem', 'periodBegin', 'periodEnd', 'alwaysActive',
                'maxApplications', 'maxApplicationsDay', 'maxWaitlist',
                'allowAnonymous', 'allowAnonymousFee', 'skipMaxApplications',
                'accessCodeStrategy'],
            array_keys($changeSet),
        );
    }

    /**
     * Application-system EditionTask changes (or persist/remove) move the
     * goalposts for every CMS-side application form on this edition's
     * offers — anonymous fee, maxApplications, maxWaitlist, firstcome vs
     * lot, applicationDeadline-equivalent state. The CMS offer detail
     * templates read these via `applicationSystem.task.*` (see
     * `cms-bundle/contao/templates/components/application_form.html.twig`),
     * so every offer detail page in this edition is now stale.
     *
     * The per-offer `CacheTag::offer` xkey doesn't fire here — we don't
     * know which offers have already been rendered. A regex BAN against
     * the canonical CMS offer-detail prefix (`/angebot/<alias>`) is the
     * right tool: host-scoped, fast, and only fires when the task is
     * actually an application_system task. Allocation / publish_lists /
     * lotting tasks skip this — they don't render anywhere on the CMS.
     */
    private function banCmsUrlsForApplicationSystemTask(EditionTask $entity): void
    {
        if (!$entity->isAnApplicationSystem()) {
            return;
        }

        $host = $this->canonicalHost();
        if ('' === $host) {
            return;
        }

        $this->cacheManager->invalidateRegex('^/angebot/', hosts: [$host]);
    }

    /**
     * `host_editing_stage` tasks gate host-user write access to the offer
     * edit screens. {@see \Ferienpass\CoreBundle\Security\Voter\OfferVoter::canEdit()}
     * returns `!$edition->getActiveTasks('host_editing_stage')->isEmpty()`,
     * and that result is baked into every cached
     * `/admin/angebote/{uuid}/bearbeiten/…` page as `isReadonly`
     * (`OfferEditController:84, :97`). When the task's active state flips
     * — `periodBegin` / `periodEnd` advancing past `now`, `alwaysActive`
     * toggling, the task being created or deleted — every cached edit
     * page for offers in this tenant is stale.
     *
     * The narrowed admin BAN regex {@see banListUrlsFor()} deliberately
     * excludes the `/bearbeiten/…` subtree to avoid evicting it on every
     * unrelated EditionTask edit (a `maxApplications` tweak shouldn't
     * throw away offer-form caches). This helper restores the eviction
     * specifically for the host_editing_stage type, which IS the case
     * where the edit page's rendered state depends on the task.
     *
     * Type-change handling: if `$args` is provided (postUpdate) we also
     * check whether the change-set OLD type was host_editing_stage, so
     * a "demote from host_editing_stage to custom" transition fires the
     * BAN even though the current `getType()` reads as `custom`. Persist /
     * remove pass `null` for `$args` and check current type only.
     */
    private function banOfferEditUrlsForHostEditingStageTask(?PostUpdateEventArgs $args, EditionTask $entity): void
    {
        if (!$this->isOrWasHostEditingStageTask($args, $entity)) {
            return;
        }

        $host = $this->canonicalHost();
        if ('' === $host) {
            return;
        }

        // Offer edit URLs are `/admin/angebote/<uuid>/bearbeiten[/section]`.
        // UUIDs are restricted to hex + `-`; tightening the regex prevents
        // accidental matches against alias-based list URLs (already covered
        // by the wider EditionTask list BAN).
        $this->cacheManager->invalidateRegex(
            '^/admin/angebote/[a-f0-9\-]+/bearbeiten',
            hosts: [$host],
        );
    }

    /**
     * True if the EditionTask is currently `host_editing_stage` OR was
     * `host_editing_stage` before this update (i.e. the change-set shows
     * a type transition out of host_editing_stage). Persist / remove
     * paths call this with a null `$args` and we just check the current
     * type — `$entity->getType()` still reads correctly during postRemove
     * since the object isn't detached until after listeners run.
     */
    private function isOrWasHostEditingStageTask(?PostUpdateEventArgs $args, EditionTask $entity): bool
    {
        if ('host_editing_stage' === $entity->getType()) {
            return true;
        }

        if (null === $args) {
            return false;
        }

        $changeSet = $args->getObjectManager()->getUnitOfWork()->getEntityChangeSet($entity);

        // Doctrine change-set shape: ['fieldName' => [oldValue, newValue]].
        // We want the OLD value here — postUpdate already has the new type
        // committed on the entity, so getType() above covered the "BECAME
        // host_editing_stage" direction; this catches "WAS host_editing_stage,
        // is now something else".
        return isset($changeSet['type']) && 'host_editing_stage' === ($changeSet['type'][0] ?? null);
    }

    /**
     * BAN every admin list URL filtering by `status` for the affected entity
     * type. The regex matches both URL-encoded (`filter%5Bstatus%5D=`) and
     * literal (`filter[status]=`) bracket forms, since Symfony's URL generator
     * sometimes leaves brackets unencoded.
     *
     * Wide pattern (`status=<anything>`) rather than per-value BANs because:
     *   - Offer.status is a JSON array of statuses (`{published: true}`);
     *     computing "added" vs "removed" values for surgical BANs would be
     *     fragile across workflow transitions that flip multiple keys.
     *   - The regex match cost in Varnish is negligible at our cache sizes.
     */
    private function banStatusFilteredUrlsFor(object $entity): void
    {
        $prefixes = match (true) {
            $entity instanceof OfferInterface => ['/admin/angebote'],
            // Attendance status appears in the participants list, in the
            // payments lists (paid/unpaid status is derived), and on the
            // offer's participant sub-list.
            $entity instanceof Attendance => ['/admin/teilnehmende', '/admin/zahlungen', '/admin/angebote'],
            $entity instanceof Payment => ['/admin/zahlungen'],
            default => [],
        };

        if ([] === $prefixes) {
            return;
        }

        $host = $this->canonicalHost();
        if ('' === $host) {
            // Same tenant-scoping guard as banListUrlsFor — skip when host
            // is unknown rather than risk a cross-tenant eviction.
            return;
        }

        foreach ($prefixes as $prefix) {
            // `^/prefix.*(\[|%5B)status(\]|%5D)=`
            $pattern = '^'.$prefix.'.*(\[|%5B)status(\]|%5D)=';
            $this->cacheManager->invalidateRegex($pattern, hosts: [$host]);
        }
    }

    private function tagsFor(object $entity): void
    {
        $tags = match (true) {
            $entity instanceof OfferInterface => [CacheTag::offer($entity)],
            $entity instanceof Host => [CacheTag::host($entity)],
            $entity instanceof ParticipantInterface => array_filter([
                CacheTag::participant($entity),
                null !== ($u = $entity->getUser()) ? CacheTag::user($u) : null,
            ]),
            $entity instanceof Attendance => array_filter([
                CacheTag::attendance($entity),
                null !== ($p = $entity->getParticipant()) ? CacheTag::participant($p) : null,
                null !== ($p = $entity->getParticipant()) && null !== ($u = $p->getUser()) ? CacheTag::user($u) : null,
                CacheTag::offer($entity->getOffer()),
            ]),
            $entity instanceof User => [CacheTag::user($entity)],
            $entity instanceof Edition => [CacheTag::edition($entity)],
            // EditionTask changes (application window, max applications, …)
            // affect every offer page and list that belongs to the edition —
            // we cascade via the edition's UUID tag.
            $entity instanceof EditionTask => [CacheTag::edition($entity->getEdition())],
            // ConsentForm is the agreement-letter / consent declaration edited
            // on the offer- and edition-level "Einverständniserklärung" sections
            // ({@see \Ferienpass\AdminBundle\Components\Offer\AgreementLetter},
            // {@see \Ferienpass\AdminBundle\Components\Edition\AgreementLetter}).
            // Each form is scoped to exactly one offer OR one edition; the pages
            // that render it — the admin edit section (tagged CacheTag::offer /
            // CacheTag::edition) and the CMS application form — carry that
            // parent's UUID tag. A pure text edit swaps the consentText
            // association, which dirties the ConsentForm row itself, so its
            // postUpdate fires and this cascade covers it (no separate
            // ConsentText case needed). Without it, editing the agreement letter
            // leaves the cached section serving the old declarations until
            // s-maxage expires.
            $entity instanceof ConsentForm => array_filter([
                null !== $entity->getOffer() ? CacheTag::offer($entity->getOffer()) : null,
                null !== $entity->getEdition() ? CacheTag::edition($entity->getEdition()) : null,
            ]),
            // Offer-child entities cascade to their parent offer's UUID tag.
            // Pages that display the offer (admin list, korrekturabzug,
            // edit form, CMS detail) tag themselves with the offer's UUID via
            // `$this->tagResponse(CacheTag::offer($offer))`. Without this
            // cascade an image upload (OfferMedia), date change (OfferDate)
            // or attachment change (OfferAttachment) would leave those pages
            // serving the stale snapshot until s-maxage expires.
            $entity instanceof DbafsOfferAssociation => [CacheTag::offer($entity->getOffer())],
            $entity instanceof OfferDate => [CacheTag::offer($entity->getOffer())],
            $entity instanceof OfferMemberAssociation => [CacheTag::offer($entity->getOffer())],
            // HostMemberAssociation links a User to a Host (and vice versa).
            // Both sides display the relationship: /admin/accounts/{user}/bearbeiten
            // lists the user's hosts; /admin/veranstaltende/{host}/bearbeiten
            // lists the host's members; the CMS host detail page may also
            // render the member list. Cascade to BOTH tags so every page
            // that could display this row gets purged on add/remove.
            $entity instanceof HostMemberAssociation => [
                CacheTag::user($entity->getUser()),
                CacheTag::host($entity->getHost()),
            ],
            // DbafsMedia / DbafsAttachment (the file entities themselves)
            // carry the metadata that admins edit on the media tab — alt text,
            // creator, importantPart (crop focus). The file is shared by N
            // offers via N OfferMedia/OfferAttachment join rows; mutating the
            // file does NOT touch any join row, so the DbafsOfferAssociation
            // branch above never fires for a metadata edit. Without this case,
            // editing an image's important part leaves every offer using it
            // serving the old crop on the korrekturabzug / CMS detail / admin
            // list cards until s-maxage expires (1 year). Iterate the file's
            // associations and emit one offer tag per linked offer.
            $entity instanceof DbafsWithOffer => array_map(
                static fn (DbafsOfferAssociation $a) => CacheTag::offer($a->getOffer()),
                iterator_to_array($entity->getOffers()),
            ),
            default => [],
        };

        if ($tags) {
            $this->cacheManager->invalidateTags(array_values($tags));
        }
    }

    /**
     * Issue a regex BAN for every admin list URL that displays the given
     * entity type, scoped to the current tenant's host.
     *
     * Why regex + host:
     *
     *   - Regex (`^/admin/accounts(/|\?|$)`) catches every sub-route
     *     (`/admin/accounts/eltern`, `/admin/accounts/eltern+admins`)
     *     and query-string variant (`?page=2&qb_query=alice`). A single
     *     `invalidatePath('/admin/accounts')` would miss all of those
     *     because PURGE matches the exact URL only.
     *
     *   - Host filter scopes the eviction to THIS tenant's host. Without
     *     it, an event in Alfeld would evict every tenant's cached
     *     admin lists because the regex match is otherwise host-agnostic
     *     (and we run one Varnish for all tenants).
     *
     * Cascades: an Attendance affects participant and user list URLs (the
     * cached counts / status badges render attendance-derived state), so
     * we BAN those URL prefixes too. Same for Participant → user.
     *
     * Per-row UUID tags (`tagsFor()`) still handle the precise per-detail-
     * page invalidation; this BAN is the additional "the list of things has
     * changed" sweep that tags can't deliver on their own.
     */
    private function banListUrlsFor(object $entity): void
    {
        $patterns = match (true) {
            $entity instanceof OfferInterface => ['^/admin/angebote(/|\?|$)'],
            $entity instanceof Host => ['^/admin/veranstaltende(/|\?|$)'],
            $entity instanceof ParticipantInterface => array_filter([
                '^/admin/teilnehmende(/|\?|$)',
                // A new participant changes the parent user's "Teilnehmende"
                // count in admin user lists.
                null !== $entity->getUser() ? '^/admin/accounts(/|\?|$)' : null,
            ]),
            $entity instanceof Attendance => array_filter([
                '^/admin/teilnehmende(/|\?|$)',
                '^/admin/zahlungen(/|\?|$)',
                // Attendance changes participant + user list-level
                // aggregations.
                null !== $entity->getParticipant() ? '^/admin/accounts(/|\?|$)' : null,
                '^/admin/angebote(/|\?|$)',
            ]),
            $entity instanceof User => ['^/admin/accounts(/|\?|$)'],
            $entity instanceof Edition => ['^/admin/saisons(/|\?|$)'],
            // Notification create/delete flips the left-nav status dot for the
            // type (missing → configured → edition-specific), and that dot is
            // rendered on EVERY notifications page — so creating/removing one
            // BANs the whole area. Plain edits, which don't change the dot, are
            // scoped to a single type in postUpdate() instead. (Admin-only pages
            // with no UUID tag, so there's no per-row tag fallback.)
            $entity instanceof Notification => ['^/admin/benachrichtigungen(/|\?|$)'],
            // EditionTask: changes to application window / max applications
            // affect the booking-status badges of every offer in the
            // edition's admin listing — but they DON'T affect the offer
            // edit form (`/admin/angebote/{uuid}/bearbeiten/…`), the print
            // proof (`/admin/angebote/{uuid}/korrekturabzug`), or any
            // other multi-segment sub-path under /admin/angebote/. Those
            // pages render Offer-entity-derived fields (name, fee, dates,
            // hosts, media — all on Offer / OfferDate / OfferMedia, none
            // of which read EditionTask). Evicting them on every task
            // edit was wasted cache churn.
            //
            // The regex now matches:
            //   - `/admin/angebote`                  ← bare list
            //   - `/admin/angebote?…`                ← list + query
            //   - `/admin/angebote/herbst-2026`      ← single-edition list
            //   - `/admin/angebote/a+b?…`            ← multi-edition + query
            //   - `/admin/angebote/<uuid>`           ← show page (also renders
            //                                          the booking badge)
            //
            // and explicitly does NOT match:
            //   - `/admin/angebote/<uuid>/bearbeiten…`
            //   - `/admin/angebote/<uuid>/korrekturabzug`
            //   - `/admin/angebote/<uuid>/anhaenge` etc.
            //
            // `[a-zA-Z0-9_\-+]+` mirrors the `edition` requirement on the
            // index route (`OffersController:72`).
            $entity instanceof EditionTask => [
                '^/admin/angebote(/[a-zA-Z0-9_\-+]+)?(\?|$)',
                '^/admin/saisons(/[a-zA-Z0-9_\-+]+)?(\?|$)',
            ],
            default => [],
        };

        if (!$patterns) {
            return;
        }

        $host = $this->canonicalHost();

        // Empty host (e.g. an unconfigured test boot) means we can't scope
        // the BAN to a single tenant. Skipping is safer than sending a
        // cross-tenant BAN: the rare cache-staleness in tests is fine, but
        // accidentally wiping prod sibling tenants is not.
        if ('' === $host) {
            return;
        }

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

    private function canonicalHost(): string
    {
        $context = $this->router->getContext();
        $host = $context->getHost();
        if ('' === $host) {
            return '';
        }

        $scheme = $context->getScheme();
        $port = 'https' === $scheme ? $context->getHttpsPort() : $context->getHttpPort();
        $defaultPort = 'https' === $scheme ? 443 : 80;
        if ($port && $port !== $defaultPort) {
            $host .= ':'.$port;
        }

        return $host;
    }

    /**
     * Invalidate CMS-side URL paths that aren't covered by collection tags.
     *
     * The admin list pages (`/admin/accounts`, `/admin/angebote`, …) are
     * covered by `collectionTagFor()` — that's the post-fix path. What's
     * left here:
     *
     *   - CMS alias-based detail pages (`/angebot/{alias}`, `/v/{alias}`)
     *     — they may have been cached as a 404 while the entity was
     *     unpublished or not yet routed; the path purge wipes that 404 so
     *     the first post-publish visitor gets the rendered page.
     *
     *   - `/account` and `/admin/teilnehmende/{uuid}/anmeldungen` — legacy
     *     hand-maintained URL purges that don't yet have collection-tag
     *     coverage. Safe to keep until those pages are migrated to emit a
     *     collection tag of their own.
     */
    private function pathsForCreatedOrRemoved(object $entity): void
    {
        $paths = match (true) {
            $entity instanceof OfferInterface => array_filter([
                // 404 purge for the CMS detail page (see method docblock).
                null !== $entity->getAlias() ? '/angebot/'.$entity->getAlias() : null,
            ]),
            $entity instanceof Host => array_filter([
                null !== $entity->getAlias() ? '/v/'.$entity->getAlias() : null,
            ]),
            $entity instanceof ParticipantInterface => [
                '/account',
            ],
            $entity instanceof Attendance => array_filter([
                null !== ($p = $entity->getParticipant())
                    ? '/admin/teilnehmende/'.$p->getUuid()->toRfc4122().'/anmeldungen'
                    : null,
                '/account',
            ]),
            // User: covered entirely by `user.collection` tag.
            // Edition: covered entirely by `edition.collection` tag.
            // EditionTask: a new/removed task changes cached pages tagged
            // with the edition's UUID and with the offer collection — both
            // already invalidated above. Nothing to add here.
            default => [],
        };

        foreach ($paths as $path) {
            $this->cacheManager->invalidatePath($path);
        }
    }
}
