<?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\CmsBundle\Tests\Controller;

use Contao\CoreBundle\EventListener\SubrequestCacheSubscriber;
use Ferienpass\CmsBundle\Controller\AbstractController;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Response;

/**
 * Unit tests for {@see AbstractController::markPageAsPrivate}, the explicit
 * page-level safety net for auth-required CMS routes.
 *
 * Why this lives at the page level (not delegated to the fragment-merge
 * mechanism):
 *
 * The fragment-merge architecture (each
 * {@see \Ferienpass\CoreBundle\Controller\AbstractPrivateFragmentController}
 * stamps a sentinel header so Contao's `SubrequestCacheSubscriber`
 * propagates `private` UP into the parent page) is correct in isolation
 * but has many moving parts:
 *
 *   1. EnableCacheMergeListener must run on the main response.
 *   2. Every embedded fragment must extend AbstractPrivateFragmentController.
 *   3. The fragment must actually run as a Symfony sub-request (not get
 *      bypassed by a custom rendering path).
 *   4. The subscriber's strategy stack must not be corrupted by interleaved
 *      requests.
 *
 * One missed extension, one Article fragment slipped in alongside the
 * private fragments, one custom controller that doesn't go through
 * PageBuilder — and the page comes back as `public, s-maxage=1y` from
 * Contao's `FrontendTemplate::setCacheHeaders`, role-bucket-caching User A's
 * data under the hash that User B will also resolve to.
 *
 * `markPageAsPrivate()` is independent of the chain: it stamps `private,
 * no-store, must-revalidate, max-age=0` directly on the response. Varnish
 * refuses to cache regardless of what fragments did. The merge sentinel is
 * kept for nested-page scenarios — cheap and forward-compatible.
 */
final class AbstractControllerMarkPageAsPrivateTest extends TestCase
{
    public function testStampsPrivateOnCacheControl(): void
    {
        $response = $this->mark(new Response('<html>...</html>'));

        $cacheControl = (string) $response->headers->get('Cache-Control');
        self::assertStringContainsString(
            'private',
            $cacheControl,
            'markPageAsPrivate must emit `private` so shared caches refuse to store the response.'
        );
        self::assertStringNotContainsString(
            'public',
            $cacheControl,
            'A private page must never advertise public — Varnish keys off the `public` substring.'
        );
    }

    public function testStampsNoStoreSoVarnishWillNotCache(): void
    {
        $response = $this->mark(new Response());

        $cacheControl = (string) $response->headers->get('Cache-Control');
        self::assertStringContainsString(
            'no-store',
            $cacheControl,
            'no-store is the belt-and-braces against any cache that incorrectly treats `private` as `public for me`.'
        );
    }

    public function testStampsMustRevalidate(): void
    {
        $response = $this->mark(new Response());

        $cacheControl = (string) $response->headers->get('Cache-Control');
        self::assertStringContainsString(
            'must-revalidate',
            $cacheControl,
            'must-revalidate forbids browser caches from serving stale per-user content while offline / after logout.'
        );
    }

    public function testStampsZeroMaxAge(): void
    {
        $response = $this->mark(new Response());

        self::assertSame(
            0,
            $response->getMaxAge(),
            'A per-user response must not advertise any positive max-age — even browser caches should re-fetch.'
        );
    }

    public function testStampsMergeCacheSentinel(): void
    {
        $response = $this->mark(new Response());

        self::assertTrue(
            $response->headers->has(SubrequestCacheSubscriber::MERGE_CACHE_HEADER),
            'The merge sentinel must be present so IF this response is ever embedded inside another '
            .'Contao page (currently no such nesting exists, but cheap to support), the `private` '
            .'state propagates up to that outer page via SubrequestCacheSubscriber.'
        );
    }

    public function testVaryIncludesCookieForBrowserCacheCorrectness(): void
    {
        $response = $this->mark(new Response());

        $vary = array_map('strtolower', $response->headers->all('Vary'));
        self::assertContains(
            'cookie',
            $vary,
            'Even though shared caches won\'t store private responses, the browser cache will. '
            .'Vary: Cookie ensures a logout + new login on the same browser invalidates the prior user\'s view.'
        );
    }

    public function testReturnsTheSameResponseInstanceForFluentChaining(): void
    {
        $input = new Response();
        $output = $this->mark($input);

        self::assertSame(
            $input,
            $output,
            'markPageAsPrivate must mutate in place and return the same instance so callers can chain it onto $builder->getResponse().'
        );
    }

    public function testPreservesPreExistingResponseBodyAndStatus(): void
    {
        $response = new Response('<h1>Account</h1>', Response::HTTP_OK);
        $response->headers->set('X-Custom', 'preserve-me');

        $this->mark($response);

        self::assertSame('<h1>Account</h1>', $response->getContent(), 'Body must be untouched');
        self::assertSame(200, $response->getStatusCode(), 'Status must be untouched');
        self::assertSame('preserve-me', $response->headers->get('X-Custom'), 'Unrelated headers must survive');
    }

    public function testOverridesAPreviouslyPublicCacheControl(): void
    {
        // Simulate Contao's FrontendTemplate::setCacheHeaders having already
        // stamped public+s-maxage. markPageAsPrivate MUST stomp on that —
        // otherwise the leak persists.
        $response = new Response();
        $response->setPublic();
        $response->setSharedMaxAge(31556926);

        $this->mark($response);

        $cacheControl = (string) $response->headers->get('Cache-Control');
        self::assertStringNotContainsString('public', $cacheControl, 'pre-existing `public` must be overridden');
        self::assertStringNotContainsString('s-maxage=31556926', $cacheControl, 'pre-existing s-maxage must be cleared');
        self::assertStringContainsString('private', $cacheControl);
    }

    /**
     * Invoke the protected helper via an anonymous subclass — keeps the
     * production class surface clean while still letting us test in
     * isolation.
     */
    private function mark(Response $response): Response
    {
        $controller = new class extends AbstractController {
            public function markPublic(Response $r): Response
            {
                return $this->markPageAsPrivate($r);
            }
        };

        return $controller->markPublic($response);
    }
}
