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

use Ferienpass\CoreBundle\Routing\AdminPath;
use Ferienpass\CoreBundle\Tenant\HostResolver;
use Ferienpass\CoreBundle\Tenant\SurfaceContext;
use Ferienpass\CoreBundle\Tenant\TenantContext;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Resolves the tenant and the surface from the request host, before anything
 * else runs — and puts the request into the URL space the routes are compiled
 * in.
 *
 * This is the tenant boundary for HTTP, so it is deliberately fail-closed: a
 * host no enabled tenant claims gets a 404, never a default tenant. It also
 * runs ahead of the firewall (priority 8) on purpose — the user provider must
 * look accounts up inside the resolved tenant, not across all of them, and the
 * firewall must see the admin path so that its matcher and the `access_control`
 * rules keep applying.
 *
 * ## Why the path is rewritten
 *
 * A domain that serves the admin at its root ({@see AdminPath}) receives
 * `/angebote` for what the route collection calls `/admin/angebote`. Putting
 * the segment back here, before routing, the firewall, `access_control` and the
 * cache-control rules, means none of them has to learn about tenant domains:
 * they keep matching the one path shape they were written for, and only the
 * edges of the request differ.
 *
 * The visitor never sees it: {@see \Ferienpass\CoreBundle\Routing\SurfaceAwareUrlGenerator}
 * takes the segment out of every generated URL again, and
 * {@see AdminRedirectListener} out of every `Location` header.
 */
#[AsEventListener(event: KernelEvents::REQUEST, priority: 250)]
class TenantRequestListener
{
    public function __construct(
        private readonly TenantContext $tenantContext,
        private readonly SurfaceContext $surfaces,
        private readonly HostResolver $hosts,
        private readonly LoggerInterface $logger,
    ) {
    }

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

        $request = $event->getRequest();
        $host = $request->getHost();

        $resolved = $this->hosts->resolve($host);
        if (null === $resolved) {
            $this->logger->warning('Rejected a request for an unknown tenant host.', ['host' => $host]);

            throw new NotFoundHttpException(\sprintf('No tenant serves the host "%s".', $host));
        }

        $this->surfaces->setCurrent($resolved);

        // The platform console belongs to no tenant: the signed-in global admin
        // picks one, which only happens once the firewall has run.
        $this->tenantContext->setTenant($resolved->tenant);

        $path = $request->getPathInfo();

        if (AdminPath::isSurfaceNeutral($path)) {
            return;
        }

        if ($resolved->adminAtRoot) {
            // A link from before the admin moved onto this domain. The page is
            // the same one, so send it there rather than 404.
            if (AdminPath::isAdmin($path)) {
                $event->setResponse(new RedirectResponse($this->withPath($request, AdminPath::strip($path)), Response::HTTP_MOVED_PERMANENTLY));

                return;
            }

            $this->moveIntoAdminSpace($request);

            return;
        }

        // A tenant that has moved its admin onto its own domain does not serve
        // it on the public site any more.
        if (!$resolved->servesAdmin && AdminPath::isAdmin($path)) {
            throw new NotFoundHttpException(\sprintf('The host "%s" serves the public site only; the admin lives on its own domain.', $host));
        }
    }

    /**
     * Re-initialises the request with `/admin` put back in front of the path.
     *
     * `Request::initialize()` is the supported way to do this: it resets the
     * derived path, request URI and base URL, which are otherwise memoised.
     * The body is deliberately left unread — passing null keeps the lazy
     * `php://input` behaviour a request that was never touched here has.
     */
    private function moveIntoAdminSpace(Request $request): void
    {
        $server = $request->server->all();
        $server['REQUEST_URI'] = $request->getBaseUrl().AdminPath::prepend($request->getPathInfo()).self::queryString($request);
        // Symfony prefers PATH_INFO over REQUEST_URI where the SAPI sets it.
        unset($server['PATH_INFO'], $server['ORIG_PATH_INFO']);

        $request->initialize(
            $request->query->all(),
            $request->request->all(),
            $request->attributes->all(),
            $request->cookies->all(),
            $request->files->all(),
            $server,
        );
    }

    private function withPath(Request $request, string $path): string
    {
        return $request->getSchemeAndHttpHost().$request->getBaseUrl().$path.self::queryString($request);
    }

    /**
     * The query string as it arrived. `Request::getQueryString()` sorts the
     * parameters, which would rewrite the visitor's URL for no reason.
     */
    private static function queryString(Request $request): string
    {
        return '' !== ($qs = (string) $request->server->get('QUERY_STRING', '')) ? '?'.$qs : '';
    }
}
