<?php

namespace App\Provisioning\Steps\Host;

use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Ssh\RemoteShell;
use App\Services\Wireguard\WireguardHub;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

/**
 * Installs WireGuard on the host, allocates a management IP, registers the host
 * as a peer on the CluPilot hub, and verifies the tunnel is up.
 *
 * SSH for this step runs over the PUBLIC IP, not the tunnel — see
 * HostStep::keyLogin(), which only prefers the tunnel once a handshake has
 * actually succeeded. That is what makes the interface handling below safe to
 * restart: the session it would otherwise cut does not go through wg0.
 */
class ConfigureWireguard extends HostStep
{
    private const CONFIG_PATH = '/etc/wireguard/wg0.conf';

    /**
     * Wie frisch ein Handshake sein muss, damit der Tunnel als stehend gilt.
     *
     * WireGuard handshaked etwa alle zwei Minuten neu, und die Konfiguration,
     * die dieser Schritt schreibt, hat `PersistentKeepalive = 25`. Drei Minuten
     * ist damit der erste Wert, der bei einem ruhenden, aber verbundenen Peer
     * nicht flackert — dieselbe Überlegung wie VpnPeer::ONLINE_AFTER_MINUTES.
     */
    private const HANDSHAKE_FRESH_SECONDS = 180;

    private const UNIT = 'wg-quick@wg0';

    public function __construct(private RemoteShell $shell, private WireguardHub $hub) {}

    public function key(): string
    {
        return 'configure_wireguard';
    }

    public function execute(ProvisioningRun $run): StepResult
    {
        $host = $this->host($run);
        $this->keyLogin($this->shell, $host);

        // Idempotent replay: the tunnel has already handshaked once (`wg_peer`)
        // — just re-verify it. Asking tunnelProven() rather than checking
        // wg_ip + the run's own breadcrumb keeps ONE definition of "the tunnel
        // works" in the pipeline, the same one keyLogin() dials on.
        if ($this->tunnelProven($host)) {
            return $this->verifyHandshake(trim($this->hub->publicKey()));
        }

        // The hub identity, checked BEFORE anything is installed, allocated or
        // written. renderConfig() would otherwise happily emit `PublicKey = ` /
        // `Endpoint = `, which is the likeliest way to reach a never-handshaking
        // tunnel at all. Checked here rather than in ValidateHostInput because
        // this is where the values are actually consumed: they come from the
        // WireguardHub (settings table, .env fallback) and can be changed between
        // step 1 and step 4, so a check further upstream would be reading
        // something other than what gets written into wg0.conf. Nothing on the
        // host and nothing in the database has been touched at this point, so the
        // run fails clean and an operator can fill the setting in and retry.
        $hubPublicKey = trim($this->hub->publicKey());
        $hubEndpoint = trim($this->hub->endpoint());
        if (blank($hubPublicKey) || blank($hubEndpoint)) {
            return StepResult::fail($this->missingHubSettingsMessage($hubPublicKey, $hubEndpoint));
        }

        if (! $this->shell->run('export DEBIAN_FRONTEND=noninteractive; apt-get install -y wireguard')->ok()) {
            return StepResult::retry(30, 'installing wireguard failed');
        }
        $this->shell->run('test -f /etc/wireguard/privatekey || (umask 077; wg genkey > /etc/wireguard/privatekey)');

        $publicKey = trim($this->shell->run('wg pubkey < /etc/wireguard/privatekey')->stdout);
        if (blank($publicKey)) {
            return StepResult::retry(20, 'could not read host WireGuard public key');
        }

        // Allocate + reserve the management IP atomically so concurrent onboarding
        // runs can never receive the same address.
        $wgIp = Cache::lock('wireguard:allocate', 30)->block(10, function () use ($host) {
            $ip = $host->wg_ip ?: $this->hub->allocateIp((int) config('provisioning.wireguard.host_offset', 100));
            if (blank($host->wg_ip)) {
                $host->update(['wg_ip' => $ip]);
            }

            return $ip;
        });

        $privateKey = trim($this->shell->run('cat /etc/wireguard/privatekey')->stdout);
        $desiredConfig = $this->renderConfig($wgIp, $privateKey, $hubPublicKey, $hubEndpoint);

        // Compare before writing. The file used to be rewritten unconditionally
        // with nothing reloading it, so an operator who corrected a wrong hub key
        // and pressed Retry saw the new file on disk and no change in behaviour —
        // the running interface still held the old peer. Knowing whether the file
        // CHANGED is what lets the interface handling below decide between
        // leaving a working tunnel alone and actually applying a correction.
        $currentConfig = $this->shell->run('cat '.escapeshellarg(self::CONFIG_PATH).' 2>/dev/null')->stdout;
        $configChanged = trim($currentConfig) !== trim($desiredConfig);

        if ($configChanged) {
            $this->shell->putFile(self::CONFIG_PATH, $desiredConfig);
        }

        if (($failure = $this->bringUpInterface($configChanged)) !== null) {
            return $failure;
        }

        // See RemoveWireguardPeer: hub mutations are serialized against the
        // reconciliation that feeds the VPN console.
        Cache::lock('wireguard:hub', 30)->block(10, fn () => $this->hub->addPeer($publicKey, $wgIp));

        // Store the pubkey now (so removal can always clean up the hub peer), but
        // record the wg_peer resource only AFTER the handshake verifies — the
        // idempotent short-circuit must not skip an un-verified configuration,
        // and keyLogin() must not dial an unproven tunnel.
        $host->update(['wg_pubkey' => $publicKey]);

        if ($this->verifyHandshake($hubPublicKey)->type !== StepResult::ADVANCE) {
            return StepResult::retry(15, 'WireGuard handshake not up yet');
        }

        $this->recordResource($run, $host, 'wg_peer', $publicKey);

        return StepResult::advance();
    }

    /**
     * Get wg0 into the state onboarding needs: up now AND up after the step-6
     * reboot, running the configuration we just wrote.
     *
     * Three states, handled separately, because the single line this replaces
     * (`systemctl enable --now wg-quick@wg0 || wg-quick up wg0`) got two of them
     * wrong. Its fallback brought the interface up WITHOUT the systemd
     * enablement, so the tunnel did not come back after the reboot; and on the
     * next attempt both halves failed with "wg0 already exists", so the step
     * retried forever against a tunnel that was in fact working.
     *
     * The order — enable before start — matters for the reboot: `enable` is what
     * survives it, and it is checked and repaired even when the interface is
     * already up by other means (an earlier `wg-quick up`, or an operator).
     */
    private function bringUpInterface(bool $configChanged): ?StepResult
    {
        $present = $this->shell->run('ip link show wg0')->ok();

        // `is-enabled` is asked for its exit status, not its wording, so a
        // template unit that has never been enabled ("disabled", exit 1) and one
        // that does not exist yet are treated the same: enable it.
        if (! $this->shell->run('systemctl is-enabled --quiet '.self::UNIT)->ok()) {
            if (! $this->shell->run('systemctl enable '.self::UNIT)->ok()) {
                return StepResult::retry(20, 'could not enable '.self::UNIT.' (wg0 would not survive a reboot)');
            }
        }

        if (! $present) {
            if (! $this->shell->run('systemctl start '.self::UNIT)->ok()) {
                return StepResult::retry(20, 'bringing up wg0 failed');
            }

            return null;
        }

        // Present and unchanged: the running interface already matches the file.
        // Restarting here is what used to make a working tunnel look broken.
        if (! $configChanged) {
            return null;
        }

        // Present with a changed configuration: a restart, not `wg syncconf`.
        // syncconf applies peers only, and a corrected Address or AllowedIPs
        // would silently not take effect — the exact class of "the file changed
        // and nothing happened" bug this method exists to remove. The brief
        // interruption costs nothing: this step's own SSH session runs over the
        // public IP (see the class docblock), and the short-circuit above means
        // a proven tunnel never reaches this line.
        if (! $this->shell->run('systemctl restart '.self::UNIT)->ok()) {
            return StepResult::retry(20, 'restarting wg0 with the corrected configuration failed');
        }

        return null;
    }

    /**
     * WireGuard selbst fragen, nicht auf einen Ping schließen.
     *
     * Hier stand `ping -c1 -W2 <hub-ip>`. Das prüfte drei Dinge auf einmal und
     * nannte nur eines davon: dass der Handshake steht, dass ICMP durchkommt —
     * und dass es `ping` auf der Maschine überhaupt GIBT. Auf Hetzners
     * `Debian-trixie-…-base` gibt es das nicht: `iputils-ping` ist nicht dabei,
     * und PrepareBaseSystem installiert es auch nicht. Der Schritt lief damit
     * fünfmal in die Wiederholung und meldete „WireGuard handshake not up yet"
     * über einen Tunnel, der stehen konnte.
     *
     * `latest-handshakes` gibt je Peer eine Unix-Zeit aus, `0` heißt „noch
     * nie". Das ist genau die Frage, die hier gestellt wird, und die Antwort
     * kommt von der Stelle, die es weiß.
     */
    private function verifyHandshake(string $hubPublicKey): StepResult
    {
        // Die Uhr des HOSTS kommt mit, in derselben Antwort. `latest-handshakes`
        // gibt eine absolute Zeit aus; sie gegen unsere Uhr zu halten hiesse,
        // eine Zeitverschiebung zwischen zwei Maschinen als Tunnelzustand zu
        // lesen. `date` ist in coreutils und überall da — anders als `ping`.
        $result = $this->shell->run('date +%s; wg show wg0 latest-handshakes');

        if (! $result->ok()) {
            return StepResult::retry(15, 'WireGuard handshake not up yet');
        }

        $lines = preg_split('/\R/', trim($result->stdout)) ?: [];
        $now = (int) trim((string) array_shift($lines));

        foreach ($lines as $line) {
            // "<pubkey>\t<unix-zeit>". Nur die Zeile des konfigurierten Hubs
            // zählt: ein fremder Peer auf wg0 ist kein Beweis dafür, dass WIR
            // erreichbar sind.
            $parts = preg_split('/\s+/', trim($line));

            if (count($parts) < 2 || $parts[0] !== $hubPublicKey) {
                continue;
            }

            $seen = (int) $parts[1];

            // `> 0` allein hiesse „hat jemals" (Codex P1). WireGuard behält den
            // Zeitstempel unbegrenzt, also meldete ein Wiederholungslauf über
            // einem längst toten Tunnel „steht" — und die Schritte danach
            // wählten die Tunneladresse für SSH, über die niemand mehr kommt.
            if ($seen > 0 && ($now - $seen) <= self::HANDSHAKE_FRESH_SECONDS) {
                return StepResult::advance();
            }
        }

        return StepResult::retry(15, 'WireGuard handshake not up yet');
    }

    /** Names the setting that is missing, so the console shows what to fill in. */
    private function missingHubSettingsMessage(string $publicKey, string $endpoint): string
    {
        $missing = [];
        if (blank($publicKey)) {
            $missing[] = 'the hub public key (CLUPILOT_WG_HUB_PUBKEY)';
        }
        if (blank($endpoint)) {
            $missing[] = 'the hub endpoint (CLUPILOT_WG_ENDPOINT, host:port)';
        }

        return 'Refusing to write wg0.conf: this installation has not been told '.
            implode(' or ', $missing).'. Set it under Integrations and retry — '.
            'a peer section without those two values can never handshake.';
    }

    private function renderConfig(string $wgIp, string $privateKey, string $hubPublicKey, string $hubEndpoint): string
    {
        $subnet = (string) config('provisioning.wireguard.subnet', '10.66.0.0/24');
        $prefix = Str::afterLast($subnet, '/'); // honour the configured prefix length

        return implode("\n", [
            '[Interface]',
            "Address = {$wgIp}/{$prefix}",
            "PrivateKey = {$privateKey}",
            '',
            '[Peer]',
            'PublicKey = '.$hubPublicKey,
            'Endpoint = '.$hubEndpoint,
            "AllowedIPs = {$subnet}",
            'PersistentKeepalive = 25',
            '',
        ]);
    }
}
