<?php

declare(strict_types=1);

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao\CoreBundle\Tests\Fragment;

use Contao\CoreBundle\Exception\ResponseException;
use Contao\CoreBundle\Fragment\FragmentConfig;
use Contao\CoreBundle\Fragment\FragmentHandler;
use Contao\CoreBundle\Fragment\FragmentPreHandlerInterface;
use Contao\CoreBundle\Fragment\FragmentRegistry;
use Contao\CoreBundle\Fragment\Reference\FragmentReference;
use Contao\CoreBundle\Fragment\UnknownFragmentException;
use Contao\CoreBundle\Routing\PageFinder;
use Contao\CoreBundle\Tests\TestCase;
use Contao\PageModel;
use Contao\System;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Fragment\FragmentHandler as BaseFragmentHandler;
use Symfony\Component\HttpKernel\Fragment\FragmentRendererInterface;

class FragmentHandlerTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        System::setContainer($this->getContainerWithContaoConfiguration());
    }

    protected function tearDown(): void
    {
        $this->resetStaticProperties([System::class]);

        parent::tearDown();
    }

    public function testThrowsAnExceptionIfTheFragmentNameIsInvalid(): void
    {
        $fragmentHandler = $this->getFragmentHandler();

        $this->expectException(UnknownFragmentException::class);

        $fragmentHandler->render(new FragmentReference('foo.bar'));
    }

    #[DataProvider('getRenderingStrategies')]
    public function testPassesTheRenderingStrategyToTheRenderer(string $renderingStrategy): void
    {
        $uri = new FragmentReference('foo.bar');

        $fragmentRegistry = new FragmentRegistry();
        $fragmentRegistry->add('foo.bar', new FragmentConfig('foo.bar', $renderingStrategy));

        $request = new Request();

        $renderers = $this->mockServiceLocatorWithRenderer(
            $renderingStrategy,
            [$uri, $request, ['ignore_errors' => false]],
        );

        $fragmentHandler = $this->getFragmentHandler($fragmentRegistry, $renderers, null, $request);
        $fragmentHandler->render($uri);
    }

    public static function getRenderingStrategies(): iterable
    {
        yield ['inline'];
        yield ['esi'];
    }

    #[DataProvider('getOptions')]
    public function testPassesTheOptionsToTheRenderer(array $options): void
    {
        $uri = new FragmentReference('foo.bar');

        $fragmentRegistry = new FragmentRegistry();
        $fragmentRegistry->add('foo.bar', new FragmentConfig('foo.bar', 'inline', $options));

        $request = new Request();

        $renderers = $this->mockServiceLocatorWithRenderer(
            'inline',
            [$uri, $request, $options + ['ignore_errors' => false]],
        );

        $fragmentHandler = $this->getFragmentHandler($fragmentRegistry, $renderers, null, $request);
        $fragmentHandler->render($uri);
    }

    public static function getOptions(): iterable
    {
        yield [['foo' => 'bar']];
        yield [['bar' => 'baz']];
    }

    #[DataProvider('getNonScalarAttributes')]
    public function testOverridesRenderingOnNonScalarAttributes(string $renderingStrategy, string $expectedRenderer): void
    {
        $uri = new FragmentReference('foo.bar');
        $uri->attributes['foo'] = new \stdClass();

        $fragmentRegistry = new FragmentRegistry();
        $fragmentRegistry->add('foo.bar', new FragmentConfig('foo.bar', $renderingStrategy));

        $request = new Request();

        $renderers = $this->mockServiceLocatorWithRenderer(
            $expectedRenderer,
            [$uri, $request, ['ignore_errors' => false]],
        );

        $fragmentHandler = $this->getFragmentHandler($fragmentRegistry, $renderers, null, $request);
        $fragmentHandler->render($uri);
    }

    public static function getNonScalarAttributes(): iterable
    {
        yield ['esi', 'forward'];
        yield ['hinclude', 'forward'];
        yield ['forward', 'forward'];
        yield ['inline', 'inline'];
    }

    public function testAddsThePageIdFromTheGlobalPageObject(): void
    {
        $uri = new FragmentReference('foo.bar');

        $fragmentRegistry = new FragmentRegistry();
        $fragmentRegistry->add('foo.bar', new FragmentConfig('foo.bar', 'inline', ['foo' => 'bar']));

        $callback = $this->callback(
            static fn () => isset($uri->attributes['pageModel']) && 42 === $uri->attributes['pageModel'],
        );

        $renderers = $this->mockServiceLocatorWithRenderer('inline', [$callback]);

        $pageFinder = $this->createMock(PageFinder::class);
        $pageFinder
            ->expects($this->once())
            ->method('getCurrentPage')
            ->willReturn($this->mockClassWithProperties(PageModel::class, ['id' => 42]))
        ;

        $fragmentHandler = $this->getFragmentHandler($fragmentRegistry, $renderers, pageFinder: $pageFinder);
        $fragmentHandler->render($uri);
    }

    public function testDoesNotOverrideAGivenPageId(): void
    {
        $uri = new FragmentReference('foo.bar', ['pageModel' => 99]);

        $fragmentRegistry = new FragmentRegistry();
        $fragmentRegistry->add('foo.bar', new FragmentConfig('foo.bar', 'inline', ['foo' => 'bar']));

        $callback = $this->callback(
            static fn () => isset($uri->attributes['pageModel']) && 99 === $uri->attributes['pageModel'],
        );

        $renderers = $this->mockServiceLocatorWithRenderer('inline', [$callback]);

        $pageFinder = $this->createMock(PageFinder::class);
        $pageFinder
            ->expects($this->never())
            ->method('getCurrentPage')
        ;

        $fragmentHandler = $this->getFragmentHandler($fragmentRegistry, $renderers, pageFinder: $pageFinder);
        $fragmentHandler->render($uri);
    }

    public function testExecutesThePreHandlers(): void
    {
        $uri = new FragmentReference('foo.bar');
        $config = new FragmentConfig('foo.bar', 'inline', ['foo' => 'bar']);

        $fragmentRegistry = new FragmentRegistry();
        $fragmentRegistry->add('foo.bar', $config);

        $preHandlers = $this->createMock(FragmentPreHandlerInterface::class);
        $preHandlers
            ->expects($this->once())
            ->method('preHandleFragment')
            ->with($uri, $config)
        ;

        $preHandlers = $this->mockServiceLocator('foo.bar', $preHandlers);
        $renderers = $this->mockServiceLocatorWithRenderer('inline');

        $fragmentHandler = $this->getFragmentHandler($fragmentRegistry, $renderers, $preHandlers);
        $fragmentHandler->render($uri);
    }

    public function testPassesUnknownUrisToTheBaseFragmentHandler(): void
    {
        $baseHandler = $this->createMock(BaseFragmentHandler::class);
        $baseHandler
            ->expects($this->once())
            ->method('render')
        ;

        $fragmentRegistry = $this->createMock(FragmentRegistry::class);
        $fragmentRegistry
            ->expects($this->never())
            ->method('get')
        ;

        $fragmentHandler = $this->getFragmentHandler($fragmentRegistry, null, null, null, $baseHandler);
        $fragmentHandler->render('foo.bar');
    }

    public function testConvertsExceptionsToResponseExceptions(): void
    {
        $uri = new FragmentReference('foo.bar');

        $fragmentRegistry = new FragmentRegistry();
        $fragmentRegistry->add('foo.bar', new FragmentConfig('foo.bar', 'foobar'));

        $request = new Request();

        $response = new Response();
        $response->setStatusCode(404);

        $renderers = $this->mockServiceLocatorWithRenderer('foobar', [$uri, $request], $response);
        $fragmentHandler = $this->getFragmentHandler($fragmentRegistry, $renderers, null, $request);

        $this->expectException(ResponseException::class);

        $fragmentHandler->render($uri);
    }

    /**
     * @param ServiceLocator<mixed>|null $renderers
     * @param ServiceLocator<mixed>|null $preHandlers
     */
    private function getFragmentHandler(FragmentRegistry|null $registry = null, ServiceLocator|null $renderers = null, ServiceLocator|null $preHandlers = null, Request|null $request = null, BaseFragmentHandler|null $fragmentHandler = null, PageFinder|null $pageFinder = null): FragmentHandler
    {
        $registry ??= new FragmentRegistry();
        $renderers ??= new ServiceLocator([]);
        $preHandlers ??= new ServiceLocator([]);
        $request ??= new Request();
        $fragmentHandler ??= $this->createMock(BaseFragmentHandler::class);
        $pageFinder ??= $this->createMock(PageFinder::class);

        $requestStack = new RequestStack();
        $requestStack->push($request);

        return new FragmentHandler($renderers, $fragmentHandler, $requestStack, $registry, $preHandlers, $pageFinder, true);
    }

    /**
     * @return ServiceLocator<mixed>&MockObject
     */
    private function mockServiceLocatorWithRenderer(string $name, array|null $with = null, Response|null $response = null): ServiceLocator&MockObject
    {
        $renderer = $this->createMock(FragmentRendererInterface::class);
        $renderer
            ->method('getName')
            ->willReturn($name)
        ;

        $method = $renderer
            ->expects($this->once())
            ->method('render')
        ;

        if (null !== $with) {
            $method->with(...$with);
        }

        $method->willReturn($response ?? new Response());

        return $this->mockServiceLocator($name, $renderer);
    }

    /**
     * @return ServiceLocator<mixed>&MockObject
     */
    private function mockServiceLocator(string $name, object $service): ServiceLocator&MockObject
    {
        $serviceLocator = $this->createMock(ServiceLocator::class);
        $serviceLocator
            ->expects($this->once())
            ->method('has')
            ->with($name)
            ->willReturn(true)
        ;

        $serviceLocator
            ->expects($this->once())
            ->method('get')
            ->with($name)
            ->willReturn($service)
        ;

        return $serviceLocator;
    }
}
