<?php

namespace App\Services\Domains;

use App\Models\Instance;
use Illuminate\Support\Str;

/**
 * Reads the proof a customer published in their own DNS.
 *
 * The whole mechanism is one TXT record at a fixed name under the customer's
 * domain. Only somebody who controls the domain can create it, which is exactly
 * the question being asked.
 *
 * Deliberately NOT a one-off. A token that is checked once can be removed the
 * minute afterwards, and a domain that later lapses and is re-registered by
 * somebody else keeps pointing at the same instance. The record has to stay,
 * and something has to go and look — see VerifyCustomDomains, which does it
 * nightly.
 */
class DomainVerifier
{
    /** Where the record goes. Prefixed so it cannot collide with anything. */
    public const RECORD_PREFIX = '_clupilot-challenge';

    /** What the record has to contain. */
    public const VALUE_PREFIX = 'cp-verify=';

    /**
     * How many consecutive failures a verified domain survives.
     *
     * One failed lookup is a nameserver having a bad minute, not a customer
     * taking their domain away; withdrawing the domain on the first miss would
     * take a working Nextcloud offline over a hiccup. Three nights is long
     * enough to be a decision and short enough to matter.
     */
    public const FAILURES_BEFORE_WITHDRAWAL = 3;

    /** @var callable(string): array<int, array<string, mixed>> */
    private $resolve;

    /**
     * The resolver is injectable so the tests can answer without touching the
     * network — a suite that depends on real DNS fails on a train.
     *
     * @param  null|callable(string): array<int, array<string, mixed>>  $resolve
     */
    public function __construct(?callable $resolve = null)
    {
        $this->resolve = $resolve ?? static fn (string $name) => dns_get_record($name, DNS_TXT) ?: [];
    }

    /** The record name a customer has to create. */
    public function recordName(string $domain): string
    {
        return self::RECORD_PREFIX.'.'.ltrim($domain, '.');
    }

    /** The value it has to hold. */
    public function recordValue(Instance $instance): string
    {
        return self::VALUE_PREFIX.$instance->domain_token;
    }

    /**
     * A fresh token. Called when a domain is set, and again whenever it
     * changes — a new domain must not be provable with the previous one's
     * token, or a customer who once verified example.com could claim any
     * domain afterwards without touching its DNS.
     */
    public function newToken(): string
    {
        return Str::lower(Str::random(32));
    }

    /**
     * Is the proof in place right now?
     *
     * Every TXT value at the name is considered, not just the first: a domain
     * may carry several TXT records at one name (SPF, other verifications), and
     * DNS returns them in no particular order.
     */
    public function proofPresent(Instance $instance): bool
    {
        if (blank($instance->custom_domain) || blank($instance->domain_token)) {
            return false;
        }

        $expected = $this->recordValue($instance);

        foreach (($this->resolve)($this->recordName($instance->custom_domain)) as $record) {
            // dns_get_record splits long values into `entries`; `txt` is the
            // joined form. Both are checked because which one is populated
            // depends on the value's length, and a token is short enough to
            // land in either.
            $values = array_merge([(string) ($record['txt'] ?? '')], (array) ($record['entries'] ?? []));

            foreach ($values as $value) {
                if (trim($value) === $expected) {
                    return true;
                }
            }
        }

        return false;
    }
}
