<?php

namespace App\Services\Dns;

use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Throwable;

/**
 * Whether the configured Hetzner DNS token can actually WRITE to the zone,
 * not merely read it.
 *
 * Listing the zone proves nothing: a read-only token lists it just as
 * cleanly as a read-write one, and looks identical in the console — it only
 * fails once a customer VM needs its A record, after the customer has
 * already paid. So this writes a throwaway TXT RRSet and deletes it again
 * immediately; the write (and its rejection) is the only thing that actually
 * tells the two kinds of token apart.
 *
 * Speaks the CLOUD API (api.hetzner.cloud/v1, Bearer, RRSets) — the old
 * dns.hetzner.com endpoint redirects to the web UI and answers nothing.
 * See HttpHetznerDnsClient for the rest of that move.
 *
 * Deliberately does its own HTTP instead of going through HttpHetznerDnsClient:
 * that client always reads the STORED token from the vault, and this check
 * has to be able to test a candidate value before it is ever saved — exactly
 * the same reason StripeCheck does not go through HttpStripeClient.
 */
final class DnsTokenCheck
{
    /** @return array<string, mixed> */
    public function run(?string $candidate = null): array
    {
        $token = $candidate ?: app(SecretVault::class)->get('dns.token');
        $zone = ProvisioningSettings::dnsZone();

        if (blank($token) || blank($zone)) {
            return ['ok' => false, 'reason' => 'missing'];
        }

        $http = fn () => Http::withToken((string) $token)->acceptJson()->timeout(15)
            ->baseUrl('https://api.hetzner.cloud/v1');

        try {
            $zones = $http()->get('/zones');
        } catch (Throwable) {
            return ['ok' => false, 'reason' => 'unreachable'];
        }

        if ($zones->status() === 401 || $zones->status() === 403) {
            return ['ok' => false, 'reason' => 'rejected'];
        }

        // Erst der Erfolg, DANN der Rumpf.
        //
        // Ohne diese Zeile wurde jede nicht klassifizierte Fehlerantwort — 404,
        // 429, 500, eine Statusseite eines Zwischenspeichers — zu einer leeren
        // Zonenliste: der Rumpf hat kein `zones`, `?? []` macht daraus keine
        // Zonen, und die Anzeige behauptete daraufhin „in diesem Konto liegt
        // keine einzige Zone". Eine Aussage über ein fremdes Konto, hergeleitet
        // aus einem Fehler, den niemand angesehen hat.
        //
        // Das ist derselbe Fehler, den diese Prüfung an anderer Stelle
        // vermeidet: `unreachable` und `read_only` sind getrennt, weil beide
        // Fehlschläge sind, aber nur einer etwas über den Token sagt. Ein
        // unerwarteter Status sagt über die Zonen gar nichts — also sagt er das
        // jetzt auch, mit der Zahl daneben.
        if (! $zones->successful()) {
            return ['ok' => false, 'reason' => 'zone_list_failed', 'status' => $zones->status()];
        }

        // Und der Rumpf muss die Frage auch BEANTWORTEN.
        //
        // `successful()` allein genügt nicht: ein Zwischending — ein Portal, ein
        // Filter, ein Firmenproxy — antwortet gern mit 200 und einer HTML-Seite.
        // Die hat kein `zones`, `?? []` macht daraus null Zonen, und die Anzeige
        // behauptet daraufhin, das Hetzner-Konto sei leer. Genau dieser Fall lag
        // vor: Token mit Lesen und Schreiben, Zone `clupilot.cloud` mit fünfzehn
        // Einträgen vorhanden — und die Konsole sagte, es gebe keine.
        //
        // Nur ein echtes `{"zones": [...]}` zählt als Antwort. Alles andere
        // heißt: hier hat jemand anderes geredet. Und genau das war die Antwort
        // des ALTEN Endpunkts, seit die DNS-Konsole in den Nur-Lese-Betrieb
        // ging: eine 301 auf eine HTML-Seite.
        $rumpf = $zones->json();

        if (! is_array($rumpf) || ! isset($rumpf['zones']) || ! is_array($rumpf['zones'])) {
            return [
                'ok' => false,
                'reason' => 'zone_list_unreadable',
                'status' => $zones->status(),
                'body' => Str::limit((string) $zones->body(), 120),
            ];
        }

        $verfuegbar = collect($rumpf['zones'])->pluck('name')->filter()->values();

        // Die Liste wird weiterhin geholt, obwohl der Zonenname inzwischen
        // direkt in den Pfad ginge: sie ist es, die den Fehlschlag unten
        // beantwortbar macht. `GET /zones/{name}` sagt nur 404 — das ist genau
        // die Sackgasse, aus der der Betreiber den Token austauscht.
        if (! $verfuegbar->contains($zone)) {
            // Die gefundenen Zonen gehören in die Antwort.
            //
            // `zone_not_found` allein ist eine Sackgasse: der Betreiber liest
            // es als „der Token ist falsch" und tauscht ihn aus, obwohl der
            // Token gerade eben erfolgreich die Zonenliste geholt hat — ein
            // schlechter Token käme oben als `rejected` heraus. Was fehlt, ist
            // die ZONE. Nebeneinander gestellt beantwortet sich die Frage von
            // selbst: gesucht wurde `clupilot.cloud`, im Konto liegt
            // `clupilot.com` — oder gar nichts, dann gehört die Zone dort
            // erst angelegt.
            return [
                'ok' => false,
                'reason' => 'zone_not_found',
                'zone' => $zone,
                'available' => $verfuegbar->all(),
            ];
        }

        return $this->probeWrite($http, (string) $zone);
    }

    /**
     * The actual point of this whole check. A read-only token gets this far and
     * looks in the console like a working one; it fails only when a customer VM
     * needs its A record — after payment.
     *
     * @param  callable(): PendingRequest  $http
     * @return array<string, mixed>
     */
    private function probeWrite(callable $http, string $zone): array
    {
        $name = '_clupilot-write-probe-'.Str::lower(Str::random(12));
        $rrsetId = $name.'/TXT';

        // Guarded the same way as the GET above: this call is just as capable
        // of hitting a broken network as the first one, and "the network died
        // mid-write" must stay distinct from "the token cannot write"
        // (read_only, below) — both are failures, but only one of them says
        // anything about the token.
        try {
            $probe = $http()->post("/zones/{$zone}/rrsets", [
                'name' => $name,
                'type' => 'TXT',
                'ttl' => 60,
                // Die Anführungszeichen gehören in den WERT.
                //
                // Am Live-Konto gemessen: ohne sie antwortet die API mit 422
                // `invalid_input`, „TXT records must be fully escaped with
                // double quotes". Vor dieser Zeile wäre daraus `read_only`
                // geworden — die Prüfung hätte einem einwandfreien Token
                // bescheinigt, er dürfe nicht schreiben, und der Betreiber
                // hätte einen funktionierenden Schlüssel ausgetauscht.
                'records' => [['value' => '"clupilot readiness probe"']],
            ]);
        } catch (Throwable) {
            return ['ok' => false, 'reason' => 'unreachable'];
        }

        if (! $probe->successful()) {
            // 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. Dieselbe Trennung wie oben bei der
            // Zonenliste, eine Stufe weiter.
            if ($probe->status() === 401 || $probe->status() === 403) {
                return ['ok' => false, 'reason' => 'read_only', 'status' => $probe->status()];
            }

            return [
                'ok' => false,
                'reason' => 'write_failed',
                'status' => $probe->status(),
                'detail' => Str::limit((string) ($probe->json('error.message') ?? $probe->body()), 160),
            ];
        }

        // The write already proved the token can write — that is the whole
        // point of this check, and nothing past this line changes that
        // verdict. Always attempt to clean up, and guard the attempt itself:
        // a delete that throws is handled exactly like one that comes back
        // non-2xx, because both leave the same TXT record sitting in the
        // zone. Named in the result either way, so someone can remove it by
        // hand instead of a record nobody knows exists.
        try {
            $removed = $http()->delete("/zones/{$zone}/rrsets/{$rrsetId}")->successful();
        } catch (Throwable) {
            $removed = false;
        }

        $result = ['ok' => true, 'reason' => 'writable', 'probe_removed' => $removed];

        if (! $removed) {
            // Der Name, den dieser Aufruf gerade nicht wegbekommen hat — nicht
            // eine ID aus der Antwort, die auch fehlen kann. Was hier steht,
            // muss man in der Konsole wiederfinden können.
            $result['leftover_record_id'] = $rrsetId;
        }

        return $result;
    }
}
