<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CoreBundle\Tests\EventListener\HttpCache;

use Contao\CoreBundle\Routing\ScopeMatcher;
use Ferienpass\CoreBundle\EventListener\HttpCache\UserContextAwarePrivateResponseListener;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;

/**
 * Covers the opt-out decisions in {@see UserContextAwarePrivateResponseListener}.
 *
 * The dangerous case — "two ROLE_MEMBER users share a cache entry and see each
 * other's personal data" — is prevented by this listener only firing its
 * opt-out when the response explicitly opts into role-bucket caching via
 * `Vary: X-User-Context-Hash` + `Cache-Control: public, s-maxage=…`. Any other
 * shape of response falls through to Contao's stock downgrade logic.
 *
 * These tests encode that safety model so a future refactor can't silently
 * weaken it.
 */
#[AllowMockObjectsWithoutExpectations]
final class UserContextAwarePrivateResponseListenerTest extends TestCase
{
    /** Positive case — a correctly-tagged role-bucket-cacheable response is left public. */
    public function testRoleBucketPublicResponseWithHashVaryIsLeftPublic(): void
    {
        $response = $this->buildRoleBucketPublicResponse();
        $this->dispatch(authenticated: true, response: $response);

        self::assertTrue($response->isCacheable(), 'Response should remain cacheable');
        self::assertSame(31556926, $response->getTtl(), 'Original s-maxage must be preserved');
        self::assertFalse(
            $response->headers->has('Contao-Private-Response-Reason'),
            'Opt-out path must NOT stamp the Contao-Private-Response-Reason debug header',
        );
    }

    /**
     * CMS URL — the listener must NOT add `Vary: Turbo-Frame`. The CMS
     * layer renders identically for Turbo and non-Turbo navigations
     * (Turbo extracts the frame client-side), so varying here just
     * fragments the cache: a filter-chip click (`Turbo-Frame:
     * cms-offer-list`) and a page reload (no header) would otherwise
     * never share a cache entry for the same URL.
     */
    public function testCmsPathDoesNotGetTurboFrameVary(): void
    {
        $request = $this->makeAuthenticatedRequest();
        $request->server->set('REQUEST_URI', '/angebote');
        $request = Request::create('/angebote', 'GET');
        $request->cookies->set('PHPSESSID', 'abc');
        $request->setSession(new Session(new MockArraySessionStorage()));
        $request->headers->set('Cookie', 'PHPSESSID=abc');

        $response = $this->buildRoleBucketPublicResponse();
        $this->dispatch(request: $request, response: $response);

        $vary = array_map('strtolower', $response->headers->all('Vary'));
        self::assertNotContains(
            'turbo-frame',
            $vary,
            'CMS URLs must not vary on Turbo-Frame — Turbo extracts the frame client-side from '
            .'the same HTML the server returns to non-Turbo requests, so varying just fragments '
            .'the cache without correctness benefit.',
        );
        self::assertContains('accept', $vary, 'Accept Vary stays — JSON vs HTML on the same URL still depends on it.');
    }

    /**
     * Admin URL — the listener MUST add `Vary: Turbo-Frame`. Admin
     * controllers like `ParticipantListDto` branch the response body
     * on the Turbo-Frame value (`slideover` → different columns), so
     * without this Vary, Varnish would serve the wrong variant to the
     * second visitor.
     */
    public function testAdminPathGetsTurboFrameVary(): void
    {
        $request = Request::create('/admin/teilnehmende', 'GET');
        $request->cookies->set('PHPSESSID', 'abc');
        $request->setSession(new Session(new MockArraySessionStorage()));
        $request->headers->set('Cookie', 'PHPSESSID=abc');

        $response = $this->buildRoleBucketPublicResponse();
        $this->dispatch(request: $request, response: $response);

        $vary = array_map('strtolower', $response->headers->all('Vary'));
        self::assertContains(
            'turbo-frame',
            $vary,
            'Admin URLs must vary on Turbo-Frame. ParticipantListDto:32 (and similar) return '
            .'different HTML when `turbo-frame=slideover` — without the Vary, Varnish would '
            .'serve the slideover-shape body to a non-slideover request and vice versa.',
        );
    }

    /**
     * Regression — the original production bug. The custom `/admin` firewall
     * is NOT a Contao frontend/backend scope: admin routes carry no `_scope`
     * request attribute, so `ScopeMatcher::isContaoMainRequest()` returns
     * FALSE for every admin URL. When the Turbo-Frame Vary lived inside the
     * scope-gated `makeResponsePrivate()`, it therefore NEVER fired for admin
     * pages — Varnish cached whichever variant it saw first (a slideover
     * reduced body) and served it to a subsequent direct visit, dropping the
     * layout shell.
     *
     * The Vary must be added off `addTurboFrameVary()`, which gates only on
     * `isMainRequest()` + the admin path — not on Contao scope. We simulate
     * the real admin request by using a scope matcher that returns FALSE
     * (exactly what production does for `/admin`).
     */
    public function testAdminResponseGetsTurboFrameVaryEvenWhenNotAContaoScope(): void
    {
        $request = Request::create('/admin/angebote/abc123', 'GET');
        $request->headers->set('Turbo-Frame', 'slideover');

        $response = $this->buildRoleBucketPublicResponse();

        // alwaysFalseScopeMatcher() == production behaviour for /admin:
        // isContaoMainRequest() is false, so makeResponsePrivate() is a no-op.
        $listener = new UserContextAwarePrivateResponseListener($this->alwaysFalseScopeMatcher());
        $event = $this->makeEvent($request, $response);
        $listener->disableSymfonyAutoCacheControl($event);
        $listener->addTurboFrameVary($event);
        $listener->makeResponsePrivate($event);

        $vary = array_map('strtolower', $response->headers->all('Vary'));
        self::assertContains(
            'turbo-frame',
            $vary,
            'Admin responses must vary on Turbo-Frame even though /admin is not a Contao scope — '
            .'otherwise Varnish serves a slideover-shaped body to a plain direct visit.',
        );
    }

    /**
     * Language-prefixed admin URL (`/de/admin/…`, `/en/admin/…`) is the
     * same admin path-space — must also get `Vary: Turbo-Frame`.
     */
    public function testLanguagePrefixedAdminPathGetsTurboFrameVary(): void
    {
        $request = Request::create('/de/admin/teilnehmende', 'GET');
        $request->cookies->set('PHPSESSID', 'abc');
        $request->setSession(new Session(new MockArraySessionStorage()));
        $request->headers->set('Cookie', 'PHPSESSID=abc');

        $response = $this->buildRoleBucketPublicResponse();
        $this->dispatch(request: $request, response: $response);

        $vary = array_map('strtolower', $response->headers->all('Vary'));
        self::assertContains('turbo-frame', $vary);
    }

    /**
     * The fix for the admin browser-cache leak: an admin response that is NOT
     * role-bucket cacheable — here a role-bucket-shaped body that carries a
     * Set-Cookie and therefore gets downgraded to `private` — MUST still carry
     * `Vary: Turbo-Frame`. Browsers cache `private` responses (per max-age) in
     * their own per-user cache; without this Vary the browser serves a
     * slideover/modal-shaped body to a subsequent direct visit of the same
     * admin URL (and vice versa). The previous code only added the Vary inside
     * the role-bucket (public) branch, so every private admin response leaked.
     */
    public function testPrivateAdminResponseStillGetsTurboFrameVary(): void
    {
        $request = Request::create('/admin/teilnehmende', 'GET');
        $request->cookies->set('PHPSESSID', 'abc');
        $request->setSession(new Session(new MockArraySessionStorage()));
        $request->headers->set('Cookie', 'PHPSESSID=abc');

        // Role-bucket-shaped, but a Set-Cookie disqualifies it → downgraded to private.
        $response = new Response('body', 200, [
            'Cache-Control' => 'public, s-maxage=31556926',
            'Vary' => 'X-User-Context-Hash',
        ]);
        $response->headers->setCookie(\Symfony\Component\HttpFoundation\Cookie::create('flashes', '{}'));

        $this->dispatch(request: $request, response: $response);

        self::assertStringContainsString(
            'private',
            (string) $response->headers->get('Cache-Control'),
            'Set-Cookie must still force the downgrade to private',
        );
        $vary = array_map('strtolower', $response->headers->all('Vary'));
        self::assertContains(
            'turbo-frame',
            $vary,
            'A downgraded-to-private admin response must STILL vary on Turbo-Frame — the browser '
            .'caches it per-user and would otherwise serve a slideover body to a direct visit.',
        );
    }

    /**
     * Negative case we MUST catch: public response with Vary: Cookie but NOT
     * Vary: X-User-Context-Hash. That would let Varnish serve one user's
     * cached content to another. The listener must downgrade to private.
     */
    public function testPublicResponseWithoutHashVaryIsDowngraded(): void
    {
        $response = new Response('body', 200, [
            'Cache-Control' => 'public, s-maxage=31556926',
            'Vary' => 'Cookie',
        ]);

        $this->dispatch(authenticated: true, response: $response);

        self::assertStringContainsString('private', (string) $response->headers->get('Cache-Control'));
        self::assertSame(
            // With Vary: Cookie + a request cookie present, the listener picks
            // condition (4) ("request-cookies"). That's the correct reason.
            'request-cookies (PHPSESSID, csrf_contao_csrf_token)',
            $response->headers->get('Contao-Private-Response-Reason'),
        );
    }

    /** Negative case: explicit `private` wins even if Vary looks right. */
    public function testPrivateResponseIsLeftAlone(): void
    {
        $response = new Response('body', 200, [
            'Cache-Control' => 'private, max-age=0',
            'Vary' => 'X-User-Context-Hash',
        ]);

        $this->dispatch(authenticated: true, response: $response);

        self::assertStringContainsString('private', (string) $response->headers->get('Cache-Control'));
    }

    /**
     * A role-bucket-shaped response that carries a Set-Cookie (typically
     * the `flashes` cookie written by FOSHttpCacheBundle's
     * FlashMessageListener, but also CSRF token rotation, remember-me,
     * etc.) MUST be downgraded to `private`. Otherwise Varnish's VCL
     * strips Set-Cookie from public responses and the cookie never
     * reaches the browser — the flash would be silently dropped.
     */
    public function testSetCookieOnRoleBucketShapedResponseForcesDowngrade(): void
    {
        $response = new Response('body', 200, [
            'Cache-Control' => 'public, s-maxage=31556926',
            'Vary' => 'X-User-Context-Hash',
        ]);
        $response->headers->setCookie(\Symfony\Component\HttpFoundation\Cookie::create('flashes', '{"confirmation":[{"text":"Saved"}]}'));

        $this->dispatch(authenticated: true, response: $response);

        self::assertStringContainsString(
            'private',
            (string) $response->headers->get('Cache-Control'),
            'A response with Set-Cookie must be private so Varnish doesn\'t strip the cookie',
        );
        self::assertStringContainsString(
            'response-cookies',
            (string) $response->headers->get('Contao-Private-Response-Reason'),
            'Downgrade must pick Contao\'s "response-cookies" condition',
        );
        self::assertStringContainsString(
            'flashes',
            (string) $response->headers->get('Contao-Private-Response-Reason'),
            'Debug header should name the offending cookie',
        );
    }

    /** Outside Contao scope → listener is a no-op. */
    public function testNonContaoScopeIsNoOp(): void
    {
        $response = $this->buildRoleBucketPublicResponse();
        // Keep explicit public + hash-vary; verify the listener doesn't try
        // to inspect it at all when scopeMatcher returns false.
        $listener = new UserContextAwarePrivateResponseListener($this->alwaysFalseScopeMatcher());
        $event = $this->makeEvent($this->makeAuthenticatedRequest(), $response);

        $listener->disableSymfonyAutoCacheControl($event);
        $listener->makeResponsePrivate($event);

        self::assertFalse(
            $response->headers->has('Symfony-Session-NoAutoCacheControl'),
            'Must not set the NO_AUTO_CACHE_CONTROL_HEADER for non-Contao scopes',
        );
        self::assertTrue($response->isCacheable());
    }

    /**
     * Regression: when the response has no `s-maxage` (just `public`),
     * `Symfony\Response::isCacheable()` is already false, so the listener
     * correctly short-circuits without running either branch. The response
     * Cache-Control stays as the controller set it and Varnish won't cache
     * it anyway (no TTL). Encode that short-circuit so a refactor can't
     * accidentally start running the opt-out for unbounded public responses.
     */
    public function testPublicWithoutSMaxageShortCircuits(): void
    {
        $response = new Response('body', 200, [
            'Cache-Control' => 'public',
            'Vary' => 'X-User-Context-Hash',
        ]);

        $this->dispatch(authenticated: true, response: $response);

        self::assertFalse($response->isCacheable(), 'Sanity: Symfony already considers this uncacheable');
        self::assertFalse(
            $response->headers->has('Contao-Private-Response-Reason'),
            'Listener must short-circuit without stamping the downgrade debug header',
        );
    }

    public function testDisableSymfonyAutoCacheControlStampsSentinelHeader(): void
    {
        $response = new Response();
        $listener = new UserContextAwarePrivateResponseListener($this->alwaysTrueScopeMatcher());
        $event = $this->makeEvent($this->makeAuthenticatedRequest(), $response);

        $listener->disableSymfonyAutoCacheControl($event);

        self::assertSame(
            '1',
            $response->headers->get('Symfony-Session-NoAutoCacheControl'),
            'Without this sentinel, Symfony\'s AbstractSessionListener downgrades the response to private anyway',
        );
    }

    // ---------------------------------------------------------------------

    private function buildRoleBucketPublicResponse(): Response
    {
        return new Response('shared role content', 200, [
            'Cache-Control' => 'public, s-maxage=31556926',
            'Vary' => 'X-User-Context-Hash, Accept-Encoding',
        ]);
    }

    /**
     * Simulate the "logged-in user" request shape: Cookie header with
     * PHPSESSID, a started session, and the Contao main scope.
     */
    private function makeAuthenticatedRequest(?Session $session = null): Request
    {
        $request = Request::create('/offers', 'GET');
        $request->cookies->set('PHPSESSID', 'abc123');
        $request->cookies->set('csrf_contao_csrf_token', 'xyz');
        $request->setSession($session ?? new Session(new MockArraySessionStorage()));
        // Setting a previous session id makes hasPreviousSession() true,
        // which is what Contao's condition (2) checks.
        $request->headers->set('Cookie', 'PHPSESSID=abc123; csrf_contao_csrf_token=xyz');

        return $request;
    }

    private function dispatch(
        bool $authenticated = false,
        ?Request $request = null,
        ?Response $response = null,
    ): void {
        $request ??= $authenticated ? $this->makeAuthenticatedRequest() : Request::create('/');
        $response ??= new Response();

        $listener = new UserContextAwarePrivateResponseListener($this->alwaysTrueScopeMatcher());
        $event = $this->makeEvent($request, $response);
        $listener->disableSymfonyAutoCacheControl($event);
        $listener->addTurboFrameVary($event);
        $listener->makeResponsePrivate($event);
    }

    private function makeEvent(Request $request, Response $response): ResponseEvent
    {
        $kernel = $this->createMock(HttpKernelInterface::class);

        return new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);
    }

    private function alwaysTrueScopeMatcher(): ScopeMatcher
    {
        $backend = $this->createMock(RequestMatcherInterface::class);
        $backend->method('matches')->willReturn(false);
        $frontend = $this->createMock(RequestMatcherInterface::class);
        $frontend->method('matches')->willReturn(true);

        return new ScopeMatcher($backend, $frontend, new RequestStack());
    }

    private function alwaysFalseScopeMatcher(): ScopeMatcher
    {
        $backend = $this->createMock(RequestMatcherInterface::class);
        $backend->method('matches')->willReturn(false);
        $frontend = $this->createMock(RequestMatcherInterface::class);
        $frontend->method('matches')->willReturn(false);

        return new ScopeMatcher($backend, $frontend, new RequestStack());
    }
}
