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

use Contao\CoreBundle\ContaoCoreBundle;
use Contao\CoreBundle\Exception\InvalidRequestTokenException;
use Contao\CoreBundle\Exception\NoRootPageFoundException;
use Contao\CoreBundle\Exception\ResponseException;
use Contao\CoreBundle\Util\LocaleUtil;
use Contao\PageModel;
use Contao\StringUtil;
use Ferienpass\CmsBundle\Fragment\FragmentReference;
use Ferienpass\CmsBundle\Page\PageBuilderFactory;
use Ferienpass\CoreBundle\Settings\MailConfigManager;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\AcceptHeader;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Twig\Environment;
use Twig\Error\Error;

#[AsEventListener(priority: -48)]
class PrettyErrorScreenListener
{
    public function __construct(#[Autowire(param: 'kernel.debug')] private readonly bool $isDebug, private readonly Environment $twig, private readonly PageBuilderFactory $pageBuilderFactory, private readonly MailConfigManager $mailConfigManager)
    {
    }

    public function __invoke(ExceptionEvent $event): void
    {
        if ($this->isDebug || !$event->isMainRequest()) {
            return;
        }

        $request = $event->getRequest();
        if (ContaoCoreBundle::SCOPE_FRONTEND !== $request->attributes->get('_scope')) {
            return;
        }

        if ('html' !== $request->getRequestFormat()) {
            return;
        }

        if (!AcceptHeader::fromString($request->headers->get('Accept'))->has('text/html')) {
            return;
        }

        $this->handleException($event);
    }

    private function handleException(ExceptionEvent $event): void
    {
        $exception = $event->getThrowable();

        switch (true) {
            case $exception instanceof NotFoundHttpException:
                $this->renderErrorScreenByType(404, $event);
                break;

            case $exception instanceof ServiceUnavailableHttpException:
                $this->renderErrorScreenByType(503, $event);

                if (!$event->hasResponse()) {
                    $this->renderTemplate('service_unavailable', 503, $event);
                }
                break;

            default:
                $this->renderErrorScreenByException($event);
        }
    }

    private function renderErrorScreenByType(int $type, ExceptionEvent $event): void
    {
        static $processing;

        if (true === $processing) {
            return;
        }

        $processing = true;

        try {
            $request = $event->getRequest();
            $pageModel = $request->attributes->get('pageModel');

            try {
                // A null response means we have no page handler for this type
                // (only 404 has one). Leave the response unset so the caller
                // can fall back to a template.
                if (null !== $response = $this->getResponseFromPageHandler($type, $pageModel)) {
                    $event->setResponse($response);
                }
            } catch (ResponseException $e) {
                $event->setResponse($e->getResponse());
            } catch (NoRootPageFoundException) {
                // The installation has no published root page for this host
                // (fresh tenant, unpublished root page, or a domain that no
                // DNS entry matches), so there is no CMS page to render the
                // error screen with. Leave the response unset: the original
                // throwable then surfaces through Symfony's default error
                // handling instead of being masked by a 500.
            } catch (\Throwable $e) {
                $event->setThrowable($e);
            }
        } finally {
            $processing = false;
        }
    }

    /**
     * Checks the exception chain for a known exception.
     */
    private function renderErrorScreenByException(ExceptionEvent $event): void
    {
        $exception = $event->getThrowable();
        $statusCode = $this->getStatusCodeForException($exception);
        $template = null;

        // Look for a template
        do {
            if ($exception instanceof InvalidRequestTokenException) {
                $template = 'invalid_request_token';
            }
        } while (null === $template && ($exception = $exception->getPrevious()) instanceof \Throwable);

        $this->renderTemplate($template ?: 'error', $statusCode, $event);
    }

    private function renderTemplate(string $template, int $statusCode, ExceptionEvent $event): void
    {
        $view = '@FerienpassCore/Error/'.$template.'.html.twig';

        // Never let a missing or broken error template mask the original
        // exception: if the pretty error screen cannot be rendered, leave the
        // response unset so the default exception handling takes over.
        if (!$this->twig->getLoader()->exists($view)) {
            return;
        }

        $parameters = $this->getTemplateParameters($view, $statusCode, $event);

        try {
            $event->setResponse(new Response($this->twig->render($view, $parameters), $statusCode));
        } catch (Error) {
            // Swallow Twig errors here so the original throwable surfaces.
        }
    }

    private function getTemplateParameters(string $view, int $statusCode, ExceptionEvent $event): array
    {
        $adminEmail = $this->mailConfigManager->getAdminEmail();
        $adminEmailName = $this->mailConfigManager->getAdminEmailName();
        $encoded = StringUtil::encodeEmail($adminEmail ?? '');

        return [
            'statusCode' => $statusCode,
            'statusName' => Response::$statusTexts[$statusCode],
            'template' => $view,
            'base' => $event->getRequest()->getBasePath(),
            'language' => LocaleUtil::formatAsLanguageTag($event->getRequest()->getLocale()),
            'adminEmail' => '&#109;&#97;&#105;&#108;&#116;&#111;&#58;'.$encoded,
            'adminEmailName' => $adminEmailName,
            'exception' => $event->getThrowable()->getMessage(),
            'throwable' => $event->getThrowable(),
        ];
    }

    private function getResponseFromPageHandler(int $type, ?PageModel $pageModel): ?Response
    {
        return match (true) {
            404 === $type => $this->pageBuilderFactory->create($pageModel)
                ->addFragment('main', new FragmentReference('ferienpass.fragment.error404'))
                ->getResponse(),
            default => null,
        };
    }

    private function getStatusCodeForException(\Throwable $exception): int
    {
        if ($exception instanceof HttpException) {
            return $exception->getStatusCode();
        }

        return 500;
    }
}
