<?php

use App\Actions\ApplyPlanChange;
use App\Livewire\Admin\Instances;
use App\Livewire\Cloud;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Models\User;
use App\Provisioning\Steps\Customer\ApplyStorageQuota;
use App\Provisioning\Steps\Customer\ConfigureDnsAndTls;
use App\Provisioning\Steps\Customer\ConfigureNextcloud;
use App\Provisioning\Steps\Customer\ResizeVirtualMachine;
use App\Services\Billing\CustomDomainAccess;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;

/**
 * A plan change that actually happens.
 *
 * PlanChange could price a move and say whether it was allowed; nothing carried
 * one out. A customer could buy an upgrade, watch the order sit there paid, and
 * keep the same contract, the same machine and the same quota — the order was
 * created by the shop and consumed by nobody.
 *
 * These prove the four halves of a change landing (contract, register,
 * entitlements, machine), both triggers, and the two things that cannot be done
 * naively to a live customer: shrinking a disk, and rebooting their cloud.
 */

/** A customer on a package, with a machine that is built, running and served. */
function planChangeFixture(string $plan = 'business', array $instanceAttributes = []): array
{
    $host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve']);
    $order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => $plan]);
    $subscription = $order->subscription;

    $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' => 'berger',
        'plan' => $plan,
        'quota_gb' => $subscription->quota_gb,
        'disk_gb' => $subscription->disk_gb,
        'ram_mb' => $subscription->ram_mb,
        'cores' => $subscription->cores,
        'status' => 'active',
        'route_written' => true,
        'routed_hostnames' => ['berger.'.ProvisioningSettings::dnsZone()],
        'cert_ok' => true,
    ], $instanceAttributes));

    $subscription->update(['instance_id' => $instance->id]);

    return ['host' => $host, 'order' => $order, 'subscription' => $subscription->fresh(), 'instance' => $instance];
}

/**
 * What the shop writes when somebody picks another package: the cart entry, and
 * — for a downgrade — the booking stamped onto the contract with the date it
 * comes due. Both, because the scheduler reads the contract and the customer
 * reads the cart, and a fixture that wrote only one of them would be testing a
 * state the shop never produces.
 */
function planChangeOrder(array $fixture, string $plan, string $type): Order
{
    if ($type === 'downgrade') {
        $fixture['subscription']->bookPendingPlanChange($plan);
    }

    return Order::create([
        'customer_id' => $fixture['order']->customer_id,
        'plan' => $plan,
        'type' => $type,
        'amount_cents' => 1000,
        'currency' => 'EUR',
        'datacenter' => 'fsn',
        'status' => 'pending',
    ]);
}

/** The run the change started, whatever step it is on. */
function planChangeRun(): ?ProvisioningRun
{
    return ProvisioningRun::query()->where('pipeline', 'plan-change')->latest('id')->first();
}

it('moves the contract onto the new package, records it once, and starts one run when the upgrade is paid', function () {
    fakeServices();
    Queue::fake();

    $fixture = planChangeFixture('team');
    $order = planChangeOrder($fixture, 'business', 'upgrade');
    $periodEnd = $fixture['subscription']->current_period_end;

    // The trigger: the order becoming paid, which is all a mocked payment is.
    $order->update(['status' => 'paid']);

    $business = Subscription::snapshotFrom('business');
    $contract = $fixture['subscription']->fresh();

    expect($contract->plan)->toBe('business')
        ->and($contract->plan_version_id)->toBe($business['plan_version_id'])
        ->and($contract->quota_gb)->toBe($business['quota_gb'])
        ->and($contract->price_cents)->toBe($business['price_cents'])
        // Term and billing period are the customer's, not the package's.
        ->and($contract->term)->toBe(Subscription::TERM_MONTHLY)
        ->and($contract->current_period_end->eq($periodEnd))->toBeTrue()
        ->and($contract->started_at->eq($fixture['subscription']->started_at))->toBeTrue();

    expect(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(1)
        ->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(1)
        ->and($order->fresh()->status)->toBe('applied')
        ->and($fixture['instance']->fresh()->plan)->toBe('business');

    // The run hangs off the machine's own purchase order, like `address` does,
    // so the steps can find this customer's contract and no second run can be
    // started against the same machine beside it.
    expect(planChangeRun()->subject_id)->toBe($fixture['order']->id);
});

it('changes nothing the second time the same order is applied', function () {
    fakeServices();
    Queue::fake();

    $fixture = planChangeFixture('team');
    $order = planChangeOrder($fixture, 'business', 'upgrade');
    $order->update(['status' => 'paid']);

    $versionBefore = $fixture['subscription']->fresh()->plan_version_id;

    // Straight back through the action, as a retried trigger or a second
    // scheduler tick would come.
    expect(app(ApplyPlanChange::class)->forOrder($order->fresh()))->toBeNull();

    expect(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(1)
        ->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(1)
        ->and($fixture['subscription']->fresh()->plan_version_id)->toBe($versionBefore);
});

it('holds a downgrade until the term is over, then applies it from the scheduler', function () {
    fakeServices();
    Queue::fake();

    $fixture = planChangeFixture('business');
    $order = planChangeOrder($fixture, 'team', 'downgrade');
    $end = $fixture['subscription']->current_period_end;

    // Mid-term: the customer bought this month, and a month is a month.
    Carbon::setTestNow($end->copy()->subDays(3));
    $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();

    expect($fixture['subscription']->fresh()->plan)->toBe('business')
        ->and($order->fresh()->status)->toBe('pending')
        ->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(0)
        ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_DOWNGRADE)->count())->toBe(0);

    // Once the term has run out it lands, and running the command again does
    // not land it a second time.
    Carbon::setTestNow($end->copy()->addHour());
    $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
    $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();

    expect($fixture['subscription']->fresh()->plan)->toBe('team')
        ->and($order->fresh()->status)->toBe('applied')
        ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_DOWNGRADE)->count())->toBe(1)
        ->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(1);

    Carbon::setTestNow();
});

it('stops serving a domain the smaller package cannot carry', function () {
    $services = fakeServices();
    Queue::fake();

    $domain = 'cloud.berger.at';
    $fixture = planChangeFixture('business', [
        'custom_domain' => $domain,
        'domain_token' => 'tok',
        'domain_verified_at' => now(),
        'domain_cert_ok' => true,
        'routed_hostnames' => ['berger.'.ProvisioningSettings::dnsZone(), $domain],
    ]);
    $customer = $fixture['order']->customer;
    $access = app(CustomDomainAccess::class);

    // Business carries an own domain; Start cannot have one at all.
    expect($access->allowsCustomer($customer))->toBeTrue();

    $order = planChangeOrder($fixture, 'start', 'downgrade');
    Carbon::setTestNow($fixture['subscription']->current_period_end->copy()->addHour());
    $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();

    expect($access->allowsCustomer($customer->fresh()))->toBeFalse();

    // And the machine is told, by this run rather than by a second one racing
    // it: the address steps are part of the plan-change pipeline.
    expect(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(0);

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

    expect($services['traefik']->serves('berger', $domain))->toBeFalse()
        ->and($services['pve']->guestRan('config:system:delete trusted_domains 2'))->toBeTrue()
        ->and($order->fresh()->status)->toBe('applied');

    Carbon::setTestNow();
});

it('shrinks the quota on a downgrade and never the disk', function () {
    $services = fakeServices();
    Queue::fake();

    $fixture = planChangeFixture('business');
    $diskBefore = (int) $fixture['instance']->disk_gb;      // 1050 GB, and it stays
    $order = planChangeOrder($fixture, 'start', 'downgrade');

    Carbon::setTestNow($fixture['subscription']->current_period_end->copy()->addHour());
    $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();

    $run = planChangeRun();
    app(ResizeVirtualMachine::class)->execute($run);
    app(ApplyStorageQuota::class)->execute($run);

    $start = Subscription::snapshotFrom('start');
    $instance = $fixture['instance']->fresh();

    // Proxmox grows a disk and cannot shrink one, so nothing asked it to — and
    // the row still states the size the machine actually holds, because the
    // host's storage accounting reads it.
    expect($services['pve']->resizeCalls)->toBe([])
        ->and($instance->disk_gb)->toBe($diskBefore)
        ->and($instance->disk_gb)->toBeGreaterThan((int) $start['disk_gb']);

    // What was sold does shrink, and Nextcloud is what enforces it.
    expect($instance->quota_gb)->toBe($start['quota_gb'])
        ->and($services['pve']->guestRan("config:app:set files default_quota --value='30 GB'"))->toBeTrue();

    Carbon::setTestNow();
});

it('grows the disk on an upgrade', function () {
    $services = fakeServices();
    Queue::fake();

    $fixture = planChangeFixture('team');
    planChangeOrder($fixture, 'business', 'upgrade')->update(['status' => 'paid']);

    app(ResizeVirtualMachine::class)->execute(planChangeRun());

    // Business ist 175 GB auf einer 200-GB-Platte.
    expect($services['pve']->resizeCalls)->toBe(['101:scsi0:200G'])
        ->and($fixture['instance']->fresh()->disk_gb)->toBe(200);
});

it('refuses an upgrade on a contract that is no longer active', function () {
    fakeServices();
    Queue::fake();

    $fixture = planChangeFixture('team');
    $fixture['subscription']->update(['status' => 'cancelled', 'cancelled_at' => now()]);

    $order = planChangeOrder($fixture, 'business', 'upgrade');
    $order->update(['status' => 'paid']);

    // Nothing at all: a customer who has left is not upgraded, billed or built.
    expect($fixture['subscription']->fresh()->plan)->toBe('team')
        ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(0)
        ->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(0)
        // Still the customer's to remove from the cart, rather than silently
        // marked as though it had been carried out.
        ->and($order->fresh()->status)->toBe('paid');
});

it('never reboots a live machine, and says so where a human looks', function () {
    $services = fakeServices();
    Queue::fake();

    $fixture = planChangeFixture('team');
    $services['pve']->runningVmids = [101];

    planChangeOrder($fixture, 'business', 'upgrade')->update(['status' => 'paid']);

    app(ResizeVirtualMachine::class)->execute($run = planChangeRun());

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

    // The configuration carries the bigger machine…
    expect($services['pve']->cloudInitParams[101]['cores'])->toBe(6)
        ->and($services['pve']->cloudInitParams[101]['memory'])->toBe(12288)
        // …the guest was left running, untouched: startVm appends to this list,
        // so an unchanged list is a machine that was never stopped and started.
        ->and($services['pve']->runningVmids)->toBe([101])
        // …and the gap is recorded rather than left for somebody to discover.
        ->and($instance->restartIsPending())->toBeTrue()
        ->and($run->events()->where('outcome', 'info')->where('step', 'resize_vm')->exists())->toBeTrue();

    // The customer is told on the page that shows them their cloud.
    $user = User::factory()->create(['email_verified_at' => now()]);
    $fixture['order']->customer->update(['user_id' => $user->id, 'email' => $user->email]);

    Livewire::actingAs($user)->test(Cloud::class)
        ->assertSee(__('cloud.restart_pending_title'));

    // And the console's instance list carries it beside the status.
    Livewire::actingAs(admin(), 'operator')->test(Instances::class)
        ->assertSee(__('admin.restart_pending'));
});

it('has nothing pending when the machine is stopped, because starting it applies the new size', function () {
    $services = fakeServices();
    Queue::fake();

    // Stopped, and carrying a pending restart from an earlier change.
    $fixture = planChangeFixture('team', ['restart_required_since' => now()->subDay()]);
    $services['pve']->runningVmids = [];

    planChangeOrder($fixture, 'business', 'upgrade')->update(['status' => 'paid']);

    app(ResizeVirtualMachine::class)->execute(planChangeRun());

    expect($fixture['instance']->fresh()->restartIsPending())->toBeFalse();
});

it('refuses to be moved onto a package the catalogue will not sell', function () {
    fakeServices();
    Queue::fake();

    $fixture = planChangeFixture('team');

    expect(app(ApplyPlanChange::class)($fixture['subscription'], 'gibtsnicht'))->toBeNull()
        ->and($fixture['subscription']->fresh()->plan)->toBe('team')
        ->and(SubscriptionRecord::query()->whereIn('event', [
            SubscriptionRecord::EVENT_UPGRADE, SubscriptionRecord::EVENT_DOWNGRADE,
        ])->count())->toBe(0);
});
