<?php

namespace App\Services\Security;

use App\Mail\SecurityBlockMail;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Operator;
use App\Models\SecurityBlock;
use App\Support\DnsLookup;
use App\Support\ProvisioningSettings;
use App\Support\Settings;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\IpUtils;
use Throwable;

/**
 * Die Sperr-Entscheidung: erst die Ausnahmeliste, dann ob schon eine Sperre
 * läuft, dann die Verdopplung bei Wiederholung — und erst danach der
 * Datensatz. Wer die Fehlversuche zählt und diese Klasse aufruft, ist eine
 * eigene, spätere Aufgabe.
 *
 * Ruft HostFirewall::block() auf, sobald ein erreichbarer Host feststeht.
 * Deren Kopfkommentar ist bindend: block() wirft nicht, wenn der Host gerade
 * nicht erreichbar ist, sondern gibt false zurück — und der Datensatz hier
 * entsteht UNABHÄNGIG von diesem Rückgabewert. Eine Sperre, die nur in der
 * Datenbank steht, ist sichtbar und wird beim nächsten Lauf erneut
 * eingetragen (eigene, spätere Aufgabe); eine Ausnahme aus block() würde
 * stattdessen den ganzen Zeitplan-Auftrag mitreißen, der diese Klasse
 * aufruft.
 */
class BlockAddress
{
    /** Erste Sperre. Jede Wiederholung binnen 24h verdoppelt bis zur Obergrenze. */
    private const BASE_SECONDS = 3600;

    private const MAX_SECONDS = 86400;

    /** @var array<int, string>|null einmal aufgelöst, siehe exemptRanges() */
    private ?array $exempt = null;

    public function __construct(private HostFirewall $firewall) {}

    public function forInstance(Instance $instance, string $ip, int $attempts): ?SecurityBlock
    {
        if ($this->isExempt($ip) || $this->hasActiveBlock($ip, instanceId: $instance->id, hostId: null)) {
            return null;
        }

        $block = $this->createBlock(
            ip: $ip,
            attempts: $attempts,
            reason: 'instance_login',
            instanceId: $instance->id,
            hostId: null,
            firewallHost: $instance->host,
        );

        $this->notifyInstanceOwner($instance, $block);

        return $block;
    }

    public function forHost(Host $host, string $ip, int $attempts): ?SecurityBlock
    {
        if ($this->isExempt($ip) || $this->hasActiveBlock($ip, instanceId: null, hostId: $host->id)) {
            return null;
        }

        $block = $this->createBlock(
            ip: $ip,
            attempts: $attempts,
            reason: 'host_ssh',
            instanceId: null,
            hostId: $host->id,
            firewallHost: $host,
        );

        $this->notifyHostManagers($host, $block);

        return $block;
    }

    /**
     * Benachrichtigt den Inhaber der Instanz per Mail — höchstens einmal je
     * Instanz und Stunde. Der Zeitpunkt der letzten Meldung steht in
     * `Settings` unter `security.notified.instance.<id>`, nicht in einer
     * eigenen Spalte: nach einer Stunde interessiert er niemanden mehr, und
     * eine Spalte dafür wäre außerhalb dieses einen Fensters immer bedeutungslos.
     *
     * Für Host-Sperren siehe `notifyHostManagers()` weiter unten — andere
     * Empfängerlogik (Betreiber statt Kunde), gleiche Drossel-Idee.
     *
     * Zustellung ist nicht die Bedingung für Schutz: die Sperre steht bereits,
     * bevor hier auch nur versucht wird zu verschicken. Die Drossel-Marke
     * wird erst NACH dem erfolgreichen Einreihen gesetzt, nicht davor —
     * sonst bliebe die Instanz nach einem einzigen fehlgeschlagenen Versuch
     * für den Rest der Stunde ganz ohne Meldung, ausgerechnet dann, wenn statt
     * einer Mail null angekommen sind. Ein Sturm bei dauerhaft kaputtem
     * Postfach ist trotzdem ausgeschlossen: Sperren selbst entstehen schon
     * nur ab der Zehner-Schwelle, nicht bei jedem Fehlversuch.
     *
     * `try/catch` fängt hier nur, was beim EINREIHEN schiefgeht — ein
     * synchroner Fehler, bevor die Mail überhaupt in der Warteschlange liegt
     * (`SecurityBlockMail` implementiert `ShouldQueue`). Was bei der
     * tatsächlichen ZUSTELLUNG schiefgeht, passiert später, in einem
     * Warteschlangen-Arbeiter, lange nachdem diese Methode zurückgekehrt ist,
     * und steht in dessen Protokoll, nicht hier. Die Sperre bleibt in beiden
     * Fällen stehen — das ist die eigentliche Zusage.
     */
    private function notifyInstanceOwner(Instance $instance, SecurityBlock $block): void
    {
        $key = 'security.notified.instance.'.$instance->id;
        $last = Settings::get($key);

        if ($last !== null && now()->subHour()->lt($last)) {
            return;
        }

        try {
            $mail = new SecurityBlockMail($block);
            Mail::mailer($mail->mailer)->to($instance->customer->email)->queue($mail);
            Settings::set($key, now()->toIso8601String());
        } catch (Throwable $e) {
            report($e);
        }
    }

    /**
     * Benachrichtigt jeden AKTIVEN Betreiber mit `hosts.manage` — höchstens
     * einmal je Host und Stunde (`security.notified.host.<id>`, dieselbe
     * Drossel-Idee wie bei Instanzen, eigener Schlüssel).
     *
     * Nicht an eine feste Adresse und nicht an eine neue Einstellung: genau
     * diese Berechtigung entscheidet schon, wer eine Host-Sperre in der
     * Konsole überhaupt aufheben darf (siehe `HostDetail`, `HostTerminal`,
     * `ConfirmRemoveHost`, alle hinter `hosts.manage`) — eine Mail an jemand
     * anderen wäre Lärm, eine an jeden mit dieser Berechtigung ist per
     * Definition der richtige Empfängerkreis, und er pflegt sich von selbst
     * bei jedem Rollenwechsel mit (Koordinator-Entscheidung, siehe Bericht).
     *
     * Kein Empfänger ist kein Fehler: eine frische Installation ohne Admin
     * neben dem Owner, oder eine, in der `hosts.manage` gerade niemandem
     * zugewiesen ist, bekommt schlicht keine Mail — die Sperre selbst steht
     * trotzdem, unabhängig davon.
     *
     * Die Drossel-Marke wird erst gesetzt, wenn mindestens ein Einreihen
     * geglückt ist — aus demselben Grund wie bei `notifyInstanceOwner()`: ein
     * Host, bei dem in einer Stunde ausgerechnet null statt einer Mail
     * ankamen, soll nicht auch noch für den Rest der Stunde verstummen. Jede
     * Adresse steht in ihrem EIGENEN `try/catch`, das nur das EINREIHEN
     * abdeckt (synchron, vor der Warteschlange) — nicht die tatsächliche
     * Zustellung, die später im Warteschlangen-Arbeiter passiert und dort
     * protokolliert wird. Ein einzelnes abgelehntes Postfach darf so weder
     * die übrigen Betreiber um ihre Meldung bringen noch die Marke für alle
     * verhindern, solange wenigstens einer sie bekommen hat.
     */
    private function notifyHostManagers(Host $host, SecurityBlock $block): void
    {
        $recipients = Operator::query()
            ->whereNull('disabled_at')
            ->permission('hosts.manage')
            ->pluck('email');

        if ($recipients->isEmpty()) {
            return;
        }

        $key = 'security.notified.host.'.$host->id;
        $last = Settings::get($key);

        if ($last !== null && now()->subHour()->lt($last)) {
            return;
        }

        $queued = false;

        foreach ($recipients as $email) {
            try {
                $mail = new SecurityBlockMail($block);
                Mail::mailer($mail->mailer)->to($email)->queue($mail);
                $queued = true;
            } catch (Throwable $e) {
                report($e);
            }
        }

        if ($queued) {
            Settings::set($key, now()->toIso8601String());
        }
    }

    /** Läuft für diese Adresse an diesem Subjekt schon eine Sperre? */
    private function hasActiveBlock(string $ip, ?int $instanceId, ?int $hostId): bool
    {
        return SecurityBlock::query()
            ->where('ip', $ip)
            ->when($instanceId !== null, fn ($q) => $q->where('instance_id', $instanceId))
            ->when($hostId !== null, fn ($q) => $q->where('host_id', $hostId))
            ->active()
            ->exists();
    }

    private function createBlock(
        string $ip,
        int $attempts,
        string $reason,
        ?int $instanceId,
        ?int $hostId,
        ?Host $firewallHost,
    ): SecurityBlock {
        $strikes = $this->strikesWithinADay($ip, $instanceId, $hostId) + 1;
        $seconds = min(self::BASE_SECONDS * 2 ** ($strikes - 1), self::MAX_SECONDS);
        $blockedAt = now();

        $block = SecurityBlock::create([
            'instance_id' => $instanceId,
            'host_id' => $hostId,
            'ip' => $ip,
            'reason' => $reason,
            'attempts' => $attempts,
            'strikes' => $strikes,
            'blocked_at' => $blockedAt,
            'expires_at' => $blockedAt->copy()->addSeconds($seconds),
        ]);

        // Ohne Host (Instanz noch nicht platziert) gibt es nichts einzutragen —
        // der Datensatz steht trotzdem, und eine spätere Zuweisung findet ihn.
        if ($firewallHost !== null) {
            $this->firewall->block($firewallHost, $ip, $seconds);
        }

        return $block;
    }

    /**
     * Die wievielte Sperre dieser Adresse an diesem Subjekt in den letzten 24
     * Stunden das hier wird. Zählt JEDE Sperre in dem Fenster, auch eine
     * inzwischen vorzeitig aufgehobene — wer freigibt, hebt die Sperre auf,
     * nicht die Erinnerung daran, dass sie fällig war.
     */
    private function strikesWithinADay(string $ip, ?int $instanceId, ?int $hostId): int
    {
        return SecurityBlock::query()
            ->where('ip', $ip)
            ->when($instanceId !== null, fn ($q) => $q->where('instance_id', $instanceId))
            ->when($hostId !== null, fn ($q) => $q->where('host_id', $hostId))
            ->where('blocked_at', '>=', now()->subDay())
            ->count();
    }

    private function isExempt(string $ip): bool
    {
        return $ip !== '' && IpUtils::checkIp($ip, $this->exemptRanges());
    }

    /**
     * Die Ausnahmeliste. Hart verdrahtet, ohne Schalter: über das
     * Verwaltungsnetz `10.66.0.0/24` erreicht CluPilot den Host überhaupt —
     * eine Sperre dort wäre das Ende der Fernwartung.
     *
     * Einmal je Instanz dieser Klasse aufgelöst und dann gemerkt: ein Lauf
     * holt sich `BlockAddress` einmal aus dem Container und fragt damit jede
     * gesichtete Adresse, und der Endpunkt-Eintrag unten kann eine
     * Namensauflösung kosten.
     *
     * @return array<int, string>
     */
    private function exemptRanges(): array
    {
        return $this->exempt ??= array_merge(
            ['10.66.0.0/24', '127.0.0.1', '::1'],
            $this->ownServerAddresses(),
        );
    }

    /**
     * Die eigene öffentliche Adresse des CluPilot-Servers, aus dem
     * WireGuard-Endpunkt.
     *
     * Der Endpunkt ist `host:port`, und `host` darf ein NAME sein — genau so
     * steht es in `.env.example` (`vpn.clupilot.com:51820`). `IpUtils::checkIp()`
     * sagt zu einem Namen immer `false`, also war die eigene Adresse in
     * dokumentierter Konfiguration still NICHT ausgenommen: der Server hätte
     * sich selbst aussperren können. Bei IPv6 blieben zusätzlich die Klammern
     * stehen (`[2001:db8::1]`), mit demselben Ergebnis.
     *
     * Schlägt die Auflösung fehl, ist das kein Fehler — dann fällt der Eintrag
     * weg wie bei leerem Endpunkt. Ein Name, der gerade nicht auflöst, darf
     * nicht dazu führen, dass gar nichts mehr gesperrt wird.
     *
     * @return array<int, string>
     */
    private function ownServerAddresses(): array
    {
        $endpoint = trim(ProvisioningSettings::wgEndpoint());

        // Leer ist kein Fehler (frische Installation ohne Endpoint) — dann
        // fällt genau dieser eine Eintrag der Liste weg.
        if ($endpoint === '') {
            return [];
        }

        $host = $this->hostPartOf($endpoint);

        if ($host === '') {
            return [];
        }

        // Schon eine Adresse: nichts aufzulösen. Deckt beide Schreibweisen ab,
        // die hostPartOf() liefern kann — die entklammerte IPv6 und die
        // gewöhnliche IPv4.
        if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
            return [$host];
        }

        // Ein Name steht für ALLE seine Adressen. Nur die erste zu nehmen
        // hieße, den Server bei einem Wechsel im Round-Robin doch wieder
        // aussperren zu können.
        return app(DnsLookup::class)->addressesFor($host);
    }

    /**
     * Der Wirt aus `wirt:port` — mit den drei Formen, die wirklich vorkommen:
     * `1.2.3.4:51820`, `[2001:db8::1]:51820` und `vpn.example.com:51820`.
     * Eine nackte IPv6 ohne Klammern und ohne Port (`2001:db8::1`) bleibt
     * ganz, weil `beforeLast(':')` sie sonst mitten im Wort abschnitte.
     */
    private function hostPartOf(string $endpoint): string
    {
        if (preg_match('/^\[(.+)\](?::\d+)?$/', $endpoint, $match) === 1) {
            return $match[1];
        }

        if (filter_var($endpoint, FILTER_VALIDATE_IP) !== false) {
            return $endpoint;
        }

        return str_contains($endpoint, ':') ? Str::beforeLast($endpoint, ':') : $endpoint;
    }
}
