<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Component\HttpKernel\Tests\EventListener;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\Cache;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\EventListener\CacheAttributeListener;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\CacheAttributeController;

class CacheAttributeListenerTest extends TestCase
{
    private CacheAttributeListener $listener;
    private Response $response;
    private Cache $cache;
    private Request $request;
    private ResponseEvent $event;

    protected function setUp(): void
    {
        $this->listener = new CacheAttributeListener();
        $this->response = new Response();
        $this->cache = new Cache();
        $this->request = $this->createRequest($this->cache);
        $this->event = $this->createEventMock($this->request, $this->response);
    }

    public function testWontReassignResponseWhenResponseIsUnsuccessful()
    {
        $response = $this->event->getResponse();

        $this->response->setStatusCode(500);

        $this->listener->onKernelResponse($this->event);

        $this->assertSame($response, $this->event->getResponse());
    }

    public function testWontReassignResponseWhenNoConfigurationIsPresent()
    {
        $response = $this->event->getResponse();

        $this->request->attributes->remove('_cache');

        $this->listener->onKernelResponse($this->event);

        $this->assertSame($response, $this->event->getResponse());
    }

    public function testResponseIsPublicIfSharedMaxAgeSetAndPublicNotOverridden()
    {
        $request = $this->createRequest(new Cache(smaxage: 1));

        $this->listener->onKernelResponse($this->createEventMock($request, $this->response));

        $this->assertTrue($this->response->headers->hasCacheControlDirective('public'));
        $this->assertFalse($this->response->headers->hasCacheControlDirective('private'));
    }

    public function testResponseIsPublicIfConfigurationIsPublicTrue()
    {
        $request = $this->createRequest(new Cache(public: true));

        $this->listener->onKernelResponse($this->createEventMock($request, $this->response));

        $this->assertTrue($this->response->headers->hasCacheControlDirective('public'));
        $this->assertFalse($this->response->headers->hasCacheControlDirective('private'));
    }

    public function testResponseIsPrivateIfConfigurationIsPublicFalse()
    {
        $request = $this->createRequest(new Cache(public: false));

        $this->listener->onKernelResponse($this->createEventMock($request, $this->response));

        $this->assertFalse($this->response->headers->hasCacheControlDirective('public'));
        $this->assertTrue($this->response->headers->hasCacheControlDirective('private'));
    }

    public function testResponseIsPublicIfConfigurationIsPublicTrueNoStoreFalse()
    {
        $request = $this->createRequest(new Cache(public: true, noStore: false));

        $this->listener->onKernelResponse($this->createEventMock($request, $this->response));

        $this->assertTrue($this->response->headers->hasCacheControlDirective('public'));
        $this->assertFalse($this->response->headers->hasCacheControlDirective('private'));
        $this->assertFalse($this->response->headers->hasCacheControlDirective('no-store'));
    }

    public function testResponseKeepPublicIfConfigurationIsPublicTrueNoStoreTrue()
    {
        $request = $this->createRequest(new Cache(public: true, noStore: true));

        $this->listener->onKernelResponse($this->createEventMock($request, $this->response));

        $this->assertTrue($this->response->headers->hasCacheControlDirective('public'));
        $this->assertFalse($this->response->headers->hasCacheControlDirective('private'));
        $this->assertTrue($this->response->headers->hasCacheControlDirective('no-store'));
    }

    public function testResponseKeepPrivateNoStoreIfConfigurationIsNoStoreTrue()
    {
        $request = $this->createRequest(new Cache(noStore: true));

        $this->listener->onKernelResponse($this->createEventMock($request, $this->response));

        $this->assertFalse($this->response->headers->hasCacheControlDirective('public'));
        $this->assertTrue($this->response->headers->hasCacheControlDirective('private'));
        $this->assertTrue($this->response->headers->hasCacheControlDirective('no-store'));
    }

    public function testResponseIsPublicIfSharedMaxAgeSetAndNoStoreIsTrue()
    {
        $request = $this->createRequest(new Cache(smaxage: 1, noStore: true));

        $this->listener->onKernelResponse($this->createEventMock($request, $this->response));

        $this->assertTrue($this->response->headers->hasCacheControlDirective('public'));
        $this->assertFalse($this->response->headers->hasCacheControlDirective('private'));
        $this->assertTrue($this->response->headers->hasCacheControlDirective('no-store'));
    }

    public function testResponseVary()
    {
        $vary = ['foobar'];
        $request = $this->createRequest(new Cache(vary: $vary));

        $this->listener->onKernelResponse($this->createEventMock($request, $this->response));
        $this->assertTrue($this->response->hasVary());
        $result = $this->response->getVary();
        $this->assertSame($vary, $result);
    }

    public function testResponseVaryWhenVaryNotSet()
    {
        $request = $this->createRequest(new Cache());
        $vary = ['foobar'];
        $this->response->setVary($vary);

        $this->listener->onKernelResponse($this->createEventMock($request, $this->response));
        $this->assertTrue($this->response->hasVary());
        $result = $this->response->getVary();
        $this->assertNotEmpty($result, 'Existing vary headers should not be removed');
        $this->assertSame($vary, $result, 'Vary header should not be changed');
    }

    public function testResponseIsPrivateIfConfigurationIsPublicNotSet()
    {
        $request = $this->createRequest(new Cache());

        $this->listener->onKernelResponse($this->createEventMock($request, $this->response));

        $this->assertFalse($this->response->headers->hasCacheControlDirective('public'));
    }

    public function testAttributeConfigurationsAreSetOnResponse()
    {
        $this->assertNull($this->response->getMaxAge());
        $this->assertNull($this->response->getExpires());
        $this->assertFalse($this->response->headers->hasCacheControlDirective('s-maxage'));
        $this->assertFalse($this->response->headers->hasCacheControlDirective('max-stale'));
        $this->assertFalse($this->response->headers->hasCacheControlDirective('stale-while-revalidate'));
        $this->assertFalse($this->response->headers->hasCacheControlDirective('stale-if-error'));
        $this->assertFalse($this->response->headers->hasCacheControlDirective('no-store'));

        $this->request->attributes->set('_cache', [new Cache(
            expires: 'tomorrow',
            maxage: '15',
            smaxage: '15',
            maxStale: '5',
            staleWhileRevalidate: '6',
            staleIfError: '7',
            noStore: true,
        )]);

        $this->listener->onKernelResponse($this->event);

        $this->assertSame(15, $this->response->getMaxAge());
        $this->assertSame('15', $this->response->headers->getCacheControlDirective('s-maxage'));
        $this->assertSame('5', $this->response->headers->getCacheControlDirective('max-stale'));
        $this->assertSame('6', $this->response->headers->getCacheControlDirective('stale-while-revalidate'));
        $this->assertSame('7', $this->response->headers->getCacheControlDirective('stale-if-error'));
        $this->assertTrue($this->response->headers->hasCacheControlDirective('no-store'));
        $this->assertInstanceOf(\DateTimeInterface::class, $this->response->getExpires());
    }

    public function testCacheMaxAgeSupportsStrtotimeFormat()
    {
        $this->request->attributes->set('_cache', [new Cache(
            maxage: '1 day',
            smaxage: '1 day',
            maxStale: '1 day',
            staleWhileRevalidate: '1 day',
            staleIfError: '1 day',
        )]);

        $this->listener->onKernelResponse($this->event);

        $this->assertSame('86400', $this->response->headers->getCacheControlDirective('s-maxage'));
        $this->assertSame(86400, $this->response->getMaxAge());
        $this->assertSame('86400', $this->response->headers->getCacheControlDirective('max-stale'));
        $this->assertSame('86400', $this->response->headers->getCacheControlDirective('stale-if-error'));
    }

    #[TestWith(['test.getDate()'])]
    #[TestWith(['date'])]
    #[TestWith(['args["test"].getDate()'])]
    #[TestWith(['request.attributes.get("date")'])]
    public function testLastModifiedNotModifiedResponse(string $expression)
    {
        $entity = new TestEntity();

        $request = $this->createRequest(new Cache(lastModified: $expression));
        $request->attributes->set('date', new \DateTimeImmutable('Fri, 23 Aug 2013 00:00:00 GMT'));
        $request->headers->add(['If-Modified-Since' => 'Fri, 23 Aug 2013 00:00:00 GMT']);

        $listener = new CacheAttributeListener();
        $controllerArgumentsEvent = new ControllerArgumentsEvent($this->getKernel(), static fn (TestEntity $test) => new Response(), [$entity], $request, null);

        $listener->onKernelControllerArguments($controllerArgumentsEvent);
        $response = $controllerArgumentsEvent->getController()($entity);

        $this->assertSame(304, $response->getStatusCode());
    }

    #[TestWith(['test.getDate()'])]
    #[TestWith(['date'])]
    #[TestWith(['args["test"].getDate()'])]
    #[TestWith(['request.attributes.get("date")'])]
    public function testLastModifiedHeader(string $expression)
    {
        $entity = new TestEntity();

        $request = $this->createRequest(new Cache(lastModified: $expression));
        $request->attributes->set('date', new \DateTimeImmutable('Fri, 23 Aug 2013 00:00:00 GMT'));

        $listener = new CacheAttributeListener();
        $controllerArgumentsEvent = new ControllerArgumentsEvent($this->getKernel(), static fn (TestEntity $test) => new Response(), [$entity], $request, null);
        $listener->onKernelControllerArguments($controllerArgumentsEvent);

        $controllerResponse = $controllerArgumentsEvent->getController()($entity);
        $responseEvent = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MAIN_REQUEST, $controllerResponse);
        $listener->onKernelResponse($responseEvent);

        $response = $responseEvent->getResponse();

        $this->assertSame(200, $response->getStatusCode());
        $this->assertTrue($response->headers->has('Last-Modified'));
        $this->assertSame('Fri, 23 Aug 2013 00:00:00 GMT', $response->headers->get('Last-Modified'));
    }

    #[TestWith(['test.getId()'])]
    #[TestWith(['id'])]
    #[TestWith(['args["test"].getId()'])]
    #[TestWith(['request.attributes.get("id")'])]
    public function testEtagNotModifiedResponse(string $expression)
    {
        $entity = new TestEntity();

        $request = $this->createRequest(new Cache(etag: $expression));
        $request->attributes->set('id', '12345');
        $request->headers->add(['If-None-Match' => \sprintf('"%s"', hash('sha256', $entity->getId()))]);

        $listener = new CacheAttributeListener();
        $controllerArgumentsEvent = new ControllerArgumentsEvent($this->getKernel(), static fn (TestEntity $test) => new Response(), [$entity], $request, null);

        $listener->onKernelControllerArguments($controllerArgumentsEvent);
        $response = $controllerArgumentsEvent->getController()($entity);

        $this->assertSame(304, $response->getStatusCode());
    }

    #[TestWith(['test.getId()'])]
    #[TestWith(['id'])]
    #[TestWith(['args["test"].getId()'])]
    #[TestWith(['request.attributes.get("id")'])]
    public function testEtagHeader(string $expression)
    {
        $entity = new TestEntity();

        $request = $this->createRequest(new Cache(etag: $expression));
        $request->attributes->set('id', '12345');

        $listener = new CacheAttributeListener();
        $controllerArgumentsEvent = new ControllerArgumentsEvent($this->getKernel(), static fn (TestEntity $test) => new Response(), [$entity], $request, null);
        $listener->onKernelControllerArguments($controllerArgumentsEvent);

        $controllerResponse = $controllerArgumentsEvent->getController()($entity);
        $responseEvent = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MAIN_REQUEST, $controllerResponse);
        $listener->onKernelResponse($responseEvent);

        $response = $responseEvent->getResponse();

        $this->assertSame(200, $response->getStatusCode());
        $this->assertTrue($response->headers->has('Etag'));
        $this->assertStringContainsString(hash('sha256', $entity->getId()), $response->headers->get('Etag'));
    }

    #[DataProvider('provideLastModifiedHeaderAndEtagClosureCases')]
    public function testLastModifiedHeaderAndEtagHeadersClosures(\Closure $lastModifiedClosure, \Closure $etagClosure)
    {
        $entity = new TestEntity();

        $request = $this->createRequest(new Cache(lastModified: $lastModifiedClosure, etag: $etagClosure));
        $request->attributes->set('date', new \DateTimeImmutable('Fri, 23 Aug 2013 00:00:00 GMT'));
        $request->attributes->set('id', '12345');

        $listener = new CacheAttributeListener();
        $controllerArgumentsEvent = new ControllerArgumentsEvent($this->getKernel(), static fn (TestEntity $test) => new Response(), [$entity], $request, null);
        $listener->onKernelControllerArguments($controllerArgumentsEvent);

        $controllerResponse = $controllerArgumentsEvent->getController()($entity);
        $responseEvent = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MAIN_REQUEST, $controllerResponse);
        $listener->onKernelResponse($responseEvent);

        $response = $responseEvent->getResponse();

        $this->assertSame(200, $response->getStatusCode());
        $this->assertTrue($response->headers->has('Last-Modified'));
        $this->assertSame('Fri, 23 Aug 2013 00:00:00 GMT', $response->headers->get('Last-Modified'));
        $this->assertTrue($response->headers->has('Etag'));
        $this->assertStringContainsString(hash('sha256', $entity->getId()), $response->headers->get('Etag'));
    }

    public static function provideLastModifiedHeaderAndEtagClosureCases(): iterable
    {
        yield 'using arguments' => [
            static fn (array $arguments, Request $request) => $arguments['test']->getDate(),
            static fn (array $arguments, Request $request) => $arguments['test']->getId(),
        ];

        yield 'using request attributes' => [
            static fn (array $arguments, Request $request) => $request->attributes->get('date'),
            static fn (array $arguments, Request $request) => $request->attributes->get('id'),
        ];
    }

    public function testConfigurationDoesNotOverrideAlreadySetResponseHeaders()
    {
        $request = $this->createRequest(new Cache(
            expires: 'Fri, 24 Aug 2013 00:00:00 GMT',
            maxage: '15',
            smaxage: '15',
            vary: ['foobar'],
            lastModified: 'Fri, 24 Aug 2013 00:00:00 GMT',
            etag: '"12345"',
        ));

        $response = new Response();
        $response->setEtag('"54321"');
        $response->setLastModified(new \DateTimeImmutable('Fri, 23 Aug 2014 00:00:00 GMT'));
        $response->setExpires(new \DateTimeImmutable('Fri, 24 Aug 2014 00:00:00 GMT'));
        $response->setSharedMaxAge(30);
        $response->setMaxAge(30);
        $response->setVary(['foobaz']);

        $listener = new CacheAttributeListener();
        $responseEvent = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MAIN_REQUEST, $response);
        $listener->onKernelResponse($responseEvent);

        $this->assertSame('"54321"', $response->getEtag());
        $this->assertEquals(new \DateTimeImmutable('Fri, 23 Aug 2014 00:00:00 GMT'), $response->getLastModified());
        $this->assertEquals(new \DateTimeImmutable('Fri, 24 Aug 2014 00:00:00 GMT'), $response->getExpires());
        $this->assertSame('30', $response->headers->getCacheControlDirective('s-maxage'));
        $this->assertSame(30, $response->getMaxAge());
        $this->assertSame(['foobaz'], $response->getVary());
    }

    public function testAttribute()
    {
        $request = new Request();
        $event = new ControllerArgumentsEvent($this->getKernel(), [new CacheAttributeController(), 'foo'], [], $request, null);
        $this->listener->onKernelControllerArguments($event);

        $response = new Response();
        $event = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MAIN_REQUEST, $response);
        $this->listener->onKernelResponse($event);

        $this->assertSame(CacheAttributeController::METHOD_SMAXAGE, $response->getMaxAge());

        $request = new Request();
        $event = new ControllerArgumentsEvent($this->getKernel(), [new CacheAttributeController(), 'bar'], [], $request, null);
        $this->listener->onKernelControllerArguments($event);

        $response = new Response();
        $event = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MAIN_REQUEST, $response);
        $this->listener->onKernelResponse($event);

        $this->assertSame(CacheAttributeController::CLASS_SMAXAGE, $response->getMaxAge());
    }

    #[DataProvider('provideVaryHeaderScenarios')]
    public function testHasRelevantVaryHeaderBehavior(array $responseVary, array $cacheVary, bool $varyByLanguage, array $expectedVary)
    {
        $request = $this->createRequest(new Cache(vary: $cacheVary));
        $request->attributes->set('_vary_by_language', $varyByLanguage);

        $response = new Response();
        $response->setVary($responseVary);

        $listener = new CacheAttributeListener();
        $event = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MAIN_REQUEST, $response);
        $listener->onKernelResponse($event);

        $this->assertSame($expectedVary, $response->getVary());
    }

    public function testAttributeRespectsExplicitPrivateFromController()
    {
        $request = $this->createRequest(new Cache(public: true));
        $response = new Response();
        $response->setPrivate();

        $this->listener->onKernelResponse($this->createEventMock($request, $response));

        $this->assertTrue($response->headers->hasCacheControlDirective('private'));
        $this->assertFalse($response->headers->hasCacheControlDirective('public'));
    }

    public function testAttributeRespectsExplicitPublicFromController()
    {
        $request = $this->createRequest(new Cache(public: false));
        $response = new Response();
        $response->setPublic();

        $this->listener->onKernelResponse($this->createEventMock($request, $response));

        $this->assertTrue($response->headers->hasCacheControlDirective('public'));
        $this->assertFalse($response->headers->hasCacheControlDirective('private'));
    }

    public static function provideVaryHeaderScenarios(): \Traversable
    {
        yield 'no vary headers at all' => [
            'responseVary' => [],
            'cacheVary' => ['X-Foo'],
            'varyByLanguage' => false,
            'expectedVary' => ['X-Foo'],
        ];
        yield 'response vary accept-language only, vary_by_language true (append new)' => [
            'responseVary' => ['Accept-Language'],
            'cacheVary' => ['X-Bar'],
            'varyByLanguage' => true,
            'expectedVary' => ['Accept-Language', 'X-Bar'], // X-Bar is added
        ];
        yield 'response vary accept-language only, vary_by_language false (no append)' => [
            'responseVary' => ['Accept-Language'],
            'cacheVary' => ['X-Bar'],
            'varyByLanguage' => false,
            'expectedVary' => ['Accept-Language'], // no append
        ];
        yield 'response vary multiple including accept-language, vary_by_language true (no append)' => [
            'responseVary' => ['Accept-Language', 'User-Agent'],
            'cacheVary' => ['X-Baz'],
            'varyByLanguage' => true,
            'expectedVary' => ['Accept-Language', 'User-Agent'], // no append
        ];
        yield 'cache vary is empty' => [
            'responseVary' => ['X-Existing'],
            'cacheVary' => [],
            'varyByLanguage' => true,
            'expectedVary' => ['X-Existing'], // nothing to add
        ];
        yield 'vary * (no append) — vary_by_language=true' => [
            'responseVary' => ['*'],
            'cacheVary' => ['X-Foo'],
            'varyByLanguage' => true,
            'expectedVary' => ['*'],
        ];
        yield 'vary * (no append) — vary_by_language=false' => [
            'responseVary' => ['*'],
            'cacheVary' => ['X-Foo'],
            'varyByLanguage' => false,
            'expectedVary' => ['*'],
        ];
    }

    #[DataProvider('provideCacheIfCases')]
    public function testCacheAppliedOnlyWhenIfEvaluatesToTrue(string|\Closure $if1, string|\Closure $if2, bool $sMaxAge, bool $public, bool $maxAge, bool $private)
    {
        $entity = new TestEntity();

        $request = $this->createRequest(
            new Cache(smaxage: '1 days', public: true, if: $if1),
            new Cache(maxage: '10 days', public: false, if: $if2),
        );

        $listener = new CacheAttributeListener();
        $controllerArgumentsEvent = new ControllerArgumentsEvent($this->getKernel(), [new TestController(true), '__invoke'], [$entity], $request, null);
        $listener->onKernelControllerArguments($controllerArgumentsEvent);

        $controllerResponse = $controllerArgumentsEvent->getController()($entity);
        $responseEvent = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MAIN_REQUEST, $controllerResponse);
        $listener->onKernelResponse($responseEvent);

        $response = $responseEvent->getResponse();

        $this->assertSame(200, $response->getStatusCode());

        $this->assertSame($sMaxAge, $response->headers->hasCacheControlDirective('s-maxage'));
        $this->assertSame($public, $response->headers->hasCacheControlDirective('public'));
        $this->assertSame($maxAge, $response->headers->hasCacheControlDirective('max-age'));
        $this->assertSame($private, $response->headers->hasCacheControlDirective('private'));
    }

    public static function provideCacheIfCases(): iterable
    {
        yield 'expression' => [
            'args["test"].getId() <= 0',
            'args["test"].getId() > 0',
            false,
            false,
            true,
            true,
        ];

        yield 'expression accessing controller' => [
            'this.cache',
            'not this.cache',
            true,
            true,
            false,
            false,
        ];

        yield 'closure' => [
            static fn (array $arguments, Request $request, ?object $controller) => $arguments['test']->getDate() <= new \DateTimeImmutable(),
            static fn (array $arguments, Request $request, ?object $controller) => $arguments['test']->getDate() > new \DateTimeImmutable(),
            true,
            true,
            false,
            false,
        ];

        yield 'closure accessing controller' => [
            static fn (array $arguments, Request $request, ?object $controller) => $controller->cache,
            static fn (array $arguments, Request $request, ?object $controller) => !$controller->cache,
            true,
            true,
            false,
            false,
        ];
    }

    public function testErrorIsThrownWhenIfEvaluatesToNonBool()
    {
        $entity = new TestEntity();

        $request = $this->createRequest(
            new Cache(smaxage: '1 days', public: true, if: static fn (array $arguments, Request $request) => 'foo'),
        );

        $listener = new CacheAttributeListener();
        $controllerArgumentsEvent = new ControllerArgumentsEvent($this->getKernel(), static fn (TestEntity $test) => new Response(), [$entity], $request, null);

        $this->expectException(\TypeError::class);
        $this->expectExceptionMessage(\sprintf('The value of the "$if" option of the "%s" attribute must evaluate to a boolean, "string" given.', Cache::class));

        $listener->onKernelControllerArguments($controllerArgumentsEvent);
    }

    private function createRequest(Cache ...$cache): Request
    {
        return new Request([], [], ['_cache' => $cache]);
    }

    private function createEventMock(Request $request, Response $response): ResponseEvent
    {
        return new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MAIN_REQUEST, $response);
    }

    private function getKernel(): Stub&HttpKernelInterface
    {
        return $this->createStub(HttpKernelInterface::class);
    }
}

class TestEntity
{
    public function getDate()
    {
        return new \DateTimeImmutable('Fri, 23 Aug 2013 00:00:00 GMT');
    }

    public function getId()
    {
        return '12345';
    }
}

class TestController
{
    public function __construct(public bool $cache)
    {
    }

    public function __invoke(TestEntity $test)
    {
        return new Response();
    }
}
