<?php

namespace App\Console\Commands;

use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Console\Command;

/**
 * Schreibt einem bereits übernommenen Host sein nftables-Regelwerk neu.
 *
 * Der Anlass: die beiden Sperrmengen `clupilot_blocked`/`clupilot_blocked6`
 * kamen mit dem Frühwarnsystem ins Regelwerk. Jeder Host, der VORHER übernommen
 * wurde, trägt noch die alte Datei — dort scheitert `nft add element` bei jedem
 * Versuch, und die Sperre steht in Datenbank, Portal, Konsole und in der Mail
 * an den Kunden als aktiv, in der Firewall aber nie. `HostFirewall` meldet
 * diesen Fall inzwischen (`report()`); DAS hier ist der Griff, mit dem man ihn
 * abstellt.
 *
 * Warum ein Befehl und kein Zeitplan — dieselbe Begründung wie bei
 * `clupilot:apply-quotas`, an dem sich dieser hier orientiert:
 *
 *  - das Loch ist endlich und schließt sich endgültig. Jeder ab jetzt
 *    übernommene Host bekommt die Mengen im Onboarding;
 *  - ein nächtlicher Lauf wäre eine zweite Instanz, die dieselbe Datei auf
 *    laufende Maschinen schreibt, und würde am Tag, an dem der Schritt in der
 *    Pipeline still kaputtginge, für ihn einspringen — das Versagen käme nie
 *    ans Licht;
 *  - eine Reparatur, die der Betreiber anstößt, ist eine, deren Ausgabe er
 *    liest. Dieser Befehl sagt, was er anfasst und was nicht, und rührt unter
 *    `--dry-run` nichts an.
 *
 * Die Arbeit selbst läuft über die `host-firewall`-Pipeline, also über
 * denselben `SecureHostFirewall`-Schritt, den auch das Onboarding fährt —
 * gedrosselt, wiederholt, protokolliert und in der Konsole sichtbar wie jede
 * andere Fernarbeit. Ein zweiter Weg, dieselbe Datei zu schreiben, würde
 * driften.
 *
 * Der Schritt prüft von der Hostseite aus selbst nach, dass der Tunnel steht,
 * und fasst nichts an, wenn nicht. Ein Host, der gerade nicht erreichbar ist,
 * wird also nicht halb umgestellt.
 */
class RefreshHostFirewall extends Command
{
    protected $signature = 'clupilot:refresh-host-firewall
        {--dry-run : auflisten, was liefe, und nichts anfassen}
        {--host= : nur dieser eine Host, per Name oder oeffentlicher Adresse}';

    protected $description = 'Schreibt uebernommenen Hosts ihr nftables-Regelwerk neu — inklusive der Sperrmengen';

    public function handle(): int
    {
        $dryRun = (bool) $this->option('dry-run');
        $started = 0;

        /** @var array<string, int> Grund => Anzahl */
        $skipped = [];

        $query = Host::query();

        if ($name = $this->option('host')) {
            $query->where(fn ($q) => $q->where('name', $name)->orWhere('public_ip', $name));
        }

        foreach ($query->cursor() as $host) {
            $reason = $this->reasonToSkip($host);

            if ($reason !== null) {
                $skipped[$reason] = ($skipped[$reason] ?? 0) + 1;

                continue;
            }

            $started++;
            $this->line(($dryRun ? '[Probelauf] ' : '')."{$host->name} ({$host->public_ip}) über {$host->wg_ip}");

            if (! $dryRun) {
                $this->startRun($host);
            }
        }

        foreach ($skipped as $reason => $count) {
            $this->line("übersprungen ({$reason}): {$count}");
        }

        $this->info($dryRun
            ? "Probelauf: {$started} Host(s) bekämen ihr Regelwerk neu, ".array_sum($skipped).' übersprungen. Nichts wurde geändert.'
            : "{$started} Lauf/Läufe gestartet, ".array_sum($skipped).' übersprungen.');

        return self::SUCCESS;
    }

    /**
     * Warum dieser Host in Ruhe gelassen wird, oder null.
     *
     * Der Grund IST der Bericht: „12 übersprungen" und sonst nichts ist keine
     * Auskunft, mit der jemand etwas anfangen kann — „12 sind noch in der
     * Übernahme" und „12 haben keinen Tunnel" sehen von hier aus gleich aus.
     */
    private function reasonToSkip(Host $host): ?string
    {
        // Der Schritt verbindet sich über die WireGuard-Adresse und prüft den
        // Tunnel von der Hostseite. Ohne beides gibt es nichts nachzuziehen.
        if (blank($host->wg_ip) || blank($host->ssh_host_key)) {
            return 'kein Tunnel, kein gepinnter Schlüssel';
        }

        // Ein Host mitten in der Übernahme bekommt sein Regelwerk ohnehin
        // gleich — und ein zweiter Lauf daneben schriebe dieselbe Datei.
        if (! in_array($host->status, ['active', 'disabled'], true)) {
            return 'nicht übernommen (Status '.$host->status.')';
        }

        if ($this->hasRunInFlight($host)) {
            return 'anderer Lauf aktiv';
        }

        return null;
    }

    private function startRun(Host $host): void
    {
        $run = ProvisioningRun::create([
            'subject_type' => Host::class,
            'subject_id' => $host->id,
            'pipeline' => 'host-firewall',
            'status' => ProvisioningRun::STATUS_PENDING,
            'current_step' => 0,
            'context' => [],
        ]);

        AdvanceRunJob::dispatch($run->uuid);
    }

    /**
     * JEDER Lauf gegen diesen Host, absichtlich weit gefasst: das hier ist ein
     * Reparatur-Durchgang. Er läuft wieder, und ein heute übergangener Host ist
     * beim nächsten Aufruf dran. Nichts geht durch Warten verloren.
     */
    private function hasRunInFlight(Host $host): bool
    {
        return ProvisioningRun::query()
            ->where('subject_type', Host::class)
            ->where('subject_id', $host->id)
            ->inFlight()
            ->exists();
    }
}
