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

use Ferienpass\CoreBundle\Tenant\Surface;
use Ferienpass\CoreBundle\Tenant\SurfaceContext;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\CacheWarmer\WarmableInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\Matcher\RequestMatcherInterface;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\RouterInterface;

/**
 * Puts every generated URL on the domain that actually serves it.
 *
 * The route collection is compiled once, with the admin under `/admin` and the
 * public site at the root — one URL space, whichever domains a tenant happens
 * to have ({@see AdminPath}). This is where a URL leaves that space again:
 *
 * - on a domain that serves the admin at its root, the `/admin` segment is
 *   dropped, so the admin links to itself the way the visitor's address bar
 *   reads;
 * - a link that crosses to the other surface — the admin linking to the public
 *   offer page, a reminder mail linking back into the admin — becomes absolute
 *   and points at the domain that serves it.
 *
 * Outside a request the same applies to the tenant the cron or worker has
 * activated. A link into a surface the tenant does not have (a public page for
 * a municipality that runs no CMS) is left alone: it is the caller's business
 * not to offer it, and rewriting it to nothing would only hide the mistake.
 *
 * Decoration priority puts this outside {@see
 * \Ferienpass\AdminBundle\Routing\Base58AdminRouter}, so it sees the final URL.
 */
#[AsDecorator('router', priority: -10)]
final class SurfaceAwareUrlGenerator implements RouterInterface, RequestMatcherInterface, WarmableInterface
{
    public function __construct(
        private readonly RouterInterface $inner,
        private readonly SurfaceContext $surfaces,
    ) {
    }

    public function generate(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
    {
        $url = $this->inner->generate($name, $parameters, $referenceType);

        if (!$this->surfaces->isEnabled()) {
            return $url;
        }

        // A relative reference ("../angebote") cannot carry a host or a
        // stripped prefix, so fall back to the absolute path — but only if the
        // rewrite changes anything at all.
        if (UrlGeneratorInterface::RELATIVE_PATH === $referenceType) {
            $absolute = $this->inner->generate($name, $parameters, UrlGeneratorInterface::ABSOLUTE_PATH);
            $rewritten = $this->rewrite($absolute);

            return $rewritten === $absolute ? $url : $rewritten;
        }

        return $this->rewrite($url);
    }

    public function match(string $pathinfo): array
    {
        return $this->inner->match($pathinfo);
    }

    public function matchRequest(Request $request): array
    {
        if ($this->inner instanceof RequestMatcherInterface) {
            return $this->inner->matchRequest($request);
        }

        return $this->inner->match($request->getPathInfo());
    }

    public function getRouteCollection(): RouteCollection
    {
        return $this->inner->getRouteCollection();
    }

    public function setContext(RequestContext $context): void
    {
        $this->inner->setContext($context);
    }

    public function getContext(): RequestContext
    {
        return $this->inner->getContext();
    }

    public function warmUp(string $cacheDir, ?string $buildDir = null): array
    {
        return $this->inner instanceof WarmableInterface ? $this->inner->warmUp($cacheDir, $buildDir) : [];
    }

    private function rewrite(string $url): string
    {
        [$origin, $path, $suffix] = AdminPath::splitUrl($url);

        // Neither surface owns these, so they stay on the host that asked. They
        // are not admin paths, so without this they would count as frontend and
        // be sent to the tenant's public site: the debug toolbar on an admin
        // domain would fetch `/_wdt` cross-origin, get HTML back, and trip
        // CORS, CSP and `nosniff` on the way.
        if (AdminPath::isSurfaceNeutral($path)) {
            return $url;
        }

        $surface = AdminPath::isAdmin($path) ? Surface::Admin : Surface::Frontend;

        if ($this->surfaces->serves($surface)) {
            // Stays on this host — only the `/admin` segment may have to go.
            return Surface::Admin === $surface && $this->surfaces->adminAtRoot()
                ? $origin.AdminPath::strip($path).$suffix
                : $url;
        }

        $host = $this->surfaces->hostFor($surface);
        if (null === $host) {
            return $url;
        }

        if (Surface::Admin === $surface) {
            // Every dedicated admin domain serves the admin at its root.
            $path = AdminPath::strip($path);
        }

        return $this->origin($host).$path.$suffix;
    }

    private function origin(string $host): string
    {
        $context = $this->getContext();
        $scheme = $context->getScheme();
        $port = 'https' === $scheme ? $context->getHttpsPort() : $context->getHttpPort();
        $default = 'https' === $scheme ? 443 : 80;

        return $scheme.'://'.$host.($port && $port !== $default ? ':'.$port : '');
    }
}
