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

/**
 * Moves a path in and out of the `/admin` segment.
 *
 * The admin route collection is compiled with that segment once, and stays
 * that way: the firewall matcher, the `access_control` rules, the
 * `fos_http_cache` cache-control rules and the invalidation patterns are all
 * written against `/admin/…`, and they would all have to grow a host dimension
 * if the compiled paths differed per tenant.
 *
 * So a domain that serves the admin at its root only differs at the edges. An
 * incoming request has the segment put back in before anything looks at it
 * ({@see \Ferienpass\CoreBundle\EventListener\TenantRequestListener}), and a
 * generated URL has it taken out again on the way out
 * ({@see SurfaceAwareUrlGenerator}).
 */
final class AdminPath
{
    public const string SEGMENT = '/admin';

    /**
     * Paths that belong to whichever host asked for them, on both edges.
     *
     * None of these is part of the admin or the frontend route collection, so
     * neither surface "owns" them: `/_fragment` is matched by Symfony's
     * `FragmentListener` on the literal path, `/_contao` is the Contao back end
     * with its own firewall, `/_wdt` and `/_profiler` are the debug toolbar,
     * and `/assets` is AssetMapper's public prefix — served through the kernel
     * in debug mode by `AssetMapperDevServerSubscriber`, which matches that
     * literal prefix. (In production `asset-map:compile` writes real files
     * under `public/assets` and the web server answers first.)
     *
     * Both edges have to agree, and for different reasons:
     *
     * - inbound, moving one into the admin space hides it from the listener
     *   that was written for it, so it falls through to the firewall — every
     *   stylesheet then answers 302 to the login page instead of CSS;
     * - outbound, none of them is an admin path, so a link generated on an
     *   admin domain would be rewritten onto the tenant's *frontend* host. The
     *   toolbar would then fetch `/_wdt` cross-origin from the public site,
     *   which answers HTML and trips CORS, CSP and `nosniff` in turn.
     *
     * Keep `/assets` in step with `framework.asset_mapper.public_prefix`.
     */
    public const array SURFACE_NEUTRAL = ['/_fragment', '/_contao', '/_wdt', '/_profiler', '/_error', '/assets'];

    /**
     * Whether $path is served by the host that asked for it, whatever surface
     * that host serves.
     */
    public static function isSurfaceNeutral(string $path): bool
    {
        foreach (self::SURFACE_NEUTRAL as $neutral) {
            if ($path === $neutral || str_starts_with($path, $neutral.'/')) {
                return true;
            }
        }

        return false;
    }

    /**
     * Whether $path addresses the admin — with or without a locale prefix.
     */
    public static function isAdmin(string $path): bool
    {
        [, $rest] = Locales::splitPrefix($path);

        return self::SEGMENT === $rest || str_starts_with($rest, self::SEGMENT.'/');
    }

    /**
     * `/nl/admin/angebote` → `/nl/angebote`, `/admin` → `/`.
     */
    public static function strip(string $path): string
    {
        [$prefix, $rest] = Locales::splitPrefix($path);

        if (self::SEGMENT === $rest) {
            return $prefix.'/';
        }

        if (!str_starts_with($rest, self::SEGMENT.'/')) {
            return $path;
        }

        return $prefix.substr($rest, \strlen(self::SEGMENT));
    }

    /**
     * `/nl/angebote` → `/nl/admin/angebote`, `/` → `/admin`.
     */
    public static function prepend(string $path): string
    {
        [$prefix, $rest] = Locales::splitPrefix($path);

        if ('/' === $rest) {
            // Keep the slash: the admin index is routed as `/admin/`, and
            // handing the router `/admin` makes it answer 301 to add the slash
            // back. That redirect is then stripped to `/` on the way out, which
            // is the request we started from — the root of an admin domain
            // redirected forever. Every other path keeps whatever trailing
            // slash it arrived with, so only this one had to be special-cased.
            return $prefix.self::SEGMENT.'/';
        }

        return $prefix.self::SEGMENT.$rest;
    }

    /**
     * Splits a URL into `[origin, path, query+fragment]`. The origin is empty
     * for a path reference (`/admin/x`) and carries scheme and authority for
     * an absolute (`https://host`) or network-path (`//host`) reference.
     *
     * @return array{string, string, string}
     */
    public static function splitUrl(string $url): array
    {
        $origin = '';

        if (str_starts_with($url, '//')) {
            $authorityAt = 2;
        } elseif (false !== $schemeAt = strpos($url, '://')) {
            $authorityAt = $schemeAt + 3;
        } else {
            $authorityAt = null;
        }

        if (null !== $authorityAt) {
            $pathAt = strpos($url, '/', $authorityAt);
            if (false === $pathAt) {
                return [$url, '/', ''];
            }

            $origin = substr($url, 0, $pathAt);
            $url = substr($url, $pathAt);
        }

        $cut = strcspn($url, '?#');

        return [$origin, substr($url, 0, $cut), substr($url, $cut)];
    }
}
