<?php

use App\Http\Controllers\BootstrapArchiveController;
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\ContactController;
use App\Http\Controllers\ImpersonationController;
use App\Http\Controllers\LandingController;
use App\Http\Controllers\PublicFileController;
use App\Http\Controllers\StatusController;
use App\Http\Controllers\StripeWebhookController;
use App\Livewire\Admin;
use App\Livewire\Auth\ForgotPassword;
use App\Livewire\Auth\Login;
use App\Livewire\Auth\Register;
use App\Livewire\Auth\ResetPassword;
use App\Livewire\Auth\TwoFactorChallenge;
use App\Livewire\Auth\VerifyEmail;
use App\Livewire\Backups;
use App\Livewire\Billing;
use App\Livewire\Checkout;
use App\Livewire\Cloud;
use App\Livewire\CustomDomain;
use App\Livewire\Dashboard;
use App\Livewire\Invoices;
use App\Livewire\Order;
use App\Livewire\Security;
use App\Livewire\Support;
use App\Livewire\Users;
use App\Models\Customer;
use App\Models\Invoice;
use App\Services\Billing\InvoiceRenderer;
use App\Services\Legal\ProcessingAgreement;
use App\Support\AdminArea;
use App\Support\Settings;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Laravel\Fortify\Http\Controllers\RegisteredUserController;

/*
| The operator console, registered BEFORE anything else in this file.
|
| Once the console has a hostname to itself it sits at the ROOT of that host —
| where `/`, `/settings` and `/customers` also exist further down for the
| public site and the customer portal. Laravel takes the first matching route,
| so the order of these two blocks is the separation.
|
| Where it mounts is AdminArea's call: `/` on the console hostname where it has
| one to itself, `/admin` on any host otherwise. The names stay `admin.*` in
| both modes, so nothing that generates a URL has to know which mode it is in.
|
| Two groups, not one: a guest group (routes/admin-guest.php — /login and
| /two-factor) reachable BEFORE 'auth' runs, and the authenticated group
| (routes/admin.php) behind it. In exclusive mode the guest group is bound to
| every accepted hostname exactly like the authenticated one below — canonical
| AND alternates — because signing in is exactly what someone locked out on a
| recovery hostname needs to do. This also makes Laravel's OWN redirect-to-
| login self-correct across hosts: Authenticate::redirectTo() always calls
| route('login') by name, never route('admin.login'), but that name is bound
| to no domain, so it resolves against whatever host the current request is
| on — landing back on THIS host's own /login, which these domain-bound
| registrations make the console's, not the portal's.
|
| The authenticated group checks 'auth:operator,web', not bare 'auth'. Bare
| 'auth' resolves whichever guard is CURRENTLY the default (config('auth.
| defaults.guard'), 'web' unless something changes it) — and signing in here
| only touches the 'operator' guard, never 'web'. An operator who just
| completed OperatorLogin would fail a bare 'auth' check and bounce straight
| back to the login they just used.
|
| 'operator' is listed FIRST, not merely included: Authenticate::authenticate()
| calls Auth::shouldUse() on whichever guard matches first, and a person can
| genuinely hold both a portal AND a console session at once in the same
| browser (config/auth.php — separate session keys, by design). On a CONSOLE
| route their operator identity has to be the one every bare auth()->user() or
| Gate::authorize() call resolves for the rest of the request, not whichever
| guard happened to be checked first. 'web' stays in the list behind it purely
| so a web-only customer at the console door still passes 'auth' (as
| themselves) and reaches EnsureAdmin's proper 403 — dropping 'web' entirely
| would turn that into a redirect instead. (Tests never caught either gap:
| actingAs($x, 'operator') flips the default guard via Auth::shouldUse()
| regardless of list order, which masks it. Found by mutation-testing
| ConfirmsPassword's guard resolution, not by a failing test.)
*/
if (AdminArea::isExclusive()) {
    // Once per accepted hostname, canonical LAST.
    //
    // ADMIN_HOSTS may list alternates — a bare IP, a second name — and those
    // are the recovery paths someone locked out reaches for. Binding only the
    // first would 404 the console through every one of them.
    //
    // Only the canonical registration carries the `admin.` names. Route caching
    // refuses to serialise two routes under one name, and the alternates exist
    // to be MATCHED, never to have URLs generated for them: route() should
    // always produce the canonical hostname.
    $hosts = AdminArea::hosts();
    $canonical = array_shift($hosts);

    foreach ($hosts as $index => $alternate) {
        // Guest FIRST: /login has to resolve to the console's own sign-in
        // before 'auth' ever runs, on every recovery hostname as much as on
        // the canonical one — that IS the recovery path.
        Route::domain($alternate)
            ->middleware(['admin.host', 'guest:operator'])
            ->prefix(AdminArea::prefix())
            ->name("admin.via{$index}.")
            ->group(base_path('routes/admin-guest.php'));

        Route::domain($alternate)
            ->middleware(['admin.host', 'auth:operator,web', 'admin', 'operator.2fa'])
            ->prefix(AdminArea::prefix())
            ->name("admin.via{$index}.")
            ->group(base_path('routes/admin.php'));
    }

    Route::domain($canonical)
        ->middleware(['admin.host', 'guest:operator'])
        ->prefix(AdminArea::prefix())
        ->name('admin.')
        ->group(base_path('routes/admin-guest.php'));

    Route::domain($canonical)
        ->middleware(['admin.host', 'auth:operator,web', 'admin', 'operator.2fa'])
        ->prefix(AdminArea::prefix())
        ->name('admin.')
        ->group(base_path('routes/admin.php'));
} else {
    // Shared with the portal: no hostname binding, and the /admin prefix keeps
    // the two apart by path.
    Route::middleware(['admin.host', 'guest:operator'])
        ->prefix(AdminArea::prefix())
        ->name('admin.')
        ->group(base_path('routes/admin-guest.php'));

    Route::middleware(['admin.host', 'auth:operator,web', 'admin', 'operator.2fa'])
        ->prefix(AdminArea::prefix())
        ->name('admin.')
        ->group(base_path('routes/admin.php'));
}

// Old console addresses, once the console has moved off /admin. Permanent,
// because it really has moved, and only on the console's own host so this
// never hands a stranger a redirect that confirms a console exists.
if (AdminArea::isExclusive()) {
    Route::domain(AdminArea::host())
        ->get('/admin/{rest?}', fn (?string $rest = null) => redirect('/'.($rest ?? ''), 301))
        ->where('rest', '.*')
        ->name('admin.legacy');
}

// The service status page. Its own address: it used to sit under /legal beside
// the imprint and the terms, and nothing about the current health of the
// platform is a legal document.
//
// Bound to its own hostname when one is configured, and every other host
// redirects there rather than 404ing — a status page is the one address people
// keep in a bookmark and reach for when something is already wrong.
$statusHost = (string) config('admin_access.status_host');

if ($statusHost !== '') {
    // At the ROOT of its own hostname. "status.clupilot.com/status" says the
    // same word twice and reads like a mistake, because it is one.
    //
    // Registered BEFORE the landing page below: `/` exists on both, Laravel
    // takes the first match, and a host-agnostic route registered earlier wins
    // over a domain-bound one registered later.
    Route::domain($statusHost)->get('/', StatusController::class)->name('status');
    // The old path on that host keeps working rather than 404ing.
    Route::domain($statusHost)->get('/status', fn () => redirect()->to('/', 301));
    Route::get('/status', fn () => redirect()->away('https://'.$statusHost, 301))->name('status.elsewhere');
} else {
    Route::get('/status', StatusController::class)->name('status');
}

/*
| Dateien zum Herunterladen, auf einem eigenen Hostnamen.
|
| Zwei Bereiche mit entgegengesetzten Regeln, und das ist der ganze Grund für
| die eigene Adresse:
|
|  - ÖFFENTLICH: alles unter `storage/app/files/public/` — AGB,
|    Auftragsverarbeitungsvertrag, TOM, Preisblätter. Anonym, indexierbar,
|    dauerhaft zitierbar, weil diese Adressen in Verträgen und Rechnungen
|    stehen und Jahre halten müssen.
|  - PRIVAT: das Bootstrap-Archiv, nur mit gültigem Einmal-Code. Es liegt in
|    keinem Verzeichnis, sondern wird aus `deploy/bootstrap` gebaut, und ohne
|    Code antwortet es mit 404 statt 403 — dieselbe Regel wie bei der Konsole.
|
| Die private Route steht ZUERST, damit `bootstrap.tar.gz` nicht vom
| Platzhalter der öffentlichen Route eingesammelt wird und dort als „Datei
| nicht gefunden" endet.
|
| Ohne FILES_HOST bleibt das Archiv auf dem Portal-Hostnamen erreichbar. So
| bricht keine bereits ausgegebene Befehlszeile, solange der DNS-Eintrag für den
| neuen Namen noch nicht steht.
*/
$filesHost = (string) config('admin_access.files_host');

if ($filesHost !== '') {
    Route::domain($filesHost)->group(function () {
        Route::get('/bootstrap.tar.gz', BootstrapArchiveController::class)->name('bootstrap.archive');
        Route::get('/{file}', PublicFileController::class)->name('files.public');

        // Und sonst NICHTS. Beide Zeilen sind nötig, weil sie zwei verschiedene
        // Löcher schließen: `/` trifft der Platzhalter oben nicht (er verlangt
        // ein Segment), und ein mehrteiliger Pfad ebenfalls nicht.
        //
        // Ohne sie fiel eine Anfrage an diesen Hostnamen auf die
        // host-unabhängigen Routen durch und landete beim Portal — wer
        // `files.clupilot.com` aufrief, wurde nach `app.` weitergeleitet. Das
        // ist nicht nur unschön: es sagt jedem, der den Namen probiert, wo das
        // Portal liegt und dass beide dieselbe Maschine sind.
        //
        // 404 statt Weiterleitung, dieselbe Haltung wie überall sonst in dieser
        // Datei: eine Adresse, die nichts anzubieten hat, hat auch nichts zu
        // erzählen.
        Route::get('/', fn () => abort(404))->name('files.root');
        Route::any('/{any}', fn () => abort(404))->where('any', '.*');
    });
} else {
    Route::get('/bootstrap.tar.gz', BootstrapArchiveController::class)->name('bootstrap.archive');
}

/*
| The public website and the customer portal, each on its own hostname.
|
| Bound with Route::domain(), which is the whole mechanism: a route registered
| for one host does not exist on another, so www.clupilot.com/dashboard is a
| 404 rather than a working page. The first attempt at this bound only the
| landing page and left everything else host-agnostic — the website vanished
| from the portal, and the portal stayed reachable from the website. Half a
| separation is no separation.
|
| Both are opt-in. Empty means nothing is bound and everything answers
| everywhere, which is what every development machine reached by a bare IP
| depends on.
|
| Deliberately NOT bound: the Stripe webhook (Stripe posts to whichever URL it
| was given), /up, and Livewire's own /livewire/update, which both the console
| and the portal post component actions to.
*/
$siteHosts = (array) config('admin_access.site_hosts', []);
$siteHost = $siteHosts[0] ?? '';          // canonical: the one that serves
$siteAliases = array_slice($siteHosts, 1); // every other name redirects to it
$appHost = (string) config('admin_access.app_host');

$publicSite = function () {
    Route::get('/', LandingController::class)->name('home');

    // Generated, not a static file: while the site is hidden this has to say
    // so, and a crawler that gets a 404 here simply crawls anyway.
    // Reachable WITHOUT an account, deliberately: somebody who has just typed
    // their password into a copy of our sign-in form is not signed in anywhere,
    // and the page they need cannot be behind the thing they lost.
    Route::get('/security', fn () => view('security'))->name('security');

    // The German path this shipped under for two releases. R13 says paths are
    // English and this one was not — mine. Kept as a permanent redirect
    // because it has already gone out in mail footers.
    Route::get('/sicherheit', fn () => redirect()->route('security', status: 301));

    // „Sprechen Sie uns an" als Seite. Die Knöpfe darauf zeigten vorher auf
    // `mailto:`, das auf den meisten Geräten sichtbar nichts tut — siehe
    // ContactController. Der Versand ist gedrosselt: das Formular steht offen
    // im Internet und schickt eine Mail, ist also ohne Bremse ein Versandweg
    // für jeden, der ihn findet.
    Route::get('/contact', [ContactController::class, 'show'])->name('contact');
    Route::post('/contact', [ContactController::class, 'send'])
        ->middleware('throttle:5,10')
        ->name('contact.send');

    Route::get('/robots.txt', function () {
        $body = Settings::bool('site.public', true)
            ? "User-agent: *\nAllow: /\n"
            : "User-agent: *\nDisallow: /\n";

        return response($body, 200, ['Content-Type' => 'text/plain']);
    })->name('robots');

    // Legal pages. Bound with the rest of the website rather than left
    // everywhere: an imprint belongs to the company's site, and binding it is
    // what makes route('legal.impressum') generate a www URL from a queued
    // mail, where there is no request to take a hostname from.
    Route::prefix('legal')->name('legal.')->group(function () {
        // R13: paths are English, headings are not. The page still says
        // "Impressum" — that is the legal term an Austrian company must use —
        // but the address is not part of the copy.
        Route::get('/imprint', fn () => view('legal', ['title' => 'Impressum']))->name('impressum');
        Route::get('/privacy', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz');
        // The AGB has a text now (resources/views/legal/terms.blade.php): the
        // order page requires accepting it, and it is where the withdrawal
        // right, the immediate start and the account deletion deadlines are
        // regulated rather than repeated in a checkbox nobody can read.
        Route::get('/terms', fn () => view('legal', ['title' => 'AGB', 'page' => 'terms']))->name('agb');

        // The German paths these shipped under. Permanent, because they have
        // been in mail footers and in the site footer for weeks.
        foreach (['impressum' => 'impressum', 'datenschutz' => 'datenschutz', 'agb' => 'agb'] as $old => $to) {
            Route::get('/'.$old, fn () => redirect()->route('legal.'.$to, status: 301));
        }
        // Kept so existing links and bookmarks do not 404 — permanently,
        // because the page has genuinely moved.
        Route::get('/status', fn () => redirect()->to(route('status'), 301))->name('status');
    });
};

$portal = function () {
    // Guest auth pages — full-page class-based Livewire components (R1/R2).
    // Fortify handles the POST actions with views off; those are bound to the
    // same host through fortify.domain, or a sign-in form on the website's
    // hostname would post to a route that answers there.
    Route::middleware('guest')->group(function () {
        Route::get('/login', Login::class)->name('login');
        Route::get('/register', Register::class)->name('register');
        // Registration POST goes through Fortify's controller but with our own
        // registration-scoped throttle (Fortify's built-in route has none).
        Route::post('/register', [RegisteredUserController::class, 'store'])
            ->middleware('throttle:registration')
            ->name('register.store');
        // Fortify registers the two POST endpoints; with views off it
        // registers no GET routes at all, so the pages are ours under its
        // names — every framework redirect and the reset mail resolve
        // `password.request` and `password.reset`.
        Route::get('/forgot-password', ForgotPassword::class)->name('password.request');
        Route::get('/reset-password/{token}', ResetPassword::class)->name('password.reset');

        Route::get('/two-factor-challenge', TwoFactorChallenge::class)->name('two-factor.login');
    });

    // Signed and single-use; see ImpersonationController. Deliberately not
    // behind `auth`: following the link is what creates the session.
    Route::get('/impersonate/enter/{customer}/{operator}', [ImpersonationController::class, 'enter'])
        ->name('impersonate.enter');

    // Signed in, but the address has not been confirmed yet. Fortify would own
    // this page, but 'views' => false — it keeps the verify and resend actions
    // and the app renders its own screens (R1/R2).
    Route::get('/email/verify', VerifyEmail::class)
        ->middleware('auth')
        ->name('verification.notice');

    // Customer portal — each sidebar tab is a full-page class-based Livewire
    // component (R1/R2); paths are English (R13).
    //
    // `verified` sits on the whole group rather than on the pages that spend
    // money. An unconfirmed address is not a billing problem to be caught at
    // checkout: it is an account that may not belong to the person holding it,
    // and everything behind here — the servers, the users, the backups — is
    // worth as much to somebody who typed a stranger's address as to its owner.
    Route::middleware(['auth', 'verified', 'customer.active'])->group(function () {
        Route::get('/dashboard', Dashboard::class)->name('dashboard');
        Route::get('/cloud', Cloud::class)->name('cloud');
        Route::get('/domain', CustomDomain::class)->name('domain');
        Route::get('/users', Users::class)->name('users');
        Route::get('/backups', Backups::class)->name('backups');
        Route::get('/invoices', Invoices::class)->name('invoices');

        // Die Sperrliste der eigenen Instanzen — das Ziel, auf das die Mail aus
        // BlockAddress verlinkt (SecurityBlockMail::content() ruft
        // route('portal.security')).
        //
        // NICHT '/security': die öffentliche Seite (oben, Zeile ~254) meldet
        // denselben Pfad an, und $appHost/$siteHost sind hier beide leer (jede
        // Entwicklungs- und Testumgebung) — beide Gruppen laufen dann
        // host-unabhängig, und Illuminate\Routing\RouteCollection indiziert
        // Routen über Methode+Domain+URI, NICHT über den Namen. Zwei GET-Routen
        // auf demselben Pfad ohne Domain überschreiben sich also gegenseitig,
        // unabhängig vom Namen — die zweite gewinnt lautlos, ganz ohne Fehler.
        // Der PFAD bleibt deshalb '/security-blocks', der NAME 'portal.security'
        // (die vertraglich fixierte Schnittstelle, die die Mail benutzt).
        Route::get('/security-blocks', Security::class)->name('portal.security');

        // The customer's own invoice as a PDF, rendered on demand from the frozen
        // document — the same renderer the console uses, because there is only one
        // version of a document that has been issued.
        //
        // A plain route rather than a Livewire action: a download is a download,
        // and routing one through a component round-trip only adds a way for it to
        // fail. The ownership check is HERE, in the query, and not in the list that
        // links to it — a URL anybody can type is not made private by not showing
        // it. firstOrFail() on a query that already carries `customer_id` gives a
        // 404 for somebody else's document, which is also the right answer: it
        // tells a stranger nothing about whether the number exists.
        Route::get('/invoices/{uuid}/pdf', function (string $uuid) {
            $customer = Customer::forUser(auth()->user());

            abort_if($customer === null, 404);

            $invoice = Invoice::query()
                ->where('customer_id', $customer->id)
                ->where('uuid', $uuid)
                ->firstOrFail();

            return response()->streamDownload(
                fn () => print (app(InvoiceRenderer::class)->forInvoice($invoice)),
                $invoice->number.'.pdf',
                ['Content-Type' => 'application/pdf'],
            );
        })->name('invoices.pdf');

        // The processing agreement the customer is being asked to accept — the
        // version in FORCE, never one named in the address: which document
        // applies is ours to say, not a parameter.
        Route::get('/processing-agreement/{which}', function (string $which) {
            abort_unless(in_array($which, ['agreement', 'measures'], true), 404);

            $version = app(ProcessingAgreement::class)->current();
            $path = $version === null
                ? null
                : ($which === 'agreement' ? $version->agreement_path : $version->measures_path);

            abort_if($path === null || ! Storage::disk('local')->exists($path), 404);

            // Inline to read, attachment to keep — and the file that lands in
            // somebody's downloads folder carries the VERSION in its name, so
            // "which one did I agree to" is answerable from the filename alone.
            $name = ($which === 'agreement' ? 'CluPilot-AV-Vertrag-' : 'CluPilot-TOM-').$version->version.'.pdf';

            return request()->boolean('download')
                ? Storage::disk('local')->download($path, $name)
                : Storage::disk('local')->response($path, $name, [
                    'Content-Type' => 'application/pdf',
                    'Content-Disposition' => 'inline; filename="'.$name.'"',
                ]);
        })->name('dpa.file');

        Route::get('/billing', Billing::class)->name('billing');
        Route::get('/settings', App\Livewire\Settings::class)->name('settings');
        Route::get('/support', Support::class)->name('support');

        // Buying a package. The page is a page like any other; the POST leaves
        // for Stripe and comes back through the webhook, which is the only
        // place a payment is believed.
        //
        // Behind `verified` with everything else, deliberately: an unconfirmed
        // address is not a billing detail to be caught at checkout, it is an
        // account that may not belong to the person holding it — and the
        // credentials for the finished cloud are sent to that address.
        Route::get('/order', Order::class)->name('order');
        // The order, before it is placed. There was no such page: the order page
        // posted straight to Stripe, so the last thing a customer saw in this
        // product's design was a grid of packages and the next thing was
        // somebody else's card form. GET, with the package and the term in the
        // address, so it survives a reload.
        Route::get('/checkout', Checkout::class)->name('checkout');
        Route::post('/checkout', [CheckoutController::class, 'start'])
            // Throttled: each attempt opens a session on somebody else's API.
            ->middleware('throttle:10,1')
            ->name('checkout.start');
        Route::get('/checkout/done', [CheckoutController::class, 'done'])
            ->name('checkout.done');

        // Return from an admin impersonation session (accessible as the
        // customer user). POST so Laravel's CSRF middleware protects the
        // identity change.
        Route::post('/impersonate/leave', [ImpersonationController::class, 'leave'])->name('impersonate.leave');
    });
};

// Domain-bound FIRST, host-agnostic last: a host-agnostic route registered
// earlier beats a bound one registered later, which is the same ordering the
// status page above already documents.
if ($appHost !== '') {
    Route::domain($appHost)->group($portal);
}

if ($siteHost !== '') {
    Route::domain($siteHost)->group($publicSite);

    // Every other name for the same site — an apex beside its www — answers
    // with a permanent redirect to the canonical one, path and query intact.
    // Serving both without picking one splits search rankings and cookies
    // between two names for one thing.
    //
    // Registered as a catch-all on those hosts alone, so it cannot swallow a
    // path on any other host. It is deliberately not a middleware: a redirect
    // that only some routes get is the half-measure this file already made
    // once.
    foreach ($siteAliases as $index => $alias) {
        Route::domain($alias)
            // The injected request, not the request() helper: this closure also
            // runs in tests that dispatch through a router of their own, where
            // the helper resolves whatever happens to be bound in the container
            // rather than the request being answered.
            ->get('/{path?}', function (Request $request, ?string $path = null) use ($siteHost) {
                $target = 'https://'.$siteHost.'/'.ltrim((string) $path, '/');
                $query = $request->getQueryString();

                return redirect()->away($query ? $target.'?'.$query : $target, 301);
            })
            ->where('path', '.*')
            ->name("site.alias{$index}");
    }

    // "/" on a host that is neither: the portal's own hostname, or a bare IP.
    // Never a 404 — "/" is what somebody types from memory, and turning that
    // into an error page to make a point about hostnames helps nobody.
    Route::get('/', function () {
        return redirect()->route(auth()->check() ? 'dashboard' : 'login');
    })->name('portal.home');
}

if ($appHost === '') {
    $portal();
}

if ($siteHost === '') {
    $publicSite();
}

// Stripe webhook — paid order → customer provisioning run (CSRF-exempt,
// signed). Host-agnostic on purpose: Stripe posts to whichever URL it was
// configured with, and a hostname mismatch there loses payments.
Route::post('/webhooks/stripe', StripeWebhookController::class)->name('webhooks.stripe');
