<?php

namespace App\Http\Middleware;

use App\Support\AdminArea;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;

/**
 * Who may reach the operator console, by network address.
 *
 * RestrictAdminHost answers "under which NAME does the console respond" — and a
 * Host header is chosen by the caller, so it can never answer "who is asking".
 * This one can: the client address behind a trusted proxy is not something the
 * client gets to pick.
 *
 * The management VPN always counts. Beyond that the owner keeps a list — an
 * office line, a home connection — so being away from the VPN does not mean
 * being locked out. That list lives in the console rather than in a proxy
 * config file, because the person who needs to change it is the person sitting
 * in the console.
 *
 * 404, never 403: a stranger must not learn that a console lives here.
 *
 * This is not a substitute for a firewall. It runs after PHP has started, so it
 * bounds who can USE the console, not who can make the server work. Where a
 * network-level ACL is also possible, both belong there.
 */
class RestrictConsoleNetwork
{
    public function handle(Request $request, Closure $next): Response
    {
        // Scoped to the console, wherever the console currently is. Asking
        // AdminArea rather than testing the path: once the console moves to the
        // root of its own hostname there is no `/admin` left to match, and a
        // guard that silently stops matching is worse than no guard.
        if (! AdminArea::isConsole($request)) {
            return $next($request);
        }

        if (! self::isRestricted()) {
            return $next($request);
        }

        if (! self::allows((string) $request->ip())) {
            abort(404);
        }

        return $next($request);
    }

    /** Whether the owner has switched the restriction on at all. */
    public static function isRestricted(): bool
    {
        return Settings::bool('console.network_restricted', false);
    }

    /**
     * Every network that may reach the console.
     *
     * The VPN is always in it and is not removable — it is the one path that
     * exists independently of whatever the owner has typed into the list, and
     * without it a bad entry would leave nobody able to fix the entry.
     *
     * @return array<int, string>
     */
    public static function allowedRanges(): array
    {
        $vpn = (array) config('admin_access.trusted_ranges', []);
        $own = (array) Settings::get('console.allowed_ips', []);

        return array_values(array_unique(array_filter(array_merge(
            $vpn,
            array_map('trim', array_map('strval', $own)),
        ))));
    }

    public static function allows(string $ip): bool
    {
        return self::covers($ip, self::allowedRanges());
    }

    /**
     * Would this address still get in, given that list?
     *
     * Split out so the "you are about to lock yourself out" decision can be
     * made — and tested — without a request: it is the one check whose failure
     * mode is that nobody can reach the page that would fix it.
     *
     * @param  array<int, string>  $extra  the owner's list AFTER the change
     */
    public static function wouldStillAllow(string $ip, array $extra): bool
    {
        $vpn = (array) config('admin_access.trusted_ranges', []);

        return self::covers($ip, array_merge($vpn, $extra));
    }

    /**
     * Whether a value is an address or a CIDR range at all.
     *
     * Shared by the console and the recovery command on purpose: an entry that
     * matches nothing is stored happily and reports success, and the operator
     * then switches the restriction on believing they are covered. That is the
     * exact situation the recovery command exists to get out of.
     */
    public static function isNetwork(string $value): bool
    {
        [$address, $prefix] = array_pad(explode('/', $value, 2), 2, null);

        if (filter_var($address, FILTER_VALIDATE_IP) === false) {
            return false;
        }

        if ($prefix === null) {
            return true;
        }

        $max = str_contains((string) $address, ':') ? 128 : 32;

        return ctype_digit((string) $prefix) && (int) $prefix >= 0 && (int) $prefix <= $max;
    }

    /** @param array<int, string> $ranges */
    private static function covers(string $ip, array $ranges): bool
    {
        $ranges = array_values(array_filter($ranges));

        return $ranges !== [] && $ip !== '' && IpUtils::checkIp($ip, $ranges);
    }
}
