<?php

namespace App\Support;

use Illuminate\Http\Request;

/**
 * Where the operator console lives, and how to tell that a request is for it.
 *
 * Three guards need this answer — the hostname guard, the network allowlist and
 * the public-site switch — and until now each decided for itself by testing the
 * path against `admin/*`. That was survivable while the console sat under
 * /admin. It stops being survivable the moment the console moves to the root of
 * its own hostname: PublicSiteGate would no longer recognise it, and with the
 * public site hidden the console would answer 503 to the person trying to sign
 * in and turn it back on. That is the lock-out this class exists to prevent.
 *
 * Two modes, and only two:
 *
 *  - A console hostname is configured. The console IS that host: it answers at
 *    the root, and every request to that host is a console request.
 *  - Nothing is configured (a fresh checkout, a development machine). The
 *    console stays under /admin on any host, exactly as before, so upgrading
 *    cannot lock anyone out of a system that was working.
 *
 * Read from config on every call rather than cached in a static: `config:cache`
 * already freezes the value at deploy time, and a second layer of staleness in
 * a long-lived worker is the kind of thing that is only discovered from the
 * wrong side of a locked door.
 */
final class AdminArea
{
    /** The path prefix used when no console hostname is configured. */
    public const FALLBACK_PREFIX = 'admin';

    /**
     * The single hostname the console answers on, or null when unconfigured.
     *
     * ADMIN_HOSTS may list several names — an operator who reaches the console
     * by IP as well as by name, for instance. The FIRST is the canonical one
     * and the one URLs are generated for; the rest keep working because
     * covers() accepts any of them. Dropping the alternates would remove
     * exactly the recovery paths someone locked out would reach for.
     */
    public static function host(): ?string
    {
        return self::hosts()[0] ?? null;
    }

    /**
     * Every hostname that reaches the console.
     *
     * @return array<int, string>
     */
    public static function hosts(): array
    {
        return array_values(array_filter(array_map(
            fn ($host) => strtolower(trim((string) $host)),
            (array) config('admin_access.hosts', []),
        )));
    }

    /**
     * May ONLY the console answer on the console's hostname?
     *
     * Separate from being host-bound, and off by default, because the two are
     * not the same thing. A development machine lists its own IP and localhost
     * in ADMIN_HOSTS so the console is reachable without DNS — and on that
     * machine the portal and the public site have to keep working. Turning
     * exclusivity on there would 404 everything that is not the console, which
     * is exactly what it did the first time this shipped.
     *
     * It belongs with the routing change that moves the console to the root of
     * its own hostname: until then a console host legitimately serves both.
     */
    public static function isExclusive(): bool
    {
        return self::isHostBound() && (bool) config('admin_access.exclusive', false);
    }

    /** Whether the console is bound to a hostname at all. */
    public static function isHostBound(): bool
    {
        return self::hosts() !== [];
    }

    /**
     * Is this request addressed to the console?
     *
     * The one question the guards ask. When the console is host-bound the whole
     * host is the console — including its sign-in page, which is precisely what
     * the path test used to miss.
     */
    public static function isConsole(Request $request): bool
    {
        // Keyed on exclusivity, not on merely having a hostname: where the
        // console shares its host with the portal it still lives under /admin,
        // and "every request to this host is the console" would be false.
        return self::isExclusive()
            ? self::covers($request->getHost())
            : $request->is(self::FALLBACK_PREFIX, self::FALLBACK_PREFIX.'/*');
    }

    /**
     * Does this URL lead into the console?
     *
     * Needed because the sign-in POST does not go to a console address in
     * shared mode — everyone posts to /login — so the request alone cannot say
     * whether the console is where this person was heading. The intended URL
     * can, and without it a non-operator turned back at /admin keeps the
     * session that was just created for them.
     */
    public static function pointsAtConsole(?string $url): bool
    {
        if ($url === null || $url === '') {
            return false;
        }

        if (self::isExclusive()) {
            $host = parse_url($url, PHP_URL_HOST);

            return is_string($host) && self::covers($host);
        }

        $path = trim((string) parse_url($url, PHP_URL_PATH), '/');

        return $path === self::FALLBACK_PREFIX || str_starts_with($path, self::FALLBACK_PREFIX.'/');
    }

    /** Is this hostname one the console answers on? */
    public static function covers(string $host): bool
    {
        return in_array(strtolower($host), self::hosts(), true);
    }

    /**
     * The path prefix console routes are registered under.
     *
     * Empty when the console has its hostname to itself — it is then the root
     * of that host. `admin` otherwise.
     */
    public static function prefix(): string
    {
        return self::isExclusive() ? '' : self::FALLBACK_PREFIX;
    }

    /**
     * Is the current request on this console route, whichever hostname it came
     * through?
     *
     * The alternates are registered under `admin.viaN.*` so route caching can
     * serialise them, which means an exact name check stops matching the moment
     * someone reaches the console through a recovery address — and the whole
     * navigation quietly loses its active state exactly when things are already
     * going wrong.
     */
    public static function routeIs(string $name): bool
    {
        $suffix = str_starts_with($name, 'admin.') ? substr($name, 6) : $name;

        return (bool) request()->routeIs('admin.'.$suffix, 'admin.via*.'.$suffix);
    }

    /** Where the console's own front page lives, as a path. */
    public static function home(): string
    {
        return '/'.self::prefix();
    }
}
