<?php

use App\Models\Host;
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Services\Terminal\TerminalTicket;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;

beforeEach(function () {
    // Der Schlüssel, den das Ticket mitgeben soll. Ohne ihn stünde im Ticket
    // ein leerer Wert, und der Test bewiese nichts.
    app(SecretVault::class)->put('ssh.private_key', "-----BEGIN OPENSSH PRIVATE KEY-----\nTEST\n-----END OPENSSH PRIVATE KEY-----", Operator::factory()->create());
});

it('trägt alles, was die Brücke braucht — und nichts davon im Klartext an den Browser', function () {
    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);
    $operator = Operator::factory()->role('Owner')->create();

    $ticket = TerminalTicket::issue($host, $operator);

    // Undurchsichtig: keine Adresse, kein Name, nichts Ratbares.
    expect($ticket)->toMatch('/^[a-f0-9]{64}$/')
        ->and($ticket)->not->toContain($host->wg_ip)
        ->and($ticket)->not->toContain($host->name);

    $payload = TerminalTicket::redeem($ticket);

    expect($payload['host_uuid'])->toBe($host->uuid)
        ->and($payload['operator_id'])->toBe($operator->id)
        // Die TUNNELADRESSE, nie die öffentliche: der Container steht im
        // Tunnel, und die öffentliche IP wäre der Weg, den SecureHostFirewall
        // ausdrücklich zumacht.
        ->and($payload['ip'])->toBe($host->wg_ip)
        ->and($payload['user'])->toBe('root')
        ->and($payload['fingerprint'])->toBe('SHA256:abc')
        ->and($payload['private_key'])->toContain('BEGIN OPENSSH PRIVATE KEY');
});

it('legt das Ticket in Redis als reines JSON ab, nicht als PHP-serialisierten Wert', function () {
    // Fix-Runde 1: Cache::put() lief über Illuminate\Cache\RedisStore, und
    // die verpackt jeden Wert mit PHP serialize(), solange kein 'serializer'
    // konfiguriert ist (ist er hier nicht) — aus dem JSON wäre in Redis ein
    // `s:412:"{...}";` geworden. Der Python-Container aus Aufgabe 3 kann
    // json_decode(), aber kein PHP serialize() — das muss also tatsächlich
    // ankommen, nicht nur im Kopfkommentar behauptet werden.
    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);
    $ticket = TerminalTicket::issue($host, Operator::factory()->role('Owner')->create());

    // Derselbe volle Schlüssel, den der Kopfkommentar von TerminalTicket für
    // Aufgabe 3 verspricht — absichtlich hier als Literal wiederholt statt
    // aus einer (privaten) Klassenkonstante gelesen, damit dieser Test genau
    // das nachvollzieht, was ein fremder Client tun müsste.
    $raw = Redis::connection('cache')->get('terminal:ticket:'.$ticket);

    expect($raw)->not->toStartWith('s:')
        ->and(json_decode($raw, true, flags: JSON_THROW_ON_ERROR))
        ->toMatchArray(['host_uuid' => $host->uuid, 'user' => 'root']);

    // Aufräumen statt auf die TTL zu warten — sonst bleibt der Testlauf
    // einen Redis-Schlüssel schuldig, den redeem() nie zu sehen bekam.
    Redis::connection('cache')->del('terminal:ticket:'.$ticket);
});

it('setzt in Redis eine Ablaufzeit von höchstens dreißig Sekunden', function () {
    // Redis führt seine Ablaufzeit über die reale Uhr des Servers, nicht
    // über die von travel() verschobene PHP-Zeit — ein 31-Sekunden-Sleep wäre
    // die einzige ehrliche Art, den tatsächlichen Ablauf hier zu erzwingen,
    // und das ist für einen Testlauf nicht verhältnismäßig (R22). Was von
    // hier aus ehrlich geprüft werden kann: dass issue() den SETEX mit der
    // richtigen Sekundenzahl aufruft. Dass eine abgelaufene Ablaufzeit Redis
    // tatsächlich leert, ist Redis' eigene, unabhängig getestete Aufgabe.
    $ticket = TerminalTicket::issue(
        Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']),
        Operator::factory()->role('Owner')->create(),
    );

    $ttl = Redis::connection('cache')->ttl('terminal:ticket:'.$ticket);

    expect($ttl)->toBeGreaterThan(0)->toBeLessThanOrEqual(TerminalTicket::TTL_SECONDS);

    TerminalTicket::redeem($ticket);
});

it('trägt genau eine Sitzung', function () {
    $ticket = TerminalTicket::issue(
        Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']),
        Operator::factory()->role('Owner')->create(),
    );

    expect(TerminalTicket::redeem($ticket))->not->toBeNull()
        // Die zweite Einlösung läuft ins Leere. Das Löschen beim Lesen IST die
        // Regel — ein Ticket, das zweimal trägt, ist ein Nachschlüssel.
        ->and(TerminalTicket::redeem($ticket))->toBeNull();
});

it('gibt kein Ticket ohne hinterlegten Schlüssel aus', function () {
    // Ein Ticket ohne Schlüssel führt zu einem Fenster, das aufgeht und nie
    // verbindet — der Fehler gehört hierher, nicht in den Container.
    app(SecretVault::class)->forget('ssh.private_key');

    // Diese Installation hat in .env tatsächlich einen SSH-Schlüssel
    // konfiguriert (CLUPILOT_SSH_PRIVATE_KEY_PATH), auf den SecretVault::get()
    // zurückfällt, solange kein Tresor-Eintrag existiert (siehe
    // OnboardingChecksTest, derselbe Rückfall) — direkt zurückgesetzt, damit
    // der Test nicht vom Entwicklerrechner abhängt.
    config()->set('provisioning.ssh.private_key', '');

    // Mit Meldungstext: SecretVault::get() wirft dieselbe Klasse auch bei
    // einem Entschlüsselungsfehler oder einem unbekannten Schlüssel — ohne
    // die Meldung könnte dieser Test aus einem ganz anderen Grund bestehen.
    expect(fn () => TerminalTicket::issue(
        Host::factory()->active()->create(),
        Operator::factory()->role('Owner')->create(),
    ))->toThrow(RuntimeException::class, 'Kein SSH-Schlüssel hinterlegt — ohne ihn kann keine Terminalsitzung entstehen.');
});

it('gibt kein Ticket ohne Tunneladresse aus', function () {
    // Kein active(): die Fabrik setzt wg_ip nur in diesem Zustand.
    $host = Host::factory()->create(['ssh_host_key' => 'SHA256:abc']);

    expect(fn () => TerminalTicket::issue($host, Operator::factory()->role('Owner')->create()))
        ->toThrow(RuntimeException::class, "Host {$host->name} hat keine Tunneladresse oder keinen geprüften Fingerabdruck hinterlegt — ohne beides kein Ticket.");
});

it('gibt kein Ticket ohne geprüften Fingerabdruck aus', function () {
    // Fix-Runde 1: active() setzt wg_ip, aber nie ssh_host_key — genau die
    // Lücke, durch die vorher drei der Tests oben Tickets ohne Fingerabdruck
    // ausstellten, ohne dass es auffiel. Ein fehlender Fingerabdruck ist der
    // gefährlichere der beiden Fälle: er übergäbe die Prüfung an Code, der
    // noch nicht existiert (Aufgabe 3), und dessen naheliegendste Fassung
    // ("kein Fingerabdruck im Ticket → nicht prüfen") eine ungepinnte
    // Root-SSH-Sitzung im Tunnel wäre.
    $host = Host::factory()->active()->create();

    expect(fn () => TerminalTicket::issue($host, Operator::factory()->role('Owner')->create()))
        ->toThrow(RuntimeException::class, "Host {$host->name} hat keine Tunneladresse oder keinen geprüften Fingerabdruck hinterlegt — ohne beides kein Ticket.");
});

// ── Aufgabe 2: die Seite und die Knöpfe ─────────────────────────────────────

it('lässt niemanden ohne hosts.manage an ein Terminal', function () {
    // Mit Fingerabdruck: sonst schlüge der Aufruf schon an issue() selbst
    // fehl (siehe Test oben), nicht an der Berechtigungsprüfung — und dieser
    // Test soll genau LETZTERE belegen.
    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);

    // „Read-only" darf die Konsole betreten und sonst nichts.
    $this->actingAs(Operator::factory()->role('Read-only')->create(), 'operator')
        ->get(route('admin.hosts.terminal', ['host' => $host->uuid]))
        ->assertForbidden();
});

it('zeigt den Vorspann und reicht das Ticket weiter, aber niemals den Schlüssel', function () {
    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);

    $html = $this->actingAs(admin(), 'operator')
        ->get(route('admin.hosts.terminal', ['host' => $host->uuid]))
        ->assertOk()
        ->getContent();

    // Der Vorspann steht da, bevor irgendetwas verbindet.
    expect($html)->toContain('CluPilot')
        ->and($html)->toContain('data-terminal-stage')
        // Das Ticket selbst muss im Markup landen — sonst bewiese dieser Test
        // trotz seines Namens nie, dass issue() etwas Brauchbares liefert.
        // 64 Hexzeichen: TerminalTicket::issue() aus Aufgabe 1 bildet das
        // Ticket über bin2hex(random_bytes(32)).
        ->and($html)->toMatch('/data-ticket="[0-9a-f]{64}"/')
        // Und nichts, was die Brücke geheim halten muss.
        ->and($html)->not->toContain('BEGIN OPENSSH PRIVATE KEY')
        ->and($html)->not->toContain($host->wg_ip);
});

it('setzt in jede Meldung, die der Browser später zeigt, den Hostnamen wirklich ein', function () {
    // Gefunden beim Hinsehen, nicht beim Testen: die Überschrift zum Abbruch
    // stand als „:host antwortet nicht" auf dem Schirm — der Platzhalter war
    // beim Übersetzen ohne Ersetzung geblieben.
    //
    // Ein Test, der `toContain(__('…title'))` schriebe, hätte das nie gefunden:
    // er vergliche ":host" mit ":host" und wäre grün. Deshalb prüft dieser hier
    // das Ergebnis — Name drin, Doppelpunkt-Platzhalter draußen.
    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);

    $html = $this->actingAs(admin(), 'operator')
        ->get(route('admin.hosts.terminal', ['host' => $host->uuid]))
        ->getContent();

    // Die Meldungen reisen als JSON im data-Attribut mit; im HTML stehen die
    // Anführungszeichen deshalb als &quot;.
    expect($html)->toContain('data-stage-messages=')
        ->and($html)->not->toContain(':host');

    // Und der Name steht wirklich drin — sonst wäre die Zeile oben auch dann
    // grün, wenn gar keine Meldung mitkäme.
    // `Str::between` schnitte bis zum LETZTEN Anführungszeichen der Seite —
    // gebraucht wird das erste, also das Ende des Attributs. Innerhalb des
    // Attributs steht ohnehin `&quot;`, nie ein rohes Zeichen.
    $messages = json_decode(html_entity_decode(
        Str::before(Str::after($html, 'data-stage-messages="'), '"'), ENT_QUOTES
    ), true);

    expect($messages['unreachable']['title'])->toContain($host->name)
        ->and($messages['ended']['note'])->toContain($host->name)
        ->and($messages['lost']['note'])->toContain($host->name);

    // Jeder Schlüssel, den terminal.js an `showStage()` übergeben kann, muss
    // hier liegen — sonst stünde im Fenster „undefined", und zwar ausgerechnet
    // in dem Moment, in dem etwas kaputt ist.
    expect(array_keys($messages))->toEqualCanonicalizing([
        'ended', 'lost', 'rejected', 'unreachable', 'service_down', 'not_here',
    ]);
});

it('bietet den Terminal-Knopf nur dem, der ihn drücken darf', function () {
    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);

    // Auf das nackte Wort „Terminal" zu prüfen wäre dieselbe Falle wie
    // assertDontSee('179') — es kann durch etwas anderes auf der Seite erfüllt
    // sein. Der Link zu genau diesem Host kann das nicht.
    $link = route('admin.hosts.terminal', ['host' => $host->uuid]);

    $erlaubt = $this->actingAs(admin(), 'operator')->get(route('admin.hosts'))->getContent();
    expect($erlaubt)->toContain($link);

    $this->actingAs(Operator::factory()->role('Read-only')->create(), 'operator');
    $verwehrt = $this->get(route('admin.hosts'))->getContent();
    expect($verwehrt)->not->toContain($link);
});

it('prüft die Berechtigung vor dem Nachschlagen — eine erfundene UUID meldet 403, nie 404', function () {
    // Der Sicherheitsriegel dieser Aufgabe: authorize() steht in mount() VOR
    // dem Host::query()->where('uuid', …)->firstOrFail(). Käme die Datenbank
    // zuerst dran, würde eine UUID, die es gar nicht gibt, mit 404 antworten —
    // und ein 404 gegen ein 403 verriete einem Operator ohne Berechtigung,
    // dass die Prüfreihenfolge übersprungen wurde.
    $this->actingAs(Operator::factory()->role('Read-only')->create(), 'operator')
        ->get(route('admin.hosts.terminal', ['host' => (string) Str::uuid()]))
        ->assertForbidden();
});

it('zeigt den Terminal-Knopf auch für einen Host, der noch nicht so weit ist', function () {
    // Mitten in der Übernahme: die Tunneladresse steht noch nicht, der
    // Fingerabdruck auch nicht (Host::factory() ohne active()).
    //
    // Der Knopf verschwand hier einmal, weil die Seite dahinter mit einer
    // unbehandelten Ausnahme aufging. Das tut sie nicht mehr — sie schreibt
    // hin, was fehlt. Ein fehlender Knopf sähe aus wie „hier gibt es kein
    // Terminal" statt „hier noch nicht, und zwar deshalb".
    $host = Host::factory()->create();

    $html = $this->actingAs(admin(), 'operator')->get(route('admin.hosts'))->getContent();

    expect($html)->toContain(route('admin.hosts.terminal', ['host' => $host->uuid]));
});

it('geht bei einem Host ohne Fingerabdruck nicht mit einer Fehlerseite auf, sondern sagt warum', function () {
    // Das war der eigentliche Fehler: TerminalTicket::issue() warf, niemand
    // fing es, und der Betreiber bekam Laravels Fehlerseite — Klassenname,
    // Dateipfad, Zeilennummer, Quelltextauszug — in einem Fenster, das er über
    // einen Knopf im eigenen Produkt geöffnet hatte.
    $host = Host::factory()->create(['wg_ip' => null, 'ssh_host_key' => null]);

    $html = $this->actingAs(admin(), 'operator')
        ->get(route('admin.hosts.terminal', ['host' => $host->uuid]))
        ->assertOk()
        ->getContent();

    expect($html)->toContain(__('hosts.terminal.problem.not_ready.title'))
        // Ohne Grund kein Ticket: ein Fenster, das trotzdem eines bekäme,
        // machte einen Socket auf und überschriebe den Satz oben mit einem
        // zweiten, ungenaueren.
        ->and($html)->toContain('data-ticket=""')
        ->and($html)->not->toContain('RuntimeException');
});

it('sagt auch dann warum, wenn gar kein Flottenschlüssel hinterlegt ist', function () {
    // Derselbe Weg, anderer Grund — und der andere Grund ist der, der ALLE
    // Hosts auf einmal betrifft. Er darf nicht als „dieser Host ist noch nicht
    // so weit" erscheinen, sonst sucht jemand am falschen Ende.
    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);

    // SecretVault::forget() fällt auf .env zurück, deshalb auch die
    // Konfiguration leeren (Muster aus OnboardingChecksTest).
    app(SecretVault::class)->forget('ssh.private_key');
    config()->set('provisioning.ssh.private_key', '');

    $html = $this->actingAs(admin(), 'operator')
        ->get(route('admin.hosts.terminal', ['host' => $host->uuid]))
        ->assertOk()
        ->getContent();

    expect($html)->toContain(__('hosts.terminal.problem.no_key.title'))
        ->and($html)->not->toContain(__('hosts.terminal.problem.not_ready.title'));
});
