<?php

namespace App\Provisioning\Jobs;

use App\Models\Host;
use App\Models\Instance;
use App\Models\SecurityBlock;
use App\Services\Security\BlockAddress;
use App\Services\Security\FailedLoginReader;
use App\Services\Security\HostFirewall;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\RateLimiter;
use Throwable;

/**
 * Der Melder: liest gescheiterte Anmeldungen von jeder aktiven Instanz und
 * jedem eingerichteten Host, sperrt ab der Schwelle über `BlockAddress`, und
 * trägt danach jede noch gültige Sperre erneut in die Firewall ein.
 *
 * Die Zusage lautet "10 Fehlversuche in 10 Minuten" — nicht "10 Fehlversuche
 * im Zuwachs eines einzelnen Laufs". Jeder Lauf liest von `FailedLoginReader`
 * nur, was seit dem letzten Mal neu dazugekommen ist (typisch: eine Minute
 * Protokoll), und wer das direkt gegen die Schwelle hielte, würde aus "10 in
 * 10 Minuten" faktisch "10 in einer Minute" machen — ein Angreifer mit drei
 * Versuchen je Minute käme auf dreißig in zehn Minuten und liefe nie über die
 * Schwelle. Deshalb wandert jeder frisch gelesene Treffer in einen
 * Zählstand je Subjekt und Adresse, der über Läufe hinweg hält und nach zehn
 * Minuten von selbst verfällt (`RateLimiter`, cache-gestützt, wie
 * `App\Livewire\Auth\OperatorLogin` es für Anmeldeversuche schon vormacht) —
 * erst DER Stand wird gegen die Schwelle gehalten.
 *
 * Das Wiedereintragen ist kein Aufräumen nebenbei: die nftables-Menge lebt im
 * Speicher des Hosts, ein Neustart leert sie, während unsere Datenbank die
 * Sperre weiterführt. Mit der RESTLAUFZEIT (`now()` bis `expires_at`), nicht
 * der ursprünglichen Dauer — sonst verlängerte sich jede Sperre bei jedem
 * Neustart des Hosts.
 *
 * Läuft auf der `provisioning`-Warteschlange: nur deren Arbeiter hängt im
 * WireGuard-Tunnel und erreicht Hosts und Gäste überhaupt (routes/console.php).
 * Jede Minute, billig, wenn nichts zu tun ist — es wird nur der Zuwachs seit
 * dem letzten Mal gelesen.
 */
class ScanForIntrusions implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /** Ab wie vielen Fehlversuchen im Fenster gesperrt wird. */
    private const THRESHOLD = 10;

    /**
     * Das Fenster, ueber das der Zaehlstand je Subjekt und Adresse haelt —
     * dieselben zehn Minuten wie die Schwelle selbst, nur jetzt als
     * Cache-Ablaufzeit statt als Zeitstempel-Filter innerhalb einer einzelnen
     * Protokoll-Zeile (das bleibt zusaetzlich in FailedLoginReader, gegen ein
     * lange nicht gelesenes Protokoll).
     */
    private const WINDOW_SECONDS = 600;

    public function __construct()
    {
        // Nicht als `public $queue`-Eigenschaft: die kollidiert mit der
        // gleichnamigen, aber anders deklarierten Eigenschaft aus dem
        // Queueable-Trait (kein Default dort) — ein fataler
        // Kompositionsfehler. onQueue() ist der Weg, den jeder andere
        // Auftrag in diesem Verzeichnis schon geht (siehe PingHosts,
        // SyncVpnPeers, CollectHostLoad).
        $this->onQueue('provisioning');
    }

    /**
     * Ohne Methodeninjektion: `Schedule::job(new ScanForIntrusions)` (wie jeder
     * andere Auftrag in routes/console.php) erzeugt die Instanz direkt statt
     * über den Container, und `app(ScanForIntrusions::class)->handle()` ruft
     * `handle()` als gewöhnlichen Methodenaufruf auf — beides läuft ohne
     * `Container::call()`, das typisierte Parameter sonst auflöst. Also holt
     * sich `handle()` seine Abhängigkeiten selbst.
     */
    public function handle(): void
    {
        $reader = app(FailedLoginReader::class);
        $blocker = app(BlockAddress::class);
        $firewall = app(HostFirewall::class);

        $this->scanInstances($reader, $blocker);
        $this->scanHosts($reader, $blocker);
        $this->reapplyActiveBlocks($firewall);
    }

    private function scanInstances(FailedLoginReader $reader, BlockAddress $blocker): void
    {
        $instances = Instance::query()
            ->where('status', 'active')
            ->whereNotNull('vmid')
            ->get();

        foreach ($instances as $instance) {
            // Wie PingHosts nebenan: je Instanz einzeln umschlossen, nicht die
            // ganze Schleife in einem Block. `FailedLoginReader::fromInstance()`
            // fängt zwar schon jede Ausnahme von `guestExec()` selbst ab
            // (Fix-Runde 2: es tat das vorher NICHT, und eine einzelne
            // abgeschaltete VM riss den ganzen Lauf mit — die übrigen
            // Instanzen wurden nicht mehr gelesen, scanHosts() lief nicht,
            // und vor allem reapplyActiveBlocks() lief nicht, jede Minute
            // erneut). Dieser zweite Ring bleibt als Netz für alles andere in
            // dieser Runde (BlockAddress, save()), aus demselben Grund.
            try {
                $result = $reader->fromInstance($instance);

                // Gast antwortet nicht — kein Vorfall, Versatz bleibt unangetastet.
                if ($result === null) {
                    continue;
                }

                $subject = 'instance:'.$instance->id;

                foreach ($result['addresses'] as $ip => $freshAttempts) {
                    $total = $this->accumulate($subject, $ip, $freshAttempts);

                    if ($total >= self::THRESHOLD) {
                        $blocker->forInstance($instance, $ip, $total);
                        $this->resetAccumulator($subject, $ip);
                    }
                }

                // Nur bei Erfolg speichern — genau die Bytes, die wirklich
                // gelesen wurden.
                $instance->forceFill(['security_log_offset' => $result['offset']])->save();
            } catch (Throwable) {
                // Absichtlich still, wie PingHosts: der Cursor bleibt
                // unangetastet, der nächste Lauf versucht es wieder.
            }
        }
    }

    private function scanHosts(FailedLoginReader $reader, BlockAddress $blocker): void
    {
        $hosts = Host::query()
            ->whereNotNull('wg_ip')
            ->whereNotNull('ssh_host_key')
            ->get();

        foreach ($hosts as $host) {
            // Derselbe Ring wie bei den Instanzen nebenan, aus demselben Grund
            // (Fix-Runde 2 hat ihn dort eingezogen, diese Methode ging leer
            // aus). `FailedLoginReader::fromHost()` fängt seine eigene
            // SSH-Ausnahme zwar schon ab, aber alles danach nicht:
            // `SecurityBlock::create()`, `Settings::set()` und die
            // Empfängersuche in `BlockAddress` (Spaties
            // `PermissionDoesNotExist`, wenn `hosts.manage` fehlt), dazu
            // `$host->save()`. Wirft eines davon, bricht der ganze Lauf ab —
            // die übrigen Hosts werden nicht mehr gelesen, und vor allem läuft
            // `reapplyActiveBlocks()` nicht, jede Minute erneut.
            try {
                $result = $reader->fromHost($host);

                if ($result === null) {
                    continue;
                }

                $subject = 'host:'.$host->id;

                foreach ($result['addresses'] as $ip => $freshAttempts) {
                    $total = $this->accumulate($subject, $ip, $freshAttempts);

                    if ($total >= self::THRESHOLD) {
                        $blocker->forHost($host, $ip, $total);
                        $this->resetAccumulator($subject, $ip);
                    }
                }

                $host->forceFill(['security_log_seen_at' => $result['seenAt']])->save();
            } catch (Throwable) {
                // Absichtlich still, wie bei den Instanzen: der Zeitpunkt
                // bleibt unangetastet, der nächste Lauf versucht es wieder.
            }
        }
    }

    /**
     * Addiert frisch gelesene Treffer auf den laufübergreifenden Stand und
     * gibt den neuen Gesamtstand zurück. Der Schlüssel trägt Subjekt UND
     * Adresse: zwei Instanzen, die von derselben Adresse Versuche sehen,
     * dürfen sich nicht gegenseitig hochzählen, und eine Adresse darf nicht
     * von den Versuchen einer anderen mitgerissen werden.
     */
    private function accumulate(string $subject, string $ip, int $freshAttempts): int
    {
        return RateLimiter::increment($this->tallyKey($subject, $ip), self::WINDOW_SECONDS, $freshAttempts);
    }

    /**
     * Nach dem Sperren zurücksetzen. Ohne das stünde der Stand weiter bei der
     * Schwelle, und sobald die Sperre abläuft oder der Inhaber sie aufhebt,
     * löste der nächste einzelne Fehlversuch sofort die nächste Sperre aus.
     */
    private function resetAccumulator(string $subject, string $ip): void
    {
        RateLimiter::clear($this->tallyKey($subject, $ip));
    }

    private function tallyKey(string $subject, string $ip): string
    {
        return 'intrusion-scan:'.$subject.':'.$ip;
    }

    /**
     * Alle noch gültigen Sperren erneut eintragen — je Host EIN Aufruf, EINE
     * Verbindung.
     *
     * Vorher lief das je Sperre einzeln, und jede Eintragung baute ihre eigene
     * SSH-Sitzung auf. Bei dreissig Sperren waren das dreissig Handshakes pro
     * Minute, seriell, auf dem einzigen Provisionierungs-Arbeiter — und
     * dreissig Sperren gibt es genau dann, wenn gerade angegriffen wird,
     * während derselbe Arbeiter die bezahlte Kundenbereitstellung fährt.
     *
     * Die RESTLAUFZEIT je Sperre, nicht die ursprüngliche Dauer, und auch
     * nicht eine gemeinsame fürs ganze Bündel: `HostFirewall::blockMany()`
     * nimmt sie einzeln entgegen. Sonst verlängerte sich jede Sperre bei jedem
     * Neustart des Hosts.
     */
    private function reapplyActiveBlocks(HostFirewall $firewall): void
    {
        $blocks = SecurityBlock::active()->with(['host', 'instance.host'])->get();

        /** @var array<int, array{host: Host, seconds: array<string, int>}> */
        $perHost = [];

        foreach ($blocks as $block) {
            $host = $block->host ?? $block->instance?->host;

            // Instanz noch nicht platziert, oder ihr Host inzwischen weg —
            // nichts, wo einzutragen wäre.
            if ($host === null) {
                continue;
            }

            // Gerundet, nicht abgeschnitten: Carbon 3 liefert diffInSeconds als
            // Fliesskommazahl, und zwischen dem Anlegen der Sperre und diesem
            // Lauf vergehen immer ein paar Millisekunden — ein bloßes (int)
            // wuerde JEDE Restlaufzeit um bis zu eine Sekunde verkuerzen.
            $seconds = (int) round(now()->diffInSeconds($block->expires_at));

            if ($seconds <= 0) {
                continue;
            }

            $perHost[$host->id] ??= ['host' => $host, 'seconds' => []];

            // Dieselbe Adresse an zwei Subjekten desselben Hosts (Instanz und
            // Host, oder zwei Instanzen) ist EIN Element in der Menge. Die
            // längere Restlaufzeit gewinnt — die kürzere zuletzt einzutragen
            // würde die längere Sperre verkürzen.
            $perHost[$host->id]['seconds'][$block->ip] =
                max($perHost[$host->id]['seconds'][$block->ip] ?? 0, $seconds);
        }

        foreach ($perHost as $entry) {
            $firewall->blockMany($entry['host'], $entry['seconds']);
        }

        $this->removeLiftedBlocks($firewall, $perHost);
    }

    /**
     * Und wieder heraus, was aufgehoben wurde, aber noch drinstehen könnte.
     *
     * `release()` scheitert still, wenn der Host im Moment des Aufhebens nicht
     * erreichbar war — der Datensatz steht dann auf aufgehoben, die Regel aber
     * noch. Ohne diesen Abgleich bliebe die Adresse bis zum Ablauf der
     * ursprünglichen Sperrzeit ausgesperrt, während Portal und Konsole
     * „Aufgehoben" zeigen. Genau die Lücke, die den Menschen ratlos lässt.
     *
     * Betrachtet werden nur aufgehobene Sperren, deren Ablaufzeit noch in der
     * Zukunft liegt: danach hat der Kernel den Eintrag selbst herausgenommen
     * (`flags timeout`), und es gäbe nichts mehr zu tun.
     *
     * Eine Adresse, die eine ANDERE aktive Sperre desselben Hosts noch trägt,
     * bleibt stehen — sonst hebt das Aufheben der einen Sperre die andere
     * gleich mit auf.
     *
     * @param  array<int, array{host: Host, seconds: array<string, int>}>  $perHost  die soeben eingetragenen
     */
    private function removeLiftedBlocks(HostFirewall $firewall, array $perHost): void
    {
        $lifted = SecurityBlock::query()
            ->whereNotNull('released_at')
            ->where('expires_at', '>', now())
            ->with(['host', 'instance.host'])
            ->get();

        /** @var array<int, array{host: Host, ips: array<string, string>}> */
        $perHostRemove = [];

        foreach ($lifted as $block) {
            $host = $block->host ?? $block->instance?->host;

            if ($host === null || isset($perHost[$host->id]['seconds'][$block->ip])) {
                continue;
            }

            $perHostRemove[$host->id] ??= ['host' => $host, 'ips' => []];
            $perHostRemove[$host->id]['ips'][$block->ip] = $block->ip; // je Adresse einmal
        }

        foreach ($perHostRemove as $entry) {
            $firewall->releaseMany($entry['host'], array_values($entry['ips']));
        }
    }
}
