<?php

use App\Http\Middleware\RequireOperatorTwoFactor;
use App\Http\Middleware\RestrictAdminHost;
use App\Models\Operator;
use App\Support\AdminArea;
use App\Support\Settings;
use Illuminate\Http\Request;
use Illuminate\Routing\Route;
use Illuminate\Routing\Route as RoutingRoute;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
 * Which host serves the console, and what else that host will answer.
 *
 * Two modes, and the difference between them is the whole point:
 *
 *  - Shared. The console lives under /admin and the same host also serves the
 *    portal and the public site. This is a development machine, which lists its
 *    own IP in ADMIN_HOSTS so the console is reachable without DNS.
 *  - Exclusive. The console has a hostname to itself, sits at the root of it,
 *    and nothing else answers there.
 *
 * Shipping the exclusive rule without the switch took the public site off a
 * development machine, because that machine's own IP was in ADMIN_HOSTS. The
 * modes are tested apart for that reason.
 */

/** Drive the guard directly: route registration is fixed at boot. */
function guard(string $host, string $path, ?string $routeName): Response|string
{
    $request = Request::create("http://{$host}{$path}", 'GET');

    $request->setRouteResolver(function () use ($routeName, $path) {
        $route = new RoutingRoute(['GET'], $path, fn () => null);

        return $routeName !== null ? $route->name($routeName) : $route;
    });

    try {
        (new RestrictAdminHost)->handle($request, fn () => response('through'));

        return 'through';
    } catch (NotFoundHttpException) {
        return '404';
    }
}

describe('shared host', function () {
    beforeEach(function () {
        config()->set('admin_access.hosts', ['10.10.90.185', 'localhost']);
        config()->set('admin_access.exclusive', false);
    });

    it('keeps the console under /admin', function () {
        expect(AdminArea::prefix())->toBe('admin')
            ->and(AdminArea::isConsole(Request::create('http://localhost/admin/customers')))->toBeTrue()
            ->and(AdminArea::isConsole(Request::create('http://localhost/cloud')))->toBeFalse();
    });

    it('still serves the portal and the public site from that host', function () {
        // The regression: a machine reached by IP has that IP in ADMIN_HOSTS,
        // and 404ing everything else there removes the product from it.
        expect(guard('localhost', '/cloud', 'cloud'))->toBe('through')
            ->and(guard('localhost', '/', 'home'))->toBe('through')
            ->and(guard('localhost', '/status', 'status'))->toBe('through');
    });

    it('keeps the console off a host that is not listed', function () {
        expect(guard('www.example.test', '/admin', 'admin.overview'))->toBe('404');
    });
});

describe('exclusive host', function () {
    beforeEach(function () {
        config()->set('admin_access.hosts', ['admin.example.test']);
        config()->set('admin_access.exclusive', true);
    });

    it('puts the console at the root of its own host', function () {
        expect(AdminArea::prefix())->toBe('')
            ->and(AdminArea::home())->toBe('/')
            ->and(AdminArea::isConsole(Request::create('http://admin.example.test/customers')))->toBeTrue()
            ->and(AdminArea::isConsole(Request::create('http://app.example.test/customers')))->toBeFalse();
    });

    it('answers nothing but the console on that host', function () {
        expect(guard('admin.example.test', '/', 'admin.overview'))->toBe('through')
            ->and(guard('admin.example.test', '/customers', 'admin.customers'))->toBe('through')
            // The portal and the public site are gone from this hostname.
            ->and(guard('admin.example.test', '/cloud', 'cloud'))->toBe('404')
            ->and(guard('admin.example.test', '/status', 'status'))->toBe('404')
            ->and(guard('admin.example.test', '/dashboard', 'dashboard'))->toBe('404');
    });

    it('lets the shared, nameless endpoints through on both hosts', function () {
        // Livewire's own endpoints and the health check are used by both
        // sides and never carry an admin.* route name. Sign-in used to be on
        // this list too — it is not any more: the console has its own named
        // routes (admin.login, admin.two-factor) and reaches them through
        // isConsoleRoute(), not through this nameless allowlist. If this list
        // is wrong the symptom is "the console is broken", so it is written
        // out rather than inferred.
        foreach (['/livewire/update', '/livewire/livewire.min.js', '/up', '/broadcasting/auth'] as $path) {
            expect(guard('admin.example.test', $path, null))->toBe('through', $path);
        }
    });

    it('does not serve the portal sign-in or registration on the console host', function () {
        // The reported fault, at its root: the console used to answer /login
        // with the portal's page, which offers "Registrieren" — and /register
        // was never in SHARED, so the link 404'd.
        expect(guard('admin.example.test', '/register', 'register'))->toBe('404')
            ->and(guard('admin.example.test', '/login', 'login'))->toBe('404');
    });

    it('still serves the console\'s own sign-in on its host', function () {
        expect(guard('admin.example.test', '/login', 'admin.login'))->toBe('through')
            ->and(guard('admin.example.test', '/two-factor', 'admin.two-factor'))->toBe('through');
    });

    it('keeps only the endpoints both sides genuinely share', function () {
        expect(guard('admin.example.test', '/livewire/update', null))->toBe('through')
            ->and(guard('admin.example.test', '/up', null))->toBe('through');
    });

    it('keeps the console off the public hosts', function () {
        expect(guard('app.example.test', '/', 'admin.overview'))->toBe('404')
            ->and(guard('www.example.test', '/customers', 'admin.customers'))->toBe('404');
    });

    it('still serves the public site on its own host', function () {
        expect(guard('www.example.test', '/', 'home'))->toBe('through')
            ->and(guard('app.example.test', '/dashboard', 'dashboard'))->toBe('through');
    });
});

it('never treats a request as the console just because a hostname is configured', function () {
    // isConsole drives the public-site switch and the network allowlist. Getting
    // it wrong in this direction hands the console's exemptions to the portal.
    config()->set('admin_access.hosts', ['admin.example.test']);
    config()->set('admin_access.exclusive', false);

    expect(AdminArea::isHostBound())->toBeTrue()
        ->and(AdminArea::isExclusive())->toBeFalse()
        ->and(AdminArea::isConsole(Request::create('http://admin.example.test/cloud')))->toBeFalse()
        ->and(AdminArea::isConsole(Request::create('http://admin.example.test/admin')))->toBeTrue();
});

it('can cache its routes with several console hostnames configured', function () {
    // Registering the console once per accepted hostname reused one set of
    // names, and route caching refuses to serialise two routes under one name —
    // which took `artisan optimize` down on the live server, mid-deploy.
    config()->set('admin_access.hosts', ['admin.example.test', '127.0.0.1', 'localhost']);
    config()->set('admin_access.exclusive', true);

    $router = new Router(app('events'), app());
    Illuminate\Support\Facades\Route::swap($router);
    require base_path('routes/web.php');
    $router->getRoutes()->refreshNameLookups();

    $names = [];
    foreach ($router->getRoutes() as $route) {
        if (($name = $route->getName()) !== null) {
            expect($names)->not->toContain($name, "duplicate route name: {$name}");
            $names[] = $name;
        }
    }

    // The canonical host still owns the plain names, so route() builds URLs
    // for it and not for a recovery address.
    expect($names)->toContain('admin.overview')
        ->and($router->getRoutes()->getByName('admin.overview')->getDomain())->toBe('admin.example.test');
});

it('keeps the navigation marked on a recovery hostname', function () {
    // The alternates are registered as admin.viaN.* so route caching can
    // serialise them — which silently breaks every exact name check, and the
    // navigation loses its active state exactly when something is already wrong.
    $named = fn (string $name) => tap(request(), fn ($r) => $r->setRouteResolver(
        fn () => (new Route(['GET'], '/', fn () => null))->name($name),
    ));

    $named('admin.overview');
    expect(AdminArea::routeIs('admin.overview'))->toBeTrue();

    $named('admin.via0.overview');
    expect(AdminArea::routeIs('admin.overview'))->toBeTrue()
        ->and(AdminArea::routeIs('admin.settings'))->toBeFalse();
});

it('lets an unenrolled operator sign out on a recovery hostname, not just the canonical one', function () {
    // Whole-branch review, P2: RequireOperatorTwoFactor's exemption used
    // AdminArea::routeIs() for the setup route but bare
    // $request->routeIs('admin.logout') for logout — which matches only the
    // canonical name. On a recovery host the logout route is named
    // admin.via0.logout (same reason the navigation test above exists), so
    // the bare check failed there and the middleware bounced an unenrolled
    // operator's logout POST back to enrolment — trapping them on exactly
    // the host that exists because the canonical one is not working. A
    // canonical-host test would pass whichever form the check takes, so this
    // one is deliberately on the alternate.
    config()->set('admin_access.hosts', ['admin.example.test', 'admin-recovery.example.test']);
    config()->set('admin_access.exclusive', true);
    Settings::set('console.require_2fa', true);

    // Register routes for real from the config just set, rather than
    // asserting the via0 name by fiat — same technique as "it can cache its
    // routes…" above. Proves the whole path: this host, this URL, genuinely
    // resolves to admin.via0.logout before the middleware is ever involved.
    $router = new Router(app('events'), app());
    Illuminate\Support\Facades\Route::swap($router);
    require base_path('routes/web.php');
    $router->getRoutes()->refreshNameLookups();

    $matched = $router->getRoutes()->match(
        Request::create('http://admin-recovery.example.test/logout', 'POST'),
    );
    expect($matched->getName())->toBe('admin.via0.logout');

    $operator = Operator::factory()->role('Owner')->create();
    Auth::guard('operator')->login($operator);

    // Drive the middleware directly, on a request resolved to that genuinely
    // registered route — RequireOperatorTwoFactor reads the route's name, not
    // which router produced it, so this is the real check without needing a
    // full dispatch (session/CSRF middleware) around it.
    $request = Request::create('http://admin-recovery.example.test/logout', 'POST');
    $request->setRouteResolver(fn () => $matched);
    app()->instance('request', $request);

    $response = (new RequireOperatorTwoFactor)->handle($request, fn ($req) => response('through'));

    expect($response->getContent())->toBe('through');
});
