<?php

namespace App\Livewire\Admin;

use App\Models\Host;
use App\Models\Operator;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use App\Provisioning\Jobs\SyncVpnPeers;
use App\Services\Wireguard\ConfigHandoff;
use App\Services\Wireguard\ConfigVault;
use App\Services\Wireguard\Keypair;
use App\Services\Wireguard\QrCode;
use App\Services\Wireguard\WireguardHub;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;

/**
 * VPN access management. The console container has no wg0, so live state is
 * read from the database and refreshed by SyncVpnPeers on the provisioning
 * queue — the poll below only nudges that job, it never talks to WireGuard.
 */
#[Layout('layouts.admin')]
class Vpn extends Component
{
    public string $name = '';

    /** Optional: a peer that generated its own key never hands us the private half. */
    public string $publicKey = '';

    /** Opaque handle for the freshly created config; see ConfigHandoff. */
    public ?string $configToken = null;

    public ?string $newConfigName = null;

    /** Owner of the new access. Defaults to the operator creating it. */
    public ?int $ownerId = null;

    /** Keep the config so its owner can fetch it again behind their password. */
    public bool $storeConfig = false;

    public bool $showQr = false;

    public function mount(): void
    {
        $this->authorize('viewAny', VpnPeer::class);
        $this->ownerId = Auth::guard('operator')->id();
    }

    /**
     * mount() runs once; every later request — including the five-second poll —
     * only hydrates. Without this, revoking vpn.manage would not take effect
     * until the operator happened to reload the page, and the open tab would
     * keep serving fresh peer state.
     */
    public function hydrate(): void
    {
        $this->authorize('viewAny', VpnPeer::class);
    }

    public function create(): void
    {
        $this->authorize('create', VpnPeer::class);

        $this->validate([
            'name' => 'required|string|max:255',
            'publicKey' => 'nullable|string|max:64',
            // An access belongs to a person, and only to an operator: a customer
            // account must never own a way into the management network — and
            // now cannot even present as one, since operators live on their
            // own table.
            'ownerId' => ['required', Rule::exists('operators', 'id')],
        ]);

        // Storing needs a key. Without one we would either write the credential
        // in the clear or pretend we stored it — both worse than saying no.
        if ($this->storeConfig && ! ConfigVault::available()) {
            $this->addError('storeConfig', __('vpn.vault_unavailable'));

            return;
        }

        $ownKey = trim($this->publicKey) !== '';

        if ($this->storeConfig && $ownKey) {
            $this->addError('storeConfig', __('vpn.cannot_store_foreign_key'));

            return;
        }

        if ($ownKey && ! Keypair::isValidKey(trim($this->publicKey))) {
            $this->addError('publicKey', __('vpn.invalid_key'));

            return;
        }

        $keypair = $ownKey ? null : Keypair::generate();
        $publicKey = $ownKey ? trim($this->publicKey) : $keypair->publicKey;

        $hub = app(WireguardHub::class);

        // Same lock the host pipeline holds while it reserves an address
        // (ConfigureWireguard). Addresses come from one subnet but live in two
        // tables, so neither unique index can catch the other's insert — the
        // shared lock is what keeps a host and an access off the same tunnel IP.
        try {
            // Filled inside the transaction; only the handoff needs it afterwards.
            $plainConfig = null;

            $peer = Cache::lock('wireguard:allocate', 30)->block(10, function () use ($hub, $publicKey, $keypair, &$plainConfig) {
                return DB::transaction(function () use ($hub, $publicKey, $keypair, &$plainConfig) {
                    // The owner row is locked for the whole insert, and revokeStaff()
                    // locks the same row: without that, a revocation could commit
                    // between the check and the insert, find no peer to remove, and
                    // leave the revoked colleague with a brand-new tunnel.
                    $owner = Operator::query()->whereKey($this->ownerId)->lockForUpdate()->first();
                    if ($owner === null || ! $owner->isOperator()) {
                        return 'owner_must_be_operator';
                    }

                    // Inside the lock: checking before it would let two concurrent
                    // requests both pass and the loser hit the unique index as a
                    // 500. withTrashed, because a revoked peer keeps its key until
                    // the hub confirms removal.
                    $existing = VpnPeer::withTrashed()->where('public_key', $publicKey)->first();
                    if ($existing !== null) {
                        return $existing->trashed() ? 'pending_removal' : 'duplicate_key';
                    }

                    // A host's key may not have been adopted into vpn_peers yet.
                    // Re-using it would make `wg set` rewrite that host's allowed-ip
                    // to the address allocated here — cutting the management tunnel
                    // to a live machine.
                    if (Host::query()->where('wg_pubkey', $publicKey)->exists()) {
                        return 'host_key';
                    }

                    $ip = $hub->allocateIp();

                    // Built and encrypted here so the stored config is part of the
                    // same insert. Doing it afterwards could leave a live access
                    // whose config was never stored — unrecoverable, and the
                    // operator would create a second one not knowing why.
                    $secret = null;
                    if ($keypair !== null) {
                        $plainConfig = $this->clientConfig($keypair, $ip);
                        if ($this->storeConfig) {
                            $secret = ConfigVault::encrypt($plainConfig);
                        }
                    }

                    return VpnPeer::create([
                        'name' => trim($this->name),
                        'kind' => VpnPeer::KIND_STAFF,
                        'user_id' => $this->ownerId,
                        'public_key' => $publicKey,
                        'allowed_ip' => $ip,
                        'config_secret' => $secret,
                        'enabled' => true,
                        'present' => false,
                        'created_by' => Auth::guard('operator')->id(),
                    ]);
                });
            });
        } catch (QueryException) {
            // Backstop: the unique index caught a writer that did not take this
            // lock. Report it like any other duplicate instead of a 500.
            $this->addError('publicKey', __('vpn.duplicate_key'));

            return;
        }

        if (is_string($peer)) {
            $field = $peer === 'owner_must_be_operator' ? 'ownerId' : 'publicKey';
            $this->addError($field, __('vpn.'.$peer));

            return;
        }

        ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true);

        // Whatever was on screen belongs to the previous access. Leaving it up
        // after creating another one would present the wrong private config
        // under the new name — and someone would hand it out.
        $this->dismissConfig();

        // Only a key we generated can be turned into a ready-to-use config.
        if ($plainConfig !== null) {
            $this->configToken = ConfigHandoff::put($plainConfig);
            $this->newConfigName = $peer->name;
        }

        $this->reset('name', 'publicKey', 'storeConfig');
        $this->ownerId = Auth::guard('operator')->id();
        $this->dispatch('notify', message: __('vpn.created'));
    }

    public function toggle(string $uuid): void
    {
        // Looked up globally and then judged by the policy: filtering it out of
        // the query here would turn "not allowed" into a button that silently
        // does nothing, which is exactly how the portal used to lie to people.
        $peer = VpnPeer::query()->where('uuid', $uuid)->first();
        if ($peer === null) {
            return;
        }
        $this->authorize('block', $peer);

        $peer->update(['enabled' => ! $peer->enabled]);
        ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, $peer->enabled);

        $this->dispatch('notify', message: $peer->enabled ? __('vpn.unblocked') : __('vpn.blocked'));
    }

    #[On('vpn-peer-deleted')]
    public function peerDeleted(): void
    {
        $this->dispatch('notify', message: __('vpn.deleted'));
    }

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

    /**
     * Replace an access's keypair.
     *
     * The way back for an access whose config was never stored: nobody can hand
     * out a private key that no longer exists, so the honest option is a new
     * one. The old key stops working the moment the hub is updated, which is
     * also what makes this the right tool for a lost or leaked device.
     */
    public function reissue(string $uuid): void
    {
        $peer = VpnPeer::query()->where('uuid', $uuid)->first();
        if ($peer === null) {
            return;
        }
        $this->authorize('update', $peer);

        if ($peer->kind !== VpnPeer::KIND_STAFF) {
            $this->dispatch('notify', message: __('vpn.reissue_staff_only'));

            return;
        }

        $keypair = Keypair::generate();
        $oldKey = $peer->public_key;
        $config = $this->clientConfig($keypair, $peer->allowed_ip);

        $peer->forceFill([
            'public_key' => $keypair->publicKey,
            'present' => false,
            // Only re-stored when the vault can actually be used: without the key
            // this would throw, and the console tells people to re-issue
            // precisely when a stored config has become unreadable.
            'config_secret' => $peer->hasStoredConfig() && ConfigVault::available()
                ? ConfigVault::encrypt($config)
                : null,
        ])->save();

        // Old key off the hub first, then the new one on: the other order would
        // briefly leave two peers claiming the same tunnel address.
        ApplyVpnPeer::dispatch($oldKey, null, false, force: true);
        ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true);

        $this->dismissConfig();
        $this->configToken = ConfigHandoff::put($config);
        $this->newConfigName = $peer->name;

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

    public function dismissConfig(): void
    {
        ConfigHandoff::forget($this->configToken);
        $this->reset('configToken', 'newConfigName', 'showQr');
    }

    /**
     * The list this operator is allowed to see: their own accesses, plus
     * everything when they hold vpn.view.all. Filtering in the query as well as
     * in the actions, so a forged uuid cannot reach a peer the page never showed.
     */
    private function visiblePeers()
    {
        $query = VpnPeer::query();

        if (! Auth::guard('operator')->user()?->can('vpn.view.all')) {
            $query->where('kind', VpnPeer::KIND_STAFF)->where('user_id', Auth::guard('operator')->id());
        }

        return $query;
    }

    public function toggleQr(): void
    {
        $this->showQr = ! $this->showQr;
    }

    /**
     * wg-quick takes the interface name from the filename, and Linux caps
     * interface names at 15 characters — so the label is slugged and cut rather
     * than handing the operator a file that fails to come up.
     */
    public function configFilename(): string
    {
        $slug = Str::slug((string) $this->newConfigName) ?: 'clupilot';

        return Str::limit($slug, 15, '').'.conf';
    }

    /** Polled by the view; throttled so a room full of open tabs cannot flood the queue. */
    public function refreshPeers(): void
    {
        if (Cache::add('vpn:sync-dispatched', true, 8)) {
            SyncVpnPeers::dispatch();
        }
    }

    /**
     * What the client sends through the tunnel: the management subnet, and only
     * that.
     *
     * It is tempting to add the server's own public address here, because the
     * console lives at a public hostname and without that route the phone sends
     * the request out over the mobile network instead — it arrives from a
     * carrier address, the proxy refuses it, and the operator sees a blank page
     * while the VPN app says "connected".
     *
     * That fix cannot work. The WireGuard endpoint is the SAME address. Routing
     * it into the tunnel routes the handshake packets into the tunnel too, and
     * a tunnel cannot carry the packets that establish it — the result is a
     * loop and a connection that never comes up at all. Strictly worse: the
     * same symptom, now with no way in.
     *
     * The console has to be reachable at an address that is INSIDE the subnet
     * for the VPN to be the way in. That is a deployment change (the proxy
     * answering on the hub address, and a name that resolves to it), not
     * something this config line can express.
     */
    private static function allowedIps(string $endpoint): string
    {
        return (string) config('provisioning.wireguard.subnet', '10.66.0.0/24');
    }

    private function clientConfig(Keypair $keypair, string $ip): string
    {
        $hub = app(WireguardHub::class);

        return implode("\n", [
            '[Interface]',
            'PrivateKey = '.$keypair->privateKey,
            'Address = '.$ip.'/32',
            // Only present when the console is actually reachable inside the
            // tunnel. Setting it otherwise would hand the device a resolver
            // that does not exist and take its name resolution with it.
            ...(config('admin_access.vpn_ready')
                ? ['DNS = '.config('provisioning.wireguard.hub_address', '10.66.0.1')]
                : []),
            '',
            '[Peer]',
            'PublicKey = '.$hub->publicKey(),
            'Endpoint = '.$hub->endpoint(),
            'AllowedIPs = '.self::allowedIps($hub->endpoint()),
            'PersistentKeepalive = 25',
            '',
        ]);
    }

    public function render()
    {
        $hub = app(WireguardHub::class);

        // Einmal geladen, zweimal gelesen. Zwei Abfragen wären zwei Zeitpunkte:
        // ein Zugang, der zwischen ihnen entsteht, stünde in keiner der beiden
        // Gruppen — oder in beiden.
        $peers = $this->visiblePeers()->with(['host', 'owner'])
            ->orderByDesc('present')->orderBy('name')->get();

        // Nach der geladenen Beziehung getrennt, nicht nach `kind`: die Plakette
        // in der Zeile fragt dasselbe, und wer einen „Host-Zugang" liest, soll
        // ihn auch unter den Hosts finden. Ein adoptierter Peer (kind=system)
        // mit host_id gehört zu den Hosts, obwohl seine Art etwas anderes sagt.
        $hostAccesses = $peers->filter(fn ($peer) => $peer->host !== null)->values();

        return view('livewire.admin.vpn', [
            'peopleAccesses' => $peers->filter(fn ($peer) => $peer->host === null)->values(),
            'hostAccesses' => $hostAccesses,
            'newConfig' => $config = ConfigHandoff::get($this->configToken),
            'qrSvg' => $this->showQr && $config !== null ? QrCode::svg($config) : null,
            // Bleibt: der Leerzustand und alles, was die Liste als Ganzes zählt.
            'peers' => $peers,
            'operators' => Operator::query()
                ->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::operatorRoles()))
                ->orderBy('name')->get(['id', 'name', 'email']),
            'canManage' => Auth::guard('operator')->user()?->can('vpn.manage.all') ?? false,
            'vaultAvailable' => ConfigVault::available(),
            'hubEndpoint' => $hub->endpoint(),
            'hubPublicKey' => $hub->publicKey(),
            'lastSync' => VpnPeer::query()->max('observed_at'),
        ]);
    }
}
