<?php

namespace App\Services\Security;

use App\Models\Host;
use App\Models\Instance;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Secrets\SecretVault;
use App\Services\Ssh\RemoteShell;
use App\Support\NextcloudOcc;
use Illuminate\Support\Carbon;
use Throwable;

/**
 * Liest gescheiterte Anmeldungen aus zwei Quellen — beide nur LESEND, beide
 * über den Weg, den das Produkt für dieses Subjekt schon benutzt: eine
 * Kundeninstanz über den Proxmox-Gastagenten (wie jeder occ-Aufruf im
 * Produkt), ein Host über SSH durch den Tunnel (wie HostFirewall).
 *
 * Zählt nicht selbst gegen die Schwelle — das ist Sache des Aufrufers
 * (ScanForIntrusions). Diese Klasse liefert je Lauf nur, was seit dem letzten
 * Mal an NEUEM dazugekommen ist, innerhalb des Zeitfensters, plus den Cursor,
 * an dem der nächste Lauf weiterlesen muss.
 *
 * Ein Gast oder Host, der nicht antwortet, ist KEIN Vorfall: beide Methoden
 * geben dann null zurück, statt zu werfen oder einen leeren Treffer
 * vorzutäuschen — der Aufrufer lässt den Cursor in diesem Fall unangetastet.
 */
class FailedLoginReader
{
    /** Nur Zeilen, die höchstens so alt sind, zählen gegen die Schwelle. */
    private const WINDOW_MINUTES = 10;

    public function __construct(
        private ProxmoxClient $pve,
        private RemoteShell $shell,
    ) {}

    /**
     * Nextclouds eigene Protokolldatei, ab dem gemerkten Byte-Versatz.
     *
     * @return array{offset: int, addresses: array<string, int>}|null
     */
    public function fromInstance(Instance $instance): ?array
    {
        $host = $instance->host;

        // Kein Host, keine Maschine — nichts, was der Gastagent fragen könnte.
        if ($host === null || $instance->vmid === null) {
            return null;
        }

        $node = (string) ($host->node ?? 'pve');
        $vmid = (int) $instance->vmid;
        $stored = (int) $instance->security_log_offset;

        try {
            $pve = $this->pve->forHost($host);

            $size = $pve->guestExec($node, $vmid, NextcloudOcc::exec('stat -c %s data/nextcloud.log'));

            if ((int) ($size['exitcode'] ?? 1) !== 0) {
                return null;
            }

            $bytes = (int) trim((string) ($size['out-data'] ?? ''));

            // Der ERSTE Lauf holt nur den Startwert und liest nichts.
            //
            // `security_log_offset` beginnt bei 0, und 0 heißt wörtlich "von
            // vorn": ohne diesen Zweig tailt der allererste Lauf die KOMPLETTE
            // Protokolldatei jeder Instanz durch den Gastagenten, alle
            // Instanzen im selben Auftrag, in derselben Minute. Gesperrt würde
            // dabei ohnehin nichts — das Zeitfenster wirft jede Zeile weg, die
            // älter als zehn Minuten ist —, es wäre also eine große Übertragung
            // für ein garantiert leeres Ergebnis.
            //
            // Bewusst getrennt vom Rotationsfall unten: dort ist die Datei
            // NEUER als der Versatz und muss von vorn gelesen werden, weil das
            // Neue am Anfang steht.
            if ($stored === 0) {
                return ['offset' => $bytes, 'addresses' => []];
            }

            // Kleiner als der gemerkte Versatz heißt: rotiert. Ohne diese
            // Behandlung liest der nächste Aufruf ins Leere und sieht nie
            // wieder etwas — siehe den eigenen Testfall dafür.
            $offset = $bytes < $stored ? 0 : $stored;

            $tail = $pve->guestExec($node, $vmid, NextcloudOcc::exec('tail -c +'.($offset + 1).' data/nextcloud.log'));
        } catch (Throwable) {
            // Wie fromHost(): ein abgeschalteter Gast oder ein Agent, der noch
            // nicht laeuft, wirft eine echte Ausnahme statt eines
            // Fehlercodes — `guestExec()` ruft `->throw()` auf jede
            // HTTP-Antwort. Genauso kein Vorfall wie ein sauberer
            // `exitcode != 0`, und kein Grund, den ganzen Lauf mitzureissen.
            return null;
        }

        if ((int) ($tail['exitcode'] ?? 1) !== 0) {
            return null;
        }

        $out = (string) ($tail['out-data'] ?? '');

        return [
            'offset' => $offset + strlen($out),
            'addresses' => $this->countWithinWindow($this->parseNextcloudLines($out)),
        ];
    }

    /**
     * Die fehlgeschlagenen SSH-Anmeldungen seit dem gemerkten Zeitpunkt.
     *
     * Verbindet direkt über die WireGuard-Adresse, nicht über
     * `HostStep::keyLogin()`s Rückfallpfad auf die öffentliche Adresse — wie
     * `HostFirewall`: dieser Melder läuft gegen Hosts, die den Tunnel schon
     * haben, nie während der Inbetriebnahme selbst.
     *
     * @return array{seenAt: Carbon, addresses: array<string, int>}|null
     */
    public function fromHost(Host $host): ?array
    {
        // Ohne einen gemerkten Zeitpunkt nur das Fenster selbst lesen, statt
        // journalctl die ganze Historie des Hosts durchsuchen zu lassen.
        $since = ($host->security_log_seen_at ?? now()->subMinutes(self::WINDOW_MINUTES))
            ->format('Y-m-d H:i:s');

        try {
            $this->shell->connectWithKey(
                $host->wg_ip,
                'root',
                (string) app(SecretVault::class)->get('ssh.private_key'),
                $host->ssh_host_key, // gepinnt bei EstablishSshTrust
            );

            $result = $this->shell->run(
                'journalctl -u ssh -u sshd --since '.escapeshellarg($since).' -o cat'
            );
        } catch (Throwable) {
            // Nicht erreichbar — kein Vorfall, kein neuer Zeitpunkt.
            return null;
        }

        if (! $result->ok()) {
            return null;
        }

        return [
            'seenAt' => now(),
            'addresses' => $this->countSshFailures($result->stdout),
        ];
    }

    /**
     * Nur Zeilen, deren `message` mit "Login failed:" beginnt — Nextclouds
     * eigenes Format, stabil über Versionen hinweg (anders als
     * `occ security:bruteforce:*`, dessen Unterbefehle gewechselt haben).
     *
     * @return array<int, array{ip: string, time: Carbon}>
     */
    private function parseNextcloudLines(string $out): array
    {
        $entries = [];

        foreach (preg_split('/\R/', trim($out)) ?: [] as $line) {
            if ($line === '') {
                continue;
            }

            $decoded = json_decode($line, true);

            if (! is_array($decoded)
                || ! str_starts_with((string) ($decoded['message'] ?? ''), 'Login failed:')) {
                continue;
            }

            $ip = (string) ($decoded['remoteAddr'] ?? '');

            if ($ip === '' || ! isset($decoded['time'])) {
                continue;
            }

            // RIEGEL GEGEN BEFEHLSEINSCHLEUSUNG. `remoteAddr` ist eine
            // beliebige Zeichenkette aus einer Protokolldatei IM GAST, und der
            // Weg von hier führt über HostFirewall::block() in ein
            // `nft`-Kommando, das PhpseclibRemoteShell als ROOT auf dem
            // Proxmox-Host ausführt. Ein Semikolon darin wäre ein zweiter
            // Befehl. Der SSH-Zweig unten begrenzt den Zeichenvorrat schon über
            // sein `preg_match`; dieser Zweig hatte kein Gegenstück.
            if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
                continue;
            }

            // VORSICHTSMASSNAHME, nicht Sicherheitsgrenze — und nur hier, nicht
            // im Host-Zweig.
            //
            // Traefik sitzt auf dem Host und leitet auf `guest_ip:80` weiter,
            // aber `TRUSTED_PROXIES` wird in die .env des Gastes nie
            // geschrieben. Was Nextcloud als `remoteAddr` protokolliert, ist
            // damit entweder die Brückenadresse des Hosts (dann zählen ALLE
            // Kunden unter einer Adresse und ein echter Angreifer nie) oder ein
            // `X-Forwarded-For`, das der Client SELBST wählt (dann macht sich
            // ein Angreifer mit `10.66.0.1` dauerhaft ausnahmefähig und sperrt
            // mit einer fremden Adresse gezielt Unbeteiligte aus). Welcher der
            // beiden Fälle zutrifft, ist ohne echte Instanz nicht zu
            // entscheiden.
            //
            // Beide Schadensfälle sind privat oder reserviert; ein echter
            // Angreifer sitzt auf einer öffentlichen Adresse. Bis die
            // Vertrauenskette zum Gast sauber gesetzt und BEWIESEN ist
            // (TRUSTED_PROXIES in die Gast-.env, dann `grep remoteAddr
            // data/nextcloud.log` an einer echten Instanz), sperrt der
            // Instanz-Zweig deshalb nur öffentliche Adressen. Diese Zeile
            // gehört dann weg — nicht früher.
            if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
                continue;
            }

            $entries[] = ['ip' => $ip, 'time' => Carbon::parse($decoded['time'])];
        }

        return $entries;
    }

    /**
     * Nur, was höchstens WINDOW_MINUTES alt ist, zählt gegen die Schwelle —
     * zehn Versuche über zwei Fenster verteilt sind eben keine zehn.
     *
     * @param  array<int, array{ip: string, time: Carbon}>  $entries
     * @return array<string, int>
     */
    private function countWithinWindow(array $entries): array
    {
        $cutoff = now()->subMinutes(self::WINDOW_MINUTES);
        $counts = [];

        foreach ($entries as $entry) {
            if ($entry['time']->lessThan($cutoff)) {
                continue;
            }

            $counts[$entry['ip']] = ($counts[$entry['ip']] ?? 0) + 1;
        }

        return $counts;
    }

    /**
     * `Failed password` und `Invalid user` — die Adresse steht in derselben
     * Zeile. `--since` hat den zeitlichen Ausschnitt schon auf den Gastagenten
     * (journalctl) verlagert, hier wird nur noch gezählt.
     *
     * @return array<string, int>
     */
    private function countSshFailures(string $out): array
    {
        $counts = [];

        foreach (preg_split('/\R/', trim($out)) ?: [] as $line) {
            if ($line === ''
                || (! str_contains($line, 'Failed password') && ! str_contains($line, 'Invalid user'))) {
                continue;
            }

            if (preg_match('/from ([0-9a-fA-F:.]+) port/', $line, $match) !== 1) {
                continue;
            }

            // Derselbe Riegel wie im Instanz-Zweig. Der Zeichenvorrat oben
            // schließt ein Semikolon zwar schon aus, aber er lässt "1.2.3" und
            // "...." durch — und der Riegel darf nicht davon abhängen, dass
            // jemand beim nächsten Ändern des Musters daran denkt.
            //
            // OHNE die Einschränkung auf öffentliche Adressen: hier stammt die
            // Adresse aus dem SSH-Journal des Hosts und ist echt, und eine
            // Sperre aus dem Verwaltungsnetz fängt die Ausnahmeliste in
            // BlockAddress ohnehin ab.
            if (filter_var($match[1], FILTER_VALIDATE_IP) === false) {
                continue;
            }

            $counts[$match[1]] = ($counts[$match[1]] ?? 0) + 1;
        }

        return $counts;
    }
}
