<?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\Security;

use Contao\CoreBundle\EventListener\SubrequestCacheSubscriber;
use Ferienpass\CmsBundle\Fragment\FragmentReference;
use Ferienpass\CmsBundle\Page\PageBuilder;
use Ferienpass\CmsBundle\Page\PageBuilderFactory;
use Ferienpass\CmsBundle\Security\AccessDeniedHandler;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;

/**
 * The handler renders Contao's `error403` fragment via PageBuilder when an
 * anonymous visitor hits a protected route (`/account`, `/meine-anmeldungen`,
 * `/bezahlen/…`). The fragment body is the CMS login form, which contains a
 * CSRF token tied to the current session.
 *
 * Before the safety stamping landed:
 *
 *   - Contao's `FrontendTemplate::setCacheHeaders` had already stamped
 *     `Cache-Control: public, s-maxage=31556926` on the response.
 *   - Symfony's security ExceptionListener rewrites the status to 403
 *     before delivery.
 *   - `UserContextAwarePrivateResponseListener` returns early because
 *     `Response::isCacheable()` excludes 403 — Contao's downgrade never
 *     runs.
 *   - Varnish 7's built-in cacheability rules currently exclude 403 by
 *     default, so nothing actually leaked — but it was one VCL tweak away
 *     and CSRF tokens are session-bound, so a baked-in token in a public
 *     cache entry would silently break every login.
 *
 * These tests lock in the explicit stamping so a future refactor can't
 * regress the contract.
 */
final class AccessDeniedHandlerTest extends TestCase
{
    public function testHandleStampsResponseAsPrivate(): void
    {
        $response = $this->handle();

        $cacheControl = (string) $response->headers->get('Cache-Control');
        self::assertStringContainsString(
            'private',
            $cacheControl,
            'The 403 carries a session-bound CSRF token in the login form; it must never enter the shared cache.'
        );
        self::assertStringNotContainsString('public', $cacheControl);
    }

    public function testHandleStampsNoStore(): void
    {
        $response = $this->handle();

        $cacheControl = (string) $response->headers->get('Cache-Control');
        self::assertStringContainsString(
            'no-store',
            $cacheControl,
            'no-store guards against any layer that incorrectly stores `private` responses.'
        );
    }

    public function testHandleStampsMustRevalidate(): void
    {
        $response = $this->handle();

        $cacheControl = (string) $response->headers->get('Cache-Control');
        self::assertStringContainsString('must-revalidate', $cacheControl);
    }

    public function testHandleStampsZeroMaxAge(): void
    {
        $response = $this->handle();

        self::assertSame(
            0,
            $response->getMaxAge(),
            'A 403 with a fresh CSRF token must not be browser-cached either — re-fetch every time.'
        );
    }

    public function testHandleStampsMergeCacheSentinel(): void
    {
        $response = $this->handle();

        self::assertTrue(
            $response->headers->has(SubrequestCacheSubscriber::MERGE_CACHE_HEADER),
            'The merge sentinel keeps the response composable in a future nested-page scenario; cheap to add.'
        );
    }

    public function testHandleAddsErrorFragmentToPageBuilder(): void
    {
        $captured = [];
        $this->handle(captureFragments: $captured);

        self::assertCount(1, $captured, 'Exactly one fragment is added: the error403 partial.');
        self::assertSame('main', $captured[0]['section']);
        self::assertInstanceOf(FragmentReference::class, $captured[0]['fragment']);
        // FragmentReference's __construct stores the controller in its parent
        // ControllerReference::$controller property. Use string cast which
        // returns the controller identifier.
        self::assertSame('ferienpass.fragment.error403', $captured[0]['fragment']->controller);
    }

    public function testHandleOverridesContaoBakedInPublicCacheControl(): void
    {
        // Simulate the upstream PageBuilder returning a response that
        // FrontendTemplate already stamped as `public, s-maxage=1y`.
        $pageBuilder = $this->mockPageBuilder(static function () {
            $response = new Response('<form>...login...</form>');
            $response->setPublic();
            $response->setSharedMaxAge(31556926);

            return $response;
        });

        $factory = $this->createMock(PageBuilderFactory::class);
        $factory->method('create')->willReturn($pageBuilder);

        $handler = new AccessDeniedHandler($factory);
        $response = $handler->handle(new Request(), new AccessDeniedException());

        self::assertNotNull($response, 'Handler must return a Response, not null');
        $cacheControl = (string) $response->headers->get('Cache-Control');
        self::assertStringNotContainsString('public', $cacheControl, 'Contao\'s public must be overridden');
        self::assertStringNotContainsString('s-maxage=31556926', $cacheControl, 'Contao\'s 1y s-maxage must be cleared');
        self::assertStringContainsString('private', $cacheControl);
    }

    /**
     * @param array<int, array{section: string, fragment: FragmentReference}>|null $captureFragments
     */
    private function handle(?array &$captureFragments = null): Response
    {
        $pageBuilder = $this->mockPageBuilder(static fn () => new Response('<form>login form</form>'), $captureFragments);

        $factory = $this->createMock(PageBuilderFactory::class);
        $factory->method('create')->willReturn($pageBuilder);

        $handler = new AccessDeniedHandler($factory);
        $response = $handler->handle(new Request(), new AccessDeniedException());

        self::assertInstanceOf(Response::class, $response);

        return $response;
    }

    /**
     * Build a PageBuilder mock that records every `addFragment()` call into
     * the optional capture array and returns the given Response from
     * `getResponse()`.
     *
     * @param callable():Response                                                  $responseFactory
     * @param array<int, array{section: string, fragment: FragmentReference}>|null $capturedFragments
     */
    private function mockPageBuilder(callable $responseFactory, ?array &$capturedFragments = null): PageBuilder
    {
        $builder = $this->createMock(PageBuilder::class);
        $builder->method('addFragment')->willReturnCallback(
            static function (string $section, FragmentReference $fragment) use ($builder, &$capturedFragments) {
                $capturedFragments[] = ['section' => $section, 'fragment' => $fragment];

                return $builder;
            }
        );
        $builder->method('getResponse')->willReturnCallback($responseFactory);

        return $builder;
    }
}
