<?php

namespace App\Provisioning\Steps\Host;

use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Dns\HostDnsDirectory;
use App\Support\Settings;
use Illuminate\Support\Facades\Cache;
use Throwable;

/**
 * Gives the host a name: fsn-01.node.clupilot.com.
 *
 * The entry points at the host's WireGuard address, NOT its public IP, and it
 * is written to the vpn-dns container's --hostsdir — resolvable only inside
 * the tunnel — rather than the public Hetzner zone. The name is for us, so an
 * operator can ssh fsn-01.node.… instead of memorising addresses; publishing
 * it in PUBLIC DNS would hand every scanner the internal subnet and roughly
 * how many hosts sit behind it, which is the one thing the whole network
 * design avoids. See App\Services\Dns\HostDnsDirectory.
 *
 * DNS is convenience, not a prerequisite for a working host: a failure here
 * logs and moves on rather than stranding an otherwise finished onboarding.
 */
class RegisterHostDns extends HostStep
{
    public function __construct(private HostDnsDirectory $dns) {}

    public function key(): string
    {
        return 'register_host_dns';
    }

    public function execute(ProvisioningRun $run): StepResult
    {
        $host = $this->host($run);

        if (blank($host->wg_ip)) {
            return StepResult::fail('The host has no management address to publish.');
        }

        // Reserved and persisted before anything is published: releasing the
        // lock with only a candidate in hand let two concurrent onboardings pick
        // the same name and overwrite each other's record.
        $name = $host->dns_name ?: $this->reserveName($host);
        $fqdn = $name.'.node.'.config('provisioning.dns.zone', 'clupilot.com');

        try {
            $this->dns->write($name, $fqdn, $host->wg_ip);
        } catch (Throwable $e) {
            // Retried first: the write is idempotent (overwrite-by-name), so a
            // retry after a transient failure (e.g. the shared volume briefly
            // unavailable) just rewrites the same entry rather than risking a
            // half-written file.
            $allowed = min(3, (int) $run->max_attempts);
            if ($run->attempt + 1 < $allowed) {
                return StepResult::retry(30, 'DNS unavailable: '.$e->getMessage());
            }

            // Out of attempts: a name is convenience, and a host that is
            // otherwise ready should not be stranded for the want of one.
            $run->events()->create([
                'step' => $this->key(),
                'outcome' => 'info',
                'message' => 'DNS name could not be registered: '.$e->getMessage(),
            ]);

            return StepResult::advance();
        }

        return StepResult::advance();
    }

    /**
     * <datacenter>-<nn>, numbered per datacenter and never reused.
     *
     * The counter is stored rather than derived from the hosts that happen to
     * exist: deriving it hands the highest number straight back out after that
     * host is removed, so a cached name would resolve to a different machine.
     */
    private function reserveName(Host $host): string
    {
        // Everything keys off the LABEL, not the raw code: two legacy codes can
        // normalise to the same label (eu_west and eu-west), and separate
        // counters would then hand both of them eu-west-01 — a unique-constraint
        // failure that blocks onboarding rather than a DNS problem we can shrug off.
        $label = $this->dnsLabel($host->datacenter);

        return Cache::lock('host-dns-name:'.$label, 30)->block(10, function () use ($host, $label) {
            $key = 'dns.sequence.'.$label;
            $next = ((int) Settings::get($key, 0)) + 1;

            // Never below what is already in use — a settings store that was
            // wiped must not start handing out live names again. Matched on the
            // name itself, so it covers every code sharing this label.
            $inUse = Host::query()
                ->where('dns_name', 'like', $label.'-%')
                ->pluck('dns_name')
                ->map(fn (string $name) => (int) substr($name, strrpos($name, '-') + 1))
                ->max() ?? 0;
            $next = max($next, $inUse + 1);

            $name = sprintf('%s-%02d', $label, $next);

            Settings::set($key, $next);
            $host->update(['dns_name' => $name]);   // reserved while still locked

            return $name;
        });
    }

    /**
     * Datacenter codes created before the rule was tightened can hold characters
     * a DNS label cannot; a name the provider rejects would leave the host
     * nameless forever.
     */
    private function dnsLabel(string $datacenter): string
    {
        $label = trim(preg_replace('/[^a-z0-9-]+/', '-', strtolower($datacenter)) ?? '', '-');

        return $label !== '' ? $label : 'node';
    }
}
