<?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 Contao\CoreBundle\EventListener\MakeResponsePrivateListener;
use Contao\CoreBundle\Routing\ScopeMatcher;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;

/**
 * Replaces Contao's stock `MakeResponsePrivateListener` with a version that
 * allows role-bucket caching on authenticated responses.
 *
 * A response is allowed to stay `public` (and therefore role-bucket
 * cacheable by Varnish) ONLY when it explicitly opts in via all three
 * signals:
 *
 *   - `Cache-Control: public`
 *   - a positive `s-maxage`
 *   - `Vary: X-User-Context-Hash`
 *
 * Anything else falls through to Contao's stock four-condition downgrade
 * (Authorization header, previous session, Set-Cookie, Vary: Cookie +
 * request cookies). Pages that render per-user data should NEVER carry
 * those three signals on their final response — they either inherit
 * `private` from a child fragment through Contao's
 * {@see \Contao\CoreBundle\EventListener\SubrequestCacheSubscriber}
 * merge, or they're flagged private in their own controller.
 *
 * Controllers that render per-user content (account pages, application
 * lists, personal data forms) should extend
 * {@see \Contao\CoreBundle\Controller\AbstractFragmentController} and
 * call `$response->setPrivate()` before returning. The merge subscriber
 * propagates `private` up to the main page response automatically — so
 * the page itself needs no configuration.
 *
 * ### Flash messages — handled elsewhere
 *
 * Flash messages previously required a flashbag peek here, because a
 * page rendering `app.flashes(...)` would bake per-user text into the
 * HTML before this listener ran. That's no longer the case: FOSHttpCache
 * `FlashMessageListener` now moves flashes session → `flashes` cookie at
 * kernel.response; CMS templates no longer read `app.flashes(...)`; and
 * the Set-Cookie check in {@see self::isRoleBucketCacheable()} downgrades
 * any cookie-bearing response to `private` so Varnish doesn't strip the
 * flashes cookie.
 */
final class UserContextAwarePrivateResponseListener
{
    /**
     * @see MakeResponsePrivateListener::DEBUG_HEADER
     */
    private const DEBUG_HEADER = 'Contao-Private-Response-Reason';

    public function __construct(
        private readonly ScopeMatcher $scopeMatcher,
    ) {
    }

    #[AsEventListener(priority: -896)]
    public function disableSymfonyAutoCacheControl(ResponseEvent $event): void
    {
        if (!$this->scopeMatcher->isContaoMainRequest($event)) {
            return;
        }

        // Disable the default Symfony auto cache control (same as stock Contao).
        $event->getResponse()->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER, '1');
    }

    /**
     * Add `Vary: Turbo-Frame` to admin responses.
     *
     * Admin pages branch their body on the Turbo-Frame header (a slideover/
     * modal navigation gets the reduced single-frame body; a direct visit gets
     * the full page — see `@FerienpassAdmin/layout/slideOverBase.html.twig`).
     * Vary on it for EVERY admin response so BOTH cache layers keep the two
     * variants apart:
     *
     *   - Varnish (public, role-bucket) partitions on it like any Vary. The
     *     production VCL honours it natively and never strips the request
     *     header (see varnish/default.vcl `vcl_backend_response`).
     *   - The browser partitions its OWN cache: an admin response that is
     *     `private` but still has a positive max-age (or carries a Set-Cookie)
     *     is cached by the browser. Without this Vary, the browser serves a
     *     slideover-shaped body to a subsequent direct visit of the same URL
     *     (and vice versa).
     *
     * This MUST NOT be gated on {@see ScopeMatcher::isContaoMainRequest()}.
     * The custom `/admin` firewall is not a Contao frontend/backend scope —
     * admin routes carry no `_scope` request attribute (see
     * `config/routes/fos_http_cache.yaml`), so `isContaoMainRequest()` is
     * FALSE for every admin URL and {@see self::makeResponsePrivate()} never
     * runs for them (admin caching is driven by FOSHttpCacheBundle's
     * user-context flow, not this Contao-scoped listener). Adding the Vary
     * inside that gated method silently dropped it for exactly the URLs that
     * need it — Varnish then cached the first-rendered variant (a slideover
     * reduced body) and served it to a subsequent direct visit of the same
     * URL, dropping the layout shell. Gate only on `isMainRequest()` + the
     * admin path so it actually fires.
     *
     * Adding a Vary is never incorrect; at worst it slightly reduces sharing
     * for the few admin URLs that don't actually branch.
     */
    #[AsEventListener(priority: -1012)]
    public function addTurboFrameVary(ResponseEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        if (!$this->shouldVaryOnTurboFrame($event->getRequest())) {
            return;
        }

        $event->getResponse()->setVary('Turbo-Frame', false);
    }

    #[AsEventListener(priority: -1012)]
    public function makeResponsePrivate(ResponseEvent $event): void
    {
        if (!$this->scopeMatcher->isContaoMainRequest($event)) {
            return;
        }

        $request = $event->getRequest();
        $response = $event->getResponse();

        // Nothing to do — response already isn't cacheable by a reverse proxy.
        if (!$response->isCacheable()) {
            return;
        }

        if ($this->isRoleBucketCacheable($response)) {
            // Controllers can return JSON for `Accept:
            // application/json` and HTML for `Accept: text/html` on the
            // same URL (Symfony's negotiation). Without this, Varnish
            // would serve the wrong representation on the second visit.
            $response->setVary('Accept', false);

            return;
        }

        $this->applyContaoDowngrade($request, $response);
    }

    /**
     * Apply Contao's four-condition downgrade to `private`. Duplicated here
     * because `MakeResponsePrivateListener` is final and its logic is in a
     * private helper — copy the behaviour verbatim and keep the same
     * `Contao-Private-Response-Reason` debug header values.
     */
    private function applyContaoDowngrade(Request $request, Response $response): void
    {
        if ('' !== (string) $request->headers->get('Authorization')) {
            $this->makePrivate($response, 'authorization');

            return;
        }

        if ($request->hasSession(true) && $request->hasPreviousSession()) {
            $this->makePrivate($response, 'session-cookie');

            return;
        }

        if ($cookies = $response->headers->getCookies()) {
            $this->makePrivate(
                $response,
                \sprintf(
                    'response-cookies (%s)',
                    implode(', ', array_map(static fn (Cookie $cookie) => $cookie->getName(), $cookies)),
                ),
            );

            return;
        }

        if ($request->cookies->count() && \in_array('cookie', array_map(strtolower(...), $response->getVary()), true)) {
            $this->makePrivate(
                $response,
                \sprintf('request-cookies (%s)', implode(', ', array_keys($request->cookies->all()))),
            );
        }
    }

    private function shouldVaryOnTurboFrame(Request $request): bool
    {
        $path = $request->getPathInfo();

        // Match `/admin`, `/admin/…`, and language-prefixed variants
        // (`/de/admin`, `/en/admin/…`) that admin routing uses.
        return 1 === preg_match('#^/(?:[a-z]{2,3}/)?admin(?:/|$)#', $path);
    }

    /**
     * "Is this response shape safe to share across a role bucket?" — public
     * cache-control, positive s-maxage, and Vary partitioned by the
     * user-context hash. These signals together mean the controller built
     * the response as a role-scoped view; Varnish will partition by the hash
     * and different roles get different variants under the same cache key.
     */
    private function isRoleBucketCacheable(Response $response): bool
    {
        // Any Set-Cookie on the response disqualifies role-bucket caching:
        // Varnish strips cookies from `public` responses (see
        // varnish/default.vcl — `if (beresp.http.Cache-Control ~ "public")
        // unset beresp.http.Set-Cookie;`), which would silently drop the
        // cookie before it reaches the browser. FOSHttpCacheBundle's
        // FlashMessageListener relies on Set-Cookie to move flashes to the
        // client. CSRF token rotation and similar per-user state behave the
        // same way. Let Contao's stock downgrade mark the response private
        // so the cookie survives intact.
        if ($response->headers->getCookies()) {
            return false;
        }

        $cacheControl = strtolower($response->headers->get('Cache-Control', ''));

        // Must be explicitly public (controller consciously opted in).
        if (!str_contains($cacheControl, 'public')) {
            return false;
        }

        // Must have a positive shared max-age.
        if (0 === $response->getMaxAge() && 0 === $response->getTtl() && !str_contains($cacheControl, 's-maxage')) {
            return false;
        }
        $sharedTtl = $response->getTtl();
        if (null !== $sharedTtl && $sharedTtl <= 0) {
            return false;
        }

        // Must be partitioned by user-context-hash — otherwise Varnish would
        // serve the same cache entry to every user.
        $vary = array_map(strtolower(...), $response->getVary());

        return \in_array('x-user-context-hash', $vary, true);
    }

    private function makePrivate(Response $response, string $reason): void
    {
        $response->setPrivate();
        $response->headers->set(self::DEBUG_HEADER, $reason);
    }
}
