<?php

namespace App\Provisioning\Steps\Customer;

use App\Models\Instance;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Dns\HetznerDnsClient;
use App\Services\Traefik\TraefikWriter;
use App\Support\ProvisioningSettings;

class ConfigureDnsAndTls extends CustomerStep
{
    /**
     * How long the step keeps looking for the CUSTOM domain's certificate
     * before it moves on without it.
     *
     * Long enough that the ordinary case lands inside the run: the customer has
     * already pointed their A record at us, Traefik sees the first request to
     * the new hostname and finishes HTTP-01 in seconds. Short enough that it is
     * never a wait — the domain may equally be pointed at us tomorrow, or
     * never, and the run has no business hanging on that.
     */
    private const CUSTOM_DOMAIN_GRACE = 120;

    public function __construct(
        private HetznerDnsClient $dns,
        private TraefikWriter $traefik,
    ) {}

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

    public function maxDuration(): int
    {
        // Above the 840s cert own-deadline below, so the step's own fail fires first.
        return 960;
    }

    public function execute(ProvisioningRun $run): StepResult
    {
        $instance = $this->instance($run);
        $host = $instance->host;
        $fqdn = $instance->subdomain.'.'.ProvisioningSettings::dnsZone();

        // The customer's own domain is served ONLY once it is proven to be
        // theirs. Everything downstream reads this one flag, so an unverified
        // hostname sitting in the column reaches neither the router nor a
        // certificate — see Instance::domainIsVerified().
        $customDomain = $instance->domainIsVerified() ? (string) $instance->custom_domain : null;

        // The platform address first: it is ours, it always works, and it is
        // what the instance falls back to the moment a custom domain goes away.
        $hostnames = array_values(array_filter([$fqdn, $customDomain]));

        // DNS — provider upsert is idempotent; the local row uses firstOrCreate on
        // the record id and is written BEFORE the breadcrumb (the short-circuit guard).
        // Only OUR zone: the custom domain's A record lives in the customer's
        // zone, which we have no access to and never will.
        if (! $this->hasResource($run, 'dns_record_id')) {
            $recordId = $this->dns->upsertRecord($fqdn, 'A', $host->public_ip);
            $instance->dnsRecords()->firstOrCreate(
                ['record_id' => $recordId],
                ['provider' => 'hetzner', 'fqdn' => $fqdn, 'type' => 'A', 'value' => $host->public_ip],
            );
            $this->recordResource($run, $host, 'dns_record_id', $recordId);
        }

        // Traefik file-provider route, written on the serving host (DNS points at
        // it) and pointing at the guest VM.
        //
        // Guarded on WHAT the router carries, not merely on whether one was ever
        // written. `route_written` alone would short-circuit exactly the case
        // this step is re-run for — an address that has changed — and the
        // customer's domain would be announced but never routed, or stay routed
        // after it was withdrawn. Comparing what was written keeps the retry
        // cheap (a second attempt at the same address writes nothing) without
        // making a re-apply a no-op.
        //
        // BOTH halves of the file are compared, because a router names a backend
        // as well as a set of hostnames, and the backend is a DHCP address. The
        // hostname list on its own said "nothing has changed" for a machine that
        // had moved: cloud-init gives every guest `ip=dhcp`, a lease expiry or a
        // cold boot can put it somewhere else, and the file kept pointing at where
        // it used to be — a 502 on the customer's own cloud that nothing would
        // ever rewrite. ConfigureNetwork re-reads that address on a restart; this
        // is what makes the router follow it.
        $trafficHost = $host->wg_ip ?? $host->public_ip;
        $backend = $instance->guest_ip ?: $host->public_ip;

        if (! $instance->route_written
            || $instance->routed_hostnames !== $hostnames
            || $instance->routed_backend !== $backend) {
            $this->traefik->write($trafficHost, $instance->subdomain, $hostnames, $backend);
            $instance->update([
                'route_written' => true,
                'routed_hostnames' => $hostnames,
                'routed_backend' => $backend,
            ]);
        }

        // TLS via HTTP-01 — poll until the certificate is served.
        if (! $instance->cert_ok) {
            if ($this->traefik->certReachable($fqdn)) {
                $instance->update(['cert_ok' => true]);
            } elseif ($run->started_at !== null && $run->started_at->copy()->addSeconds(840)->isPast()) {
                return StepResult::fail('cert_timeout');
            } else {
                return StepResult::poll(20, 'waiting for certificate');
            }
        }

        return $this->settleCustomDomainCertificate($run, $instance, $customDomain);
    }

    /**
     * The custom domain's certificate: recorded, never required.
     *
     * The platform address above is ours — no certificate there means something
     * is broken on our side and the run fails so somebody looks. This one is the
     * opposite: it can only be issued once the customer has pointed their own A
     * record at us, and whether they have is not ours to decide, not ours to
     * check (behind a CDN the address is invisible from outside) and possibly
     * never going to happen. Failing a run over it would mean a customer who
     * typed a domain and went to lunch could not get their cloud built.
     *
     * So the outcome is written to `domain_cert_ok` and the run advances either
     * way. The portal reads that flag to tell "proven" apart from "answering",
     * because it is the portal that prints this address as the customer's.
     */
    private function settleCustomDomainCertificate(ProvisioningRun $run, Instance $instance, ?string $customDomain): StepResult
    {
        if ($customDomain === null) {
            // No verified domain: whatever was true before is not true now.
            if ($instance->domain_cert_ok) {
                $instance->update(['domain_cert_ok' => false]);
            }

            return StepResult::advance();
        }

        if ($this->traefik->certReachable($customDomain)) {
            $instance->update(['domain_cert_ok' => true]);

            return StepResult::advance();
        }

        // A short look, not a wait — see CUSTOM_DOMAIN_GRACE.
        if ($run->started_at !== null && ! $run->started_at->copy()->addSeconds(self::CUSTOM_DOMAIN_GRACE)->isPast()) {
            return StepResult::poll(15, 'waiting for the custom domain certificate');
        }

        $instance->update(['domain_cert_ok' => false]);

        // Said out loud rather than swallowed: from here the domain is proven,
        // routed, and answering nothing, and that is a state an operator
        // reading this run should be able to see without going looking.
        $run->events()->create([
            'step' => $this->key(),
            'attempt' => $run->attempt,
            'outcome' => 'info',
            'message' => "Eigene Domain {$customDomain}: noch kein Zertifikat — zeigt der A-Eintrag des Kunden schon auf uns?",
        ]);

        return StepResult::advance();
    }
}
