<?php

use App\Livewire\CustomDomain;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Subscription;
use App\Models\User;
use App\Services\Domains\DomainVerifier;
use Livewire\Livewire;

/**
 * Proving a customer owns the domain they pointed at us.
 *
 * Before this, `custom_domain` was a free-text field and everything downstream
 * believed it: the proxy served it, the certificate was issued for it, and
 * Nextcloud trusted it. Anybody who pointed any hostname at the platform got
 * somebody else's files under their own name.
 *
 * The tests are about the two ways that can come back: a domain being believed
 * without proof, and a proof being believed after it has gone.
 */
function withResolver(array $records): void
{
    // No real DNS: a suite that needs a nameserver fails on a train, and it
    // would be testing the internet rather than this code.
    app()->bind(DomainVerifier::class, fn () => new DomainVerifier(
        fn (string $name) => $records[$name] ?? [],
    ));
}

function customerInstance(array $attributes = [], string $plan = 'business'): Instance
{
    // customers.user_id, not users.customer_id — the link runs the other way.
    $user = User::factory()->create(['email_verified_at' => now()]);
    $customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]);

    $instance = Instance::factory()->create([
        'customer_id' => $customer->id,
        'status' => 'active',
        'subdomain' => 'berger',
        ...$attributes,
    ]);

    // On business by default, because reaching this page at all now takes a
    // package that carries an own domain (CustomDomainAccess). These tests are
    // about proving ownership of a domain, not about who may have one — that
    // rule has its own suite — so they start from a customer who may.
    Subscription::factory()->plan($plan)->create([
        'customer_id' => $customer->id,
        'instance_id' => $instance->id,
    ]);

    return $instance;
}

it('does not serve a domain nobody has proven', function () {
    // The hole, stated as a test: a hostname in the column is a request, not
    // an address.
    $instance = customerInstance(['custom_domain' => 'cloud.fremde-firma.at', 'domain_token' => 'abc']);

    expect($instance->domainIsVerified())->toBeFalse()
        ->and($instance->address('clupilot.com'))->toBe('berger.clupilot.com');
});

it('serves it once the proof is in the DNS', function () {
    $instance = customerInstance(['custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123']);

    withResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=tok123']]]);

    expect(app(DomainVerifier::class)->proofPresent($instance))->toBeTrue();

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

    $instance->refresh();

    expect($instance->domainIsVerified())->toBeTrue()
        ->and($instance->address('clupilot.com'))->toBe('cloud.berger.at');
});

it('finds the proof among a domain’s other TXT records', function () {
    // A name commonly carries several: SPF, other providers' verifications.
    // DNS returns them in no particular order, so only checking the first
    // would fail on a domain that is already in use.
    $instance = customerInstance(['custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123']);

    withResolver(['_clupilot-challenge.cloud.berger.at' => [
        ['txt' => 'v=spf1 -all'],
        ['txt' => 'google-site-verification=xyz'],
        ['txt' => 'cp-verify=tok123'],
    ]]);

    expect(app(DomainVerifier::class)->proofPresent($instance))->toBeTrue();
});

it('is not satisfied by somebody else’s token', function () {
    $instance = customerInstance(['custom_domain' => 'cloud.berger.at', 'domain_token' => 'mine']);

    withResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=someone-elses']]]);

    expect(app(DomainVerifier::class)->proofPresent($instance))->toBeFalse();
});

it('withdraws a domain whose proof was taken away — but not on the first miss', function () {
    // The reason the check repeats at all: put the token in, pass, take it
    // straight back out. And the reason it does not act immediately: one failed
    // lookup is a nameserver having a bad minute, and withdrawing takes a
    // working Nextcloud off its own address.
    $instance = customerInstance([
        'custom_domain' => 'cloud.berger.at',
        'domain_token' => 'tok123',
        'domain_verified_at' => now()->subDays(30),
    ]);

    withResolver([]);   // the record is gone

    $this->artisan('clupilot:verify-domains');
    expect($instance->fresh()->domainIsVerified())->toBeTrue()
        ->and($instance->fresh()->domain_failures)->toBe(1);

    $this->artisan('clupilot:verify-domains');
    expect($instance->fresh()->domainIsVerified())->toBeTrue();

    $this->artisan('clupilot:verify-domains');
    expect($instance->fresh()->domainIsVerified())->toBeFalse()
        // The domain and its token stay on the row: the customer has to see
        // what is wrong and put the record back, not start over.
        ->and($instance->fresh()->custom_domain)->toBe('cloud.berger.at')
        ->and($instance->fresh()->domain_token)->toBe('tok123');
});

it('forgives a miss once the record comes back', function () {
    $instance = customerInstance([
        'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123',
        'domain_verified_at' => now()->subDay(), 'domain_failures' => 2,
    ]);

    withResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=tok123']]]);

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

    expect($instance->fresh()->domain_failures)->toBe(0)
        ->and($instance->fresh()->domainIsVerified())->toBeTrue();
});

it('mints a new token when the domain changes', function () {
    // Otherwise a customer who once verified example.com could claim any other
    // domain later without touching its DNS: the old record is still sitting
    // there and only the value is compared.
    $instance = customerInstance([
        'custom_domain' => 'alt.berger.at', 'domain_token' => 'old-token',
        'domain_verified_at' => now(),
    ]);

    Livewire::actingAs(User::query()->findOrFail($instance->customer->user_id))
        ->test(CustomDomain::class)
        ->set('domain', 'neu.berger.at')
        ->call('save')
        ->assertHasNoErrors();

    $instance->refresh();

    expect($instance->custom_domain)->toBe('neu.berger.at')
        ->and($instance->domain_token)->not->toBe('old-token')
        // And it is not live until the new one is proven.
        ->and($instance->domainIsVerified())->toBeFalse();
});

it('lets a customer take the domain away again', function () {
    // It was suggested this should be one-way. It should not: moving an address
    // is a proxy entry, a certificate and a line in trusted_domains.
    $instance = customerInstance([
        'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
    ]);

    Livewire::actingAs(User::query()->findOrFail($instance->customer->user_id))
        ->test(CustomDomain::class)
        ->set('domain', '')
        ->call('save');

    $instance->refresh();

    expect($instance->custom_domain)->toBeNull()
        ->and($instance->domain_verified_at)->toBeNull()
        ->and($instance->address('clupilot.com'))->toBe('berger.clupilot.com');
});

it('takes a pasted URL and stores a hostname', function () {
    $instance = customerInstance();

    Livewire::actingAs(User::query()->findOrFail($instance->customer->user_id))
        ->test(CustomDomain::class)
        ->set('domain', 'https://Cloud.Berger.at/')
        ->call('save');

    expect($instance->fresh()->custom_domain)->toBe('cloud.berger.at');
});

it('refuses something that is not a hostname', function () {
    $instance = customerInstance();

    Livewire::actingAs(User::query()->findOrFail($instance->customer->user_id))
        ->test(CustomDomain::class)
        ->set('domain', 'nicht mal annähernd')
        ->call('save')
        ->assertHasErrors('domain');

    expect($instance->fresh()->custom_domain)->toBeNull();
});

it('does not let the entry package reach the page at all', function () {
    // The guard, at the component rather than on the route: a Livewire action
    // posts to /livewire/update on its own, so a page-only check would leave
    // every action here open to a package that may not have a domain.
    $instance = customerInstance([], 'start');

    Livewire::actingAs($instance->customer->user)
        ->test(CustomDomain::class)
        ->assertForbidden();
});
