<?php

namespace App\Livewire\Admin;

use App\Jobs\RunCatalogueSync;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Services\Billing\CatalogueSyncStatus;
use App\Services\Deployment\UpdateChannel;
use App\Services\Env\EnvFileEditor;
use App\Services\Env\InvalidEnvContentException;
use App\Services\Mail\InboundMailbox;
use App\Services\Mail\InboundMailStatus;
use App\Services\Secrets\SecretVault;
use App\Services\Ssh\Keypair;
use App\Support\OperatingMode;
use App\Support\ProvisioningSettings;
use App\Support\Settings;
use App\Support\StripePublishableKey;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
use Throwable;

/**
 * Connected services, one page, grouped by what they are for — Zahlungen,
 * DNS, Monitoring, VPN/WireGuard, SSH-Identität — not by which of the two
 * mechanisms below happens to hold a given value.
 *
 * Replaces Admin\Secrets and Admin\Infrastructure, which is what they were:
 * one console area split across two pages by storage mechanism, reported as
 * exactly the wrong axis to split on. An operator setting up an integration
 * should not have to know whether a given field happens to be encrypted.
 *
 * Both mechanisms are unchanged underneath — this is a presentation change,
 * not a data migration:
 *
 * - SecretVault: a curated, encrypted key/value store for credentials that
 *   actually stop the business when they leak or expire. Gated by
 *   `secrets.manage` (Owner only) AND a recently confirmed password — the
 *   realistic threat is an unlocked machine, not a stranger. A stored value
 *   is write-only: the page only ever shows an outline of it.
 * - App\Support\Settings: plain deployment configuration. Gated by
 *   `hosts.manage` (Owner + Admin) alone — none of it opens anything by
 *   itself. Shows its value in full, because there is nothing to protect.
 *
 * mount() is reachable with EITHER capability, because the two halves are no
 * longer two pages an operator's role happens to see one, both, or neither
 * of — they are sections of the SAME page now. Each section, and each save
 * action, still checks its OWN capability (guardSecrets()/guardInfra())
 * server-side; an Admin who can reach this page for the DNS zone still gets
 * 403 the moment they try to touch a vault entry.
 *
 * The .env editor (Part B) shares the secrets lock: it needs `secrets.manage`
 * and the same confirmed password, because it is the one place on this page
 * that can reach every credential the vault deliberately keeps write-only —
 * see EnvFileEditor for the safeguards that make that survivable.
 *
 * TABBED, like Admin\Settings and for the same reason: this was one narrow
 * column of six cards, and an operator connecting DNS scrolled past Stripe to
 * get to it. The split is by what a section is FOR — outside services we buy
 * from, our own machines, and the raw file underneath — which is the same axis
 * the page was rebuilt around when it stopped being split by storage mechanism.
 *
 * The open tab is in the query string, so a reload or a bookmark lands where
 * the operator was. The .env tab in particular is worth linking to directly,
 * and the unlock survives the switch because the confirmation is a session
 * fact, not a property of a tab.
 */
#[Layout('layouts.admin')]
class Integrations extends Component
{
    /**
     * The tabs, in order. The list IS the schema: it validates the query
     * string, builds the bar and decides what renders.
     *
     * `services` is what we buy from outside (Stripe, Hetzner DNS, the
     * monitoring bridge), `platform` is how we reach our own machines (the
     * WireGuard hub, the SSH identity), `env` is the file underneath all of it.
     */
    public const TABS = ['services', 'platform', 'env'];

    /**
     * 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 = 'services';

    use ConfirmsPassword {
        forgetPasswordConfirmation as private lockAgain;
    }

    // Vault entries — same shape ConfirmSaveSecret/ConfirmForgetSecret expect
    // and dispatch back into (secret-save-confirmed / secret-forget-confirmed).
    public array $entered = [];

    public ?array $check = null;

    /**
     * Zu welchem Eintrag `$check` gehört.
     *
     * Ohne das erschien das Ergebnis eines DNS-Tests unter der Stripe-Karte —
     * beide Prüfungen teilen sich eine Eigenschaft, und die Anzeige braucht
     * den Bezug.
     */
    public ?string $checkedKey = null;

    // Plain settings — App\Support\ProvisioningSettings' full list.
    public string $dnsZone = '';

    public string $wgEndpoint = '';

    public string $wgHubPubkey = '';

    public string $traefikDynamicPath = '';

    public string $sshPublicKey = '';

    /**
     * Der veröffentlichbare Stripe-Schlüssel des aktiven Modus.
     *
     * Keine Tresor-Eigenschaft: er ist öffentlich und wird im Klartext gezeigt.
     */
    public string $stripePublishableKey = '';

    public string $monitoringUrl = '';

    /**
     * The support mailbox this installation reads customer mail from.
     *
     * Host, user and folder only. The PASSWORD is a vault entry — it opens
     * something, and this page's rule is that anything which opens something is
     * write-only and behind a confirmed password.
     */
    public string $inboundHost = '';

    public string $inboundPort = '993';

    public string $inboundUsername = '';

    public string $inboundFolder = 'INBOX';

    // .env editor.
    public string $envContent = '';

    /**
     * Loaded lazily, on the first render after unlocking — never in mount(),
     * so a merely-reachable page never pulls the whole credential file into
     * component state for an operator who has not confirmed anything yet.
     */
    public bool $envLoaded = false;

    /**
     * True from the moment saveEnv() successfully asks the agent to restart
     * the workers, until render() observes the request is no longer pending.
     *
     * Drives the small inline "wird neu gestartet" indicator on this page —
     * not the full-screen maintenance overlay in layouts/admin.blade.php,
     * which is for an actual deployment. A worker restart is four containers
     * coming back in a few seconds, this page (and the console around it)
     * never goes offline for it, and `app` — the container answering this
     * very poll — is never among them. wire:poll on this page's own
     * component is therefore enough to watch it finish; nothing here needs
     * the client-side watcher an in-flight deployment does.
     */
    public bool $envRestartWatching = false;

    protected function confirmationGuard(): string
    {
        return 'operator';
    }

    public function mount(): void
    {
        abort_unless(Gate::any(['hosts.manage', 'secrets.manage']), 403);

        // A tab name out of the URL is a string a stranger typed.
        if (! in_array($this->tab, self::TABS, true)) {
            $this->tab = self::TABS[0];
        }

        // The raw file is Owner-only. Landing on the first tab says that
        // without a blank page; the editor itself checks again anyway.
        if ($this->tab === 'env' && ! Gate::allows('secrets.manage')) {
            $this->tab = self::TABS[0];
        }

        // Öffentlich, also ohne die hosts.manage-Bedingung darunter: er wird
        // im Klartext angezeigt und schützt nichts.
        $this->stripePublishableKey = StripePublishableKey::current();

        if (Gate::allows('hosts.manage')) {
            $this->dnsZone = ProvisioningSettings::dnsZone();
            $this->wgEndpoint = ProvisioningSettings::wgEndpoint();
            $this->wgHubPubkey = ProvisioningSettings::wgHubPublicKey();
            $this->traefikDynamicPath = ProvisioningSettings::traefikDynamicPath();
            $this->sshPublicKey = ProvisioningSettings::sshPublicKey();
            $this->monitoringUrl = ProvisioningSettings::monitoringUrl();

            $mailbox = ProvisioningSettings::inboundMailbox();
            $this->inboundHost = $mailbox['host'];
            $this->inboundPort = (string) $mailbox['port'];
            $this->inboundUsername = $mailbox['username'];
            $this->inboundFolder = $mailbox['folder'];
        }
    }

    // ---- The operating mode — governs every vault entry below it. ----

    /**
     * Den Betriebsmodus umlegen.
     *
     * Hinter derselben Sperre wie der Tresor — `secrets.manage` plus bestätigtes
     * Passwort — weil der Wechsel auf Live der Moment ist, ab dem echtes Geld
     * fließt. Das ist keine Umschaltfläche zum Danebenklicken.
     */
    #[On('mode-switch-confirmed')]
    public function switchMode(string $mode): void
    {
        $this->guardSecrets();

        // Ein String aus dem Browser. tryFrom, nicht from.
        $target = OperatingMode::tryFrom($mode);

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

        OperatingMode::set($target);

        // Der Platz unter dem Feld hat gewechselt. Ohne diese Zeile zeigt es
        // weiter den Schlüssel des anderen Kontos — und speichert ihn beim
        // nächsten Klick in den falschen.
        $this->stripePublishableKey = StripePublishableKey::current();

        $this->dispatch('notify', message: __('integrations.mode_switched', [
            'mode' => __('readiness.mode.'.$target->value),
        ]));
    }

    // ---- Plain settings — App\Support\Settings, hosts.manage, no password. ----

    public function saveInfra(): void
    {
        $this->guardInfra();

        $data = $this->validate([
            'dnsZone' => ['nullable', 'string', 'max:255'],
            'wgEndpoint' => ['nullable', 'string', 'max:255'],
            'wgHubPubkey' => ['nullable', 'string', 'max:255'],
            'traefikDynamicPath' => ['nullable', 'string', 'max:255'],
            'sshPublicKey' => ['nullable', 'string', 'max:1000'],
            'monitoringUrl' => ['nullable', 'url', 'max:255'],
            'inboundHost' => ['nullable', 'string', 'max:255'],
            'inboundPort' => ['required', 'integer', 'min:1', 'max:65535'],
            'inboundUsername' => ['nullable', 'string', 'max:255'],
            'inboundFolder' => ['required', 'string', 'max:255'],
        ]);

        Settings::set('provisioning.dns_zone', trim((string) $data['dnsZone']));
        Settings::set('provisioning.wg_endpoint', trim((string) $data['wgEndpoint']));
        Settings::set('provisioning.wg_hub_pubkey', trim((string) $data['wgHubPubkey']));
        Settings::set('provisioning.traefik_dynamic_path', trim((string) $data['traefikDynamicPath']));
        Settings::set('provisioning.ssh_public_key', trim((string) $data['sshPublicKey']));
        Settings::set('monitoring.api_url', trim((string) $data['monitoringUrl']));
        Settings::set('inbound_mail.host', trim((string) $data['inboundHost']));
        Settings::set('inbound_mail.port', (int) $data['inboundPort']);
        Settings::set('inbound_mail.username', trim((string) $data['inboundUsername']));
        Settings::set('inbound_mail.folder', trim((string) $data['inboundFolder']));

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

    // ---- Vault entries — SecretVault, secrets.manage + confirmed password. ----

    public function save(string $key): void
    {
        $this->guardSecrets();

        $field = self::field($key);
        $value = trim((string) ($this->entered[$field] ?? ''));

        if ($value === '') {
            $this->addError('entered.'.$field, __('secrets.empty'));

            return;
        }

        try {
            app(SecretVault::class)->put($key, $value, Auth::guard('operator')->user());
        } catch (Throwable $e) {
            $this->addError('entered.'.$field, $e->getMessage());

            return;
        }

        $this->entered[$field] = '';
        $this->check = null;
        $this->checkedKey = null;
        $this->dispatch('notify', message: __('secrets.saved'));
    }

    /** ConfirmSaveSecret dispatches this back (R23) — see that class. */
    #[On('secret-save-confirmed')]
    public function onSaveConfirmed(string $key): void
    {
        $this->save($key);
    }

    /**
     * Der dritte Stripe-Wert, und der einzige, der kein Geheimnis ist.
     *
     * Eigene Aktion statt eines Tresoreintrags: er ist öffentlich, wird im
     * Klartext angezeigt und nicht maskiert — siehe StripePublishableKey.
     * Hinter derselben Sperre wie der Rest der Zahlungskarte, weil er auf ihr
     * steht und nicht, weil er schützenswert wäre.
     */
    public function savePublishableKey(): void
    {
        $this->guardSecrets();

        $this->validate(['stripePublishableKey' => ['nullable', 'string', 'max:255']]);

        StripePublishableKey::set($this->stripePublishableKey);

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

    public function forget(string $key): void
    {
        $this->guardSecrets();

        app(SecretVault::class)->forget($key);
        $this->check = null;
        $this->checkedKey = null;
        $this->dispatch('notify', message: __('secrets.removed'));
    }

    /** ConfirmForgetSecret dispatches this back (R23) — see that class. */
    #[On('secret-forget-confirmed')]
    public function onForgetConfirmed(string $key): void
    {
        $this->forget($key);
    }

    /**
     * Mint a fresh SSH identity here, rather than in a shell somewhere.
     *
     * `ssh.private_key` was the one vault entry with no way to fill it from the
     * console: the readiness page flagged it, and the only cure was
     * `ssh-keygen` in a terminal, a copy-paste of a private key through a
     * clipboard, and remembering to shred the temp file. A page that reports a
     * gap it cannot close has moved the work, not done it.
     *
     * Both halves are written in one go, deliberately. They are two records of
     * ONE fact, and this is the only moment both are known — the private half
     * is write-only from here on, so a public half saved separately (or not at
     * all) could never be checked against it again. Order matters too: the
     * private half goes in first, because a published public key whose private
     * half was never stored would have EstablishSshTrust install a key nothing
     * can log in with, and scrub the root password on the way past.
     *
     * The private half is never returned, rendered or put in the snapshot; the
     * public half is, because it has to be copied onto a machine by hand.
     */
    public function generateSshKey(): void
    {
        $this->guardSecrets();

        $pair = Keypair::generate();

        app(SecretVault::class)->put('ssh.private_key', $pair->privateKey, Auth::guard('operator')->user());
        Settings::set('provisioning.ssh_public_key', $pair->publicKey);

        // The form field on this same page — otherwise the next saveInfra()
        // would write the OLD public key back over the one just published.
        $this->sshPublicKey = $pair->publicKey;
        $this->entered[self::field('ssh.private_key')] = '';
        $this->check = null;

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

    /** ConfirmGenerateSshKey dispatches this back (R23) — see that class. */
    #[On('ssh-key-generate-confirmed')]
    public function onGenerateSshKeyConfirmed(): void
    {
        $this->generateSshKey();
    }

    /**
     * Push the plan catalogue into Stripe from here, rather than from a shell.
     *
     * Deliberately NOT synchronous. The command talks to Stripe and creates
     * objects; a first run on a full catalogue is seconds to minutes, and a
     * button that held the request open for it would answer with a gateway
     * timeout while the run carried on behind it. So it goes on the queue and
     * the result is shown on this page when it lands — see RunCatalogueSync.
     *
     * The dry run is the FIRST button and asks nothing: it changes nothing and
     * prints what would be created. The live run is the second and confirms
     * (R23). Anything else would be a button that spends money on one click.
     *
     * `plans.manage` rather than the vault lock: this is the plan catalogue
     * being mirrored, the same decision as publishing a version, and it reveals
     * no credential. Owner and Admin, which is the same pair that can reach
     * this page for the DNS zone.
     */
    public function syncCatalogue(bool $dryRun = true): void
    {
        $this->authorize('plans.manage');

        RunCatalogueSync::dispatch(
            dryRun: $dryRun,
            by: Auth::guard('operator')->user()?->email ?? 'console',
        );

        $this->dispatch('notify', message: __($dryRun
            ? 'integrations.catalogue_dry_run_queued'
            : 'integrations.catalogue_sync_queued'));
    }

    /** ConfirmSyncCatalogue dispatches this back (R23) — see that class. */
    #[On('catalogue-sync-confirmed')]
    public function onCatalogueSyncConfirmed(): void
    {
        $this->syncCatalogue(false);
    }

    public function test(string $key): void
    {
        $this->guardSecrets();

        $checker = SecretVault::REGISTRY[$key]['check'] ?? null;
        abort_if($checker === null, 404);

        $candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null;
        $this->check = app($checker)->run($candidate);
        $this->checkedKey = $key;

        // Als Modal, nicht nur an der Karte. Zweimal gemeldet: erst „der Knopf
        // tut nichts" (die Antwort stand am Seitenende), dann
        // „runterscrollen tut niemand, wenn man es nicht weiss". Eine Antwort,
        // die man suchen muss, ist für den Betreiber keine.
        $this->dispatch('openModal', component: 'admin.check-result', arguments: [
            'entryKey' => $key,
            'result' => $this->check,
        ]);
    }

    /** The dotless form key for a registry key (a dot means nesting to Livewire). */
    public static function field(string $key): string
    {
        return str_replace('.', '_', $key);
    }

    // ---- The .env editor — Part B. secrets.manage + confirmed password. ----

    /**
     * Validate, back up, write — then do the two things saving this file used
     * to leave as homework: clear the config cache (this container can do
     * that itself, no host privileges needed) and ask the host-side agent to
     * restart queue, queue-provisioning, scheduler and reverb (UpdateChannel
     * — it cannot be done from in here; see that class for why).
     *
     * EnvFileEditor does the write itself and the actual refusing; this only
     * translates its one exception into a form error instead of a 500.
     */
    public function saveEnv(): void
    {
        $this->guardSecrets();

        try {
            $backup = app(EnvFileEditor::class)->write($this->envContent);
        } catch (InvalidEnvContentException $e) {
            $this->addError('envContent', $e->invalidLine !== null
                ? __('integrations.env_invalid', ['line' => $e->invalidLine])
                : __('integrations.env_empty'));

            return;
        }

        // Unconditional, and deliberately not gated on
        // app()->configurationIsCached() first: Illuminate's own ConfigClear
        // command is already exactly that guard — it deletes the cache file
        // if one exists and does nothing if there is none, so adding a check
        // here would only save a single is_file() call in the common case at
        // the cost of a second source of truth to keep in sync with it. This
        // dev machine has no cache right now (checked: no
        // bootstrap/cache/config.php), but deploy/update.sh and
        // deploy/install-agent.sh both run `config:cache` as standard
        // practice, so a real installation usually does — and while one is
        // active, Laravel skips loading .env on every request AT ALL
        // (LoadEnvironmentVariables bootstraps straight from the cached
        // values instead). Left uncleared there, the value just written would
        // stay invisible — not merely to the four workers below, to this
        // application too — until the cache was rebuilt by hand.
        Artisan::call('config:clear');

        $channel = app(UpdateChannel::class);
        $agentAlive = $channel->state()['agent_seen'];
        $by = Auth::guard('operator')->user()?->email ?? 'console';

        // '' means there was no previous file to protect — EnvFileEditor's
        // own docblock explains why that is not an error.
        $backupName = $backup !== '' ? basename($backup) : __('integrations.env_no_previous_file');

        if (! $agentAlive) {
            // Honest, not silent: the values ARE saved, but nothing is going
            // to pick them up on its own. Pretending otherwise here is worse
            // than the manual-restart card this replaces — see
            // admin_settings.update_no_agent for the same shape on updates.
            $this->envRestartWatching = false;
            $this->dispatch('notify', message: __('integrations.env_saved_no_agent', ['backup' => $backupName]));

            return;
        }

        if (! $channel->requestRestart($by)) {
            // The single request slot is already taken by something else
            // (a check, an update, or another restart just asked for) — rare,
            // but real, and not something to paper over with a message that
            // says "restarting" when nothing was actually queued.
            $this->envRestartWatching = false;
            $this->dispatch('notify', message: __('integrations.env_saved_restart_busy', ['backup' => $backupName]));

            return;
        }

        $this->envRestartWatching = true;
        $this->dispatch('notify', message: __('integrations.env_saved_restarting', ['backup' => $backupName]));
    }

    /** ConfirmSaveEnv dispatches this back (R23) — see that class. */
    #[On('env-save-confirmed')]
    public function onEnvSaveConfirmed(): void
    {
        $this->saveEnv();
    }

    /**
     * Overrides ConfirmsPassword's own method (aliased above to lockAgain) to
     * also drop the Stripe check result — the render()-side check just below
     * handles $envContent/$envLoaded, and handles it for BOTH ways a session
     * can end up locked: this explicit click, and the confirmation window
     * simply expiring with nobody clicking anything. A clear here alone would
     * only ever cover the first.
     */
    public function forgetPasswordConfirmation(): void
    {
        $this->lockAgain();

        $this->check = null;
        $this->checkedKey = null;
    }

    private function guardInfra(): void
    {
        $this->authorize('hosts.manage');
    }

    /** Capability AND a recently confirmed password, on every vault/.env action. */
    private function guardSecrets(): void
    {
        $this->authorize('secrets.manage');
        abort_unless($this->passwordRecentlyConfirmed(), 403);
    }

    /**
     * Reach the support mailbox now and say what came back.
     *
     * The one thing an operator wants after typing a host, a user and a
     * password: did any of it work. Waiting for the next scheduled run to find
     * out is not an answer, and "der Posteingang ist leer" is not one either.
     *
     * Saved first, deliberately: testing what is on screen while the server
     * still holds the previous values would report on a mailbox nobody
     * configured. The unsaved form is the question being asked.
     */
    public function testInbound(): void
    {
        $this->guardInfra();
        $this->saveInfra();

        $check = app(InboundMailbox::class)->check();
        app(InboundMailStatus::class)->record($check['ok'], $check['message'], $check['unseen']);

        $this->dispatch('notify', message: $check['ok']
            ? ($check['unseen'] === null
                ? __('integrations.inbound_ok')
                : trans_choice('integrations.inbound_ok_waiting', $check['unseen'], ['count' => $check['unseen']]))
            : __('integrations.inbound_failed_'.$check['message']));
    }

    public function render()
    {
        $vault = app(SecretVault::class);
        $canSecrets = Gate::allows('secrets.manage');
        $canInfra = Gate::allows('hosts.manage');
        $unlocked = $this->passwordRecentlyConfirmed();

        // Loaded here rather than in mount(): a page that is merely reachable
        // must not already hold the whole credential file in component state
        // for an operator who has not confirmed a password this session.
        if ($canSecrets && $unlocked && ! $this->envLoaded) {
            $this->envContent = app(EnvFileEditor::class)->read();
            $this->envLoaded = true;
        }

        // The mirror case, and the one forgetPasswordConfirmation() alone
        // does not cover: the confirmation window can also expire on its
        // own, with nobody clicking "Wieder sperren" — $unlocked simply goes
        // false on whatever the next render happens to be. Without this, the
        // textarea disappears from the page but $envContent — the ENTIRE
        // credential file — stays sitting in the component snapshot, reachable
        // to anyone with the browser this was left open on (Codex review,
        // P1). Checked on every render, not only the explicit lock action.
        if (! $unlocked && $this->envLoaded) {
            $this->envContent = '';
            $this->envLoaded = false;
        }

        $restart = app(UpdateChannel::class)->state();

        // The transition the small inline indicator exists to show: a restart
        // THIS component asked for is no longer pending. Read off
        // 'requested_at' rather than 'restarting': the latter is gated on
        // agent_seen (see UpdateChannel::state()), so if the agent went
        // quiet in the few seconds this was being waited on, 'restarting'
        // would already read false EVEN THOUGH THE REQUEST FILE IS STILL
        // SITTING THERE, unread — and this would announce "restarted"
        // for a restart that never ran. 'requested_at' answers the only
        // question that matters here — is a request still on disk — with no
        // opinion about the agent either way. Fires once — the instant it
        // does, envRestartWatching drops so the next poll (or the one after,
        // on a slower host with no on-demand wake) does not dispatch it
        // again. wire:poll on the .env section is what keeps calling
        // render() while this is being waited on; see $envRestartWatching's
        // own docblock for why that is enough and the full-page deployment
        // overlay is not needed here.
        if ($this->envRestartWatching && $restart['requested_at'] === null) {
            $this->envRestartWatching = false;
            $this->dispatch('notify', message: __('integrations.env_restart_done'));
        }

        return view('livewire.admin.integrations', [
            // Only the tabs this operator can open. A tab that renders nothing
            // is worse than one that is not there.
            'tabs' => array_values(array_filter(
                self::TABS,
                fn (string $tab) => $tab !== 'env' || $canSecrets,
            )),
            // When the mailbox was last reached, and what came back. Null until
            // somebody — or the scheduler — has asked once.
            'inboundStatus' => app(InboundMailStatus::class)->last(),
            // What the last catalogue run printed. Null until somebody has
            // pressed one of the two buttons once.
            'catalogueSync' => app(CatalogueSyncStatus::class)->last(),
            'canCatalogue' => Gate::allows('plans.manage'),
            'canSecrets' => $canSecrets,
            'canInfra' => $canInfra,
            'unlocked' => $unlocked,
            'usable' => $vault->isUsable(),
            'mode' => OperatingMode::current(),
            'restart' => $restart,
            'entries' => collect(SecretVault::REGISTRY)
                // Was diese Anwendung sich selbst ausstellt, gehört nicht auf
                // eine Seite zum Einfügen fremder Zugangsdaten — siehe
                // `internal` in der Registry. Vor dem map(), nicht danach:
                // solche Einträge nennen gar keinen .env-Namen, den die Zeile
                // darunter zeigen könnte.
                ->reject(fn (array $meta) => $meta['internal'] ?? false)
                ->map(fn (array $meta, string $key) => [
                    'key' => $key,
                    'field' => self::field($key),
                    'label' => __($meta['label']),
                    'envKey' => $meta['env_key'],
                    'testable' => isset($meta['check']),
                    'source' => $vault->source($key),
                    'outline' => $unlocked ? $vault->outline($key) : null,
                    'updated_at' => $unlocked ? $vault->updatedAt($key) : null,
                    // The SSH identity is a multi-line PEM key: a single-line
                    // password field would mangle it on paste.
                    'multiline' => $key === 'ssh.private_key',
                    // …and the only entry this console can mint for itself.
                    // Everything else here is issued by somebody else (Stripe,
                    // Hetzner, the mail host) and can only be pasted in.
                    'generatable' => $key === 'ssh.private_key',
                    // Das Ergebnis GEHÖRT zu diesem Eintrag, sonst null. So
                    // steht die Antwort unter dem Knopf, der sie ausgelöst hat.
                    'check' => $this->checkedKey === $key ? $this->check : null,
                ])
                ->keyBy('key'),
        ]);
    }
}
