<?php

use App\Actions\OpenSubscription;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Provisioning\RunRunner;
use App\Provisioning\Steps\Customer\CloneVirtualMachine;
use App\Provisioning\Steps\Customer\ReserveResources;
use App\Provisioning\Steps\Customer\ValidateOrder;
use App\Services\Billing\PlanCatalogue;
use App\Services\Proxmox\ProxmoxClient;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;

/**
 * The catalogue describes what we sell today. A customer who has already paid
 * bought today's terms and keeps them — so once an order is paid, nothing the
 * operator changes in the catalogue may reach that customer, not their price
 * and not the machine they were promised.
 */

/** A paid order with its contract already opened, plus a host to place it on. */
function paidOrderWithSubscription(string $plan = 'team'): array
{
    $host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve']);
    $order = Order::factory()->create(['datacenter' => 'fsn', 'plan' => $plan, 'status' => 'paid']);
    $subscription = app(OpenSubscription::class)($order);

    $run = ProvisioningRun::factory()->create([
        'subject_type' => Order::class,
        'subject_id' => $order->id,
        'pipeline' => 'customer',
        'context' => [],
    ]);

    return compact('host', 'order', 'subscription', 'run');
}

/**
 * The operator changes what a plan is, after somebody has already bought it.
 *
 * Publishing a successor and closing the old window is the ONLY way the catalogue
 * can move: a published version is immutable, precisely so that what a customer
 * bought keeps describing what they are owed. So it is also the only way this
 * regression can be constructed — and the tests below used to try it with
 * config('provisioning.plans.*') writes, against a key that has not existed since
 * the catalogue moved into plan_families/plan_versions/plan_prices. Every one of
 * those writes was a no-op, so each test proved only that the contract agrees with
 * itself.
 *
 * @param  array<string, mixed>  $changes
 */
function publishSuccessor(string $plan, array $changes): PlanVersion
{
    $catalogue = app(PlanCatalogue::class);
    $current = $catalogue->currentVersion($plan);
    $currency = Subscription::catalogueCurrency();
    $price = $current->priceFor(Subscription::TERM_MONTHLY)->amount_cents;

    // Half-open windows: the old one ends at the instant the new one starts, so
    // there is neither a gap nor two versions on sale at once.
    $catalogue->schedule($current, $current->available_from, now());

    $successor = PlanVersion::query()->create([
        ...$current->only([
            'plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb',
            'cores', 'disk_gb', 'performance', 'template_vmid',
        ]),
        ...$changes,
        'version' => $current->version + 1,
        'features' => $current->features,
        'available_from' => now(),
    ]);

    $successor->prices()->create(['term' => 'monthly', 'amount_cents' => $price, 'currency' => $currency]);
    $successor->prices()->create(['term' => 'yearly', 'amount_cents' => $price * 12, 'currency' => $currency]);

    return $catalogue->publish($successor, now());
}

it('freezes the catalogue onto a subscription when the checkout is paid', function () {
    Queue::fake();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_snap',
        'type' => 'checkout.session.completed',
        'data' => ['object' => [
            'id' => 'cs_snap',
            'payment_status' => 'paid',
            'customer_details' => ['email' => 'kunde@example.com', 'name' => 'Kanzlei Berger'],
            'amount_total' => 9480,
            'currency' => 'eur',
            'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
        ]],
    ])->assertOk();

    $order = Order::query()->where('stripe_event_id', 'cs_snap')->sole();
    $subscription = Subscription::query()->where('order_id', $order->id)->sole();

    expect($subscription->plan)->toBe('team')
        ->and($subscription->customer_id)->toBe($order->customer_id)
        ->and($subscription->ram_mb)->toBe(8192)
        ->and($subscription->disk_gb)->toBe(100)
        ->and($subscription->price_cents)->toBe(7900)
        ->and($subscription->tier)->toBe(2)
        ->and($subscription->status)->toBe('active')
        ->and($subscription->current_period_end->greaterThan($subscription->current_period_start))->toBeTrue();
});

it('opens exactly one contract when Stripe retries the webhook', function () {
    Queue::fake();

    $event = [
        'id' => 'evt_twice',
        'type' => 'checkout.session.completed',
        'data' => ['object' => [
            'id' => 'cs_twice',
            'payment_status' => 'paid',
            'customer_details' => ['email' => 'zwei@example.com'],
            'amount_total' => 4900,
            'currency' => 'eur',
            'metadata' => ['plan' => 'start', 'datacenter' => 'fsn'],
        ]],
    ];

    $this->postJson(route('webhooks.stripe'), $event)->assertOk();
    $this->postJson(route('webhooks.stripe'), $event)->assertOk();

    expect(Subscription::query()->count())->toBe(1);
    Queue::assertPushed(AdvanceRunJob::class, 1);
});

it('sizes the machine from the contract, not from a catalogue edited afterwards', function () {
    ['order' => $order, 'run' => $run] = paidOrderWithSubscription('team');

    // The operator cuts the team plan back after this customer has paid for it.
    publishSuccessor('team', ['ram_mb' => 4096, 'cores' => 2, 'disk_gb' => 60, 'quota_gb' => 50]);

    // The premise, asserted and not assumed. This edit used to be four
    // config('provisioning.plans.team.*') writes — a key that has not existed
    // since the catalogue moved into the database — so the catalogue was never
    // touched at all and the test could not detect the regression it exists for.
    expect(Subscription::snapshotFrom('team')['ram_mb'])->toBe(4096)
        ->and(Subscription::snapshotFrom('team')['disk_gb'])->toBe(60);

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

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

    expect($instance->ram_mb)->toBe(8192)
        ->and($instance->cores)->toBe(4)
        ->and($instance->disk_gb)->toBe(100)
        ->and($instance->quota_gb)->toBe(85);
});

it('links the instance back to the contract it was provisioned for', function () {
    ['order' => $order, 'subscription' => $subscription, 'run' => $run] = paidOrderWithSubscription('team');

    app(ReserveResources::class)->execute($run);

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

    expect($subscription->fresh()->instance_id)->toBe($instance->id);
});

it('clones the template the contract was sold with, not a later one', function () {
    ['host' => $host, 'order' => $order, 'run' => $run] = paidOrderWithSubscription('team');

    $instance = Instance::factory()->create([
        'order_id' => $order->id, 'customer_id' => $order->customer_id,
        'host_id' => $host->id, 'status' => 'provisioning',
    ]);
    $run->mergeContext(['instance_id' => $instance->id, 'host_id' => $host->id, 'node' => 'pve']);

    // A new blueprint is published after this customer bought — the real thing,
    // a successor version, because a published version's template is frozen with
    // it. The line here was a config write against a key that no longer exists.
    publishSuccessor('team', ['template_vmid' => 9999]);

    expect(Subscription::snapshotFrom('team')['template_vmid'])->toBe(9999);

    $cloned = null;
    $pve = Mockery::mock(ProxmoxClient::class);
    $pve->shouldReceive('forHost')->andReturnSelf();
    $pve->shouldReceive('nextVmid')->andReturn(120);
    $pve->shouldReceive('vmExists')->andReturn(false);
    $pve->shouldReceive('cloneVm')->andReturnUsing(function ($node, $template, $vmid, $name) use (&$cloned) {
        $cloned = $template;

        return 'UPID:pve:clone';
    });
    $pve->shouldReceive('taskStatus')->andReturn(['status' => 'stopped', 'exitstatus' => 'OK']);

    app()->instance(ProxmoxClient::class, $pve);

    expect(app(CloneVirtualMachine::class)->execute($run->fresh())->type)->toBe('advance')
        ->and($cloned)->toBe(9000);
});

it('backfills a contract for an order that was already paid before contracts existed', function () {
    // An order from before this migration: paid, machine running, no contract.
    $order = Order::factory()->create(['status' => 'active', 'plan' => 'team', 'created_at' => now()->subMonths(3)]);
    $instance = Instance::factory()->create([
        'order_id' => $order->id, 'customer_id' => $order->customer_id,
        // Sized from a catalogue that has since been cut back.
        'quota_gb' => 750, 'disk_gb' => 800, 'ram_mb' => 16384, 'cores' => 6,
    ]);

    $migration = require database_path('migrations/2026_07_26_030000_link_subscriptions_to_orders.php');
    (new ReflectionMethod($migration, 'backfillContracts'))->invoke($migration);

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

    // Reconstructed from what was actually delivered, not from today's plan.
    expect($subscription->ram_mb)->toBe(16384)
        ->and($subscription->cores)->toBe(6)
        ->and($subscription->disk_gb)->toBe(800)
        ->and($subscription->quota_gb)->toBe(750)
        ->and($subscription->instance_id)->toBe($instance->id)
        ->and($subscription->status)->toBe('active')
        // The term is anchored on the purchase and rolled forward to the period
        // running now, so the next renewal lands on the right day.
        ->and($subscription->started_at->toDateString())->toBe(now()->subMonths(3)->toDateString())
        ->and($subscription->current_period_end->isFuture())->toBeTrue();

    // Running it twice must not open a second contract.
    (new ReflectionMethod($migration, 'backfillContracts'))->invoke($migration);
    expect(Subscription::query()->where('order_id', $order->id)->count())->toBe(1);
});

it('backfills no contract for an order that never bought anything', function () {
    // Never paid: the cart leaves an order pending with no Stripe event, and a
    // run against it fails without any money having changed hands.
    $failed = Order::factory()->create(['status' => 'failed', 'plan' => 'team', 'stripe_event_id' => null]);
    $pending = Order::factory()->create(['status' => 'pending', 'plan' => 'team', 'stripe_event_id' => null]);

    $migration = require database_path('migrations/2026_07_26_030000_link_subscriptions_to_orders.php');
    (new ReflectionMethod($migration, 'backfillContracts'))->invoke($migration);

    expect(Subscription::query()->whereIn('order_id', [$failed->id, $pending->id])->exists())->toBeFalse();
});

it('adopts a contract that already exists instead of opening a second one', function () {
    $order = Order::factory()->create(['status' => 'active', 'plan' => 'team']);
    $instance = Instance::factory()->create(['order_id' => $order->id, 'customer_id' => $order->customer_id]);

    // A contract from before anything set order_id — carrying terms that have
    // since been cut from the catalogue.
    $existing = Subscription::factory()->create([
        'customer_id' => $order->customer_id,
        'instance_id' => $instance->id,
        'order_id' => null,
        'template_vmid' => null,
    ]);
    DB::table('subscriptions')->where('id', $existing->id)->update(['ram_mb' => 32768]);

    $migration = require database_path('migrations/2026_07_26_030000_link_subscriptions_to_orders.php');
    (new ReflectionMethod($migration, 'backfillContracts'))->invoke($migration);

    expect(Subscription::query()->where('customer_id', $order->customer_id)->count())->toBe(1);

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

    expect($adopted->id)->toBe($existing->id)
        ->and($adopted->ram_mb)->toBe(32768)          // their real terms, not the catalogue's
        ->and($adopted->template_vmid)->toBe(9000);   // only the missing piece filled in
});

it('backfills no contract for a payment the catalogue cannot price', function () {
    $order = Order::factory()->create(['status' => 'active', 'plan' => 'team', 'currency' => 'CHF']);

    $migration = require database_path('migrations/2026_07_26_030000_link_subscriptions_to_orders.php');
    (new ReflectionMethod($migration, 'backfillContracts'))->invoke($migration);

    expect(Subscription::query()->where('order_id', $order->id)->exists())->toBeFalse();
});

it('backfills a contract for a payment whose provisioning failed', function () {
    // Nothing refunds this order — onProvisioningFailed() just marks it failed —
    // so the customer is still owed what they paid for.
    $order = Order::factory()->create([
        'status' => 'failed', 'plan' => 'team', 'stripe_event_id' => 'cs_paid_then_failed',
    ]);

    $migration = require database_path('migrations/2026_07_26_030000_link_subscriptions_to_orders.php');
    (new ReflectionMethod($migration, 'backfillContracts'))->invoke($migration);

    expect(Subscription::query()->where('order_id', $order->id)->sole()->plan)->toBe('team');
});

it('refuses to provision an order that has no contract', function () {
    $order = Order::factory()->create(['status' => 'paid', 'plan' => 'team']);
    $run = ProvisioningRun::factory()->create([
        'subject_type' => Order::class, 'subject_id' => $order->id, 'pipeline' => 'customer', 'context' => [],
    ]);

    $result = app(ValidateOrder::class)->execute($run);

    expect($result->type)->toBe('fail')
        ->and($result->reason)->toBe('no_subscription');
});

it('finishes a contract a crash left unopened, on the next Stripe retry', function () {
    Queue::fake();

    $event = [
        'id' => 'evt_resume',
        'type' => 'checkout.session.completed',
        'data' => ['object' => [
            'id' => 'cs_resume',
            'payment_status' => 'paid',
            'customer_details' => ['email' => 'absturz@example.com'],
            'amount_total' => 17900,
            'currency' => 'eur',
            'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
        ]],
    ];

    $this->postJson(route('webhooks.stripe'), $event)->assertOk();

    // Simulate the crash window: the order and run committed, the contract
    // never got written.
    $order = Order::query()->where('stripe_event_id', 'cs_resume')->sole();
    Subscription::query()->where('order_id', $order->id)->delete();

    // Stripe retries until it gets a 2xx — and the retry repairs it.
    $this->postJson(route('webhooks.stripe'), $event)->assertOk();

    expect(Subscription::query()->where('order_id', $order->id)->sole()->plan)->toBe('team')
        ->and(Order::query()->where('stripe_event_id', 'cs_resume')->count())->toBe(1);
});

it('restarts a run that already failed for want of the contract', function () {
    Queue::fake();

    // The plan cannot be priced when the payment lands, so no contract opens.
    $price = app(PlanCatalogue::class)->currentVersion('team')->priceFor('monthly');
    $amount = $price->amount_cents;
    // Forced past the guard, the way a bad repair script would.
    PlanPrice::query()->whereKey($price->id)->delete();

    $event = [
        'id' => 'evt_strand',
        'type' => 'checkout.session.completed',
        'data' => ['object' => [
            'id' => 'cs_strand',
            'payment_status' => 'paid',
            'customer_details' => ['email' => 'gestrandet@example.com'],
            'amount_total' => 17900,
            'currency' => 'eur',
            'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
        ]],
    ];

    $this->postJson(route('webhooks.stripe'), $event)->assertOk();

    $order = Order::query()->where('stripe_event_id', 'cs_strand')->sole();
    $run = ProvisioningRun::query()->where('subject_id', $order->id)->sole();

    // The run gets as far as validating and stops dead.
    app(RunRunner::class)->advance($run);

    expect($run->fresh()->status)->toBe('failed')
        ->and($run->fresh()->error)->toContain('no_subscription')
        ->and($order->fresh()->status)->toBe('failed');

    // The owner restores the price. Nothing sweeps failed runs, so without the
    // retry repairing it this customer would stay paid-for and unprovisioned.
    app(PlanCatalogue::class)->currentVersion('team')
        ->prices()->create(['term' => 'monthly', 'amount_cents' => $amount, 'currency' => 'EUR']);

    $this->postJson(route('webhooks.stripe'), $event)->assertOk();

    expect(Subscription::query()->where('order_id', $order->id)->sole()->plan)->toBe('team')
        ->and($run->fresh()->status)->toBe('pending')
        ->and($run->fresh()->error)->toBeNull()
        ->and($order->fresh()->status)->toBe('paid');

    Queue::assertPushed(AdvanceRunJob::class);
});

it('keeps the record of a payment even when no contract can be opened', function () {
    Queue::fake();

    // The monthly price row is gone — the plan looks live but cannot be sold.
    // Forced past the guard, the way a bad repair script would.
    PlanPrice::query()
        ->whereKey(app(PlanCatalogue::class)->currentVersion('team')->priceFor('monthly')->id)
        ->delete();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_unpriced',
        'type' => 'checkout.session.completed',
        'data' => ['object' => [
            'id' => 'cs_unpriced',
            'payment_status' => 'paid',
            'customer_details' => ['email' => 'ohnepreis@example.com'],
            'amount_total' => 17900,
            'currency' => 'eur',
            'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
        ]],
    ])->assertOk();

    // The money is on record and Stripe is answered; the run stops visibly.
    $order = Order::query()->where('stripe_event_id', 'cs_unpriced')->sole();

    expect($order->amount_cents)->toBe(17900)
        ->and(Subscription::query()->where('order_id', $order->id)->exists())->toBeFalse();

    $run = ProvisioningRun::query()->where('subject_id', $order->id)->sole();
    expect(app(ValidateOrder::class)->execute($run)->reason)->toBe('no_subscription');
});

it('opens no contract for a payment in a currency the catalogue cannot express', function () {
    Queue::fake();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_chf',
        'type' => 'checkout.session.completed',
        'data' => ['object' => [
            'id' => 'cs_chf',
            'payment_status' => 'paid',
            'customer_details' => ['email' => 'schweiz@example.com'],
            'amount_total' => 17900,
            'currency' => 'chf',
            'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
        ]],
    ])->assertOk();

    // Freezing a EUR price onto a CHF payment would leave the contract and the
    // payment disagreeing forever, so no contract is opened — but the payment
    // is still recorded, and the run stops visibly instead of building anything.
    $order = Order::query()->where('stripe_event_id', 'cs_chf')->sole();

    expect($order->currency)->toBe('CHF')
        ->and(Subscription::query()->where('order_id', $order->id)->exists())->toBeFalse();

    $run = ProvisioningRun::query()->where('subject_id', $order->id)->sole();
    expect(app(ValidateOrder::class)->execute($run)->reason)->toBe('no_subscription');
});

it('leaves a paid order behind when the plan is unknown, instead of losing the payment', function () {
    Queue::fake();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_ghost',
        'type' => 'checkout.session.completed',
        'data' => ['object' => [
            'id' => 'cs_ghost',
            'payment_status' => 'paid',
            'customer_details' => ['email' => 'geist@example.com'],
            'amount_total' => 4900,
            'currency' => 'eur',
            'metadata' => ['plan' => 'ghost', 'datacenter' => 'fsn'],
        ]],
    ])->assertOk();

    // The order is recorded so the payment is traceable; the run fails visibly.
    $order = Order::query()->where('stripe_event_id', 'cs_ghost')->sole();
    expect(Subscription::query()->where('order_id', $order->id)->exists())->toBeFalse();

    $run = ProvisioningRun::query()->where('subject_id', $order->id)->sole();
    expect(app(ValidateOrder::class)->execute($run)->type)->toBe('fail');
});
