<?php

use App\Actions\ReapplyInstanceAddress;
use App\Livewire\CustomDomain;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\User;
use App\Provisioning\RunRunner;
use App\Provisioning\Steps\Customer\ConfigureDnsAndTls;
use App\Provisioning\Steps\Customer\ConfigureNextcloud;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Domains\DomainVerifier;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;

/**
 * Serving the domain, as opposed to announcing it.
 *
 * A verified custom domain was reported as the customer's address by
 * Instance::address(), by the portal and by the credentials mail, while nothing
 * on the platform ever routed it: the Traefik router's rule was hard-coded to
 * `{subdomain}.{zone}`, so the address the customer had just been given
 * answered nothing at all. The other direction was worse — a withdrawn domain
 * stayed in the router and in Nextcloud's trusted_domains forever, because the
 * only thing that ever wrote either was the initial provisioning run.
 *
 * These tests are about the two halves of that: an address that has to become
 * real, and one that has to stop being real.
 */

/** An instance that is finished, running, and reachable at its platform address. */
function servedInstance(array $attributes = [], string $subdomain = 'berger'): array
{
    $host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve']);
    $order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => 'business']);

    $instance = Instance::factory()->create(array_merge([
        'order_id' => $order->id,
        'customer_id' => $order->customer_id,
        'host_id' => $host->id,
        'vmid' => 101,
        'guest_ip' => '10.20.0.7',
        'subdomain' => $subdomain,
        'status' => 'active',
        // The state a re-apply actually finds: built, routed, certified. Both
        // halves of what the router file says are recorded, hostnames AND
        // backend — an instance whose `routed_backend` were left null would be
        // one the step has to rewrite, which is not the state being described.
        'route_written' => true,
        'routed_hostnames' => [platformAddress($subdomain)],
        'routed_backend' => '10.20.0.7',
        'cert_ok' => true,
    ], $attributes));

    return compact('host', 'order', 'instance');
}

/** A run of the `address` pipeline against that instance's order. */
function addressRun(array $fixture): ProvisioningRun
{
    return ProvisioningRun::factory()->create([
        'subject_type' => Order::class,
        'subject_id' => $fixture['order']->id,
        'pipeline' => 'address',
        'context' => [
            'instance_id' => $fixture['instance']->id,
            'host_id' => $fixture['host']->id,
            'node' => 'pve',
            'vmid' => 101,
            'subdomain' => $fixture['instance']->subdomain,
        ],
    ]);
}

/** No real DNS: a suite that needs a nameserver fails on a train. */
function bindDomainResolver(array $records): void
{
    app()->bind(DomainVerifier::class, fn () => new DomainVerifier(
        fn (string $name) => $records[$name] ?? [],
    ));
}

function platformAddress(string $subdomain = 'berger'): string
{
    return $subdomain.'.'.ProvisioningSettings::dnsZone();
}

/** The portal account that owns the fixture's instance. */
function signIntoPortal(array $fixture): User
{
    $user = User::factory()->create(['email_verified_at' => now()]);

    // customers.user_id, not users.customer_id — the link runs the other way.
    $fixture['order']->customer->update(['user_id' => $user->id, 'email' => $user->email]);

    return $user;
}

it('puts a verified custom domain in the router beside the platform address, and an unverified one nowhere', function () {
    $s = fakeServices();

    // Unverified: the hostname is in the column, so everything that reads the
    // column would announce it — but it is a request, not an address.
    $unproven = servedInstance(['custom_domain' => 'cloud.fremde-firma.at', 'domain_token' => 'tok'], 'fremde');
    expect(app(ConfigureDnsAndTls::class)->execute(addressRun($unproven))->type)->toBe('advance')
        ->and($s['traefik']->writes)->toBe(0)   // nothing changed, so nothing was written
        ->and($unproven['instance']->fresh()->routed_hostnames)->toBe([platformAddress('fremde')]);

    // Proven: one router, both names, the platform address first — it is what
    // the instance falls back to the moment the domain goes away.
    $proven = servedInstance([
        'custom_domain' => 'cloud.berger.at',
        'domain_token' => 'tok',
        'domain_verified_at' => now(),
    ]);

    expect(app(ConfigureDnsAndTls::class)->execute(addressRun($proven))->type)->toBe('advance')
        ->and($s['traefik']->hostnames['berger'])->toBe([platformAddress(), 'cloud.berger.at'])
        ->and($proven['instance']->fresh()->routed_hostnames)->toBe([platformAddress(), 'cloud.berger.at'])
        ->and($proven['instance']->fresh()->domain_cert_ok)->toBeTrue();
});

it('writes one router file for both names rather than a second one', function () {
    // Two routers for one backend are two things to keep in step, and the
    // second is the one nobody remembers on the day the domain is withdrawn.
    $s = fakeServices();
    $fixture = servedInstance([
        'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
    ]);

    app(ConfigureDnsAndTls::class)->execute(addressRun($fixture));

    expect($s['traefik']->writes)->toBe(1)
        ->and(array_keys($s['traefik']->hostnames))->toBe(['berger']);
});

it('stops serving a withdrawn domain and takes it out of trusted_domains', function () {
    $s = fakeServices();

    // Verified yesterday, served ever since, and withdrawn this morning: the
    // proof is gone but the router and Nextcloud have never been told.
    $fixture = servedInstance([
        'custom_domain' => 'cloud.berger.at',
        'domain_token' => 'tok',
        'domain_verified_at' => null,
        'domain_cert_ok' => true,
        'routed_hostnames' => [platformAddress(), 'cloud.berger.at'],
    ]);

    $run = addressRun($fixture);

    expect(app(ConfigureDnsAndTls::class)->execute($run)->type)->toBe('advance')
        ->and(app(ConfigureNextcloud::class)->execute($run)->type)->toBe('advance');

    expect($s['traefik']->hostnames['berger'])->toBe([platformAddress()])
        ->and($s['traefik']->serves('berger', 'cloud.berger.at'))->toBeFalse()
        ->and($fixture['instance']->fresh()->routed_hostnames)->toBe([platformAddress()])
        ->and($fixture['instance']->fresh()->domain_cert_ok)->toBeFalse()
        // Deleted, not blanked: an entry left in trusted_domains means Nextcloud
        // still answers to that name whenever anything reaches it.
        ->and($s['pve']->guestRan('config:system:delete trusted_domains 2'))->toBeTrue()
        ->and($s['pve']->guestRan('config:system:set trusted_domains 2'))->toBeFalse();
});

it('trusts a verified domain and leaves the delete alone', function () {
    $s = fakeServices();
    $fixture = servedInstance([
        'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
    ]);

    app(ConfigureNextcloud::class)->execute(addressRun($fixture));

    expect($s['pve']->guestRan("config:system:set trusted_domains 2 --value='cloud.berger.at'"))->toBeTrue()
        ->and($s['pve']->guestRan('config:system:delete trusted_domains 2'))->toBeFalse();
});

it('never fails a run over the custom domain’s certificate, but still fails over the platform’s', function () {
    $s = fakeServices();

    // The customer has proven the domain is theirs and has NOT pointed it at
    // us — which is an A record in their own zone, may take days, and may never
    // happen at all. Their cloud still has to be delivered.
    $s['traefik']->certUnreachable = ['cloud.berger.at'];
    $fixture = servedInstance([
        'custom_domain' => 'cloud.berger.at',
        'domain_token' => 'tok',
        'domain_verified_at' => now(),
        'cert_ok' => false,
    ]);
    $run = addressRun($fixture);
    $run->update(['started_at' => now()->subSeconds(300)]); // past the short look

    expect(app(ConfigureDnsAndTls::class)->execute($run)->type)->toBe('advance');

    $instance = $fixture['instance']->fresh();
    expect($instance->cert_ok)->toBeTrue()          // ours works
        ->and($instance->domain_verified_at)->not->toBeNull()
        ->and($instance->domain_cert_ok)->toBeFalse()  // theirs does not, and is recorded as such
        ->and($instance->domainIsVerified())->toBeTrue()
        ->and($instance->domainIsServed())->toBeFalse()
        // Visible to an operator reading the run, not swallowed.
        ->and($run->events()->where('outcome', 'info')->where('step', 'configure_dns_and_tls')->exists())->toBeTrue();

    // The platform address is ours: no certificate there is a broken run.
    $s2 = fakeServices();
    $s2['traefik']->certReady = false;
    $second = servedInstance(['cert_ok' => false], 'zweite');
    $failing = addressRun($second);
    $failing->update(['started_at' => now()->subSeconds(900)]);

    $result = app(ConfigureDnsAndTls::class)->execute($failing);
    expect($result->type)->toBe('fail')
        ->and($result->reason)->toBe('cert_timeout');
});

it('looks again for a short while before giving up on the custom certificate', function () {
    // Traefik finishes HTTP-01 seconds after the first request to a new
    // hostname, so advancing on the very first probe would mark a domain that
    // is about to work as not working.
    $s = fakeServices();
    $s['traefik']->certUnreachable = ['cloud.berger.at'];
    $fixture = servedInstance([
        'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
    ]);
    $run = addressRun($fixture);
    $run->update(['started_at' => now()->subSeconds(5)]);

    expect(app(ConfigureDnsAndTls::class)->execute($run)->type)->toBe('poll');
});

it('starts exactly one re-apply when verification flips, and none when nothing changed', function () {
    Queue::fake();
    $fixture = servedInstance(['custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123']);
    bindDomainResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=tok123']]]);

    // The flip: proof appears, so the domain has to become an address.
    $this->artisan('clupilot:verify-domains')->assertSuccessful();

    $runs = ProvisioningRun::query()->where('pipeline', 'address');
    expect($runs->count())->toBe(1)
        ->and($fixture['instance']->fresh()->domainIsVerified())->toBeTrue();

    // Finished, so nothing is in flight to suppress a second one — the only
    // thing stopping it must be that nothing changed.
    $runs->first()->update(['status' => ProvisioningRun::STATUS_COMPLETED]);

    $this->artisan('clupilot:verify-domains')->assertSuccessful();

    expect(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);

    // And the other flip: the proof is taken away for the third time running.
    bindDomainResolver([]);
    $this->artisan('clupilot:verify-domains');
    $this->artisan('clupilot:verify-domains');
    expect(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);

    $this->artisan('clupilot:verify-domains');
    expect($fixture['instance']->fresh()->domainIsVerified())->toBeFalse()
        ->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(2);
});

it('stops serving the domain when the package stops allowing one', function () {
    $s = fakeServices();
    Queue::fake();

    $fixture = servedInstance([
        'custom_domain' => 'cloud.berger.at',
        'domain_token' => 'tok',
        'domain_verified_at' => now(),
        'domain_cert_ok' => true,
        'routed_hostnames' => [platformAddress(), 'cloud.berger.at'],
    ]);

    // What PlanChange::settleCustomDomain() reaches through CustomDomainAccess
    // when a downgrade lands on a package that carries no own domain.
    expect(app(CustomDomainAccess::class)->deactivate($fixture['instance']))->toBeTrue();

    $run = ProvisioningRun::query()->where('pipeline', 'address')->latest('id')->first();
    expect($run)->not->toBeNull();

    app(ConfigureDnsAndTls::class)->execute($run);
    app(ConfigureNextcloud::class)->execute($run);

    expect($fixture['instance']->fresh()->custom_domain)->toBeNull()
        ->and($s['traefik']->serves('berger', 'cloud.berger.at'))->toBeFalse()
        ->and($s['traefik']->hostnames['berger'])->toBe([platformAddress()])
        ->and($s['pve']->guestRan('config:system:delete trusted_domains 2'))->toBeTrue();
});

it('does not start a second run while one is already going', function () {
    Queue::fake();
    $fixture = servedInstance([
        'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
    ]);

    $reapply = app(ReapplyInstanceAddress::class);

    expect($reapply($fixture['instance']))->not->toBeNull()
        ->and($reapply($fixture['instance']))->toBeNull()
        ->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);

    // Nor beside the build itself: the customer run applies the address on its
    // way past, and two runs writing one router is the thing being avoided.
    ProvisioningRun::query()->where('pipeline', 'address')->update(['status' => ProvisioningRun::STATUS_COMPLETED]);
    ProvisioningRun::factory()->create([
        'subject_type' => Order::class,
        'subject_id' => $fixture['order']->id,
        'pipeline' => 'customer',
        'status' => ProvisioningRun::STATUS_RUNNING,
    ]);

    expect($reapply($fixture['instance']))->toBeNull()
        ->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
});

it('does start one beside a run that will not apply the address itself', function () {
    // The guard used to stand aside for ANY run in flight, on the reasoning that
    // whatever is running reads the domain state when it gets to the step. True of
    // `customer` and `plan-change`, which carry both address steps. False of a
    // restart, which carries neither — and nothing looks again afterwards, because
    // the nightly proof check only re-applies on the FLIP from unproven to proven
    // and that flip has already happened. So a domain proven while the customer's
    // machine happened to be going round was announced, charged for, and never
    // routed for the life of the instance.
    Queue::fake();
    $fixture = servedInstance([
        'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
    ]);

    foreach (['restart', 'storage', 'quota'] as $pipeline) {
        ProvisioningRun::query()->delete();
        ProvisioningRun::factory()->create([
            'subject_type' => Order::class,
            'subject_id' => $fixture['order']->id,
            'pipeline' => $pipeline,
            'status' => ProvisioningRun::STATUS_RUNNING,
        ]);

        expect(app(ReapplyInstanceAddress::class)($fixture['instance']))->not->toBeNull()
            ->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
    }

    // And still stands aside for a plan change, which reuses these two steps and
    // will read the same domain state when it reaches them.
    ProvisioningRun::query()->delete();
    ProvisioningRun::factory()->create([
        'subject_type' => Order::class,
        'subject_id' => $fixture['order']->id,
        'pipeline' => 'plan-change',
        'status' => ProvisioningRun::STATUS_RUNNING,
    ]);

    expect(app(ReapplyInstanceAddress::class)($fixture['instance']))->toBeNull();
});

it('keeps the domain unannounced when the run fails before it has been routed', function () {
    // The half-finished address, which is the ordinary outcome of a failed run.
    // With the router written first, a run that got the certificate and then fell
    // over at Nextcloud left the customer's own domain serving Nextcloud's
    // untrusted-domain error page under a valid certificate — and `domain_cert_ok`
    // was already true, so the portal was printing that address as live.
    Queue::fake();
    $s = fakeServices();
    $fixture = servedInstance([
        'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
    ]);

    // Nextcloud refuses the trusted_domains write; guest() throws, which the
    // runner turns into a retry, so the run never reaches the router at all.
    $s['pve']->guestScript('config:system:set trusted_domains', 1);

    $run = addressRun($fixture);
    $run->update(['status' => ProvisioningRun::STATUS_RUNNING, 'current_step' => 0]);
    app(RunRunner::class)->advance($run);

    $instance = $fixture['instance']->fresh();

    expect($run->fresh()->status)->toBe(ProvisioningRun::STATUS_WAITING)
        ->and($run->fresh()->current_step)->toBe(0)
        // Nothing announced, nothing routed: the customer is still on the platform
        // address, which is the honest half to be left holding.
        ->and($instance->domain_cert_ok)->toBeFalse()
        ->and($instance->routed_hostnames)->toBe([platformAddress()])
        ->and($s['traefik']->serves('berger', 'cloud.berger.at'))->toBeFalse();
});

it('stops serving the old domain the moment the customer changes or clears it', function () {
    Queue::fake();

    $fixture = servedInstance([
        'custom_domain' => 'cloud.berger.at',
        'domain_token' => 'tok',
        'domain_verified_at' => now(),
        'domain_cert_ok' => true,
        'routed_hostnames' => [platformAddress(), 'cloud.berger.at'],
    ]);
    $user = signIntoPortal($fixture);

    // Moving to another domain leaves the old one routed and trusted until
    // something says otherwise — the new one is unproven and serves nothing.
    Livewire::actingAs($user)->test(CustomDomain::class)
        ->set('domain', 'neu.berger.at')
        ->call('save')
        ->assertHasNoErrors();

    expect(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1)
        ->and($fixture['instance']->fresh()->domain_cert_ok)->toBeFalse();

    ProvisioningRun::query()->where('pipeline', 'address')->update(['status' => ProvisioningRun::STATUS_COMPLETED]);

    // Clearing an unproven domain changes nothing that is being served, so it
    // is not worth a run on a live machine.
    Livewire::actingAs($user)->test(CustomDomain::class)
        ->set('domain', '')
        ->call('save');

    expect($fixture['instance']->fresh()->custom_domain)->toBeNull()
        ->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
});

it('makes the domain an address as soon as the customer’s own check finds the proof', function () {
    Queue::fake();

    $fixture = servedInstance(['custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123']);
    $user = signIntoPortal($fixture);
    bindDomainResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=tok123']]]);

    Livewire::actingAs($user)->test(CustomDomain::class)->call('checkNow');

    expect($fixture['instance']->fresh()->domainIsVerified())->toBeTrue()
        ->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
});

it('has nothing to re-apply for an instance with no machine', function () {
    Queue::fake();

    // A reservation that never became a VM, and a build that failed: the steps
    // would reach for a guest agent that is not there and spend the run's
    // retries finding out.
    $reserved = Instance::factory()->create(['status' => 'reserving', 'vmid' => null]);

    expect(app(ReapplyInstanceAddress::class)($reserved))->toBeNull()
        ->and(app(ReapplyInstanceAddress::class)(null))->toBeNull()
        ->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(0);
});
