<?php

use App\Livewire\Admin\Readiness;
use App\Models\Host;
use App\Models\Operator;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Services\Dns\DnsTokenCheck;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Proxmox\VmTemplateCheck;
use App\Services\Vpn\WireguardEndpointCheck;
use App\Support\Settings;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

/**
 * Ein UDP-Port lässt sich von außen nicht sauber anklopfen — es gibt keinen
 * Handshake zu beobachten. Was ENTSCHEIDBAR ist: ob dort überhaupt eine
 * Adresse steht, die ein Host im Internet erreichen kann.
 *
 * Am 2026-07-30 stand `10.10.90.185:51820` in der Konfiguration. Das sieht
 * gesetzt aus und ist für jeden Host außerhalb dieses Netzes unerreichbar.
 *
 * Jeder Test ruft trotzdem Http::fake() — ohne Ausnahme, wie jeder Test dieser
 * drei Prüfungen. WireguardEndpointCheck macht heute keinen Netzverkehr, aber
 * die Zusicherung gilt der Prüfung, nicht der heutigen Implementierung.
 */
it('rejects a private address', function () {
    Http::fake();
    expect((new WireguardEndpointCheck)->run('10.10.90.185:51820')['ok'])->toBeFalse();
});

it('rejects a documentation address', function () {
    // RFC 5737 — Seed- und Beispieldaten landen erfahrungsgemäß in echten
    // Installationen.
    Http::fake();
    expect((new WireguardEndpointCheck)->run('203.0.113.11:51820')['ok'])->toBeFalse();
});

it('rejects loopback', function () {
    Http::fake();
    expect((new WireguardEndpointCheck)->run('127.0.0.1:51820')['ok'])->toBeFalse();
});

it('accepts a public address', function () {
    Http::fake();
    expect((new WireguardEndpointCheck)->run('198.51.45.9:51820')['ok'])->toBeTrue();
});

it('accepts a hostname, which it cannot judge', function () {
    // Ein Name kann öffentlich auflösen; hier zu raten wäre schlechter als
    // durchzulassen — die Prüfung sagt, was sie weiß, nicht was sie vermutet.
    Http::fake();
    expect((new WireguardEndpointCheck)->run('vpn.clupilot.cloud:51820')['ok'])->toBeTrue();
});

it('rejects an endpoint without a port', function () {
    Http::fake();
    expect((new WireguardEndpointCheck)->run('198.51.45.9')['ok'])->toBeFalse();
});

it('names the offending address, so the page can show it', function () {
    Http::fake();
    expect((new WireguardEndpointCheck)->run('10.10.90.185:51820')['reason'])->toBe('not_public');
});

it('rejects a blank endpoint', function () {
    Http::fake();
    expect((new WireguardEndpointCheck)->run('')['reason'])->toBe('missing');
});

/**
 * Fix-Runde (Befund 1): FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE
 * does not reject RFC 6598 Carrier-Grade NAT — verified live against
 * `100.64.0.5:51820` before this fix, which came back `ok: true`. A host
 * behind CGNAT is exactly as unreachable from outside as one behind RFC 1918,
 * it is just an ISP handing the address out instead of an operator.
 */
it('rejects a carrier-grade NAT address', function () {
    Http::fake();
    expect((new WireguardEndpointCheck)->run('100.64.0.5:51820')['ok'])->toBeFalse();
});

/**
 * Fix-Runde (Befund 1, vollständiger Durchgang): kein Mehrfach-Empfänger ist
 * je der eigene Endpunkt eines einzelnen WireGuard-Peers.
 */
it('rejects a multicast address', function () {
    Http::fake();
    expect((new WireguardEndpointCheck)->run('224.1.1.1:51820')['ok'])->toBeFalse();
});

// --- DnsTokenCheck --------------------------------------------------------
//
// SICHERHEITSAUFLAGE: jeder dieser Tests benutzt Http::fake() und darf unter
// keinen Umständen die echte Hetzner-API oder die echte Zone berühren. Ein
// Test, der clupilot.cloud tatsächlich anfasst, ist ein Fehlschlag der
// Aufgabe, auch wenn er grün ist.

beforeEach(function () {
    // Eine feste, erfundene Zone statt des .env-Werts dieser Installation —
    // ein Test, der den echten Zonennamen des Entwicklerrechners nachrechnet,
    // beweist nichts (siehe phpunit.xml zu CLUPILOT_DNS_ZONE).
    Settings::set('provisioning.dns_zone', 'probe.example');
});

it('confirms write access by writing a probe rrset and removing it again', function () {
    // Genau der Punkt der ganzen Prüfung: Zone LISTEN allein beweist nichts,
    // weil ein Leserecht-Token das ebenfalls anstandslos tut. Erst Schreiben
    // und Löschen beweisen ein Schreibrecht-Token.
    Http::fake([
        'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 42, 'name' => 'probe.example']]]),
        'api.hetzner.cloud/v1/zones/probe.example/rrsets' => Http::response([
            'rrset' => ['id' => '_clupilot-write-probe-abc/TXT'],
        ], 201),
        'api.hetzner.cloud/v1/zones/probe.example/rrsets/*' => Http::response([], 201),
    ]);

    $result = (new DnsTokenCheck)->run('rw-token');

    expect($result['ok'])->toBeTrue()
        ->and($result['reason'])->toBe('writable')
        ->and($result['probe_removed'])->toBeTrue();

    // Bearer, nicht mehr `Auth-API-Token`: die Cloud-API ignoriert den alten
    // Kopf und weist den Aufruf als unangemeldet ab.
    Http::assertSent(fn ($request) => $request->method() === 'POST'
        && str_contains((string) $request->url(), '/zones/probe.example/rrsets')
        && $request->hasHeader('Authorization', 'Bearer rw-token'));

    // Gelöscht wird über {name}/{typ} — es gibt keine Record-ID mehr, an der
    // man den Probeeintrag festhalten könnte. Und zwar über GENAU den Namen,
    // der eben geschrieben wurde: aus der Antwort gegriffen wäre die Prüfung
    // blind dafür, dass sie einen fremden Eintrag wegräumt oder ihren eigenen
    // stehen lässt.
    $geschrieben = Http::recorded(fn ($request) => $request->method() === 'POST')->first()[0]['name'];

    expect($geschrieben)->toStartWith('_clupilot-write-probe-');

    Http::assertSent(fn ($request) => $request->method() === 'DELETE'
        && str_ends_with((string) $request->url(), "/rrsets/{$geschrieben}/TXT"));
});

it('escapes the probe TXT value in double quotes', function () {
    // Am Live-Konto gemessen, und teuer gewesen wäre es still: ein TXT-Wert
    // ohne Anführungszeichen wird mit 422 `invalid_input` abgewiesen
    // („TXT records must be fully escaped with double quotes"). Der Token
    // wäre dadurch als `read_only` gemeldet worden — eine Aussage über eine
    // Berechtigung, hergeleitet aus einem Formatfehler im eigenen Aufruf.
    Http::fake([
        'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 42, 'name' => 'probe.example']]]),
        'api.hetzner.cloud/v1/zones/probe.example/rrsets' => Http::response(['rrset' => ['id' => 'x/TXT']], 201),
        'api.hetzner.cloud/v1/zones/probe.example/rrsets/*' => Http::response([], 201),
    ]);

    (new DnsTokenCheck)->run('rw-token');

    Http::assertSent(function ($request) {
        if ($request->method() !== 'POST' || ! str_ends_with((string) $request->url(), '/rrsets')) {
            return false;
        }

        $value = $request['records'][0]['value'] ?? '';

        return str_starts_with($value, '"') && str_ends_with($value, '"');
    });
});

it('reports the zone as not found when the token cannot see it', function () {
    // Ein Token für eine andere Zone (oder gar keine) listet erfolgreich,
    // findet die konfigurierte Zone darin aber nicht.
    Http::fake([
        'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 9, 'name' => 'someone-elses-zone.example']]]),
    ]);

    $result = (new DnsTokenCheck)->run('some-token');

    expect($result['ok'])->toBeFalse()
        ->and($result['reason'])->toBe('zone_not_found')
        ->and($result['zone'])->toBe('probe.example');

    // Kein Schreibversuch, wenn die Zone gar nicht gefunden wurde.
    Http::assertNotSent(fn ($request) => $request->method() === 'POST');
});

it('tells a read-only token apart from one that can write', function () {
    // Genau der Leserecht-Token aus dem Handoff: er listet die Zone
    // anstandslos (200) und sieht in der Konsole aus wie ein funktionierender
    // — bis zum Schreibversuch, den Hetzner mit 403 quittiert.
    Http::fake([
        'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 42, 'name' => 'probe.example']]]),
        'api.hetzner.cloud/v1/zones/probe.example/rrsets' => Http::response([
            'error' => ['code' => 'forbidden', 'message' => 'insufficient permissions'],
        ], 403),
    ]);

    $result = (new DnsTokenCheck)->run('read-only-token');

    expect($result['ok'])->toBeFalse()
        ->and($result['reason'])->toBe('read_only')
        ->and($result['status'])->toBe(403);

    // Kein Löschversuch für einen Eintrag, der nie geschrieben wurde.
    Http::assertNotSent(fn ($request) => $request->method() === 'DELETE');
});

it('does not call a rejected write a read-only token', function (int $status, string $code) {
    // Dasselbe Muster wie bei der Zonenliste, eine Stufe weiter: nur 401 und
    // 403 sagen etwas über die BERECHTIGUNG. Ein 422 sagt, dass der Aufruf
    // falsch geformt war, ein 409 dass schon etwas da liegt, ein 503 dass
    // Hetzner gerade nicht mag — und keiner davon ist ein Grund, dem Betreiber
    // zu raten, einen funktionierenden Token auszutauschen.
    Http::fake([
        'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 42, 'name' => 'probe.example']]]),
        'api.hetzner.cloud/v1/zones/probe.example/rrsets' => Http::response([
            'error' => ['code' => $code, 'message' => 'nope'],
        ], $status),
    ]);

    $result = (new DnsTokenCheck)->run('rw-token');

    expect($result['ok'])->toBeFalse()
        ->and($result['reason'])->toBe('write_failed')
        ->and($result['status'])->toBe($status)
        ->and($result['detail'])->toBe('nope');
})->with([
    [422, 'invalid_input'],
    [409, 'uniqueness_error'],
    [503, 'unavailable'],
]);

/**
 * Fix-Runde (Befund 2): Das GET auf /zones ist gegen Throwable abgesichert
 * (Reason "unreachable"), der Schreibversuch danach war es nicht — ein
 * Netzwerkfehler genau dort hätte die Ausnahme aus run() herausgeworfen, auf
 * einer Seite, deren einziger Zweck ist, ruhig zu berichten. "Netz kaputt"
 * bleibt dabei von "Token darf nicht schreiben" (read_only, 403 s.o.) und von
 * "Zone falsch benannt" getrennt — dieselbe Reason wie beim GET, weil es
 * dieselbe Fehlerklasse ist, nur an einer anderen Stelle der Kette.
 */
it('reports unreachable when the write attempt itself cannot reach the network', function () {
    Http::fake([
        'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 42, 'name' => 'probe.example']]]),
        'api.hetzner.cloud/v1/zones/probe.example/rrsets' => function () {
            throw new ConnectionException('Could not resolve host');
        },
    ]);

    $result = (new DnsTokenCheck)->run('rw-token');

    expect($result['ok'])->toBeFalse()
        ->and($result['reason'])->toBe('unreachable');
});

/**
 * Fix-Runde (Befund 2, Sonderfall Aufräumen): der Schreibversuch selbst hat
 * bereits bewiesen, dass der Token schreiben darf — das ist der Zweck der
 * ganzen Prüfung, und ein kaputtes Netz beim Löschen ändert daran nichts.
 * `probe_removed` muss trotzdem false sein, und der liegengebliebene Eintrag
 * muss benennbar bleiben, sonst weiß niemand, dass er in der Zone liegt.
 */
it('still proves write access when the cleanup delete cannot reach the network', function () {
    Http::fake([
        'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 42, 'name' => 'probe.example']]]),
        'api.hetzner.cloud/v1/zones/probe.example/rrsets' => Http::response([
            'rrset' => ['id' => '_clupilot-write-probe-orphan/TXT'],
        ], 201),
        'api.hetzner.cloud/v1/zones/probe.example/rrsets/*' => function () {
            throw new ConnectionException('Could not resolve host');
        },
    ]);

    $result = (new DnsTokenCheck)->run('rw-token');

    expect($result['ok'])->toBeTrue()
        ->and($result['reason'])->toBe('writable')
        ->and($result['probe_removed'])->toBeFalse()
        // Der RRSet-Name statt einer Record-ID: das ist die Adresse, unter der
        // jemand den liegengebliebenen Eintrag von Hand wiederfindet.
        // Dass sie auf denselben Eintrag zeigt, der geschrieben wurde, hält
        // der Erfolgsfall oben fest — hier lässt sich der DELETE nicht
        // beobachten, weil eine geworfene Attrappe nicht aufgezeichnet wird.
        ->and($result['leftover_record_id'])->toStartWith('_clupilot-write-probe-')
        ->and($result['leftover_record_id'])->toEndWith('/TXT');
});

it('does not attempt anything without a token or a zone', function () {
    // Zurückgesetzt statt sich auf den .env-Wert dieser Installation zu
    // verlassen — genau die Falle, die phpunit.xml zu CLUPILOT_DNS_ZONE
    // dokumentiert, und der Grund, warum HETZNER_DNS_TOKEN dort jetzt
    // ebenfalls auf leer gepinnt ist.
    config()->set('provisioning.dns.token', '');
    Http::fake();

    expect((new DnsTokenCheck)->run('')['reason'])->toBe('missing');

    Http::assertNothingSent();
});

// --- VmTemplateCheck -------------------------------------------------------

beforeEach(function () {
    // 2026_07_26_040000_create_plan_catalogue_tables.php seeds a real baseline
    // catalogue (start/team/business/enterprise, all template_vmid 9000) as
    // part of the migration itself — not a Seeder, so it is present in every
    // test database and does not roll away with RefreshDatabase. Left alone,
    // it is unconditionally "required" by VmTemplateCheck alongside whatever
    // a test creates. Closed here instead of deleted: a published version
    // cannot be deleted (see PlanVersion::booted()), but its availability
    // WINDOW is deliberately not frozen — closing it changes nothing about
    // what the baseline promises, it only takes it out of "currently sold"
    // so these tests can speak about their own versions in isolation.
    PlanVersion::query()->whereNotNull('published_at')->update(['available_until' => now()->subDay()]);
});

function makePublishedVersion(int $templateVmid, ?string $familyKey = null): void
{
    $family = PlanFamily::query()->create([
        'key' => $familyKey ?? 'active-check-'.Str::random(8),
        'name' => 'Active Check Plan',
        'tier' => 9,
    ]);
    $version = $family->versions()->create([
        'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024,
        'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [],
        'available_from' => now(),
    ]);
    $version->update(['published_at' => now(), 'template_vmid' => $templateVmid]);
}

it('is satisfied once the template actually exists on an active host', function () {
    // FakeProxmoxClient talks to nothing real; Http::fake() rides along anyway
    // — every test of these three checks does, without exception.
    Http::fake();

    $pve = new FakeProxmoxClient;
    // Present AND marked as a template — the two facts VmTemplateCheck now
    // tells apart, because "exists" let an abandoned build pass for a
    // finished one and the failure then surfaced after payment.
    $pve->clonedVmids = [4001];
    $pve->templateVmids = [4001];
    app()->instance(ProxmoxClient::class, $pve);

    Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'pve']);
    makePublishedVersion(4001);

    $result = app(VmTemplateCheck::class)->run();

    expect($result['ok'])->toBeTrue()
        ->and($result['reason'])->toBe('present');
});

it('names the missing vmid when the template is not on any active host', function () {
    Http::fake();

    $pve = new FakeProxmoxClient; // clonedVmids/runningVmids stay empty: nothing exists anywhere
    app()->instance(ProxmoxClient::class, $pve);

    Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'pve']);
    makePublishedVersion(4002);

    $result = app(VmTemplateCheck::class)->run();

    expect($result['ok'])->toBeFalse()
        ->and($result['reason'])->toBe('template_missing')
        ->and($result['vmids'])->toContain(4002);
});

/**
 * Fix-Runde (Befund 3): ein Host, der gerade nicht antwortet, ist kein
 * Beweis dafür, dass die Vorlage fehlt — sie könnte genau auf diesem Host
 * liegen. Der bestehende FakeProxmoxClient kann das nicht abbilden (jede
 * Antwort ist entweder true oder false, nie eine Ausnahme), deshalb eine
 * anonyme Unterklasse nach dem Muster aus TrafficTest.php
 * ($flaky/$blind), die vmExists() gezielt scheitern lässt.
 */
it('does not confuse a silent host with a confirmed-missing template', function () {
    Http::fake();

    $silent = new class extends FakeProxmoxClient
    {
        public function isTemplate(string $node, int $vmid): bool
        {
            throw new RuntimeException('proxmox unreachable');
        }
    };
    app()->instance(ProxmoxClient::class, $silent);

    Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'pve']);
    makePublishedVersion(4004);

    $result = app(VmTemplateCheck::class)->run();

    // "build the template" and "your hosts are not answering" are two
    // different instructions — a reason of 'template_missing' here would
    // send an operator to build a template that might already exist.
    expect($result['ok'])->toBeFalse()
        ->and($result['reason'])->toBe('hosts_unreachable')
        ->and($result['reason'])->not->toBe('template_missing')
        ->and($result['vmids'])->toContain(4004);
});

/**
 * Fix-Runde (Befund 3, Gegenprobe): ein Host, der antwortet und die Vorlage
 * bestätigt, darf das Ergebnis nicht kippen lassen, nur weil ein ANDERER
 * Host in derselben Runde schweigt — "gefunden" sticht "still", und die
 * Schleife bricht bei einem Treffer ab, statt den stillen Host danach noch
 * als Beweis für irgendetwas zu werten.
 *
 * Eine einzige ProxmoxClient-Instanz bedient in VmTemplateCheck ALLE Hosts
 * (`forHost($host)` wird pro Host auf demselben `$this->pve` aufgerufen), also
 * unterscheidet dieser Fake anhand dessen, welcher Host zuletzt über
 * `forHost()` hereinkam — genau das Feld, das `FakeProxmoxClient::forHost()`
 * ohnehin schon merkt.
 */
it('still confirms a template that a working host has, despite a silent one', function () {
    Http::fake();

    $mixed = new class extends FakeProxmoxClient
    {
        public function isTemplate(string $node, int $vmid): bool
        {
            if ($this->host?->node === 'silent-node') {
                throw new RuntimeException('proxmox unreachable');
            }

            return parent::isTemplate($node, $vmid);
        }
    };
    $mixed->templateVmids = [4005]; // what the WORKING host actually has
    app()->instance(ProxmoxClient::class, $mixed);

    Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'silent-node']);
    Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-2', 'node' => 'working-node']);
    makePublishedVersion(4005);

    $result = app(VmTemplateCheck::class)->run();

    expect($result['ok'])->toBeTrue()
        ->and($result['reason'])->toBe('present');
});

it('reports no usable host when nothing is left to ask', function () {
    Http::fake();
    app()->instance(ProxmoxClient::class, new FakeProxmoxClient);

    // Kein aktiver Host mit lesbarem Token — derselbe Zustand, den
    // ProvisioningChecks::usable_host als Sperre meldet.
    Host::factory()->create(['status' => 'onboarding', 'api_token_ref' => 'ref-1', 'node' => 'pve']);
    makePublishedVersion(4003);

    $result = app(VmTemplateCheck::class)->run();

    expect($result['ok'])->toBeFalse()
        ->and($result['reason'])->toBe('no_usable_host');
});

it('is satisfied when no plan version is published yet', function () {
    Http::fake();
    app()->instance(ProxmoxClient::class, new FakeProxmoxClient);

    $result = app(VmTemplateCheck::class)->run();

    expect($result['ok'])->toBeTrue()
        ->and($result['reason'])->toBe('nothing_published');
});

/**
 * Der Fund vom Live-Server: grünes „Erfüllt" über rotem „nicht in Ordnung",
 * in derselben Zeile — beim DNS-Token und bei der VM-Vorlage.
 *
 * Die Plakette kam allein aus der passiven Prüfung („etwas ist eingetragen"),
 * die Messung stand daneben und wurde überstimmt. Wer eine Liste überfliegt,
 * liest die Plakette und nicht die Kleinschrift, und geht mit „alles grün"
 * weiter, obwohl gemessen wurde, dass es nicht geht. Das ist die Attrappe aus
 * R19 in ihrer teuersten Form.
 */
it('lets a failed measurement beat a green badge', function () {
    $operator = Operator::factory()->role('Owner')->create();

    $seite = Livewire::actingAs($operator, 'operator')->test(Readiness::class);

    // Ohne Messung entscheidet die passive Prüfung — wie bisher.
    $seite->assertOk();

    // Mit einer gescheiterten Messung darf in dieser Zeile kein „Erfüllt"
    // mehr stehen.
    $seite->set('results', ['provisioning.dns_token' => ['ok' => false, 'reason' => 'zone_not_found', 'zone' => 'clupilot.cloud', 'available' => ['clupilot.com']]])
        ->assertSee('clupilot.cloud')
        ->assertSee('clupilot.com')
        ->assertSee(__('readiness.check_failed'));
});

/**
 * `zone_not_found` allein ist eine Sackgasse: es liest sich wie „Token
 * falsch", obwohl der Token gerade eben die Zonenliste geholt hat — ein
 * schlechter käme als `rejected` heraus. Was fehlt, ist die ZONE, und das
 * sagt erst der Vergleich.
 */
it('names the zone it wanted and the zones it found', function () {
    $ergebnis = ['ok' => false, 'reason' => 'zone_not_found', 'zone' => 'clupilot.cloud', 'available' => ['clupilot.com', 'example.org']];

    Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
        ->test(Readiness::class)
        ->set('results', ['provisioning.dns_token' => $ergebnis])
        ->assertSee(__('readiness.zone_wanted', ['zone' => 'clupilot.cloud']))
        ->assertSee('clupilot.com, example.org');
});

/**
 * Eine Fehlerantwort ist keine leere Zonenliste.
 *
 * Ohne diese Unterscheidung wurde aus jedem nicht klassifizierten Status — 404,
 * 429, 500 — die Aussage „in diesem Konto liegt keine einzige Zone". Eine
 * Behauptung über ein fremdes Hetzner-Konto, hergeleitet aus einem Fehler, den
 * niemand angesehen hat. Dieselbe Trennung, die diese Prüfung zwischen
 * `unreachable` und `read_only` schon zieht: beides sind Fehlschläge, aber nur
 * einer sagt etwas über den Token.
 */
it('does not call a failed zone list an empty account', function (int $status) {
    // Ein Datensatz statt einer Schleife: `Http::fake()` ERGÄNZT die Attrappen,
    // es ersetzt sie nicht — in einer Schleife hätte die erste alle folgenden
    // beantwortet, und der Test hätte dreimal dasselbe geprüft.
    Http::fake(['api.hetzner.cloud/v1/zones' => Http::response('<html>nope</html>', $status)]);

    $ergebnis = (new DnsTokenCheck)->run('ein-token');

    expect($ergebnis['ok'])->toBeFalse()
        ->and($ergebnis['reason'])->toBe('zone_list_failed')
        ->and($ergebnis['status'])->toBe($status);
})->with([404, 429, 500, 502]);

/**
 * Eine ECHTE leere Liste bleibt dagegen, was sie ist: 200 mit null Zonen heißt,
 * dass der Token zu einem Projekt ohne Zonen gehört.
 */
it('still calls a genuinely empty account empty', function () {
    Http::fake(['api.hetzner.cloud/v1/zones' => Http::response(['zones' => []], 200)]);

    $ergebnis = (new DnsTokenCheck)->run('ein-token');

    expect($ergebnis['reason'])->toBe('zone_not_found')
        ->and($ergebnis['available'])->toBe([]);
});

/**
 * Der gemessene Fall vom Live-Server.
 *
 * Token mit Lesen und Schreiben, Zone `clupilot.cloud` mit fünfzehn Einträgen
 * vorhanden — und die Konsole sagte, das Konto sei leer. `successful()` allein
 * genügt nicht: ein Portal oder Filter antwortet mit 200 und einer HTML-Seite,
 * die hat kein `zones`, und `?? []` machte daraus null Zonen.
 */
it('does not read someone else answering as an empty account', function (string $body, int $status) {
    Http::fake(['api.hetzner.cloud/v1/zones' => Http::response($body, $status, ['Content-Type' => 'text/html'])]);

    $ergebnis = (new DnsTokenCheck)->run('ein-token');

    expect($ergebnis['reason'])->toBe('zone_list_unreadable')
        ->and($ergebnis)->not->toHaveKey('available');
})->with([
    ['<html><body>Blocked by policy</body></html>', 200],
    ['{"error":"something else"}', 200],
    ['', 200],
]);
