<?php

namespace App\Services\Dns;

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

/**
 * Hetzner DNS client, speaking the CLOUD API. Zone from ProvisioningSettings
 * (console-stored, falling back to CLUPILOT_DNS_ZONE); the token comes from the
 * vault (console-stored, falling back to HETZNER_DNS_TOKEN).
 * Not unit-tested against the real service (live I/O).
 *
 * Hetzner moved DNS into the cloud console on 7 October 2025; the old console
 * went read-only on 20 May 2026 and `dns.hetzner.com/api/v1` has redirected to
 * the web UI ever since. Old tokens and zone ids were not carried over. Until
 * this class was moved across, every provisioning run died in
 * ConfigureDnsAndTls — after the customer had paid.
 *
 * Two things changed beyond the host name:
 *
 * - Authentication is `Authorization: Bearer`, not `Auth-API-Token`.
 * - There are no individual records any more, only RRSets addressed by
 *   `{name}/{type}` (see RrsetId). A set carries several values and
 *   `set_records` replaces the whole set, so writing is idempotent at the
 *   provider. That deletes the old find-then-update walk over `GET /records` —
 *   and with it the paging bug that once published a SECOND A record for a name
 *   past the hundredth entry in the zone, round-robining a customer's cloud
 *   onto a host it was not on. There is no lookup left to get wrong.
 */
class HttpHetznerDnsClient implements HetznerDnsClient
{
    /**
     * Unchanged from the old client on purpose: this migration moves the calls,
     * not the zone's behaviour. Hetzner's floor is 60s.
     */
    private const TTL = 300;

    private function http(): PendingRequest
    {
        // Read HERE, at the point of use, rather than in a constructor or
        // overlaid onto config at boot — see SecretVault's own docblock (rule
        // 3): an overlay would add a query to every request, and a
        // long-running queue worker would keep whatever token was true when
        // it started.
        return Http::withToken((string) app(SecretVault::class)->get('dns.token'))
            ->acceptJson()
            ->baseUrl('https://api.hetzner.cloud/v1')
            ->timeout(15);
    }

    public function upsertRecord(string $fqdn, string $type, string $value): string
    {
        // The zone name goes straight into the path now — the old client had to
        // look its id up first, and that lookup is simply gone.
        $zone = RrsetId::zone();
        $name = RrsetId::relativeName($fqdn);
        $type = Str::upper($type);
        $records = [['value' => $value]];

        $created = $this->http()->post("/zones/{$zone}/rrsets", [
            'name' => $name,
            'type' => $type,
            'ttl' => self::TTL,
            'records' => $records,
        ]);

        // 409 `uniqueness_error` — measured against the live account — is the
        // API saying the set is already there, which on a re-run is the normal
        // case rather than a fault. Replacing the whole set from here is what
        // makes the second pass land on exactly one value: the address a
        // customer's instance moved to, with the one it moved off gone in the
        // same call.
        //
        // Create-first rather than replace-first because it terminates in at
        // most two calls with no race window: a 409 proves something exists, and
        // set_records then overwrites it whatever it was. The other order
        // (replace, 404, create) can lose a race and 409 on the create.
        if ($created->status() === 409) {
            $this->http()
                ->post("/zones/{$zone}/rrsets/{$name}/{$type}/actions/set_records", ['records' => $records])
                ->throw();
        } else {
            $created->throw();
        }

        // Composed rather than read back out of the response, because the
        // replace branch above and the create branch have to hand back the same
        // thing, and because this is the string deleteRecord() takes apart
        // again. The composition is the API's own — see RrsetId.
        return "{$name}/{$type}";
    }

    public function deleteRecord(string $recordId): void
    {
        // An id from the OLD record API cannot address anything here, and
        // throwing over it would be worse than the leak it is: PurgeHost calls
        // this before deleting the row that holds the id, so a host carrying a
        // legacy one could never be purged at all — every attempt would die on
        // the same line, for ever, and the machine would sit in the pool.
        //
        // Said out loud rather than swallowed. The record does stay in the zone,
        // and this log line is the only place that can still be noticed.
        // See LegacyRecordIds for the ids that CAN be brought across, and for
        // why I was wrong to treat one empty database as proof.
        if (! RrsetId::isRrsetId($recordId)) {
            Log::warning('Skipping a legacy DNS record id — not addressable in the cloud API, remove it by hand in the Hetzner console.', [
                'record_id' => $recordId,
                'zone' => RrsetId::zone(),
            ]);

            return;
        }

        [$name, $type] = RrsetId::split($recordId);

        $response = $this->http()->delete('/zones/'.RrsetId::zone()."/rrsets/{$name}/{$type}");

        // A set that is already gone is the outcome we wanted. Without this, a
        // lost response or a retry after a later failure turns every further
        // attempt into a 404 — and a host that can then never be purged.
        if ($response->notFound()) {
            return;
        }

        $response->throw();
    }
}
