<?php

namespace App\Livewire;

use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Seat;
use App\Provisioning\Jobs\SyncSeatToNextcloud;
use App\Services\Mail\GuestMailConfig;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;

#[Layout('layouts.portal-app')]
class Users extends Component
{
    use ResolvesCustomer;

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

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

    #[Validate('required|in:admin,member,readonly')]
    public string $inviteRole = 'member';

    public function mount(): void
    {
        // Every customer starts with themselves as the owner seat. firstOrCreate
        // keyed on (customer_id, email) is idempotent; the catch covers the
        // concurrent-first-visit race against the unique index.
        $customer = $this->customer();

        if ($customer === null) {
            return;
        }

        if ($customer->seats()->count() === 0) {
            try {
                $customer->seats()->firstOrCreate(
                    ['email' => $customer->email],
                    ['name' => $customer->name, 'role' => 'owner', 'status' => 'active', 'invited_at' => now()],
                );
            } catch (UniqueConstraintViolationException) {
                // Another concurrent first visit created it — fine.
            }
        }

        /*
         * Der Inhaber-Sitz wird mit dem bestehenden Admin-Konto verknüpft.
         *
         * Dieses Konto gibt es in der Nextcloud längst — CreateCustomerAdmin
         * hat es beim Aufbau angelegt. Ohne diese Verknüpfung stünde der Sitz
         * auf `none`, und das Panel böte dem Inhaber an, sich SELBST
         * einzuladen; der Auftrag träfe dann auf einen Benutzer, den es schon
         * gibt.
         *
         * Bei JEDEM Besuch versucht, nicht nur beim allerersten: wer das Panel
         * öffnet, bevor die Bereitstellung das Admin-Konto angelegt hat,
         * behielte sonst für immer einen owner-Sitz ohne Verknüpfung. Der
         * Aufruf ist folgenlos, solange es kein Konto gibt.
         *
         * Die Bedingung hängt an `nc_username`, weil das die Angabe ist, um
         * die es geht: genau einmal gesetzt, danach unveränderlich. Sie ist in
         * jedem ERREICHBAREN Zustand gleichbedeutend mit
         * `nc_state === STATE_NONE` — sie heilt also NICHTS, was
         * `=== STATE_NONE` nicht auch heilen würde, und insbesondere heilt sie
         * einen bereits beschädigten Sitz NICHT: der trägt ja einen
         * `nc_username`, und dann greift sie nie.
         *
         * Beschädigt heisst hier: Inhaber-Sitz mit gesetztem `nc_username`, der
         * nicht das Admin-Konto der Instanz ist. Der Weg dorthin ist seit dem
         * Fix zu (sendInvite() und retry() weisen `owner` ab), und im Bestand
         * dürfte es keinen geben, weil sämtliche `nc_*`-Spalten aus der
         * Wanderung von heute stammen. Taucht doch einer auf, hilft nur ein
         * Eingriff an der Zeile — nicht diese Bedingung.
         *
         * Die Wanderung aus Aufgabe 5 tut dasselbe für den Bestand — sie führt
         * ihre eigene, eingefrorene Fassung. Hier gilt die laufende.
         */
        $owner = $customer->seats()->where('role', 'owner')->first();

        if ($owner !== null && blank($owner->nc_username)) {
            $owner->linkToInstanceAdmin();
        }
    }

    /**
     * Anlegen — der erste der beiden Vorgänge.
     *
     * Schickt ausdrücklich NICHTS los und lässt `nc_state` auf `none`: ein
     * Inhaber soll sein Team vorbereiten können, ohne dass jemand eine Mail
     * bekommt. Erst `sendInvite()` erzeugt einen Benutzer im Gast.
     *
     * Und es ist zugleich die Vordertür zurück: ein entzogener Sitz derselben
     * Adresse wird hier WIEDER AUFGENOMMEN, statt an der Eindeutigkeit von
     * (customer_id, email) abzuprallen. Wer geht und ein Jahr später
     * wiederkommt, muss wieder Zugang bekommen können; verhindert werden soll
     * die VERSEHENTLICHE Wiederbelebung mit zwei Klicks an der Zeile — und die
     * ginge an der Platzgrenze vorbei, die nur hier steht. Hier nicht: die
     * Wiederaufnahme geht durch dieselbe Prüfung wie jeder neue Sitz.
     */
    public function addSeat(): void
    {
        $customer = $this->requireCustomer();
        if ($customer === null) {
            return;
        }

        $data = $this->validate();

        // Serialize the limit check with the insert so two concurrent invites for
        // the last free seat can't both pass (lock the customer row).
        $result = DB::transaction(function () use ($customer, $data) {
            $locked = Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
            $vorhanden = $locked->seats()->where('email', $data['inviteEmail'])->first();

            if ($vorhanden !== null && $vorhanden->status !== 'revoked') {
                return 'duplicate';
            }

            // Der entzogene Sitz zählt heute NICHT gegen die Grenze
            // (usedSeats() lässt 'revoked' aus). Sobald er wieder auf
            // 'invited' steht, zählt er — die Prüfung gehört also davor,
            // genau wie beim Anlegen.
            if ($this->usedSeats($locked) >= $this->seatLimit($locked)) {
                return 'limit';
            }

            if ($vorhanden !== null) {
                // `nc_username` und `nc_synced_at` bleiben, wie sie sind: der
                // Anmeldename gehört weiter dieser Person (Nextcloud kann
                // nicht umbenennen), und dass das Konto im Gast existiert,
                // bleibt wahr — daran hängt später das Entsperren.
                //
                // `nc_state` dagegen MUSS zurück auf `none`: der Sitz stand
                // auf `synced`, und `status = 'invited'` daneben liesse die
                // Zeile „Eingeladen" zeichnen — für jemanden, den niemand
                // eingeladen hat und dessen Konto im Gast weiter gesperrt ist.
                // Dieselbe Lüge wie eine Zeile, die einen Fehlschlag
                // verschweigt, nur in die andere Richtung. Mit `none` heisst
                // sie „angelegt — noch nicht eingeladen", der Knopf heisst
                // „Einladen", und beides stimmt.
                $vorhanden->forceFill([
                    'name' => $data['inviteName'] ?: $vorhanden->name,
                    'role' => $data['inviteRole'],
                    'status' => 'invited',
                    'invited_at' => now(),
                    'nc_state' => Seat::STATE_NONE,
                    'nc_error' => null,
                ])->save();

                return 'reinstated';
            }

            $locked->seats()->create([
                'email' => $data['inviteEmail'],
                'name' => $data['inviteName'] ?: null,
                'role' => $data['inviteRole'],
                'status' => 'invited',
                'invited_at' => now(),
            ]);

            return 'ok';
        });

        if ($result === 'limit') {
            $this->addError('inviteEmail', __('users.limit_reached'));

            return;
        }
        if ($result === 'duplicate') {
            $this->addError('inviteEmail', __('users.duplicate'));

            return;
        }

        $this->reset('inviteEmail', 'inviteName', 'inviteRole');
        $this->inviteRole = 'member';
        $this->dispatch('notify', message: __($result === 'reinstated' ? 'users.reinstated' : 'users.added'));
    }

    /**
     * Einladen — der zweite, getrennte Schritt.
     *
     * Anlegen und Einladen sind ausdrücklich zwei Vorgänge: ein Inhaber
     * soll sein Team vorbereiten und die Einladungen später verschicken
     * können, etwa alle am ersten Arbeitstag.
     */
    public function sendInvite(string $uuid): void
    {
        $customer = $this->requireCustomer();

        if ($customer === null) {
            return;
        }

        $seat = $customer->seats()->where('uuid', $uuid)->first();

        if ($seat === null) {
            return;
        }

        // Entzogen bleibt entzogen. Ohne diese Zeile wäre „Einladen" an einer
        // entzogenen Zeile der Weg zurück in einen Zugang, den der Inhaber
        // beendet hat — und er ginge an der Platzgrenze vorbei, die nur in
        // addSeat() steht. Der richtige Weg ist ein NEUER Sitz.
        if ($seat->status === 'revoked') {
            $this->dispatch('notify', message: __('users.revoked_closed'));

            return;
        }

        // Den Inhaber lädt niemand ein. Sein Konto legt die Bereitstellung an
        // (CreateCustomerAdmin); ein Auftrag von hier aus würde entweder auf
        // einen bestehenden Benutzer treffen oder — wenn die Bereitstellung
        // noch nicht so weit ist — später ein ZWEITES Konto in der Gruppe
        // `admin` anlegen, neben dem echten.
        if ($seat->role === 'owner') {
            $this->dispatch('notify', message: __('users.owner_not_invitable'));

            return;
        }

        // Die Einladung verschickt NEXTCLOUD, nicht CluPilot — nur so entsteht
        // das Passwort dort, wo niemand sonst es zu sehen bekommt. Ohne
        // eingerichteten Mailversand kommt dort aber nichts heraus, und
        // `occ user:add --generate-password --email` GELINGT trotzdem:
        // Nextcloud legt das Konto an, versucht die Willkommensmail,
        // protokolliert intern einen Fehler und beendet mit 0. Der Auftrag
        // meldete Erfolg, die Zeile sprang auf „Eingeladen", die Meldung
        // versprach einen Link — und niemand bekam etwas.
        //
        // Am Tag der Auslieferung ist das kein Randfall, sondern der
        // Normalzustand: der Mailserver dieses Produkts entsteht gerade erst,
        // und das Postfach steht bis dahin leer.
        //
        // VOR dem Ratelimit, damit ein abgewiesener Versuch kein Kontingent
        // verbraucht: sonst hätte der Inhaber seine Versuche aufgebraucht,
        // bevor überhaupt einer hinausgehen konnte.
        if (! $this->mailversandBereit($customer)) {
            $this->dispatch('notify', message: __('users.invite_no_mail'));

            return;
        }

        if (($warten = $this->rateLimited($customer, $seat)) !== null) {
            $this->dispatch('notify', message: __('users.too_many_invites', ['minutes' => $warten]));

            return;
        }

        // Einmal gesetzt, nie wieder geändert: Nextcloud kann Benutzer nicht
        // umbenennen. Ein Sitz, dessen Adresse sich später ändert, behält
        // seinen Anmeldenamen.
        if (blank($seat->nc_username)) {
            $seat->nc_username = $seat->email;
        }

        $seat->forceFill([
            'nc_username' => $seat->nc_username,
            'status' => 'invited',
            'invited_at' => now(),
            'nc_state' => Seat::STATE_PENDING,
            'nc_error' => null,
        ])->save();

        SyncSeatToNextcloud::dispatch($seat->uuid, 'invite');

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

    /**
     * Der zweite Versuch nach einem Fehlschlag.
     *
     * Ohne ihn bliebe dem Inhaber bei einem Gast, der einmal nicht erreichbar
     * war, nur die Zeile zu löschen und neu anzulegen — also genau der
     * Datenverlust, den `revoke()` gerade abgeschafft hat.
     *
     * Kein Ratelimit: der Knopf erscheint nur an einem fehlgeschlagenen Sitz
     * und setzt ihn sofort auf `pending`, wo er keinen Knopf mehr hat. Er kann
     * also gar nicht schneller gedrückt werden, als die Warteschlange
     * antwortet — und ihn zu drosseln hiesse, die Rückfahrkarte aus einem
     * Fehlschlag zu drosseln.
     */
    public function retry(string $uuid): void
    {
        $customer = $this->requireCustomer();

        if ($customer === null) {
            return;
        }

        $seat = $customer->seats()->where('uuid', $uuid)->first();

        if ($seat === null || $seat->nc_state !== Seat::STATE_FAILED) {
            return;
        }

        // Dieselbe Wache wie in sendInvite(), und aus demselben Grund: an einem
        // beschädigten Inhaber-Sitz (`failed`, nie gespiegelt) wählte das
        // match unten `invite` und legte in der Nextcloud ein ZWEITES Konto in
        // der Gruppe `admin` an, neben dem echten. Gehärtet war bisher nur der
        // Knopf davor, nicht der Auftragsweg dahinter.
        if ($seat->role === 'owner') {
            $this->dispatch('notify', message: __('users.owner_not_invitable'));

            return;
        }

        // Ein ENTZOGENER Sitz darf hier ausdrücklich durch. Er ist der Fall,
        // für den dieser Knopf am dringendsten gebraucht wird: scheitert das
        // `disable`, ist das Konto im Gast weiter offen, während die Zeile
        // „entzogen" sagt. Gefährlich wäre nur ein Weg zurück — den gibt es
        // nicht, das match unten bildet 'revoked' ausnahmslos auf `disable` ab.
        //
        // Welcher Auftrag der richtige ist, steht am Sitz selbst: was der
        // Inhaber WILL (`status`) und ob dieser Sitz je in der Nextcloud
        // ankam (`nc_synced_at`). Den letzten Auftrag mitzuschreiben wäre ein
        // Feld, das nach dem ersten Erfolg nie wieder stimmt.
        //
        // Der letzte Zweig hiess `role`, und darin steckte ein Loch: `enable`
        // stand in keinem. Ein gescheitertes Entsperren wurde damit als
        // Rollenänderung wiederholt — die fuhr Gruppen und Quota, gelang, die
        // Zeile sprang auf „Aktiv", und `user:enable` war nie geschickt. Der
        // Mitarbeiter kam weiter nicht hinein, der Knopf war weg.
        //
        // Woran erkennt dieser Zweig, dass zuletzt ein `enable` fällig war?
        // Gar nicht — der Statuswechsel trägt es nicht ('active' ist auch
        // der Normalzustand), und ein Feld dafür wäre wieder eine
        // Behauptung über die Cloud des Kunden, die irgendwann nicht mehr
        // stimmt. Deshalb rät er nicht, sondern schickt an einer OFFENEN
        // Zeile beides: aufsperren UND die Rolle setzen (`restore`). Welcher
        // der beiden Befehle beim letzten Mal scheiterte, spielt dann keine
        // Rolle mehr.
        //
        // `suspend()` bleibt bei `enable`: dort IST bekannt, was fehlt.
        $action = match (true) {
            in_array($seat->status, ['revoked', 'suspended'], true) => 'disable',
            $seat->nc_synced_at === null => 'invite',
            // Fünfte Aktion statt gemerkter letzter Auftrag: woran ein fälliges
            // `enable` zu erkennen wäre, steht nicht am Sitz — ein Feld dafür
            // wäre die nächste Behauptung des Portals über eine Cloud, in die
            // es nicht sehen kann. Siehe den Absatz oben.
            default => 'restore',
        };

        // Dieselbe Wache wie in sendInvite(), und aus demselben Grund: dieser
        // `invite`-Zweig schickt genau dasselbe
        // `occ user:add --generate-password --email`.
        //
        // Der Weg hierher fuehrt an der Wache dort vorbei, und er ist am ersten
        // Tag rein durch Klicken erreichbar: /users steht offen, solange die
        // Instanz noch in Bereitstellung ist (`customer.active` prueft den
        // Kunden, nicht die Instanz). Ohne AKTIVE Instanz sagt
        // mailversandBereit() `true` — also kein Hinweis, keine Wache, die
        // Einladung geht hinaus und scheitert an `no_instance`. Die Zeile wird
        // rot, der Wiederholen-Knopf erscheint. Wird die Bereitstellung fertig,
        // ohne dass der Versand steht, legte ein Klick das Konto an, occ
        // beendete mit 0, und die Plakette sagte „Eingeladen".
        //
        // Zweiter Weg zum selben Ende, ganz ohne Bereitstellung: ein einmal
        // eingerichtetes Versandkonto wird abgeschaltet (Anbieterwechsel,
        // Passwortwechsel), und jede rote Zeile ohne `nc_synced_at` fuehrt beim
        // Wiederholen dorthin.
        //
        // Nur fuer `invite`. `disable` und `restore` verschicken nichts — sie
        // duerfen auch ohne Mailversand jederzeit laufen, und sie sind die
        // Rueckfahrkarte aus einem Fehlschlag.
        if ($action === 'invite' && ! $this->mailversandBereit($customer)) {
            $this->dispatch('notify', message: __('users.invite_no_mail'));

            return;
        }

        // Derselbe Vorbehalt wie in queueSync(), und er fehlte hier: war dieser
        // Sitz nie in der Nextcloud, gibt es dort nichts zu sperren. Ohne die
        // Zeile drehte sich der Knopf an einer entzogenen, nie gespiegelten
        // Zeile im Kreis — anlegen, einladen, Auftrag scheitert an
        // `no_instance`, entziehen (queueSync bricht korrekt ab, `failed`
        // bleibt stehen), und jeder Druck schickte ein `user:disable` gegen
        // einen Benutzer, den es nie gab. Genau der Dauerfehler, den
        // queueSync() schon abgestellt hat, nur über die andere Tür.
        //
        // `invite` ist ausgenommen: das ist der Fall, FUER den dieser Knopf
        // gebaut ist — dort soll das Konto ja erst entstehen.
        if ($action !== 'invite' && $seat->nc_synced_at === null) {
            return;
        }

        $seat->forceFill(['nc_state' => Seat::STATE_PENDING, 'nc_error' => null])->save();
        SyncSeatToNextcloud::dispatch($seat->uuid, $action);

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

    /**
     * Zwei Grenzen, beide aus dem Betrieb heraus gefordert: eine je Kunde
     * gegen den Rundumschlag, eine je Sitz gegen das wiederholte Draufdrücken
     * an derselben Zeile.
     *
     * Gibt die Restzeit in Minuten zurück, oder null wenn frei. Eine stumme
     * Verweigerung wäre schlimmer als die Grenze selbst.
     */
    private function rateLimited(Customer $customer, Seat $seat): ?int
    {
        foreach ([
            ['seat-invite:customer:'.$customer->id, 10],
            ['seat-invite:seat:'.$seat->id, 3],
        ] as [$schluessel, $grenze]) {
            if (RateLimiter::tooManyAttempts($schluessel, $grenze)) {
                return (int) ceil(RateLimiter::availableIn($schluessel) / 60);
            }
        }

        RateLimiter::increment('seat-invite:customer:'.$customer->id, 3600);
        RateLimiter::increment('seat-invite:seat:'.$seat->id, 3600);

        return null;
    }

    /**
     * Ein Sitz, der noch nie erfolgreich in der Nextcloud angekommen ist,
     * bekommt keinen Auftrag — dort gäbe es nichts zu ändern.
     *
     * Massgeblich ist `nc_synced_at`, nicht `nc_state`: `nc_state` sagt nur,
     * wie der LETZTE Versuch ausging. Eine Einladung, die an einem nicht
     * erreichbaren Gast gescheitert ist, hinterlässt einen Sitz mit
     * Anmeldenamen und `failed` — in der Nextcloud aber nichts. Ein
     * `user:disable` darauf muss scheitern und liesse die Zeile dauerhaft rot
     * stehen, mit einem Knopf, der nur weiter scheitert: eine Fehlermeldung
     * für etwas, das nie ein Fehler war.
     *
     * Das trägt nur, weil der Auftrag ein angelegtes Konto SOFORT vermerkt,
     * auch wenn die Gruppe danach scheitert — siehe SyncSeatToNextcloud. Sonst
     * bliebe genau der gefährliche Fall offen: Konto da, Sperre nie geschickt.
     *
     * EINE Ausnahme, und sie war der dritte, fehlende Wächter-Fall: steht
     * `pending`, ist gerade ein Auftrag unterwegs. Dann entsteht dort in
     * diesem Moment etwas, das gesperrt werden muss — und weil der Arbeiter
     * sich die Warteschlange mit der bezahlten Bereitstellung teilt, sind das
     * Minuten, nicht Millisekunden. Der nachgeschickte Auftrag läuft hinter
     * dem ersten (dieselbe serielle Warteschlange, dieselbe Reihenfolge).
     *
     * Der Auftrag selbst prüft die Absicht am Ende noch einmal nach; diese
     * Zeile ist die Rückfahrkarte für den Fall, dass er dabei schon vorbei
     * war.
     */
    private function queueSync(Seat $seat, string $action): void
    {
        if ($seat->nc_synced_at === null && $seat->nc_state !== Seat::STATE_PENDING) {
            return;
        }

        $seat->forceFill(['nc_state' => Seat::STATE_PENDING, 'nc_error' => null])->save();
        SyncSeatToNextcloud::dispatch($seat->uuid, $action);
    }

    /**
     * Die Rolle umstellen — an jedem Sitz ausser dem des Inhabers.
     *
     * `owner` ist hier weder Ziel noch Ausgangspunkt, und beides ist nötig.
     * Die früheren Wachen zählten nur owner-Sitze, und diese Zählung liess
     * sich vorbereiten: `setRole(<fremderSitz>, 'owner')` — das Auswahlfeld
     * bietet die Rolle nicht an, die Livewire-Methode ist trotzdem öffentlich
     * erreichbar — machte zwei owner, und danach griff keine Wache mehr. Der
     * Inhaber liess sich entziehen (`user:disable admin` samt
     * `user:auth-tokens:delete admin` in seiner EIGENEN Cloud) oder auf
     * `member` stellen, womit das Admin-Konto im eigenen Gast seine
     * Adminrechte verlor. Vor dem Anschluss an die Nextcloud war das folgenlos
     * — seit ihm wirkt es.
     *
     * Damit ist die Zählung selbst weg: wer nicht zum Inhaber gemacht werden
     * kann und dem Inhaber nichts wegnehmen kann, braucht kein „ist noch
     * einer übrig?". Eine Sperre, die man sich erst erarbeiten muss, ist
     * keine.
     */
    public function setRole(string $uuid, string $role): void
    {
        // `Seat::ROLES` enthält `owner` — die Menge, die HIER zulässig ist,
        // ist eine andere. Dieselbe Liste wie im Auswahlfeld der Zeile.
        if (! in_array($role, ['admin', 'member', 'readonly'], true)) {
            return;
        }

        $customer = $this->requireCustomer();
        if ($customer === null) {
            return;
        }

        $seat = $customer->seats()->where('uuid', $uuid)->first();

        if ($seat === null) {
            return;
        }

        if ($seat->role === 'owner') {
            $this->dispatch('notify', message: __('users.owner_role_locked'));

            return;
        }

        $seat->update(['role' => $role]);

        // Erst nach dem Schreiben: ein Auftrag, den die Warteschlange
        // schneller aufnimmt als die Zeile geschrieben ist, liest die alte
        // Rolle.
        $this->queueSync($seat, 'role');
    }

    /**
     * Pause a seat without destroying it.
     *
     * The action an owner actually needs when someone leaves: access stops now,
     * and the record of who held it survives — which is the half a deletion
     * throws away, on a product sold on being able to show who had access to
     * what.
     */
    public function suspend(string $uuid): void
    {
        $customer = $this->requireCustomer();

        if ($customer === null) {
            return;
        }

        $seat = $customer->seats()->where('uuid', $uuid)->first();

        if ($seat === null || $seat->role === 'owner') {
            // Der Inhaber sperrt sich nicht selbst aus seiner eigenen Cloud aus.
            $this->dispatch('notify', message: __('users.owner_locked'));

            return;
        }

        // Entzogen bleibt entzogen. Der Umschalter unten kennt nur zwei
        // Zustände: an einem entzogenen Sitz machte er beim ersten Klick
        // `suspended` und beim zweiten `active` — samt `user:enable`. Der
        // Mensch, dessen Zugang der Inhaber beendet hat, könnte sich wieder
        // anmelden, und er zählte wieder gegen die Platzgrenze, ohne dass sie
        // hier jemals geprüft wird.
        if ($seat->status === 'revoked') {
            $this->dispatch('notify', message: __('users.revoked_closed'));

            return;
        }

        $seat->update(['status' => $seat->status === 'suspended' ? 'active' : 'suspended']);

        // Der Klick allein sperrt niemanden aus: bis der Auftrag durch ist,
        // arbeitet der Gesperrte weiter. Deshalb fährt die Sperre in den Gast
        // und nicht nur in die Statusspalte.
        $this->queueSync($seat, $seat->status === 'suspended' ? 'disable' : 'enable');

        $this->dispatch('notify', message: __(
            $seat->status === 'suspended' ? 'users.suspended' : 'users.reactivated',
        ));
    }

    public function revoke(string $uuid): void
    {
        $customer = $this->requireCustomer();
        if ($customer === null) {
            return;
        }

        $seat = $customer->seats()->where('uuid', $uuid)->first();

        if ($seat === null) {
            return;
        }

        // Aus demselben Grund wie suspend() und setRole(): der Inhaber sperrt
        // sich nicht selbst aus seiner eigenen Cloud aus. Die frühere Wache
        // zählte owner-Sitze — und diese Zählung liess sich über setRole()
        // vorbereiten. Siehe den Kopf von setRole(). Eigene Meldung statt der
        // wortgleichen von suspend(): „entfernen" ist nicht „sperren".
        if ($seat->role === 'owner') {
            $this->dispatch('notify', message: __('users.owner_revoke_locked'));

            return;
        }

        // Nicht löschen. Der Zugang ist zu, die Arbeit bleibt dort, wo sein
        // Team sie braucht. Wer wirklich löschen will, tut das in der
        // Nextcloud, wo Nextcloud danach fragt, was mit den Dateien geschehen
        // soll.
        $seat->update(['status' => 'revoked']);

        $this->queueSync($seat, 'disable');
        $this->dispatch('notify', message: __('users.revoked'));
    }

    /**
     * The revoke button opens ConfirmRevokeSeat instead of calling revoke()
     * directly (R23); its confirm button dispatches back here.
     */
    #[On('seat-revoke-confirmed')]
    public function onRevokeConfirmed(string $uuid): void
    {
        $this->revoke($uuid);
    }

    /**
     * Ob eine Einladung dieses Kunden überhaupt ankommen könnte.
     *
     * Ein reiner Blick in die Datenbank — Servereinstellung und
     * Versandkonto — kein Tunnel, keine Warteschlange. Deshalb darf er auf der
     * Seite stehen und nicht erst im Auftrag.
     *
     * OHNE Instanz gibt es hier nichts zu entscheiden: dann scheitert der
     * Auftrag ohnehin an `no_instance`, und die Zeile sagt das im Klartext.
     * Diese Wache gilt dem anderen Fall — die Cloud LÄUFT, nur der Versand
     * fehlt. Genau dort meldet occ Erfolg, und niemand bekommt eine Mail.
     *
     * Dieselbe Auswahl wie im Auftrag (SyncSeatToNextcloud): die Instanz, in
     * der die Einladung tatsächlich landen würde.
     *
     * Der Bereitstellungsschritt bleibt davon unberührt. Dort gilt weiter
     * „nicht eingerichtet ist etwas anderes als kaputt", und eine bezahlte
     * Bestellung läuft durch — eine Cloud ohne Mailversand funktioniert
     * vollständig. Was sie nicht kann, ist eine Einladung verschicken, und nur
     * darum geht es hier.
     */
    private function mailversandBereit(Customer $customer): bool
    {
        $instance = $customer->instances()
            ->whereIn('status', ['active', 'cancellation_scheduled'])
            ->latest('id')->first();

        return $instance === null || GuestMailConfig::for($instance)->available();
    }

    private function usedSeats(Customer $customer): int
    {
        return $customer->seats()->where('status', '!=', 'revoked')->count();
    }

    private function seatLimit(Customer $customer): int
    {
        // The entitlement follows the active (or cancelling) package, not a newer
        // failed/deprovisioned record.
        $instance = $customer->instances()->whereIn('status', ['active', 'cancellation_scheduled'])->latest('id')->first()
            ?? $customer->instances()->latest('id')->first();

        // From the contract: how many people a customer may invite is part of
        // what they bought. Cutting a plan's seats in the catalogue must not
        // lock users out of an existing customer's cloud.
        return (int) ($instance?->subscription?->seats ?? 5);
    }

    public function render()
    {
        $customer = $this->customer();
        $seats = $customer ? $customer->seats()->orderByRaw("role = 'owner' desc")->orderBy('email')->get() : collect();

        // The actions column is ALWAYS drawn. It used to be hidden when the
        // only seat was the owner, on the reasoning that there was nothing to
        // act on — but every seat can be renamed, and a column that disappears
        // does not read as "nothing applies here", it reads as "this product
        // cannot do that". Which is exactly how it was reported.
        return view('livewire.users', [
            'seats' => $seats,
            'used' => $customer ? $this->usedSeats($customer) : 0,
            'limit' => $customer ? $this->seatLimit($customer) : 0,
            'roles' => Seat::ROLES,
            // Der Hinweis steht dort, wo der Inhaber ihn liest, bevor er
            // drückt. Der Knopf bleibt trotzdem stehen — siehe das Blade.
            'versandBereit' => $customer === null || $this->mailversandBereit($customer),
        ]);
    }
}
