<?php

use App\Models\Host;
use App\Provisioning\Jobs\RemoveWireguardPeer;
use App\Services\Dns\FileHostDnsDirectory;
use App\Services\Dns\HttpHetznerDnsClient;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\HostLoadSeries;
use App\Services\Proxmox\HttpProxmoxClient;
use App\Services\Ssh\CommandResult;
use App\Services\Ssh\FakeRemoteShell;
use App\Services\Wireguard\FakeWireguardHub;
use App\Services\Wireguard\LocalWireguardHub;
use App\Support\PveVersion;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

it('scripts remote command output and records calls (FakeRemoteShell)', function () {
    $shell = new FakeRemoteShell;
    $shell->script('dpkg -l proxmox-ve', CommandResult::success('ii  proxmox-ve  8.2'));
    $shell->connectWithPassword('1.2.3.4', 'root', 'pw');

    expect($shell->run('dpkg -l proxmox-ve')->stdout)->toContain('proxmox-ve')
        ->and($shell->run('whoami')->ok())->toBeTrue()          // default success
        ->and($shell->ran('dpkg -l proxmox-ve'))->toBeTrue();

    $shell->putFile('/etc/wireguard/wg0.conf', '[Interface]');
    expect($shell->files()['/etc/wireguard/wg0.conf'])->toBe('[Interface]')
        ->and($shell->connectionsWith('password'))->toHaveCount(1);
});

it('removes a peer via the RemoveWireguardPeer job', function () {
    $hub = new FakeWireguardHub;
    $hub->addPeer('PK', '10.66.0.9');

    (new RemoveWireguardPeer('PK'))->handle($hub);

    expect($hub->peerIps())->toBe([]);
});

it('provides a deterministic VM lifecycle (FakeProxmoxClient)', function () {
    $pve = new FakeProxmoxClient;

    $a = $pve->nextVmid();
    $b = $pve->nextVmid();
    expect($b)->toBe($a + 1);

    $upid = $pve->cloneVm('pve', 9000, $a, 'nc-test');
    expect($upid)->toContain((string) $a)
        ->and($pve->clonedVmids)->toContain($a)
        ->and($pve->taskStatus('pve', $upid))->toBe(['status' => 'stopped', 'exitstatus' => 'OK'])
        ->and($pve->vmStatus('pve', $a)['status'])->toBe('stopped');

    $pve->startVm('pve', $a);
    expect($pve->vmStatus('pve', $a)['status'])->toBe('running')
        ->and($pve->guestAgentPing('pve', $a))->toBeTrue();

    $pve->guestScript('occ status', 0, 'installed: true');
    expect($pve->guestExec('pve', $a, 'occ status')['out-data'])->toContain('installed')
        ->and($pve->guestExec('pve', $a, 'whoami')['exitcode'])->toBe(0)
        ->and($pve->guestRan('occ status'))->toBeTrue();
});

it('can force a running task and a failed exit status (FakeProxmoxClient)', function () {
    $pve = new FakeProxmoxClient;

    $pve->forceTaskStatus = 'running';
    expect($pve->taskStatus('pve', 'UPID')['status'])->toBe('running');

    $pve->forceTaskStatus = null;
    $pve->taskExitStatus = 'clone failed';
    expect($pve->taskStatus('pve', 'UPID')['exitstatus'])->toBe('clone failed');
});

it('allocates ips and tracks peers (FakeWireguardHub)', function () {
    $hub = new FakeWireguardHub;

    $a = $hub->allocateIp();
    $b = $hub->allocateIp();
    expect($a)->not->toBe($b);

    $hub->addPeer('PUBKEY', '10.66.0.9');
    expect($hub->peerIps())->toBe(['PUBKEY' => '10.66.0.9']);

    $hub->removePeer('PUBKEY');
    expect($hub->peerIps())->toBe([]);
});

it('allocates addresses within a /24 subnet, skipping hub and used (LocalWireguardHub)', function () {
    config()->set('provisioning.wireguard.subnet', '10.66.0.0/24');
    config()->set('provisioning.wireguard.hub_ip', '10.66.0.1');
    Host::factory()->create(['wg_ip' => '10.66.0.2']);

    // .0 network, .1 hub, .2 used → first free is .3
    expect((new LocalWireguardHub)->allocateIp())->toBe('10.66.0.3');
});

it('respects a non-/24 subnet (LocalWireguardHub)', function () {
    config()->set('provisioning.wireguard.subnet', '10.66.5.128/25');
    config()->set('provisioning.wireguard.hub_ip', '10.66.5.129');

    // network .128, hub .129 → first free is .130
    expect((new LocalWireguardHub)->allocateIp())->toBe('10.66.5.130');
});

it('reports node capacity for a host (FakeProxmoxClient)', function () {
    $host = Host::factory()->create(['wg_ip' => '10.66.0.5']);
    $client = (new FakeProxmoxClient)->forHost($host);

    expect($client->listNodes())->not->toBeEmpty()
        ->and($client->nodeStatus('pve')['memory']['total'])->toBeGreaterThan(0)
        ->and($client->nodeStorage('pve')[0]['total'])->toBeGreaterThan(0);
});

// --- HttpProxmoxClient::isTemplate ------------------------------------------
//
// Die eine Frage in diesem Client, deren falsche Antwort ETWAS ZERSTÖRT:
// BuildVmTemplate liest ein `false` als „Vorlage fehlt" und startet einen Bau,
// der mit `qm destroy --purge` anfängt. Eine Störung von zwanzig Sekunden darf
// deshalb niemals wie eine fehlende Vorlage aussehen.

function proxmoxHost(): Host
{
    return Host::factory()->create(['wg_ip' => '10.66.0.5', 'api_token_ref' => 'automation@pve!clupilot=secret']);
}

it('never mistakes a proxmox outage for a missing template', function () {
    Http::fake(['*' => Http::response('gateway timeout', 502)]);

    // Muss WERFEN, nicht false liefern: der Aufrufer fängt Ausnahmen und
    // wiederholt, und genau dieser Weg rettet die vorhandene Vorlage.
    expect(fn () => app(HttpProxmoxClient::class)->forHost(proxmoxHost())->isTemplate('pve', 9000))
        ->toThrow(RequestException::class);
});

it('reports a vmid that is not on the node at all as no template', function () {
    // Die Liste ist eindeutig, wo der Rückgabecode es nicht ist: Proxmox
    // beantwortet die Konfiguration einer nicht vorhandenen VM mit 500, nicht
    // mit 404 — „nicht da" und „kaputt" wären am Code nicht zu unterscheiden.
    Http::fake(['*/nodes/pve/qemu' => Http::response(['data' => [['vmid' => 101, 'template' => 0]]])]);

    expect(app(HttpProxmoxClient::class)->forHost(proxmoxHost())->isTemplate('pve', 9000))->toBeFalse();
});

it('confirms a template from the configuration document', function () {
    Http::fake([
        '*/nodes/pve/qemu' => Http::response(['data' => [['vmid' => 9000]]]),
        '*/nodes/pve/qemu/9000/config' => Http::response(['data' => ['template' => 1, 'name' => 'clupilot-nextcloud-vorlage']]),
    ]);

    expect(app(HttpProxmoxClient::class)->forHost(proxmoxHost())->isTemplate('pve', 9000))->toBeTrue();
});

it('reports an ordinary virtual machine carrying the vmid as no template', function () {
    // Der abgebrochene Bau: VMID 9000 ist da, `template: 1` ist es nicht.
    Http::fake([
        '*/nodes/pve/qemu' => Http::response(['data' => [['vmid' => 9000]]]),
        '*/nodes/pve/qemu/9000/config' => Http::response(['data' => ['name' => 'halbfertig']]),
    ]);

    expect(app(HttpProxmoxClient::class)->forHost(proxmoxHost())->isTemplate('pve', 9000))->toBeFalse();
});

// --- HostLoadSeries ---------------------------------------------------------
//
// Die Stundenkurve auf der Host-Seite. Proxmox zeichnet CPU und Speicher
// ohnehin auf; hier wird daraus nur gerechnet — und die eine Regel eingehalten,
// die eine Messreihe ehrlich hält: eine Lücke ist keine Null.

/** Holen (dort, wo der Tunnel ist) und lesen (dort, wo die Seite ist). */
function collectAndRead(Host $host): array
{
    app(HostLoadSeries::class)->collect($host);

    return app(HostLoadSeries::class)->forHost($host);
}

function rrdRow(int $time, ?float $cpu, ?int $memused, ?int $memtotal): array
{
    return array_filter([
        'time' => $time,
        'cpu' => $cpu,
        'memused' => $memused,
        'memtotal' => $memtotal,
    ], fn ($v) => $v !== null);
}

it('turns proxmox rrd rows into cpu and ram percentages', function () {
    Http::fake(['*/rrddata*' => Http::response(['data' => [
        rrdRow(1754035200, 0.0125, 8_589_934_592, 68_719_476_736),  // 1.25 % CPU, 12.5 % RAM
        rrdRow(1754035260, 0.5, 34_359_738_368, 68_719_476_736),    // 50 % CPU, 50 % RAM
    ]])]);

    $series = collectAndRead(proxmoxHost());

    expect($series['cpu'])->toBe([1.25, 50.0])
        ->and($series['ram'])->toBe([12.5, 50.0])
        ->and($series['labels'])->toHaveCount(2);
});

it('turns the recorded network rates into mebibytes per second', function () {
    // Proxmox zählt Bytes je Sekunde. 2 097 152 B/s sind 2 MiB/s — und die
    // Einheit steht in der Kachel daneben, damit niemand MB und MiB rät.
    Http::fake(['*/rrddata*' => Http::response(['data' => [
        ['time' => 1754035200, 'netin' => 2_097_152, 'netout' => 524_288],
    ]])]);

    $series = collectAndRead(proxmoxHost());

    expect($series['netin'])->toBe([2.0])
        ->and($series['netout'])->toBe([0.5]);
});

it('leaves a missing network sample as a gap too', function () {
    Http::fake(['*/rrddata*' => Http::response(['data' => [
        ['time' => 1754035200, 'netin' => 1_048_576, 'netout' => 1_048_576],
        ['time' => 1754035260], // Proxmox lässt die Felder weg
    ]])]);

    $series = collectAndRead(proxmoxHost());

    expect($series['netin'])->toBe([1.0, null])
        ->and($series['netout'])->toBe([1.0, null]);
});

it('leaves a missing sample as a gap, never as zero', function () {
    // Dieselbe Regel, die in instance_metrics begründet steht: eine Messung,
    // die scheiterte, muss von einer Messung mit dem Wert null unterscheidbar
    // sein. Eine Null-Linie behauptet einen ruhigen Host.
    Http::fake(['*/rrddata*' => Http::response(['data' => [
        rrdRow(1754035200, 0.25, 34_359_738_368, 68_719_476_736),
        rrdRow(1754035260, null, null, null), // Proxmox lässt die Felder weg
    ]])]);

    $series = collectAndRead(proxmoxHost());

    expect($series['cpu'])->toBe([25.0, null])
        ->and($series['ram'])->toBe([50.0, null]);
});

it('reports no series at all when the host does not answer', function () {
    // Nicht eine Reihe aus Nullen: der Host sagt nichts, und die Tafel muss das
    // sagen dürfen statt eine ruhige Stunde zu zeichnen.
    Http::fake(['*' => Http::response('gateway timeout', 502)]);

    $series = collectAndRead(proxmoxHost());

    expect($series['cpu'])->toBe([])
        ->and($series['ram'])->toBe([])
        ->and($series['available'])->toBeFalse();
});

it('ignores a cached series written by an older version of itself', function () {
    // Der Zwischenspeicher überlebt einen Deploy. Ein Eintrag aus der Fassung
    // vor den Netz-Reihen kennt netin/netout nicht — gelesen wie einer von
    // heute, stirbt die Host-Seite 55 Sekunden lang an einem fehlenden
    // Schlüssel, und zwar genau in dem Moment, in dem jemand nachsieht, ob das
    // Update durch ist.
    $host = proxmoxHost();
    Cache::put('host-load:'.$host->id, ['labels' => [1], 'cpu' => [1.0], 'ram' => [2.0], 'available' => true], 600);
    Http::fake(['*/rrddata*' => Http::response(['data' => [rrdRow(1754035200, 0.1, 1_048_576, 2_097_152)]])]);

    $series = collectAndRead($host);

    expect($series)->toHaveKeys(['netin', 'netout'])
        ->and($series['cpu'])->toBe([10.0]); // frisch geholt, nicht der alte Eintrag
});

it('never reaches for the host when the page asks', function () {
    // DER Fehler, der die Kacheln auf echter Hardware leer ließ: nur der
    // provisioning-Container hängt im WireGuard-Tunnel (NET_ADMIN, /dev/net/tun).
    // Der `app`-Container, der die Seite rendert, erreicht 10.66.0.x gar nicht —
    // ein API-Aufruf aus render() konnte deshalb nie etwas anderes als eine
    // Zeitüberschreitung sein. Gelesen wird ab jetzt ausschließlich, was der
    // Sammler hinterlegt hat.
    Http::fake();

    $series = app(HostLoadSeries::class)->forHost(proxmoxHost());

    Http::assertNothingSent();
    expect($series['available'])->toBeFalse();
});

it('serves the page from what the collector left behind', function () {
    Http::fake(['*/rrddata*' => Http::response(['data' => [rrdRow(1754035200, 0.4, 1_048_576, 2_097_152)]])]);
    $host = proxmoxHost();

    app(HostLoadSeries::class)->collect($host);   // im Tunnel
    Http::fake();                                 // ab hier darf nichts mehr raus
    $series = app(HostLoadSeries::class)->forHost($host);

    Http::assertNothingSent();
    expect($series['cpu'])->toBe([40.0])
        ->and($series['available'])->toBeTrue();
});

it('leaves no measurements behind when the host cannot be reached', function () {
    // Und schreibt den Grund ins Protokoll, statt ihn zu verschlucken: eine
    // leere Kachel sagt nicht, ob der Host schweigt oder der Weg fehlt.
    Log::spy();
    Http::fake(['*' => Http::response('gateway timeout', 502)]);
    $host = proxmoxHost();

    app(HostLoadSeries::class)->collect($host);

    expect(app(HostLoadSeries::class)->forHost($host)['available'])->toBeFalse();
    Log::shouldHaveReceived('warning')->once();
});

// --- PveVersion -------------------------------------------------------------

it('reads the version out of what proxmox calls itself', function () {
    $v = PveVersion::parse('pve-manager/9.2.6/7f8d010005bd7231');

    expect($v['version'])->toBe('9.2.6')
        ->and($v['build'])->toBe('7f8d010005bd7231');
});

it('shows an unexpected version string unchanged rather than nothing', function () {
    // Eine Anzeige, die an einer ungewohnten Form lieber nichts zeigt, ist
    // schlechter als eine, die das Rohe durchreicht.
    $v = PveVersion::parse('irgendwas-anderes');

    expect($v['version'])->toBe('irgendwas-anderes')
        ->and($v['build'])->toBeNull();
});

it('has nothing to say about a host that never reported a version', function () {
    expect(PveVersion::parse(null)['version'])->toBeNull();
});

// Der Hetzner-DNS-Client steht in HetznerCloudDnsTest — seit dem Umzug auf die
// Cloud-API (RRSets statt einzelner Records) gehört dazu mehr als zwei Fälle.
// Die alte Seitenlauf-Falle, die hier stand, kann es nicht mehr geben: es gibt
// keinen Lookup mehr, den sie überspringen könnte. Warum das so ist, steht im
// Kopfkommentar von HttpHetznerDnsClient.

it('writes and removes a real hostsdir entry on disk (FileHostDnsDirectory)', function () {
    $dir = sys_get_temp_dir().'/clupilot-dns-hosts-test-'.uniqid();
    config()->set('provisioning.dns.hosts_dir', $dir);
    $writer = new FileHostDnsDirectory;

    // The directory itself is created on first write — dnsmasq --hostsdir
    // only needs it to exist, not to be pre-provisioned by anything else.
    $writer->write('fsn-01', 'fsn-01.node.clupilot.com', '10.66.0.11');
    $path = $dir.'/fsn-01.hosts';

    expect(is_file($path))->toBeTrue()
        ->and(trim(file_get_contents($path)))->toBe('10.66.0.11 fsn-01.node.clupilot.com');

    // Overwrite is the update path — RegisterHostDns calls write() again on
    // retry with the same name, and that must replace, not append.
    $writer->write('fsn-01', 'fsn-01.node.clupilot.com', '10.66.0.99');
    expect(trim(file_get_contents($path)))->toBe('10.66.0.99 fsn-01.node.clupilot.com');

    $writer->remove('fsn-01');
    expect(is_file($path))->toBeFalse();

    File::deleteDirectory($dir);
});

it('treats removing a name that was never written as done (FileHostDnsDirectory)', function () {
    $dir = sys_get_temp_dir().'/clupilot-dns-hosts-test-'.uniqid();
    config()->set('provisioning.dns.hosts_dir', $dir);

    // Mirrors PurgeHost's guard (filled($host->name)) not always holding
    // — a host that never got a name still has to purge cleanly.
    (new FileHostDnsDirectory)->remove('never-written');
})->throwsNoExceptions();

it('starts people at the bottom of the subnet and machines at the host offset', function () {
    // Ein Bereich, keine zweite Grenze: `from` verschiebt nur den Anfang. Liefe
    // er als Obergrenze, stünde die Vergabe irgendwann still, obwohl das
    // Subnetz noch Platz hat.
    expect((new LocalWireguardHub)->allocateIp())->toBe('10.66.0.2')
        ->and((new LocalWireguardHub)->allocateIp(100))->toBe('10.66.0.100');
});

it('falls back into the lower range when the host offset does not fit the subnet', function () {
    // Ein /26 hat 64 Adressen; der Versatz von 100 liegt ausserhalb. Eine
    // Vergabe, die dann "Subnetz erschöpft" meldet, während unten alles frei
    // ist, wäre die schlechtere Antwort — der Versatz ist eine Bevorzugung,
    // keine Bedingung.
    config()->set('provisioning.wireguard.subnet', '10.66.7.0/26');
    config()->set('provisioning.wireguard.hub_ip', '10.66.7.1');

    expect((new LocalWireguardHub)->allocateIp(100))->toBe('10.66.7.2');
});
