<?php

namespace App\Livewire\Admin;

use App\Http\Middleware\RestrictConsoleNetwork;
use App\Livewire\Concerns\ChangesOwnPassword;
use App\Livewire\Concerns\ResolvesOperator;
use App\Mail\OperatorInvitationMail;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use App\Services\Deployment\UpdateChannel;
use App\Services\Deployment\UpdateWindow;
use App\Services\Terminal\ServerIdentity;
use App\Services\Terminal\ServerTerminalSetup;
use App\Services\Terminal\ServerTicket;
use App\Support\PasswordPolicy;
use App\Support\Settings as AppSettings;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Permission\Models\Role;
use Symfony\Component\HttpFoundation\IpUtils;

/**
 * Operator settings: the signed-in operator's own account plus (Owner-only)
 * staff management. All staff mutations are capability-gated server-side and
 * protect the last-Owner and self-role invariants transactionally.
 *
 * Two-factor ENROLMENT is the `two-factor` tab, rendered by the unchanged
 * Admin\TwoFactorSetup component. That component also still answers at its own
 * route, because RequireOperatorTwoFactor sends an operator there who may not
 * open anything else yet — this page included. One component, two ways in: the
 * everyday one is a tab beside the other settings, the other is a gate.
 *
 * The page is TABBED rather than one column of eleven cards. It had grown to
 * three headed sections and roughly two thousand pixels of scroll, and the tab
 * a person wants is a property of what they came to do, not of how far they
 * scroll. The choice lives in the query string (?tab=…), so a reload, a
 * bookmark and the back button all land where the operator was — a tab state
 * kept only in the component is a tab state that resets on every refresh, and
 * this page refreshes itself while an update runs.
 */
#[Layout('layouts.admin')]
class Settings extends Component
{
    use ChangesOwnPassword;
    use ResolvesOperator;

    /**
     * The tabs, in the order they are shown.
     *
     * The list IS the schema: it validates the query string, builds the tab
     * bar, and decides which section renders. A fourth place to add a tab is a
     * fourth place to forget one.
     */
    public const TABS = ['installation', 'account', 'two-factor', 'team'];

    /**
     * Die kürzeste zulässige Passwortlänge dieser Installation.
     *
     * Gilt für Kunden wie für Mitarbeiter — deshalb auf dem Sicherheits-Reiter
     * und nicht bei „Mein Konto". Siehe App\Support\PasswordPolicy: bis zum
     * 31.7.2026 stand die Zahl an drei Stellen im Code, mit drei
     * verschiedenen Antworten, und war nirgends einstellbar.
     */
    public int|string $passwordMinLength = PasswordPolicy::DEFAULT_LENGTH;

    /**
     * Which one is open, taken from and written back to the URL.
     *
     * `except` keeps ?tab=installation out of the address bar: the default
     * needs no parameter, and a query string that appears the moment a page
     * loads makes every link to it look different from the one in the menu.
     */
    /**
     * Which tab is open, in the address bar.
     *
     * `history: true`, so each tab is a step the back button can take — and no
     * `except`, so the parameter is there from the first render rather than
     * appearing only once somebody leaves the default tab. Hiding it on the
     * default read as "the tab is not in the URL", which is exactly how it was
     * reported: a link to this page could not say which tab it meant unless the
     * tab happened not to be the first one.
     */
    #[Url(history: true)]
    public string $tab = 'installation';

    /** This page changes the signed-in OPERATOR's password, never a portal one. */
    protected function passwordAccountGuard(): string
    {
        return 'operator';
    }

    // My account
    #[Validate('required|string|max:255')]
    public string $name = '';

    #[Validate('required|email|max:255')]
    public string $email = '';

    // Invite staff
    #[Validate('required|string|max:255')]
    public string $staffName = '';

    #[Validate('required|email|max:255')]
    public string $staffEmail = '';

    public string $staffRole = 'Support';

    public function savePasswordPolicy(): void
    {
        $this->authorize('settings.manage');

        $this->validate(['passwordMinLength' => [
            'required', 'integer',
            'min:'.PasswordPolicy::FLOOR, 'max:'.PasswordPolicy::CEILING,
        ]]);

        PasswordPolicy::set((int) $this->passwordMinLength);

        // Zurückgelesen statt geglaubt: set() klemmt, und das Feld muss zeigen,
        // was wirklich gilt — nicht, was eingetippt wurde.
        $this->passwordMinLength = PasswordPolicy::minLength();

        $this->dispatch('notify', message: __('admin_settings.password_policy_saved'));
    }

    public function mount(): void
    {
        $this->passwordMinLength = PasswordPolicy::minLength();

        // A tab name out of the URL is a string a stranger typed. Anything
        // unknown falls back to the first tab rather than rendering a settings
        // page with no section on it at all. Before the early return, because
        // the tab has to be valid whether or not an operator resolves.
        if (! in_array($this->tab, self::TABS, true)) {
            $this->tab = self::TABS[0];
        }

        if (! $operator = $this->currentOperator()) {
            return;
        }

        // ?tab=team without the capability would open a tab that renders
        // nothing — the section is gated on its own, as it must be. Landing on
        // the first tab says the same thing without the blank page.
        if ($this->tab === 'team' && ! $operator->can('staff.manage')) {
            $this->tab = self::TABS[0];
        }

        $this->name = $operator->name;
        $this->email = $operator->email;
        $this->requireTwoFactor = AppSettings::bool('console.require_2fa', false);

        $window = app(UpdateWindow::class);
        $this->autoUpdate = $window->enabled();
        $this->autoDays = $window->days();
        $this->autoFrom = $window->from();
        $this->autoTo = $window->to();
    }

    /**
     * Take the marketing site and the customer portal offline, or back online.
     * The console keeps working either way — otherwise this switch could only
     * ever be flipped once.
     */
    public function toggleSiteVisibility(): void
    {
        $this->authorize('site.manage');

        $public = ! AppSettings::bool('site.public', true);
        AppSettings::set('site.public', $public);

        $this->dispatch('notify', message: $public
            ? __('admin_settings.site_now_public')
            : __('admin_settings.site_now_hidden'));
    }

    public function saveAccount(): void
    {
        if (! $operator = $this->currentOperator()) {
            return;
        }

        $data = $this->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|max:255|unique:operators,email,'.$operator->id,
        ]);

        // An operator email must never collide with a customer's — that would
        // block the customer from ever obtaining a portal login (ensureUser).
        // Checked directly against `users`, not only `customers`: see
        // Customer::emailTaken().
        if (Customer::emailTaken($data['email'])) {
            $this->addError('email', __('admin_settings.is_customer'));

            return;
        }

        $operator->update($data);
        $this->dispatch('notify', message: __('admin_settings.account_saved'));
    }

    public function inviteStaff(): void
    {
        $this->authorize('staff.manage');

        if (! $operator = $this->currentOperator()) {
            return;
        }

        $data = $this->validate([
            'staffName' => 'required|string|max:255',
            'staffEmail' => 'required|email|max:255|unique:operators,email',
            // Aus der Datenbank, nicht aus einer Zeile hier: sonst bietet
            // das Formular eine gerade angelegte Rolle nicht an — und
            // `Developer` war genau so jahrelang unsichtbar.
            'staffRole' => ['required', Rule::in(Operator::operatorRoles())],
        ]);

        // Only an Owner may create another Owner.
        if ($data['staffRole'] === 'Owner' && ! $operator->hasRole('Owner')) {
            $this->addError('staffRole', __('admin_settings.owner_only'));

            return;
        }
        // Never turn a customer's portal login into an operator.
        if (Customer::emailTaken($data['staffEmail'])) {
            $this->addError('staffEmail', __('admin_settings.is_customer'));

            return;
        }

        // Alles, was diese Einladung anlegt, in EINER Transaktion.
        //
        // Vorher liefen Operator::create() und assignRole() fest, bevor der
        // Broker den Token zog und die Mail hinausging. Ging die Datenbank
        // oder Redis genau dazwischen kurz weg, blieb ein Konto stehen, das
        // nie eine Einladung bekommen hatte — und `unique:operators,email`
        // blockierte seine Adresse danach für immer, ohne einen Rückweg
        // ausser der Shell. Eine vorübergehende Störung darf keinen
        // dauerhaften Schaden hinterlassen: bricht hier irgendetwas ab, rollt
        // die Transaktion Konto, Rolle und Token gemeinsam zurück.
        DB::transaction(function () use ($data, $operator) {
            // Ein Konto, dessen Passwort niemand kennt — auch der Inhaber nicht.
            //
            // Hier wurde bis zuletzt ein Passwort erzeugt und dem Inhaber einmal
            // angezeigt, damit er es „sicher weitergibt": per Chat, per Zuruf. Für
            // einen Zugang zur Konsole, die die ganze Flotte und jede
            // Kundeninstanz verwaltet. Ein Passwort, das zwei Menschen kennen,
            // gehört keinem von beiden.
            //
            // Die Zufallszeichenkette unten ist deshalb kein „temporäres
            // Passwort", sondern das Gegenteil: sie entsteht in dieser Zeile, wird
            // vom `hashed`-Cast verhasht und ist danach fort — sie steht in keiner
            // Eigenschaft, in keiner Mail und in keinem Protokoll. Anmeldbar wird
            // das Konto erst, wenn der Eingeladene über den Link sein eigenes
            // Passwort vergibt (App\Livewire\Auth\OperatorSetPassword).
            $newStaff = Operator::create([
                'name' => $data['staffName'],
                'email' => $data['staffEmail'],
                'password' => Str::random(64),
            ]);
            $newStaff->assignRole($data['staffRole']);

            // Der Token kommt vom BETREIBER-Broker: eigene Tabelle, eigener
            // Provider (R21, siehe config/auth.php). Nicht sendResetLink() —
            // das verschickt Laravels eigene Benachrichtigung mit einem Link auf
            // die Seite des PORTALS und wäre genau die Vermischung, die diese
            // Trennung beendet hat.
            $token = Password::broker('operators')->createToken($newStaff);

            $mail = new OperatorInvitationMail(
                $newStaff,
                $operator->name,
                route('admin.invitation', ['token' => $token, 'email' => $newStaff->email]),
                OperatorInvitationMail::validHours(),
            );
            Mail::mailer($mail->mailer)->to($newStaff->email)->queue($mail);
        });

        $this->reset('staffName', 'staffEmail');
        $this->staffRole = 'Support';
        $this->dispatch('notify', message: __('admin_settings.staff_invited'));
    }

    /**
     * Ein neues Einladungs-Token für ein Konto, das noch niemand benutzt hat.
     *
     * Für den Vierte-Tag-Fall: der Link war 72 Stunden gültig, Sam war länger
     * weg. `unique:operators,email` verweigert danach eine zweite Einladung
     * an dieselbe Adresse — das Konto existiert ja schon —, und es gab bisher
     * keinen Weg zurück ausser der Shell (clupilot:create-operator), die dabei
     * gleich auf `Owner` setzt. Eine Rettung aus einer abgelaufenen
     * Support-Einladung, die den Betroffenen zum Inhaber macht, ist keine
     * Rettung.
     *
     * Legt bewusst KEIN neues Konto an — nur einen neuen Token für das
     * bestehende. `createToken()` löscht die alte Token-Zeile von sich aus,
     * bevor es die neue einfügt (operator_password_reset_tokens hat die
     * Adresse als Primärschlüssel, siehe DatabaseTokenRepository::create()):
     * der alte Link verliert dadurch seine Gültigkeit, ohne dass hier extra
     * aufgeräumt werden müsste.
     */
    public function resendInvitation(int $id): void
    {
        $this->authorize('staff.manage');

        if (! $operator = $this->currentOperator()) {
            return;
        }

        $target = Operator::query()->whereKey($id)->first();

        // Nur für jemanden, der sich noch nie angemeldet hat. `last_login_at`
        // bleibt null, bis OperatorLogin::completeLogin() einmal durchläuft,
        // und wird sonst nirgends gesetzt — der einzige Bestand, an dem sich
        // „hat den Link noch nie benutzt" von „arbeitet hier längst" verlässlich
        // unterscheiden lässt. Ein Knopf, der einem angemeldeten Kollegen das
        // Passwort neu vergeben liesse, wäre ein Fehler, kein Komfort — und
        // schützt nebenbei auch vor der eigenen Kachel: die Person, die diesen
        // Knopf drückt, ist selbst angemeldet und hat also selbst schon ein
        // gesetztes `last_login_at`.
        if ($target === null || ! $target->isOperator() || $target->last_login_at !== null) {
            return;
        }

        $token = Password::broker('operators')->createToken($target);

        $mail = new OperatorInvitationMail(
            $target,
            $operator->name,
            route('admin.invitation', ['token' => $token, 'email' => $target->email]),
            OperatorInvitationMail::validHours(),
        );
        Mail::mailer($mail->mailer)->to($target->email)->queue($mail);

        $this->dispatch('notify', message: __('admin_settings.invitation_resent'));
    }

    public function setStaffRole(int $id, string $role): void
    {
        $this->authorize('staff.manage');
        if (! in_array($role, Operator::operatorRoles(), true)) {
            return;
        }

        if (! $operator = $this->currentOperator()) {
            return;
        }

        $result = DB::transaction(function () use ($id, $role, $operator) {
            // Serialize on the Owner role so the global owner count can't be
            // raced to zero by concurrent demotions of different owners.
            Role::query()->where('name', 'Owner')->lockForUpdate()->first();

            $target = Operator::query()->whereKey($id)->lockForUpdate()->first();
            if ($target === null) {
                return 'gone';
            }
            if ($target->id === $operator->id) {
                return 'self';
            }
            // Only existing operators may be re-roled. The Customer check below
            // can no longer fire in practice — operators and customers are
            // different tables now, so a tampered id cannot resolve to one from
            // the other — but it costs nothing to leave as a second line.
            if (! $target->isOperator() || Customer::query()->where('email', $target->email)->exists()) {
                return 'not_staff';
            }
            // Granting or revoking Owner is Owner-only.
            if (($role === 'Owner' || $target->hasRole('Owner')) && ! $operator->hasRole('Owner')) {
                return 'owner_only';
            }
            // Never demote the last Owner.
            if ($target->hasRole('Owner') && $role !== 'Owner' && $this->ownerCount() <= 1) {
                return 'last_owner';
            }
            $target->syncRoles([$role]);

            return 'ok';
        });

        $this->flash($result);
    }

    public function revokeStaff(int $id): void
    {
        $this->authorize('staff.manage');

        $revokedPeers = [];

        $result = DB::transaction(function () use ($id, &$revokedPeers) {
            Role::query()->where('name', 'Owner')->lockForUpdate()->first();

            $target = Operator::query()->whereKey($id)->lockForUpdate()->first();
            if ($target === null || ! $target->isOperator()) {
                return 'gone';
            }
            if ($target->id === Auth::guard('operator')->id()) {
                return 'self';
            }
            if ($target->hasRole('Owner') && $this->ownerCount() <= 1) {
                return 'last_owner';
            }
            $target->syncRoles([]);

            // Taking away the console but leaving the tunnel would be the worst
            // of both: no access to the panel, still a route into the
            // management network. Same revocation path as the VPN page uses.
            foreach (VpnPeer::query()->where('user_id', $target->id)->get() as $peer) {
                $peer->purgeSecret();
                $peer->delete();
                $revokedPeers[] = $peer->public_key;
            }

            return 'revoked';
        });

        // Dispatched only once the rows are committed: a worker picking the job
        // up mid-transaction would still see the peer as active, leave it on the
        // hub, and never be asked again — a revoked colleague would keep their
        // tunnel until the next reconciliation happened to notice.
        foreach ($revokedPeers as $publicKey) {
            ApplyVpnPeer::dispatch($publicKey, null, false);
        }

        $this->flash($result);
    }

    private function flash(string $result): void
    {
        $msg = match ($result) {
            'ok' => __('admin_settings.role_updated'),
            'revoked' => __('admin_settings.staff_revoked'),
            'self' => __('admin_settings.not_self'),
            'owner_only' => __('admin_settings.owner_only'),
            'last_owner' => __('admin_settings.last_owner'),
            'not_staff' => __('admin_settings.not_staff'),
            default => null,
        };
        if ($msg !== null) {
            $this->dispatch('notify', message: $msg);
        }
    }

    private function ownerCount(): int
    {
        return Operator::query()->whereHas('roles', fn ($q) => $q->where('name', 'Owner'))->count();
    }

    /** A new entry for the console allowlist: a single address or a CIDR range. */
    public string $consoleIp = '';

    /**
     * Add a network that may reach the console without the VPN.
     *
     * Validated as an address or CIDR before it is stored: a typo that silently
     * matches nothing is how someone locks themselves out while believing they
     * have not.
     */
    public function addConsoleIp(): void
    {
        $this->authorize('site.manage');

        $value = trim($this->consoleIp);

        if (! RestrictConsoleNetwork::isNetwork($value)) {
            $this->addError('consoleIp', __('admin_settings.console_ip_invalid'));

            return;
        }

        $list = (array) AppSettings::get('console.allowed_ips', []);

        if (! in_array($value, $list, true)) {
            $list[] = $value;
            AppSettings::set('console.allowed_ips', array_values($list));
        }

        $this->consoleIp = '';
        $this->dispatch('notify', message: __('admin_settings.console_ip_added', ['ip' => $value]));
    }

    /**
     * Remove one — unless it is the reason you can see this page.
     *
     * Refusing here rather than warning afterwards: by the time the page
     * reloads, the request that would show the warning has already been
     * rejected. The VPN is never in this list, so an operator on the VPN can
     * always clear it out.
     */
    public function removeConsoleIp(string $value): void
    {
        $this->authorize('site.manage');

        $list = array_values(array_filter(
            (array) AppSettings::get('console.allowed_ips', []),
            fn ($entry) => $entry !== $value,
        ));

        $ip = (string) request()->ip();

        if (RestrictConsoleNetwork::isRestricted()
            && ! RestrictConsoleNetwork::wouldStillAllow($ip, $list)) {
            $this->dispatch('notify', message: __('admin_settings.console_ip_last', ['ip' => $ip]));

            return;
        }

        AppSettings::set('console.allowed_ips', $list);
        $this->dispatch('notify', message: __('admin_settings.console_ip_removed', ['ip' => $value]));
    }

    /**
     * Switch the restriction on or off.
     *
     * Turning it ON is refused unless the address doing the switching is
     * already covered — otherwise the click that secures the console is also
     * the click that locks its owner out of it.
     */
    public function toggleConsoleRestriction(): void
    {
        $this->authorize('site.manage');

        $on = ! RestrictConsoleNetwork::isRestricted();

        if ($on && ! RestrictConsoleNetwork::allows((string) request()->ip())) {
            $this->dispatch('notify', message: __('admin_settings.console_lock_refused', ['ip' => (string) request()->ip()]));

            return;
        }

        AppSettings::set('console.network_restricted', $on);
        $this->dispatch('notify', message: __($on ? 'admin_settings.console_locked' : 'admin_settings.console_unlocked'));
    }

    /**
     * Eine neue SSH-Kennung für das Terminal zum CluPilot-Server selbst.
     *
     * `server.terminal` und nichts anderes: wer diese Kennung erzeugt, bestimmt,
     * mit welchem Schlüssel eine Root-Schale in die Steuerungsebene aufgeht.
     * `site.manage` — die Berechtigung des Abschnitts nebenan — ist dafür die
     * falsche Frage.
     *
     * Ersetzen sperrt aus, bis der Server neu eingerichtet ist. Deshalb die
     * Rückfrage davor (Admin\ConfirmGenerateServerKey) und deshalb sagt die
     * Karte danach, welche Zeile auf dem Server noch fehlt.
     */
    public function generateServerKey(): void
    {
        $this->authorize('server.terminal');

        /** @var Operator $operator */
        $operator = Auth::guard('operator')->user();

        ServerIdentity::generate($operator);

        $this->dispatch('notify', message: __('admin_settings.server_terminal_generated'));
    }

    /** ConfirmGenerateServerKey schickt das hierher zurück (R23) — siehe dort. */
    #[On('server-key-generate-confirmed')]
    public function onGenerateServerKeyConfirmed(): void
    {
        $this->generateServerKey();
    }

    /** Whether two-factor is compulsory for every operator on the console. */
    public bool $requireTwoFactor = false;

    /**
     * Make two-factor compulsory for every operator, or make it voluntary again.
     *
     * The lock-out guard, same shape as console.allowed_ips: the page that
     * could switch this back off sits behind the switch.
     */
    public function saveTwoFactorPolicy(): void
    {
        $this->authorize('site.manage');

        if ($this->requireTwoFactor && Auth::guard('operator')->user()?->two_factor_confirmed_at === null) {
            $this->addError('requireTwoFactor', __('admin_settings.two_factor_self_first'));

            return;
        }

        AppSettings::set('console.require_2fa', $this->requireTwoFactor);
        $this->dispatch('notify', message: __('admin_settings.saved'));
    }

    /**
     * Ask the host-side agent to update this installation — a real run.
     *
     * Deliberately a request rather than an action: see UpdateChannel. The
     * button cannot report success, because success means this container is
     * restarted out from under the response — so it reports that the update was
     * asked for, and the page shows the outcome once the agent has written it.
     *
     * The sibling of requestCheck() below: this one applies, downtime and all —
     * but only a released version above the one installed. There is no longer
     * such a thing as "update to whatever is on the branch".
     */
    /** The update window — see App\Services\Deployment\UpdateWindow. */
    public bool $autoUpdate = false;

    /** @var array<int, int> ISO weekdays */
    public array $autoDays = [];

    public string $autoFrom = '';

    public string $autoTo = '';

    /**
     * Switch the automation on or off and set its window.
     *
     * One action for all four values: a half-saved window — enabled with no
     * days, or a start without an end — is a configuration that reads as
     * working and does nothing.
     */
    public function saveUpdateWindow(): void
    {
        $this->authorize('site.manage');

        $this->validate([
            'autoDays' => 'array',
            'autoDays.*' => 'integer|min:1|max:7',
            'autoFrom' => ['required', 'regex:/^([01]\\d|2[0-3]):[0-5]\\d$/'],
            'autoTo' => ['required', 'regex:/^([01]\\d|2[0-3]):[0-5]\\d$/'],
        ]);

        // Refused rather than silently corrected: an automation switched on
        // with no day selected would never run, and an interface that accepts
        // that has told the operator something untrue.
        if ($this->autoUpdate && $this->autoDays === []) {
            $this->addError('autoDays', __('admin_settings.auto_days'));

            return;
        }

        AppSettings::set(UpdateWindow::KEY_ENABLED, $this->autoUpdate);
        AppSettings::set(UpdateWindow::KEY_DAYS, array_values(array_map('intval', $this->autoDays)));
        AppSettings::set(UpdateWindow::KEY_FROM, $this->autoFrom);
        AppSettings::set(UpdateWindow::KEY_TO, $this->autoTo);

        $this->dispatch('notify', message: __('admin_settings.auto_saved'));
    }

    public function requestUpdate(): void
    {
        $this->authorize('site.manage');

        if (! $operator = $this->currentOperator()) {
            return;
        }

        // Checked here as well as in the markup. The button is disabled when
        // there is nothing released to install, and the agent refuses such a
        // request too — but a Livewire action is a public endpoint, and the one
        // place a rule may not live is only in the disabled attribute of a
        // button. Queueing a run for nothing costs a maintenance window.
        if (! app(UpdateChannel::class)->state()['available']) {
            $this->dispatch('notify', message: __('admin_settings.update_nothing_released'));

            return;
        }

        $accepted = app(UpdateChannel::class)->request($operator->email);

        $this->dispatch('notify', message: __($accepted
            ? 'admin_settings.update_requested'
            : 'admin_settings.update_already_requested'));

        // Opens the maintenance overlay on THIS click rather than on the
        // watcher's next poll. Without it there were visibly two screens: this
        // component re-rendered at once and grew a little "läuft gerade" panel
        // inside the settings card, and up to three seconds later the overlay
        // covered the page and said the same thing again. Reported as "first a
        // small window, then the big one".
        //
        // Dispatched only once a run is actually pending — an overlay that
        // covers the console because a button was pressed, over nothing, is
        // worse than a slow one.
        if ($accepted) {
            $this->dispatch('update-started');
        }
    }

    /**
     * Ask the host-side agent to look for updates — never to apply one.
     *
     * For the operator who only wants an answer: no maintenance window, no
     * restart, just the next tick's reading reported back.
     */
    public function requestCheck(): void
    {
        $this->authorize('site.manage');

        if (! $operator = $this->currentOperator()) {
            return;
        }

        $accepted = app(UpdateChannel::class)->requestCheck($operator->email);

        $this->dispatch('notify', message: __($accepted
            ? 'admin_settings.update_check_requested'
            : 'admin_settings.update_already_requested'));
    }

    public function render()
    {
        $operator = $this->currentOperator();

        $staff = Operator::query()
            ->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::operatorRoles()))
            ->with('roles')
            ->orderBy('name')
            ->get()
            ->map(fn (Operator $u) => [
                'id' => $u->id,
                'name' => $u->name,
                'email' => $u->email,
                'role' => $u->roles->first()?->name ?? '—',
                'self' => $u->id === Auth::guard('operator')->id(),
                // Der Beleg für „hat den Einladungslink noch nie benutzt": wird
                // ausschliesslich bei einer echten Anmeldung gesetzt (siehe
                // OperatorLogin::completeLogin), nicht beim Anlegen des Kontos.
                'never_signed_in' => $u->last_login_at === null,
            ]);

        return view('livewire.admin.settings', [
            'update' => app(UpdateChannel::class)->state(),
            'updateLog' => app(UpdateChannel::class)->lastLog(),
            'sitePublic' => AppSettings::bool('site.public', true),
            'canManageSite' => $operator?->can('site.manage') ?? false,
            // Shown so nobody has to guess why they still see the real site.
            'viewerOnVpn' => IpUtils::checkIp(
                (string) request()->ip(),
                (array) config('admin_access.trusted_ranges', []),
            ),
            // Who may reach the console, and from where the viewer is asking —
            // shown so the consequence of a change is visible before it is made.
            'consoleRestricted' => RestrictConsoleNetwork::isRestricted(),
            'consoleIps' => (array) AppSettings::get('console.allowed_ips', []),
            // Getrennt, nicht in einen Topf: nur das Management-Netz und der
            // eigene Rechner sind wirklich unveraenderlich. Was sonst in
            // TRUSTED_RANGES steht, hat jemand von Hand eingetragen und muss
            // auch von Hand wieder heraus — die Oberflaeche sagt das jetzt,
            // statt es als „nicht entfernbar" auszugeben.
            'consoleVpnRanges' => array_values(array_intersect(
                (array) config('admin_access.trusted_ranges', []),
                (array) config('admin_access.structural_ranges', []),
            )),
            'consoleEnvRanges' => array_values(array_diff(
                (array) config('admin_access.trusted_ranges', []),
                (array) config('admin_access.structural_ranges', []),
            )),
            'viewerIp' => (string) request()->ip(),
            // Das Terminal zum Server selbst. Alle vier Werte, damit die Karte
            // sagen kann, WARUM der Knopf gesperrt ist — ein Knopf, der ohne
            // Begründung nicht geht, schickt den Betreiber auf die Suche.
            'canServerTerminal' => $operator?->can('server.terminal') ?? false,
            'serverTerminalProblem' => ServerTicket::blocker() ?? '',
            // Ohne den Schalter von RestrictConsoleNetwork gefragt: für diese
            // Tür gilt die Liste immer, auch wenn die Konsole gerade offen ist.
            'serverTerminalOnNetwork' => RestrictConsoleNetwork::allows((string) request()->ip()),
            'serverTerminalInstalledAt' => ServerTerminalSetup::read()?->installedAt,
            'staff' => $staff,
            'roles' => Operator::operatorRoles(),
            'canManageStaff' => $operator?->can('staff.manage') ?? false,
            'isOwner' => $operator?->hasRole('Owner') ?? false,
            // Damit der Hinweis unter dem Einladen-Formular dieselbe Frist
            // nennt wie die Mail und der Broker — eine Quelle, drei Leser.
            'inviteHours' => OperatorInvitationMail::validHours(),
            // Only the tabs this operator can actually open. A tab that raises
            // a 403 is worse than one that is not there.
            'tabs' => array_values(array_filter(
                self::TABS,
                fn (string $tab) => $tab !== 'team' || ($operator?->can('staff.manage') ?? false),
            )),
        ]);
    }
}
