<?php

use App\Actions\EndInstanceService;
use App\Livewire\ConfirmCancelPackage;
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\User;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use App\Support\ProvisioningSettings;
use Livewire\Livewire;

/**
 * The router that was never torn down.
 *
 * `TraefikWriter::remove()` had no caller anywhere in the application. Every
 * route this platform wrote was written for good: the file stayed on the serving
 * host, the hostname went on resolving, and it went on pointing at a guest
 * address the host is free to hand to a different customer's VM later. What was
 * missing was not the removal — it was the MOMENT. ConfirmCancelPackage wrote a
 * date into `service_ends_at` and nothing ever went back to it.
 *
 * These tests are about that moment, and about the line either side of it: a
 * cancellation that has merely been scheduled is a paying customer.
 */

/** A live, routed instance with a DNS record in our zone. */
function routedInstance(array $attributes = [], string $subdomain = 'berger'): array
{
    $services = fakeServices();

    $host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'public_ip' => '203.0.113.9']);
    $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',
        'route_written' => true,
        'routed_hostnames' => [$subdomain.'.'.ProvisioningSettings::dnsZone()],
        'cert_ok' => true,
    ], $attributes));

    $recordId = $services['dns']->upsertRecord($subdomain.'.'.ProvisioningSettings::dnsZone(), 'A', $host->public_ip);
    $instance->dnsRecords()->create([
        'provider' => 'hetzner',
        'record_id' => $recordId,
        'fqdn' => $subdomain.'.'.ProvisioningSettings::dnsZone(),
        'type' => 'A',
        'value' => $host->public_ip,
    ]);

    $services['traefik']->write($host->wg_ip ?? $host->public_ip, $subdomain, $instance->routed_hostnames, $instance->guest_ip);

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

it('takes the router and the DNS record down when the paid term has run out', function () {
    ['services' => $services, 'instance' => $instance] = routedInstance([
        'status' => 'cancellation_scheduled',
        'cancel_requested_at' => now()->subMonth(),
        'service_ends_at' => now()->subHour(),
    ]);

    expect($services['traefik']->routes)->toHaveKey('berger');

    $this->artisan('clupilot:end-due-services')->assertSuccessful();

    expect($services['traefik']->routes)->not->toHaveKey('berger')
        // The A record goes with the route: it is in OUR zone, it points at a
        // host that goes on serving other customers, and a name we own that
        // resolves to a live machine with nothing of its owner's behind it is
        // the shape of every subdomain takeover there has ever been.
        ->and($services['dns']->records)->toBe([])
        ->and($instance->dnsRecords()->count())->toBe(0)
        ->and($instance->fresh()->status)->toBe('ended')
        // The address state goes too, so a revived instance is not announced
        // and routed nowhere by a step that thinks it has already written.
        ->and($instance->fresh()->route_written)->toBeFalse()
        ->and($instance->fresh()->routed_hostnames)->toBeNull()
        ->and($instance->fresh()->cert_ok)->toBeFalse();
});

it('leaves a scheduled cancellation exactly where it is until the day arrives', function () {
    ['services' => $services, 'instance' => $instance] = routedInstance([
        'status' => 'cancellation_scheduled',
        'cancel_requested_at' => now(),
        'service_ends_at' => now()->addWeeks(3),
    ]);

    $this->artisan('clupilot:end-due-services')->assertSuccessful();

    // They have paid to the end of the term and are working in it this
    // afternoon. Pulling the address the day somebody schedules a cancellation
    // would be a far worse fault than the leak this closes.
    expect($services['traefik']->routes)->toHaveKey('berger')
        ->and($services['dns']->records)->not->toBe([])
        ->and($instance->fresh()->status)->toBe('cancellation_scheduled')
        ->and($instance->fresh()->route_written)->toBeTrue();
});

it('does not touch an active instance, however old', function () {
    ['services' => $services, 'instance' => $instance] = routedInstance([
        'created_at' => now()->subYears(2),
    ]);

    $this->artisan('clupilot:end-due-services')->assertSuccessful();

    expect($services['traefik']->routes)->toHaveKey('berger')
        ->and($instance->fresh()->status)->toBe('active');
});

it('leaves a cancellation with no end date alone rather than inventing one', function () {
    ['services' => $services, 'instance' => $instance] = routedInstance([
        'status' => 'cancellation_scheduled',
        'cancel_requested_at' => now()->subMonth(),
        'service_ends_at' => null,
    ]);

    $this->artisan('clupilot:end-due-services')->assertSuccessful();

    // A broken record, not an expired one. Guessing an end date for it would
    // invent the very moment this whole thing is written to respect.
    expect($services['traefik']->routes)->toHaveKey('berger')
        ->and($instance->fresh()->status)->toBe('cancellation_scheduled');
});

it('changes nothing in dry-run mode', function () {
    ['services' => $services, 'instance' => $instance] = routedInstance([
        'status' => 'cancellation_scheduled',
        'service_ends_at' => now()->subDay(),
    ]);

    $this->artisan('clupilot:end-due-services', ['--dry-run' => true])
        ->expectsOutputToContain('Probelauf')
        ->assertSuccessful();

    expect($services['traefik']->routes)->toHaveKey('berger')
        ->and($services['dns']->records)->not->toBe([])
        ->and($instance->fresh()->status)->toBe('cancellation_scheduled');
});

it('is safe to run twice', function () {
    ['services' => $services, 'instance' => $instance] = routedInstance([
        'status' => 'cancellation_scheduled',
        'service_ends_at' => now()->subDay(),
    ]);

    $this->artisan('clupilot:end-due-services')->assertSuccessful();
    $this->artisan('clupilot:end-due-services')->assertSuccessful();

    expect($instance->fresh()->status)->toBe('ended')
        ->and($services['traefik']->routes)->toBe([]);
});

it('carries the whole way from the customer cancelling to the address going away', function () {
    $this->travelTo('2026-05-10 09:00:00');

    ['services' => $services, 'instance' => $instance, 'order' => $order] = routedInstance();

    $stripe = new FakeStripeClient;
    app()->instance(StripeClient::class, $stripe);

    $contract = Subscription::query()->where('order_id', $order->id)->sole();

    $contract->update([
        'stripe_subscription_id' => 'sub_journey',
        // The boundary the customer has paid to, and the one Stripe pushes
        // forward on every renewal. Written as a literal date on purpose: this
        // test used to travel to whatever `service_ends_at` the implementation
        // had just computed, which made it recompute the code instead of
        // checking it — a monthly answer on a yearly contract passed as happily
        // as the right one.
        'current_period_end' => '2026-08-01 09:00:00',
    ]);

    $user = User::factory()->create(['email_verified_at' => now()]);
    Customer::query()->whereKey($instance->customer_id)->update(['user_id' => $user->id, 'email' => $user->email]);

    Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
        ->set('confirmName', $instance->subdomain)
        ->set('reason', 'no_longer_needed')
        ->set('exportWish', true)
        ->call('cancelPackage');

    $instance->refresh();

    expect($instance->status)->toBe('cancellation_scheduled')
        ->and($instance->service_ends_at->toDateTimeString())->toBe('2026-08-01 09:00:00');

    // The billing half, which was the broken half: the money stops too. Nothing
    // in this application could cancel a Stripe subscription at all, so a
    // customer whose address came down here went on being charged every month.
    expect($stripe->cancellations)->toHaveCount(1)
        ->and($stripe->cancellations[0]['subscription'])->toBe('sub_journey')
        ->and($stripe->cancellations[0]['when'])->toBe(StripeClient::CANCEL_AT_PERIOD_END)
        ->and($contract->fresh()->cancel_requested_at?->toDateTimeString())->toBe('2026-05-10 09:00:00')
        // Still active: until the first of August this is a paying customer.
        ->and($contract->fresh()->status)->toBe('active');

    // Still theirs, still served, for the whole of the term they bought — an hour
    // before it runs out as much as on the day they cancelled.
    $this->artisan('clupilot:end-due-services')->assertSuccessful();
    expect($services['traefik']->routes)->toHaveKey('berger');

    $this->travelTo('2026-08-01 08:00:00');
    $this->artisan('clupilot:end-due-services')->assertSuccessful();
    expect($services['traefik']->routes)->toHaveKey('berger');

    // And gone the moment it is genuinely over.
    $this->travelTo('2026-08-01 09:01:00');
    $this->artisan('clupilot:end-due-services')->assertSuccessful();

    expect($services['traefik']->routes)->toBe([])
        ->and($instance->fresh()->status)->toBe('ended');
});

it('refuses to end an instance whose term has not run out, whoever asks', function () {
    ['instance' => $instance] = routedInstance([
        'status' => 'cancellation_scheduled',
        'service_ends_at' => now()->addMonth(),
    ]);

    // The rule lives on the action, not in the command's query — anything that
    // ever ends an instance has to ask the same question.
    expect(app(EndInstanceService::class)->hasEnded($instance))->toBeFalse()
        ->and(app(EndInstanceService::class)($instance))->toBeFalse()
        ->and($instance->fresh()->status)->toBe('cancellation_scheduled');
});
