<?php

namespace App\Support;

use App\Models\Host;
use App\Provisioning\Jobs\ApplyHostVpnPeer;
use App\Services\Wireguard\Keypair;
use App\Services\Wireguard\WireguardHub;
use Illuminate\Support\Str;

/**
 * Der Einmal-Code und das Schlüsselpaar, mit denen ein Host sich übernehmen
 * lässt.
 *
 * Das Henne-Ei-Problem und seine Auflösung stehen in §5 der Spec: damit der Hub
 * den Host hereinlässt, muss er dessen öffentlichen Schlüssel kennen — erzeugte
 * der Host ihn selbst, müsste er ihn melden, BEVOR der Tunnel steht, und genau
 * dafür wäre ein öffentlicher Endpunkt nötig. Also erzeugt CluPilot das Paar
 * hier, nimmt den Peer sofort auf und gibt den privaten Teil in der kopierten
 * Befehlszeile mit.
 *
 * Der private Schlüssel wird NICHT gespeichert. Er steht in der Zwischenablage
 * des Betreibers und sonst nirgends, und Task 9 des Bootstrap-Skripts tauscht
 * ihn ohnehin gegen einen frisch auf der Maschine erzeugten — danach ist er
 * wertlos. Ihn aufzubewahren hieße, ein Geheimnis mit einer Lebensdauer von
 * Minuten dauerhaft zu lagern.
 */
final class HostEnrolment
{
    /**
     * Großzügig, weil der Code nichts nach außen öffnet: er ist nur aus dem
     * WireGuard-Subnetz überhaupt verwendbar (Spec §5). Er begrenzt, wie lange
     * ein Betreiber zwischen „Host anlegen" und „Rettungssystem starten" Zeit
     * hat, und einen Server zu bestellen dauert manchmal einen Nachmittag.
     */
    private const LIFETIME_HOURS = 24;

    /**
     * SHA-256 statt bcrypt, mit Absicht.
     *
     * Der Code sind 32 Zeichen aus einem kryptografischen Zufallsgenerator —
     * er hat die Entropie, die ein Passwort erst durch Streckung bekommt, und
     * ist gegen Erraten nicht zu verstärken. Entscheidend ist die andere Seite:
     * beide Endpunkte müssen den Host ÜBER den Code finden. Mit bcrypt ginge das
     * nur, indem man jede Zeile der Tabelle durchprobiert; mit einem Hash ist es
     * ein indizierter Zugriff.
     */
    private static function hash(string $code): string
    {
        return hash('sha256', $code);
    }

    /** Legt Code, Schlüsselpaar und Peer an und gibt den Klartext-Code zurück. */
    public static function issue(Host $host): string
    {
        return self::issueWithKeys($host)['code'];
    }

    /**
     * Wie `issue()`, gibt aber auch den privaten Schlüssel zurück — den braucht
     * die Befehlszeile, und er ist danach nirgends mehr zu holen.
     *
     * @return array{code: string, private_key: string, public_key: string, wg_ip: string}
     */
    public static function issueWithKeys(Host $host): array
    {
        $hub = app(WireguardHub::class);

        $keypair = Keypair::generate();

        // Die Tunneladresse bleibt, wenn es schon eine gibt. Ein zweiter
        // Übernahmeversuch soll denselben Host an derselben Adresse ergeben —
        // die Spalte ist eindeutig indiziert, und eine neue Adresse hieße, die
        // alte für immer belegt zu lassen.
        $ip = $host->wg_ip ?: $hub->allocateIp((int) config('provisioning.wireguard.host_offset', 100));

        $previous = $host->wg_pubkey;

        $code = Str::random(32);

        $host->forceFill([
            'wg_ip' => $ip,
            'wg_pubkey' => $keypair->publicKey,
            'enrolment_code_hash' => self::hash($code),
            'enrolment_expires_at' => now()->addHours(self::LIFETIME_HOURS),
            // Ein neuer Code macht den alten wertlos. Sonst hätte eine Maschine,
            // die im Rettungssystem hängengeblieben ist, weiter einen gültigen
            // Ausweis für einen Host, den der Betreiber gerade neu übernimmt.
            'enrolment_used_at' => null,
        ])->save();

        // Der Peer wird NACH dem Speichern eingereiht, und nicht hier
        // eingetragen: `wg set` gelingt nur im Provisioning-Container, dem
        // einzigen mit NET_ADMIN und `wg0`. Der Web-Request lief dagegen im
        // `app`-Container — dort scheiterte er, und das Anlegen eines Hosts
        // endete in einem 500, bevor der Betreiber seinen Code je zu sehen bekam.
        //
        // Nach dem Speichern, weil der Auftrag den Sollzustand aus der Zeile
        // liest. Bei `QUEUE_CONNECTION=sync` — der Testsuite — läuft er sofort,
        // und die Reihenfolge ist dann nicht Stilfrage, sondern Bedingung.
        ApplyHostVpnPeer::dispatch($host->id, $previous);

        return [
            'code' => $code,
            'private_key' => $keypair->privateKey,
            'public_key' => $keypair->publicKey,
            'wg_ip' => $ip,
        ];
    }

    /**
     * Löst einen gültigen, unverbrauchten Code auf, OHNE ihn zu verbrauchen.
     *
     * Das ist der Weg für `/host/progress`: die Fortschrittsmeldungen kommen vor
     * der Registrierung, und würden sie den Code verbrauchen, könnte der Host
     * sich nach seiner ersten Meldung nie mehr registrieren.
     */
    public static function resolve(string $code): ?Host
    {
        if ($code === '') {
            return null;
        }

        return Host::query()
            ->where('enrolment_code_hash', self::hash($code))
            ->whereNull('enrolment_used_at')
            ->where('enrolment_expires_at', '>', now())
            ->first();
    }

    /** Löst einen Code auf und verbraucht ihn. Der Weg für `/host/register`. */
    public static function claim(string $code): ?Host
    {
        $host = self::resolve($code);

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

        $host->forceFill(['enrolment_used_at' => now()])->save();

        return $host;
    }
}
