<?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\AttendanceExtraFile;
use Ferienpass\CoreBundle\Entity\BookEntry;
use Ferienpass\CoreBundle\Entity\Consent;
use Ferienpass\CoreBundle\Entity\ConsentForm;
use Ferienpass\CoreBundle\Entity\ConversationItem;
use Ferienpass\CoreBundle\Entity\DbafsOfferAssociation;
use Ferienpass\CoreBundle\Entity\DbafsWithOffer;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Entity\EditionTask;
use Ferienpass\CoreBundle\Entity\Host;
use Ferienpass\CoreBundle\Entity\HostMemberAssociation;
use Ferienpass\CoreBundle\Entity\IntegrationConfig;
use Ferienpass\CoreBundle\Entity\IntegrationCredential;
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\PageConfig;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Entity\Payment;
use Ferienpass\CoreBundle\Entity\SentMessage;
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)** → URL invalidation for
 *     CMS-side detail pages (`/o/{edition}/{base58}`, `/v/{alias}`). These
 *     may have been cached as a 404 while the entity was unpublished or not
 *     yet routed, and a 404 carries no xkey — so only wiping it by URL lets
 *     the first post-publish visitor get 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.
 *
 * ## Locale prefixes
 *
 * Admin and CMS `Controller/Page/` routes are mounted once per locale, so the
 * same page is cached under `/admin/angebote` AND `/nl/admin/angebote`. Every
 * BAN pattern therefore carries {@see self::LOCALE_PREFIX}; the per-entity
 * `match` arms are written UNANCHORED and the prefix is prepended in the loop
 * that issues them, so a new arm cannot forget it. Contao page-tree URLs
 * (`/account`, the offer_list reader) are exempt — their prefix is root-page
 * data this listener cannot resolve.
 *
 * Entities handled directly: Offer, Host, Participant, Attendance, User,
 * Edition, EditionTask, Notification, Payment, BookEntry.
 *
 * 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.
 *
 * PageConfig / IntegrationConfig / IntegrationCredential are the tenant-global
 * settings singletons. They carry no UUID any page tags itself with, and their
 * values are read from templates across the whole admin AND the CMS, so they get
 * their own (mostly whole-host) BAN — see {@see self::invalidateSettings()}.
 *
 * Payment / BookEntry are admin-only but handled here. Their per-row pages carry
 * a `payment.<uuid>` / `debtor.<uuid>` xkey ({@see self::tagsFor()} +
 * {@see \Ferienpass\AdminBundle\Components\SearchableQueryableList::tagForEntity()}),
 * so content edits — including the daily `pmpayment:fetch-status` cron's pay/fail
 * transition — purge exactly the affected receipts/debtors pages, host-lessly
 * (the cron's CLI process has no request host for a BAN, but an xkey purge needs
 * none). {@see self::invalidatePaymentArea()} adds a host-scoped LIST BAN on
 * persist/remove for the one thing a UUID tag can't do: make a brand-new row
 * appear in (or a deleted one vanish from) the cached lists. They go through the
 * ORM (persist/flush), so the Doctrine events fire reliably — also on Live-Component
 * write actions the old `#[InvalidateRoute]` GET-route attributes never covered.
 *
 * Remaining admin-only entities (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
{
    /**
     * Optional language segment for a BAN regex.
     *
     * Admin routes are mounted once per locale — `de: ''` (unprefixed) plus
     * `/nl`, `/en`, `/ar`, `/ru`, `/uk`, `/tr`, `/pl`, `/sq`, `/ku`, `/es`,
     * `/fr`, `/pt`, `/sw`, `/prs`, `/zh`, `/sr`, `/hr`, `/bs` (see
     * `admin-bundle/config/routes.yaml`; `cms-bundle/config/routes.php` mounts
     * its `Controller/Page/` routes the same way). Every one of those is a
     * SEPARATE Varnish object, so a pattern anchored at `^/admin/…` evicts the
     * German page and leaves an admin working in any other language staring at
     * the stale list for the full `s-maxage=1y` the blanket `/admin` rule in
     * `fos_http_cache.yaml` grants it.
     *
     * `[a-z]{2,3}` covers the whole list (2-letter codes plus `prs`) without
     * enumerating it — over-matching a segment that is not a configured locale
     * costs nothing, since no such URL is ever cached.
     *
     * NB this does NOT apply to Contao page-tree URLs (`/account`, the
     * offer_list reader pages) — those are not Symfony routes and carry
     * whatever prefix the tenant's root page defines.
     */
    private const LOCALE_PREFIX = '(/[a-z]{2,3})?';

    /**
     * Fields of {@see PageConfig} whose ONLY rendered surface is the settings
     * screen itself — everything else reads them from a cron, a mail pipeline or
     * at row-creation time, never from a cached page. An update touching only
     * these gets the narrow `/admin/system` BAN instead of the tenant-wide one.
     *
     *   - eraseDataPeriod, hostDataRetentionPeriod → the data-erasure cron
     *   - attendanceRemindPeriod, remindAttendance → the attendance-reminder cron
     *   - receiptNumberPrefix                      → {@see \Ferienpass\CoreBundle\Payments\ReceiptNumberGenerator},
     *                                                stamped onto the receipt at creation
     *   - imprint*                                 → not rendered anywhere yet
     *
     * Deliberately NOT listed (they leak into cached pages, so they take the
     * broad BAN): pageTitle / colorPrimary (admin layout `<title>` + brand CSS on
     * EVERY admin page, plus the CMS `fe_page`), language / dateFormat / timezone
     * (date rendering everywhere), friendsBooking / requirePostalAddress /
     * ageTolerance / withdrawGracePeriod / noWithdrawAfter* / paymentAllowSelect /
     * preferredPostalCodes (the CMS application form and account pages),
     * adminEmail* / replyTo* (the 403 error screens).
     *
     * Keep this an ALLOW-list: a new PageConfig column that nobody classifies
     * falls through to the broad BAN, which is the fail-safe direction.
     */
    private const SETTINGS_PAGE_ONLY_FIELDS = [
        'eraseDataPeriod',
        'hostDataRetentionPeriod',
        'attendanceRemindPeriod',
        'remindAttendance',
        'receiptNumberPrefix',
        'imprintName',
        'imprintDepartment',
        'imprintStreet',
        'imprintPostalCode',
        'imprintCity',
    ];

    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->banOfferEditUrlsForHostEditingStageTask(null, $entity);
        }
        // A new offer may enter an edition's published CMS listing, and adds a
        // draft "Angebot abschließen" row to its host's dashboard next-steps
        // plus an activity-feed entry.
        if ($entity instanceof OfferInterface) {
            $this->tagOfferCollection($entity);
            $this->invalidateDashboard();
        }
        // 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);
        $this->invalidatePaymentArea($entity);
        $this->invalidateSettings($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->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(
                '^'.self::LOCALE_PREFIX.'/admin/benachrichtigungen/'.preg_quote($entity->getType()).'(/|\?|$)',
                hosts: [$host],
            );
        }

        // Workflow status transitions on Offer / Attendance 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();

                // The assignment page (/admin/saisons/{alias}/zuordnen) renders
                // this attendance's status in its right participant column and is
                // cached under the edition's UUID tag (see EditionsController).
                // tagsFor() only purges the attendance/participant/offer tags, so
                // without this the column keeps showing the old status — even on a
                // page reload — until s-maxage expires. Purge the edition tag too.
                if (null !== ($edition = $entity->getOffer()->getEdition())) {
                    $this->cacheManager->invalidateTags([CacheTag::edition($edition)]);
                }
            }

            // An offer's status transition (draft → reviewed → published,
            // cancel, …) restates the dashboard next-steps as well: the host's
            // inline "Angebot abschließen" (draft) and "Teilnahmeliste von …"
            // (published + online-application) rows are bucketed by offer
            // status, and the admin's "Angebote freischalten" (finalized) row
            // plus the activity feed key off it too.
            if ($entity instanceof OfferInterface) {
                $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
        // /o/{edition}/{base58} (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. The
        // 404 carries no xkey, so the CacheTag::offer purge cannot reach it.
        if ($entity instanceof OfferInterface || $entity instanceof Host) {
            $this->pathsForCreatedOrRemoved($entity);
        }

        // Payment / BookEntry content edits (status transition incl. the cron's
        // pay/fail, billing address, a manual book entry's value) restate the
        // receipts/debtors rows and detail pages — but the list pages that
        // rendered the row carry its `payment.<uuid>` / `debtor.<uuid>` xkey
        // (see tagsFor() + SearchableQueryableList::tagForEntity), so the surgical
        // tag purge already issued by tagsFor() covers updates. The list-MEMBERSHIP
        // BAN (invalidatePaymentArea) only fires on persist/remove, below.

        // Tenant-global settings singletons. The change-set decides how wide the
        // BAN goes, so this needs the postUpdate `$args`.
        $this->invalidateSettings($entity, $args);
    }

    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->banOfferEditUrlsForHostEditingStageTask(null, $entity);
        }
        // A removed offer drops out of its edition's published CMS listing and
        // out of its host's dashboard next-steps.
        if ($entity instanceof OfferInterface) {
            $this->tagOfferCollection($entity);
            $this->invalidateDashboard();
        }
        // 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);
        $this->invalidatePaymentArea($entity);
        $this->invalidateSettings($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
    {
        // Payment rows carry a `payment.<uuid>` xkey, so a status edit is purged
        // surgically by tagsFor() (the receipts list has no status filter, so
        // there's no filtered-variant membership change to BAN). Payment is
        // intentionally not handled here.
        if (!($entity instanceof OfferInterface || $entity instanceof Attendance)) {
            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 dashboard (see {@see CacheTag::dashboard()}).
     *
     * Two surfaces carry this tag. The admin dashboard's ESI fragments render
     * attendance- and offer-derived aggregates — heatmap counts, the
     * uncommitted/waiting/finalized next-steps, and the recent-activity feed.
     * The host dashboard renders its personal next-steps ("Angebot abschließen",
     * "Teilnahmeliste von …") inline on the /admin page itself (see
     * HomeController), bucketed by offer + attendance status. Neither carries a
     * per-entity UUID tag the surgical `attendance.<uuid>` / `offer.<uuid>` purge
     * could reach, and the `/admin` BAN list deliberately stops at the entity
     * list URLs — so an xkey purge (no host needed) busts them from request and
     * worker contexts alike, the same way {@see invalidateStashCount()} handles
     * the staged-count badge. Fired on attendance AND offer lifecycle events.
     */
    private function invalidateDashboard(): void
    {
        $this->cacheManager->invalidateTags([CacheTag::dashboard($this->appId)]);
    }

    /**
     * BAN the payment-area LIST trees when a row enters or leaves them — the only
     * change a per-row UUID xkey can't deliver. Called from persist/remove only:
     *
     *   - **Payment** (create — e.g. a CMS/admin payment, or a reversal) → BAN the
     *     receipts (/admin/zahlungen/belege) and debtors (/admin/zahlungen/debitoren)
     *     LIST trees. A brand-new receipt has no cached list carrying its
     *     `payment.<uuid>` yet, so only a BAN makes it appear.
     *   - **Standalone manual BookEntry** (no Payment — the admin "freie Buchung")
     *     → BAN the whole /admin/zahlungen tree (a new debtor balance row).
     *
     * Content edits to existing rows — status transition (incl. the daily
     * pmpayment:fetch-status cron's pay/fail), billing edit, abandon, a pay-linked
     * BookEntry's value — are NOT handled here: the list/detail pages that rendered
     * the row carry its `payment.<uuid>` / `debtor.<uuid>` xkey, so {@see tagsFor()}
     * purges them surgically and, crucially, host-lessly (this BAN is fail-closed
     * skipped in the request-less CLI cron — see {@see canonicalHost()}). The cron
     * only ever edits existing payments, so it never needs this BAN.
     */
    private function invalidatePaymentArea(object $entity): void
    {
        // Unanchored, like banListUrlsFor() — see the note there.
        $patterns = match (true) {
            $entity instanceof Payment => [
                '/admin/zahlungen/belege(/|\?|$)',
                '/admin/zahlungen/debitoren(/|\?|$)',
            ],
            $entity instanceof BookEntry && null === $entity->getPayment() => [
                '/admin/zahlungen(/|\?|$)',
            ],
            default => [],
        };

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

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

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

    /**
     * Invalidate after a write to one of the tenant-global settings singletons:
     * {@see PageConfig} (saved by the Settings live components on
     * `/admin/system/allgemein`) and {@see IntegrationConfig} /
     * {@see IntegrationCredential} (the integration cards + their configure
     * screens).
     *
     * These carry no per-entity UUID that any page tags itself with — and
     * couldn't usefully, because the settings they hold are read through
     * PageConfigManager / SystemSettingsManager / IntegrationsManager from
     * templates all over the app rather than from a rendered entity. Without a
     * BAN the settings screen itself keeps serving its pre-toggle snapshot for
     * the full `/admin → public, s-maxage=1y` TTL (fos_http_cache.yaml), which is
     * exactly what "toggling remind_attendance doesn't refresh the page" looks
     * like. Note the {@see \Ferienpass\CoreBundle\Settings\PageConfigManager}
     * app-cache eviction only makes the DATA fresh; Varnish still has to be told.
     *
     * Breadth: settings edits are rare (a handful per tenant per year), so a
     * whole-host BAN is the right default — most PageConfig fields, and the
     * `isIntegrationConfigured()` checks, render into pages spread across the
     * entire admin AND the CMS (see SETTINGS_PAGE_ONLY_FIELDS for the exceptions
     * and the reasoning). An update confined to the settings-screen-only fields
     * narrows to the `/admin/system` tree so that flipping a cron toggle doesn't
     * throw away every cached offer listing.
     */
    private function invalidateSettings(object $entity, ?PostUpdateEventArgs $args = null): void
    {
        if (!$entity instanceof PageConfig && !$entity instanceof IntegrationConfig && !$entity instanceof IntegrationCredential) {
            return;
        }

        $host = $this->canonicalHost();
        if ('' === $host) {
            // Same tenant-scoping guard as banListUrlsFor() — skip when the host
            // is unknown rather than risk a cross-tenant eviction. A settings
            // write only ever happens from the admin UI, so there is always one.
            return;
        }

        $pattern = $this->settingsChangeIsPageLocal($entity, $args)
            ? '^'.self::LOCALE_PREFIX.'/admin/system(/|\?|$)'
            : '^/';

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

    /**
     * True if this settings write cannot affect anything outside the
     * `/admin/system` tree — i.e. it is a PageConfig UPDATE whose change-set is
     * confined to {@see self::SETTINGS_PAGE_ONLY_FIELDS}.
     *
     * Everything else answers false and takes the tenant-wide BAN: a persist or
     * remove (the singleton row materialising brings the whole default config
     * into play) and any IntegrationConfig / IntegrationCredential write (the
     * `isIntegrationConfigured('PmPaymentProvider')` check gates the CMS
     * application form's payment block, see `_participant_form.html.twig`).
     */
    private function settingsChangeIsPageLocal(object $entity, ?PostUpdateEventArgs $args): bool
    {
        if (!$entity instanceof PageConfig || null === $args) {
            return false;
        }

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

        return [] !== $changed && [] === array_diff($changed, self::SETTINGS_PAGE_ONLY_FIELDS);
    }

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

    /*
     * There is deliberately no CMS-side BAN for application-system EditionTask
     * changes any more.
     *
     * `banCmsUrlsForApplicationSystemTask()` used to BAN `^/angebot/` here on the
     * premise that "the CMS offer detail templates read applicationSystem.task.*,
     * so every offer detail page in this edition is now stale". Both halves of
     * that were wrong:
     *
     *   - The URL never existed. The offer reader is a Contao page-tree page
     *     (`#[AsPage('offer_list')]`), so its segment is the per-edition
     *     `tl_page` alias — real tenants use `kletterwoche`, `ferienbetreuung`,
     *     `restplaetze`, … — and its item segment is `{base58}-{slug}`
     *     ({@see \Ferienpass\CoreBundle\Routing\OfferReaderUrl}), not a bare
     *     alias. Nothing has ever been cached under `/angebot/…`.
     *
     *   - Nothing it aimed at is cached. `application_form.html.twig` renders
     *     inside `ApplicationFormController`, which extends
     *     {@see \Ferienpass\CoreBundle\Controller\AbstractPrivateFragmentController}
     *     and therefore answers `private, no-store`; the offer detail body pulls
     *     it in via `render_esi(...)` (`fragment/offer_details.html.twig`) and
     *     carries no task-derived state of its own. Varnish never stores the
     *     form, so an application-system change is live on the next request.
     *
     * The one cached surface that DOES render task state — the offer listing's
     * filter (`components/filter.html.twig`, firstcome branch) — is tagged
     * `CacheTag::edition()` by {@see \Ferienpass\CmsBundle\Controller\Fragment\OfferListController},
     * and {@see self::tagsFor()} already purges that tag on every EditionTask
     * lifecycle event. So the correct invalidation was in place all along and
     * the BAN was pure dead weight.
     */

    /**
     * `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(
            '^'.self::LOCALE_PREFIX.'/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'],
            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) {
            // `^(/xx)?/prefix.*(\[|%5B)status(\]|%5D)=`
            $pattern = '^'.self::LOCALE_PREFIX.$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()),
            ]),
            // A file answer is rendered as a cell in the offer's participant list
            // (and on the attendance show page), which carry the attendance's and
            // the offer's xkeys. Replacing a file need not change the attendance
            // row at all, so tag off the file itself as well.
            $entity instanceof AttendanceExtraFile => null !== ($a = $entity->getAttendance()) ? array_filter([
                CacheTag::attendance($a),
                null !== ($p = $a->getParticipant()) ? CacheTag::participant($p) : null,
                CacheTag::offer($a->getOffer()),
            ]) : [],
            $entity instanceof User => [CacheTag::user($entity)],
            $entity instanceof Edition => [CacheTag::edition($entity)],
            // A comment/reply changes the subject's inline thread (the show page
            // carries the subject's UUID tag) and the Postausgang (new thread
            // item + reply badges). Without this, posting a comment on
            // /admin/teilnehmende/{uuid} leaves both serving the stale snapshot.
            $entity instanceof ConversationItem => $this->conversationTags($entity),
            // A logged email is a new Postausgang row. The mail is logged from
            // the async worker (no request host), so the host-less outbox xkey is
            // the only tool that reaches it — flushed by
            // FlushCacheManagerOnWorkerMessageListener.
            $entity instanceof SentMessage => [CacheTag::outbox($this->appId)],
            // Payment: the receipts list rows + the receipt detail carry
            // `payment.<uuid>` (see SearchableQueryableList::tagForEntity and
            // PaymentsController::show), so a status transition (incl. the daily
            // pmpayment:fetch-status cron), billing edit or abandon purges exactly
            // those pages. The debtor it belongs to also surfaces it (balance /
            // debtor detail), so cascade to the debtor's tag too. xkey purges need
            // no host → this fires from the per-tenant CLI cron, where a host-scoped
            // BAN could not (see invalidatePaymentArea()).
            $entity instanceof Payment => array_filter([
                CacheTag::payment($entity),
                null !== ($d = $entity->getDebtor()) ? CacheTag::debtor($d) : null,
            ]),
            // BookEntry moves a debtor's Guthaben/Offen/Gesamt (debtors list +
            // debtor detail, tagged `debtor.<uuid>`); a pay-linked entry also
            // belongs to a receipt. The pay workflow creates one inside the cron,
            // so this is the host-less purge that refreshes the debtor balances.
            $entity instanceof BookEntry => array_filter([
                null !== ($d = $entity->getDebtor()) ? CacheTag::debtor($d) : null,
                null !== ($p = $entity->getPayment()) ? CacheTag::payment($p) : null,
            ]),
            // Consent (the participant's actual agreement record, rendered in
            // the /admin/einwilligungen list + its detail page). Beyond its own
            // UUID tag it cascades to the surfaces a host signature drives — see
            // {@see self::consentTags()}. NB this is distinct from the ConsentForm
            // arm below (the agreement-letter / declaration template).
            $entity instanceof Consent => $this->consentTags($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,
                // The host declaration ("Einverständnis der Veranstaltenden"): a
                // text edit mints a new ConsentText and invalidates every host's
                // signature, re-gating the participant lists of every offer in the
                // edition (host-consent.<edition>). Enabling/disabling it
                // (postPersist/postRemove) raises/lifts the gate the same way.
                $entity->isHost() && null !== $entity->getEdition() ? CacheTag::hostConsent($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));
        }
    }

    /**
     * Cache tags for a comment/reply: its subject's UUID tag (the show page that
     * renders the inline thread — participant / offer / attendance / account /
     * host / edition) plus the host-less Postausgang aggregate (the outbox lists
     * the thread and its reply badges). Attendances cascade to their offer and
     * participant, mirroring the {@see self::tagsFor()} Attendance arm, since an
     * attendance's conversation surfaces on those pages too.
     *
     * @return list<string>
     */
    private function conversationTags(ConversationItem $item): array
    {
        $subject = $item->getSubject();

        $tags = match (true) {
            $subject instanceof OfferInterface => [CacheTag::offer($subject)],
            $subject instanceof Attendance => array_filter([
                CacheTag::attendance($subject),
                CacheTag::offer($subject->getOffer()),
                null !== ($p = $subject->getParticipant()) ? CacheTag::participant($p) : null,
            ]),
            $subject instanceof ParticipantInterface => [CacheTag::participant($subject)],
            $subject instanceof User => [CacheTag::user($subject)],
            $subject instanceof Host => [CacheTag::host($subject)],
            $subject instanceof Edition => [CacheTag::edition($subject)],
            default => [],
        };

        $tags[] = CacheTag::outbox($this->appId);

        return array_values($tags);
    }

    /**
     * Cache tags for a Consent's lifecycle (sign / revoke / delete).
     *
     * Beyond its own `consent.<uuid>` tag (the /admin/einwilligungen list rows +
     * detail page), a host confidentiality signature (subject=host) drives two
     * edition-keyed surfaces:
     *
     *   - the signee count on the season's hostConsent editor — the whole edition
     *     edit page is tagged `edition.<uuid>` (see
     *     {@see \Ferienpass\AdminBundle\Controller\Page\EditionsEditController}),
     *     so this purges the edition tag;
     *   - the participant-list gate on every offer in the edition — carried by the
     *     `host-consent.<edition>` tag (see
     *     {@see \Ferienpass\AdminBundle\Controller\Page\OfferParticipantsController}
     *     + `missing_privacy_statement.html.twig`).
     *
     * An offer-scoped participant consent instead surfaces in that one offer's
     * participant list (the consent column / `hasConsents`), so it purges the
     * offer tag. The list-membership BAN stays in {@see banListUrlsFor()}; this
     * only builds the xkey tag set.
     *
     * @return list<string>
     */
    private function consentTags(Consent $consent): array
    {
        $tags = [CacheTag::consent($consent)];

        $edition = $consent->getEdition();
        if (null !== $edition && ($consent->getConsentForm()?->isHost() ?? false)) {
            $tags[] = CacheTag::edition($edition);
            $tags[] = CacheTag::hostConsent($edition);
        }

        if (null !== ($offer = $consent->getOffer())) {
            $tags[] = CacheTag::offer($offer);
        }

        return $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 catches every list sub-route (`/admin/accounts/eltern`,
     *     `/admin/accounts/eltern+admins`) and query-string variant
     *     (`?page=2&qb_query=alice`); a single `invalidatePath` PURGE matches
     *     the exact URL only.
     *   - Host filter scopes the eviction to THIS tenant (one Varnish serves
     *     all tenants).
     *
     * Granularity: the patterns deliberately stop at ONE path segment
     * (`^/admin/X(/[a-zA-Z0-9_\-+]+)?(\?|$)`), so they match the bare list, its
     * `?query` variants, the single-segment edition/role filter and the
     * `/{uuid}` show page — but NOT the per-entity detail subtree
     * (`/{uuid}/bearbeiten`, `…/korrekturabzug`, `…/anhaenge`, `…/anmeldungen`).
     * Those pages already carry the entity's own `tagsFor()` UUID tag, and a
     * brand-new/removed row never changes another row's edit form, so evicting
     * the whole subtree on every create/remove was wasted churn.
     * (`[a-zA-Z0-9_\-+]+` mirrors the `edition`/`role` route requirements.)
     *
     * Cascades: an Attendance affects participant + user list URLs (cached
     * counts / status badges render attendance-derived state). Same for
     * Participant → user.
     *
     * Per-row UUID tags (`tagsFor()`) handle the precise per-detail-page
     * invalidation; this BAN is the "list membership changed" sweep that tags
     * can't deliver (a new row carries no cached tag yet).
     */
    private function banListUrlsFor(object $entity): void
    {
        // NB the arms below are UNANCHORED — the `^` and the optional language
        // segment are prepended once in the loop at the bottom, so a new arm
        // cannot forget the locale prefix (the omission this indirection exists
        // to prevent; see {@see self::LOCALE_PREFIX}).
        $patterns = match (true) {
            $entity instanceof OfferInterface => ['/admin/angebote(/[a-zA-Z0-9_\-+]+)?(\?|$)'],
            $entity instanceof Host => ['/admin/veranstaltende(/[a-zA-Z0-9_\-+]+)?(\?|$)'],
            $entity instanceof ParticipantInterface => array_filter([
                '/admin/teilnehmende(/[a-zA-Z0-9_\-+]+)?(\?|$)',
                // A new participant changes the parent user's "Teilnehmende"
                // count in admin user lists.
                null !== $entity->getUser() ? '/admin/accounts(/[a-zA-Z0-9_\-+]+)?(\?|$)' : null,
            ]),
            $entity instanceof Attendance => array_filter([
                '/admin/teilnehmende(/[a-zA-Z0-9_\-+]+)?(\?|$)',
                // zahlungen stays broad (whole subtree): the receipt / debtor
                // DETAIL pages and the edition-scoped payment lists share the
                // same `/admin/zahlungen/{seg}/{seg}` shape, and the debtor
                // detail carries no attendance tag, so a depth-limited regex
                // would drop list coverage. Over-evicting receipt details on an
                // attendance write is the accepted cost here.
                '/admin/zahlungen(/|\?|$)',
                // Attendance changes participant + user list-level aggregations.
                null !== $entity->getParticipant() ? '/admin/accounts(/[a-zA-Z0-9_\-+]+)?(\?|$)' : null,
                '/admin/angebote(/[a-zA-Z0-9_\-+]+)?(\?|$)',
            ]),
            $entity instanceof User => ['/admin/accounts(/[a-zA-Z0-9_\-+]+)?(\?|$)'],
            $entity instanceof Edition => ['/admin/saisons(/[a-zA-Z0-9_\-+]+)?(\?|$)'],
            // A new/removed Consent must appear/disappear in the admin list; the
            // per-row `consent.<uuid>` tag (tagsFor) can't add a brand-new row.
            // Revoke/edit of an existing consent is covered by that UUID tag.
            $entity instanceof Consent => ['/admin/einwilligungen(/[a-zA-Z0-9_\-+]+)?(\?|$)'],
            // Notification create/delete flips the left-nav status dot rendered
            // on EVERY notifications page, so it BANs the whole (depth-2
            // type/edition) area — kept broad on purpose. Plain edits are scoped
            // to a single type in postUpdate().
            $entity instanceof Notification => ['/admin/benachrichtigungen(/|\?|$)'],
            // EditionTask changes booking-status badges across the edition's
            // offer listing but not the per-offer detail subtree — same narrowed
            // shape as the entity lists above.
            $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('^'.self::LOCALE_PREFIX.$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 detail pages (`/o/{edition}/{base58}`, `/v/{alias}`) — they may
     *     have been cached as a **404** while the entity was unpublished or not
     *     yet routed, and a 404 carries no xkey (the controller throws before
     *     `tagResponse()`), so `CacheTag::offer` cannot reach it. Only a URL
     *     invalidation wipes it, so the first post-publish visitor gets the
     *     rendered page instead of a year-old 404.
     *
     *   - `/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.
     *
     * Two invalidation shapes, split by how the URL is routed:
     *
     *   - **Symfony-routed** (`/o/…` from `CmsBundle\Controller\Page\EditionLandingController`,
     *     `/v/{alias}` from its `HostController`, the admin attendance sub-list)
     *     exist once per locale, so an exact-URL PURGE would only ever hit the
     *     German one. They go out as host-scoped regex BANs carrying
     *     {@see self::LOCALE_PREFIX} instead — one BAN rather than 19 PURGEs.
     *
     *   - **Contao page-tree** (`/account`) is not a Symfony route; its prefix
     *     comes from the tenant's root page, which this listener cannot
     *     resolve. It stays an exact-URL PURGE.
     */
    private function pathsForCreatedOrRemoved(object $entity): void
    {
        // Contao page-tree URLs — exact-URL PURGE, no locale prefix to add.
        $paths = match (true) {
            $entity instanceof ParticipantInterface, $entity instanceof Attendance => [
                '/account',
            ],
            default => [],
        };

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

        $this->banLocalisedPathsForCreatedOrRemoved($entity);
    }

    /**
     * BAN pattern (unanchored, see {@see self::banListUrlsFor()}) covering every
     * canonical reader URL of one offer:
     *
     *     /o/{edition}/{base58}                     cms_edition_offer
     *     /o/{edition}/{base58}-{slug}              cms_edition_offer_slug
     *     /o/{edition}/{base58}[-{slug}].ics        cms_offer_ics[_slug]
     *
     * — plus their `?k=…` private-link variants and, via
     * {@see self::LOCALE_PREFIX}, every language mount of the `#[Route('/o')]`
     * controller. The base58 uuid is the authoritative segment
     * ({@see \Ferienpass\CoreBundle\Routing\OfferReaderUrl}), so terminating on
     * `-` / `.` / `?` / end keeps the BAN to this one offer instead of every
     * offer whose id happens to share a prefix.
     *
     * `null` when the offer has no edition: `/o/{edition}/…` cannot be built,
     * and an offer outside an edition has no canonical reader URL to purge.
     *
     * NB the legacy page-tree reader (`/{tl_page alias}/{base58}-{slug}`, served
     * by `CmsBundle\Controller\Page\OfferListPage`) is NOT covered: its path
     * segment is per-edition tenant data this listener cannot resolve. Its
     * successful renders are tagged `CacheTag::offer` and so stay correct; only
     * a 404 cached there before the offer went public survives, until the
     * reader is retired or starts 301ing unconditionally.
     */
    private function offerReaderPattern(OfferInterface $offer): ?string
    {
        $editionAlias = $offer->getEdition()?->getAlias();
        if (null === $editionAlias || '' === $editionAlias) {
            return null;
        }

        return '/o/'.preg_quote($editionAlias).'/'.preg_quote($offer->getUuid()->toBase58()).'(-|\.|\?|$)';
    }

    /**
     * The Symfony-routed half of {@see self::pathsForCreatedOrRemoved()} — see
     * that method's docblock for why these can't be exact-URL PURGEs.
     */
    private function banLocalisedPathsForCreatedOrRemoved(object $entity): void
    {
        $patterns = match (true) {
            $entity instanceof OfferInterface => array_filter([$this->offerReaderPattern($entity)]),
            $entity instanceof Host => array_filter([
                null !== $entity->getAlias() ? '/v/'.preg_quote($entity->getAlias()).'(\?|$)' : null,
            ]),
            $entity instanceof Attendance => array_filter([
                // Stays scoped to THIS participant. The canonical route is
                // base58 ({@see \Ferienpass\AdminBundle\Routing\Base58}) with the
                // RFC4122 form still served as a legacy alias, so match either
                // form of this one uuid rather than only the legacy one (which
                // is not what any generated link — and therefore any cached
                // URL — actually uses).
                null !== ($p = $entity->getParticipant())
                    ? '/admin/teilnehmende/('.preg_quote($p->getUuid()->toBase58()).'|'.preg_quote($p->getUuid()->toRfc4122()).')/anmeldungen(\?|$)'
                    : null,
            ]),
            // 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 => [],
        };

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

        $host = $this->canonicalHost();
        if ('' === $host) {
            // Same tenant-scoping guard as banListUrlsFor.
            return;
        }

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