<?php

use App\Livewire\Admin\ConfirmRemoveHost;
use App\Livewire\Admin\HostCreate;
use App\Livewire\Admin\HostDetail;
use App\Livewire\Admin\Hosts;
use App\Livewire\Admin\ReissueTakeover;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Models\User;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Provisioning\Jobs\CollectHostLoad;
use App\Provisioning\Jobs\PurgeHost;
use App\Provisioning\Jobs\RemoveWireguardPeer;
use App\Services\Proxmox\HostLoadSeries;
use App\Support\HostEnrolment;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;

it('gates the add-host page', function () {
    $this->get(route('admin.hosts.create'))->assertRedirect(route('admin.login'));
    $this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('admin.hosts.create'))->assertForbidden();
    $this->actingAs(admin(), 'operator')->get(route('admin.hosts.create'))->assertOk()->assertSee(__('hosts.create_title'));
});

it('gates the host detail page', function () {
    $host = Host::factory()->create();
    $this->get(route('admin.hosts.show', $host))->assertRedirect(route('admin.login'));
    $this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('admin.hosts.show', $host))->assertForbidden();
    $this->actingAs(admin(), 'operator')->get(route('admin.hosts.show', $host))->assertOk()->assertSee($host->name);
});

it('lists real hosts for admins', function () {
    Host::factory()->active()->create(['name' => 'pve-zzz-1']);

    $this->actingAs(admin(), 'operator')->get(route('admin.hosts'))->assertOk()->assertSee('pve-zzz-1');
});

it('creates a host and starts onboarding with an encrypted password', function () {
    Queue::fake();
    // Das Anlegen erzeugt jetzt auch das WireGuard-Schlüsselpaar und nimmt den
    // Peer beim Hub auf, damit der Host schon im Tunnel bekannt ist, bevor er
    // das erste Mal bootet (Spec §5). Ohne den gefälschten Hub griffe das nach
    // `wg` auf einer Maschine, die keine wg0 hat.
    fakeServices();
    Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);

    Livewire::actingAs(admin(), 'operator')
        ->test(HostCreate::class)
        ->set('datacenter', 'fsn')
        ->set('public_ip', '203.0.113.7')
        ->set('root_password', 'supersecret')
        ->call('save')
        // KEINE Weiterleitung mehr, und das ist der Punkt: die Befehlszeile für
        // das Rettungssystem gibt es nur hier und nur jetzt. Wer den Betreiber
        // weiterschickt, schickt ihn von dem einzigen Wert weg, den er braucht.
        ->assertNoRedirect()
        // Keine Befehlszeile mehr: das Speichern startet die Pipeline, die
        // sich selbst anmeldet. Was bleibt, ist der Weg zum Zusehen.
        ->assertDontSee('clupilot-bootstrap.sh')
        ->assertSee(__('hosts.takeover.watch'));

    $host = Host::query()->where('name', 'fsn-01')->first();
    expect($host)->not->toBeNull()->and($host->status)->toBe('pending');

    $run = ProvisioningRun::query()->where('subject_id', $host->id)->first();
    expect($run)->not->toBeNull()
        ->and($run->pipeline)->toBe('host')
        ->and(Crypt::decryptString($run->context('root_password')))->toBe('supersecret');

    Queue::assertPushed(AdvanceRunJob::class);
});

it('rejects a duplicate public ip', function () {
    Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
    Host::factory()->create(['public_ip' => '203.0.113.99']);

    Livewire::actingAs(admin(), 'operator')
        ->test(HostCreate::class)
        ->set('datacenter', 'fsn')
        ->set('public_ip', '203.0.113.99')
        ->set('root_password', 'supersecret')
        ->call('save')
        ->assertHasErrors(['public_ip']);
});

it('validates the add-host form', function () {
    Livewire::actingAs(admin(), 'operator')
        ->test(HostCreate::class)
        ->set('public_ip', 'not-an-ip')
        ->set('root_password', 'short')
        ->call('save')
        ->assertHasErrors(['public_ip', 'root_password']);
});

it('retries a failed run from the detail page', function () {
    Queue::fake();
    $host = Host::factory()->create(['status' => 'error']);
    $run = ProvisioningRun::factory()->forHost($host)->create([
        'status' => 'failed', 'current_step' => 3, 'error' => 'boom',
        'started_at' => now()->subHour(),
    ]);

    Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])->call('retry');

    $run->refresh();
    expect($run->status)->toBe('running')
        ->and($run->error)->toBeNull()
        ->and($run->started_at->greaterThan(now()->subMinute()))->toBeTrue()
        ->and($host->fresh()->status)->toBe('onboarding');
    Queue::assertPushed(AdvanceRunJob::class);
});

it('deactivates the host and queues an async purge', function () {
    Queue::fake();
    $host = Host::factory()->active()->create(['wg_pubkey' => 'HOSTPUB=']);
    ProvisioningRun::factory()->forHost($host)->create();

    Livewire::actingAs(admin(), 'operator')
        ->test(ConfirmRemoveHost::class, ['uuid' => $host->uuid])
        ->call('remove')
        ->assertRedirect(route('admin.hosts'));

    expect($host->fresh()->status)->toBe('disabled');
    Queue::assertPushed(PurgeHost::class, fn ($job) => $job->uuid === $host->uuid);
});

it('purges a host: deletes its runs and record and queues peer removal', function () {
    Queue::fake([RemoveWireguardPeer::class]);
    $host = Host::factory()->active()->create(['wg_pubkey' => 'HOSTPUB=']);
    ProvisioningRun::factory()->forHost($host)->create();

    (new PurgeHost($host->uuid))->handle();

    expect(Host::query()->find($host->id))->toBeNull();
    Queue::assertPushed(RemoveWireguardPeer::class, fn ($job) => $job->publicKey === 'HOSTPUB=');
});

it('renders the live stepper for a running host', function () {
    $host = Host::factory()->create(['status' => 'onboarding']);
    ProvisioningRun::factory()->forHost($host)->create(['status' => 'running', 'current_step' => 2]);

    $this->actingAs(admin(), 'operator')->get(route('admin.hosts.show', $host))
        ->assertOk()
        ->assertSee(__('hosts.step.prepare_base_system'))
        ->assertSee(__('hosts.progress'));
});

it('filters the host list by search and datacenter', function () {
    Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
    Datacenter::query()->firstOrCreate(['code' => 'hel'], ['name' => 'Helsinki']);
    Host::factory()->create(['name' => 'pve-fsn-9', 'datacenter' => 'fsn', 'public_ip' => '203.0.113.9']);
    Host::factory()->create(['name' => 'pve-hel-9', 'datacenter' => 'hel', 'public_ip' => '203.0.113.19']);

    Livewire::actingAs(admin(), 'operator')->test(Hosts::class)
        ->set('search', 'fsn-9')
        ->assertSee('pve-fsn-9')
        ->assertDontSee('pve-hel-9')
        ->set('search', '')
        ->set('datacenter', 'hel')
        ->assertSee('pve-hel-9')
        ->assertDontSee('pve-fsn-9');
});

it('edits host reserve and toggles maintenance', function () {
    $host = Host::factory()->active()->create(['reserve_pct' => 15]);

    Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
        ->call('saveReserve', 25)
        ->call('toggleMaintenance');

    $host->refresh();
    expect($host->reserve_pct)->toBe(25)->and($host->status)->toBe('disabled');

    Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])->call('toggleMaintenance');
    expect($host->fresh()->status)->toBe('active');
});

it('clamps host reserve to a sane range', function () {
    $host = Host::factory()->active()->create(['reserve_pct' => 15]);

    Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])->call('saveReserve', 500);
    expect($host->fresh()->reserve_pct)->toBe(90);
});

it('reports host heartbeat health from last_seen_at', function () {
    $online = Host::factory()->create(['last_seen_at' => now()->subMinutes(2)]);
    $stale = Host::factory()->create(['last_seen_at' => now()->subMinutes(20)]);
    $offline = Host::factory()->create(['last_seen_at' => now()->subHours(3)]);
    $never = Host::factory()->create(['last_seen_at' => null]);

    expect($online->healthState())->toBe('online')
        ->and($stale->healthState())->toBe('stale')
        ->and($offline->healthState())->toBe('offline')
        ->and($never->healthState())->toBe('offline');
});

/*
|--------------------------------------------------------------------------
| Die Befehlszeile noch einmal
|--------------------------------------------------------------------------
|
| Es gab sie genau einmal, auf der Seite direkt nach dem Anlegen. Wer sie dort
| nicht kopierte — oder wem sie wegbrach, wie beim WireGuard-Fehler bis
| 1.3.80 — hatte danach einen Host in der Liste, für den es keinen Weg zu
| einer Befehlszeile mehr gab: ein zweites Anlegen scheitert an der eindeutigen
| IP, also blieb nur Entfernen und von vorn.
*/

it('hands out a fresh command line for a host that already exists', function () {
    Queue::fake();
    $host = Host::factory()->create(['status' => 'onboarding', 'wg_ip' => null, 'wg_pubkey' => null]);

    $component = Livewire::actingAs(admin(), 'operator')
        ->test(ReissueTakeover::class, ['uuid' => $host->uuid])
        ->assertSet('command', null)
        ->call('issue');

    $command = $component->get('command');

    // Die ganze Zeile, nicht nur der Code — das ist der Punkt: sie trägt auch
    // den privaten WireGuard-Schlüssel, die Tunneladresse und den FQDN.
    expect($command)->toContain('clupilot-bootstrap.sh')
        ->and($command)->toContain('--code ')
        ->and($command)->toContain('--wg-private ')
        ->and($command)->toContain('--fqdn ');
});

it('makes the previously issued code worthless', function () {
    // Sonst hätte eine Maschine, die im Rettungssystem hängengeblieben ist,
    // weiter einen gültigen Ausweis für einen Host, den der Betreiber gerade
    // neu übernimmt.
    Queue::fake();
    $host = Host::factory()->create(['status' => 'onboarding', 'wg_ip' => null, 'wg_pubkey' => null]);
    $alt = HostEnrolment::issue($host);

    Livewire::actingAs(admin(), 'operator')
        ->test(ReissueTakeover::class, ['uuid' => $host->uuid])
        ->call('issue');

    expect(HostEnrolment::resolve($alt))->toBeNull();
});

it('does not hand the takeover line to an operator who may not manage hosts', function () {
    // In der Antwort steht ein privater WireGuard-Schlüssel. Ein Modal ist ohne
    // die Route-Middleware der Seite erreichbar (R20), also prüft es selbst.
    $host = Host::factory()->create(['status' => 'onboarding']);

    Livewire::actingAs(operator('Read-only'), 'operator')
        ->test(ReissueTakeover::class, ['uuid' => $host->uuid])
        ->call('issue')
        ->assertForbidden();
});

it('refuses to rotate the credentials of a host that is already running', function () {
    // Der Knopf steht dort gar nicht — aber ein Modal, das offen blieb, während
    // der Host aktiv wurde, ruft die Methode trotzdem, und eine
    // Livewire-Methode ist ohnehin von außen aufrufbar. Ein neuer Schlüssel
    // schnitte die laufende Maschine aus dem Tunnel.
    Queue::fake();
    $host = Host::factory()->active()->create(['wg_ip' => null, 'wg_pubkey' => null]);
    $alt = HostEnrolment::issue($host);

    Livewire::actingAs(admin(), 'operator')
        ->test(ReissueTakeover::class, ['uuid' => $host->uuid])
        ->call('issue')
        ->assertSet('command', null)
        ->assertSet('refusal', __('hosts.reissue.not_eligible'));

    // Und der bisherige Code gilt weiter: eine abgelehnte Ausstellung darf
    // nichts entwerten.
    expect(HostEnrolment::resolve($alt)?->id)->toBe($host->id);
});

it('issues nothing while the tunnel settings are incomplete', function () {
    // Sonst wird ein gültiger Code entwertet und dafür eine Zeile ausgegeben,
    // der die Tunnel-Angaben fehlen — sie läuft und endet in einem Tunnel ohne
    // Handshake, und das fällt erst auf der Maschine auf.
    Queue::fake();
    config()->set('provisioning.wireguard.hub_public_key', '');

    $host = Host::factory()->create(['status' => 'onboarding', 'wg_ip' => null, 'wg_pubkey' => null]);
    $alt = HostEnrolment::issue($host);

    Livewire::actingAs(admin(), 'operator')
        ->test(ReissueTakeover::class, ['uuid' => $host->uuid])
        ->call('issue')
        ->assertSet('command', null)
        ->assertSet('refusal', __('hosts.takeover.missing_title'));

    expect(HostEnrolment::resolve($alt)?->id)->toBe($host->id);
});

it('treats a drained host as running, not as one waiting for takeover', function () {
    // `disabled` sieht aus wie „noch nicht fertig" und ist das Gegenteil:
    // toggleMaintenance() schaltet einen LAUFENDEN Host so still, um ihn aus
    // der Platzierung zu nehmen. Ein Knopf, der dort eine Neuinstallation
    // anbietet, trifft eine Produktionsmaschine.
    Queue::fake();
    $host = Host::factory()->active()->create(['status' => 'disabled', 'wg_ip' => null, 'wg_pubkey' => null]);
    $alt = HostEnrolment::issue($host);

    Livewire::actingAs(admin(), 'operator')
        ->test(ReissueTakeover::class, ['uuid' => $host->uuid])
        ->call('issue')
        ->assertSet('command', null)
        ->assertSet('refusal', __('hosts.reissue.not_eligible'));

    expect(HostEnrolment::resolve($alt)?->id)->toBe($host->id)
        ->and(ReissueTakeover::eligible($host))->toBeFalse();
});

// --- Detailseite: lebendig und fest getrennt ---------------------------------

it('shows the version number rather than the build hash it is buried in', function () {
    // In der schmalen Karte wurde `pve-manager/9.2.6/7f8d…` mitten in der
    // Bau-Kennung abgeschnitten — sichtbar war alles außer der Zahl, um die es
    // geht.
    fakeServices();
    $host = Host::factory()->active()->create(['pve_version' => 'pve-manager/9.2.6/7f8d010005bd7231']);

    Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
        ->assertSee('Proxmox VE 9.2.6');
});

it('gives every measurement its own tile, so none of them needs a legend', function () {
    // Eine Reihe je Kachel: die Beschriftung benennt sie, und damit entfällt
    // die Legende, die vorher unter der gemeinsamen Kurve stand.
    $s = fakeServices();
    // Bewusst unverwechselbare Zahlen: „50" träfe auch die 500 GB in der
    // Instanzenliste darunter, und ein Test, der aus Versehen recht behält,
    // prüft nichts. Siehe die lose Zahlen-Falle, die das Repo schon kennt.
    $s['pve']->rrd = [
        ['time' => 1754035200, 'cpu' => 0.11, 'memused' => 11, 'memtotal' => 100, 'netin' => 1_048_576, 'netout' => 1_048_576],
        ['time' => 1754035260, 'cpu' => 0.37, 'memused' => 63, 'memtotal' => 100, 'netin' => 3_145_728, 'netout' => 5_242_880],
    ];
    $host = Host::factory()->active()->create(['node' => 'pve-fns-1', 'api_token_ref' => 'ref']);

    // Der Sammler läuft im provisioning-Container, die Seite liest nur. Genau
    // diese Reihenfolge, sonst prüft der Test einen Weg, den es nicht gibt.
    app(HostLoadSeries::class)->collect($host);

    Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
        ->assertSee(__('hosts.detail.cpu_load'))
        ->assertSee(__('hosts.detail.ram_load'))
        ->assertSee(__('hosts.detail.net_in'))
        ->assertSee(__('hosts.detail.net_out'))
        // Der jüngste Messwert je Kachel, jeder eine Zahl, die sonst nirgends
        // auf dieser Seite vorkommt.
        ->assertSee('37')      // CPU
        ->assertSee('63')      // RAM
        ->assertSee('3,00')    // eingehend, MiB/s
        ->assertSee('5,00')    // ausgehend
        ->assertDontSee(__('hosts.detail.no_load_short'));
});

it('shows nothing until the collector has run, because the page cannot fetch', function () {
    // Der Fehler auf echter Hardware: der `app`-Container hängt nicht im
    // WireGuard-Tunnel. Eine Seite, die selbst holt, zeigt dort für immer
    // Leerstellen — und behauptet damit etwas über den Host, das nur über den
    // Weg dorthin stimmt. Queue::fake() hält den Sammler an, damit dieser Test
    // den Zustand VOR seinem ersten Lauf zeigt.
    Queue::fake();
    $s = fakeServices();
    $s['pve']->rrd = [['time' => 1754035200, 'cpu' => 0.25, 'memused' => 1, 'memtotal' => 2]];
    $host = Host::factory()->active()->create(['api_token_ref' => 'ref']);

    Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
        ->assertSee(__('hosts.detail.no_load_short'));
});

it('shows the public ip on the page, not only in the small print', function () {
    // Die Adresse, unter der der Host wirklich erreichbar ist, stand nirgends
    // in der Seite — nur klein unter der Überschrift.
    fakeServices();
    $host = Host::factory()->active()->create(['public_ip' => '49.12.121.79']);

    Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
        ->assertSee(__('hosts.detail.public_ip'))
        ->assertSee('49.12.121.79');
});

it('keeps the reserve control with the settings, not on a reading tile', function () {
    fakeServices();
    $host = Host::factory()->active()->create(['reserve_pct' => 15]);

    Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
        ->assertSee(__('hosts.detail.spec'))
        ->assertSee(__('hosts.detail.reserve_label'));
});

it('says a silent host has no measurements instead of drawing a quiet hour', function () {
    // Der ganze Grund, warum HostLoadSeries eine leere Reihe liefert statt
    // Nullen: eine Null-Linie behauptet, der Host habe nichts zu tun gehabt.
    fakeServices(); // rrd bleibt leer
    $host = Host::factory()->active()->create();

    Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
        // Jede Messkachel sagt es für sich, und keine zeigt eine 0.
        ->assertSee(__('hosts.detail.no_load_short'))
        ->assertSee(__('hosts.detail.cpu_load'));
});

it('folds the finished onboarding away but keeps it reachable', function () {
    fakeServices();
    $host = Host::factory()->active()->create();
    ProvisioningRun::factory()->forHost($host)->create(['status' => ProvisioningRun::STATUS_COMPLETED]);

    Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
        // Fünfzehn abgehakte Schritte sind auf einem laufenden Host kein
        // Dauerinhalt — aufklappbar, nicht weg.
        ->assertSee('<details', false)
        ->assertSee(__('hosts.detail.onboarding_done'));
});

it('keeps the stepper open while the onboarding is still running', function () {
    fakeServices();
    $host = Host::factory()->create(['status' => 'onboarding']);
    ProvisioningRun::factory()->forHost($host)->create(['status' => ProvisioningRun::STATUS_RUNNING]);

    Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
        ->assertDontSee('<details', false)
        ->assertSee(__('hosts.progress'));
});

it('asks for a collection at once when the page finds no measurements', function () {
    // Sonst bleiben die Kacheln bis zum nächsten minütlichen Lauf leer, und ein
    // leerer Kasten liest sich als "kaputt", nicht als "gleich".
    Queue::fake();
    fakeServices();
    $host = Host::factory()->active()->create(['api_token_ref' => 'ref']);

    Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]);

    Queue::assertPushed(CollectHostLoad::class);
});

it('does not ask again when the measurements are already there', function () {
    Queue::fake();
    $s = fakeServices();
    $s['pve']->rrd = [['time' => 1754035200, 'cpu' => 0.5, 'memused' => 1, 'memtotal' => 2]];
    $host = Host::factory()->active()->create(['api_token_ref' => 'ref']);
    app(HostLoadSeries::class)->collect($host);

    Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]);

    Queue::assertNotPushed(CollectHostLoad::class);
});

it('draws an idle host as idle, on the page itself', function () {
    // Der Test für den Maßstab prüfte bisher nur das Bauteil. Die Kacheln
    // müssen ihm die Grenzen aber auch WIRKLICH mitgeben — ohne max=100 zeichnet
    // ein Host mit 0,2 % CPU wieder eine Welle über die volle Höhe und
    // widerspricht der Zahl daneben.
    $s = fakeServices();
    $s['pve']->rrd = collect(range(0, 20))->map(fn ($i) => [
        'time' => 1754035200 + $i * 60,
        'cpu' => 0.001 + ($i % 3) * 0.001,     // 0,1–0,3 %
        'memused' => 2 + ($i % 3),
        'memtotal' => 100,                      // 2–4 %
    ])->all();
    $host = Host::factory()->active()->create(['api_token_ref' => 'ref']);
    app(HostLoadSeries::class)->collect($host);

    $html = Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])->html();

    preg_match_all('/class="line" d="M([^"]+)"/', $html, $m);
    expect($m[1])->not->toBeEmpty();

    foreach ($m[1] as $d) {
        foreach (preg_split('/\s+/', trim($d)) as $i => $n) {
            // 30 ist die Grundlinie, 4 die Oberkante.
            if ($i % 2 === 1) {
                expect((float) $n)->toBeGreaterThan(28.0);
            }
        }
    }
});

it('does not start a fleet-wide collection for a host the collector skips', function () {
    // Ein Host mitten in der Übernahme hat noch keinen Token. Der Sammler
    // überspringt ihn — ein Aufruf seiner Seite würde also die ganze Flotte
    // abklappern und diese Kacheln trotzdem leer lassen.
    Queue::fake();
    fakeServices();
    $host = Host::factory()->create(['status' => 'onboarding', 'api_token_ref' => null]);

    Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]);

    Queue::assertNotPushed(CollectHostLoad::class);
});
