<?php

use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\MultipleRecordsFoundException;

/**
 * The catalogue lives in three tables. This is the shadow comparison it has to
 * survive: every figure the seeded catalogue serves is pinned literally, so a
 * migration that quietly changes what we sell fails here rather than in a
 * customer's invoice.
 *
 * The figures below are the ladder the owner decided on — nine start customers
 * per host instead of three — not a transcript of what the code happens to
 * return. Enterprise is deliberately absent: it is answered by enquiry now, so
 * it is not in the shop and not in this list.
 */

/** The ladder as sold, capability by capability. */
const THE_LADDER_WE_SELL = [
    'start' => ['tier' => 1, 'traffic_gb' => 1000, 'quota_gb' => 30, 'disk_gb' => 40, 'ram_mb' => 6144, 'cores' => 2, 'seats' => 10, 'performance' => 'standard', 'price_cents' => 3900, 'template_vmid' => 9000],
    'team' => ['tier' => 2, 'traffic_gb' => 2000, 'quota_gb' => 85, 'disk_gb' => 100, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25, 'performance' => 'enhanced', 'price_cents' => 7900, 'template_vmid' => 9000],
    'business' => ['tier' => 3, 'traffic_gb' => 4000, 'quota_gb' => 175, 'disk_gb' => 200, 'ram_mb' => 12288, 'cores' => 6, 'seats' => 50, 'performance' => 'high', 'price_cents' => 13900, 'template_vmid' => 9000],
];

it('sells exactly the ladder the owner decided on', function () {
    $sellable = app(PlanCatalogue::class)->sellable();

    expect(array_keys($sellable))->toBe(array_keys(THE_LADDER_WE_SELL));

    foreach (THE_LADDER_WE_SELL as $key => $expected) {
        foreach ($expected as $field => $value) {
            expect($sellable[$key][$field])->toBe($value, "{$key}.{$field}");
        }
    }
});

it('freezes exactly that ladder onto a contract', function () {
    foreach (THE_LADDER_WE_SELL as $key => $expected) {
        $snapshot = Subscription::snapshotFrom($key);

        expect($snapshot['price_cents'])->toBe($expected['price_cents'])
            ->and($snapshot['ram_mb'])->toBe($expected['ram_mb'])
            ->and($snapshot['cores'])->toBe($expected['cores'])
            ->and($snapshot['disk_gb'])->toBe($expected['disk_gb'])
            ->and($snapshot['quota_gb'])->toBe($expected['quota_gb'])
            ->and($snapshot['traffic_gb'])->toBe($expected['traffic_gb'])
            ->and($snapshot['seats'])->toBe($expected['seats'])
            ->and($snapshot['tier'])->toBe($expected['tier'])
            ->and($snapshot['template_vmid'])->toBe($expected['template_vmid'])
            ->and($snapshot['plan_version_id'])->not->toBeNull();

        // Ten paid months, because every package on the new ladder gives two
        // away for a yearly term. The figure comes from its own row, not from
        // arithmetic at read time — this is the pin on what was typed into it.
        expect(Subscription::snapshotFrom($key, 'yearly')['price_cents'])
            ->toBe($expected['price_cents'] * 10);
    }
});

it('hides a plan the shop cannot price for every term it sells', function () {
    $catalogue = app(PlanCatalogue::class);
    // Forced past the guard, the way a bad repair script would.
    PlanPrice::query()->whereKey($catalogue->currentVersion('team')->priceFor('yearly')->id)->delete();

    // The shop and the checkout have to agree, or a customer picks a plan,
    // pays, and lands on a contract that cannot be opened.
    expect($catalogue->isSellable('team'))->toBeFalse()
        ->and(array_keys($catalogue->sellable()))->not->toContain('team')
        ->and(array_keys($catalogue->sellable()))->toContain('start');
});

it('records which version a contract was sold under', function () {
    $snapshot = Subscription::snapshotFrom('team');
    $version = app(PlanCatalogue::class)->version($snapshot['plan_version_id']);

    // Version 2: the ladder switch closed v1 and published its successor, so a
    // contract opened today records the second one.
    expect($version->family->key)->toBe('team')
        ->and($version->version)->toBe(2);
});

it('contracts a checkout to the version it was shown, not the one that replaced it', function () {
    $catalogue = app(PlanCatalogue::class);
    $shown = $catalogue->currentVersion('team');
    $currency = Subscription::catalogueCurrency();

    // The customer is looking at the running version when the owner's
    // scheduled swap lands.
    $catalogue->schedule($shown, $shown->available_from, now());

    $successor = PlanVersion::query()->create([
        ...$shown->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
        'version' => 3, 'features' => $shown->features, 'available_from' => now(),
    ]);
    $successor->prices()->create(['term' => 'monthly', 'amount_cents' => 24900, 'currency' => $currency]);
    $successor->prices()->create(['term' => 'yearly', 'amount_cents' => 298800, 'currency' => $currency]);
    $catalogue->publish($successor, now());

    // Their payment arrives after the swap. They pay what they were quoted.
    $snapshot = Subscription::snapshotFrom('team', 'monthly', $shown->id);

    expect($snapshot['price_cents'])->toBe(7900)
        ->and($snapshot['plan_version_id'])->toBe($shown->id);

    // Someone arriving now gets the successor.
    expect(Subscription::snapshotFrom('team')['price_cents'])->toBe(24900);
});

it('fixes the price of a published version, so a checkout cannot be repriced mid-flight', function () {
    $price = app(PlanCatalogue::class)->currentVersion('team')->priceFor('monthly');

    expect(fn () => $price->update(['amount_cents' => 24900]))
        ->toThrow(RuntimeException::class, 'fixed');

    expect($price->fresh()->amount_cents)->toBe(7900);

    // Stripe's id is filled in afterwards, so that stays writable. Re-read
    // first: the refused edit is still sitting on the rejected object, and the
    // guard would rightly stop it riding along on the next save.
    $price->refresh()->update(['stripe_price_id' => 'price_123']);
    expect($price->fresh()->stripe_price_id)->toBe('price_123')
        ->and($price->fresh()->amount_cents)->toBe(7900);
});

it('publishes a version once, even if two attempts arrive together', function () {
    $catalogue = app(PlanCatalogue::class);
    $family = PlanFamily::query()->where('key', 'team')->sole();
    $currency = Subscription::catalogueCurrency();

    $live = $catalogue->currentVersion('team');
    $catalogue->schedule($live, $live->available_from, now());

    $draft = $catalogue->draft($family, [
        'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
        'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
        'features' => [],
    ], ['monthly' => 18900, 'yearly' => 226800]);

    $catalogue->publish($draft, now(), now()->addYear());

    // A second attempt on the same version must not sail past and overwrite the
    // window the first one just set.
    $stale = PlanVersion::query()->findOrFail($draft->id);
    $stale->published_at = null;   // as a racing request would still see it

    expect(fn () => $catalogue->publish($stale, now(), null))
        ->toThrow(RuntimeException::class, 'already published');

    expect($draft->fresh()->available_until)->not->toBeNull();
});

it('numbers drafts in sequence without colliding', function () {
    $catalogue = app(PlanCatalogue::class);
    $family = PlanFamily::query()->where('key', 'team')->sole();

    $capabilities = [
        'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
        'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
        'features' => [],
    ];

    $first = $catalogue->draft($family, $capabilities, ['monthly' => 18900, 'yearly' => 226800]);
    $second = $catalogue->draft($family, $capabilities, ['monthly' => 19900, 'yearly' => 238800]);

    // Three and four: the seeded version and the one the ladder switch
    // published are already there.
    expect($first->version)->toBe(3)
        ->and($second->version)->toBe(4)
        ->and($second->priceFor('monthly')->amount_cents)->toBe(19900);
});

it('refuses to delete a published version or its price, but lets a draft go', function () {
    $published = app(PlanCatalogue::class)->currentVersion('team');

    expect(fn () => $published->delete())->toThrow(RuntimeException::class, 'cannot be deleted')
        ->and(fn () => $published->priceFor('monthly')->delete())->toThrow(RuntimeException::class, 'cannot be deleted');

    // Contracts point at the version to record what was sold; deleting it would
    // null that away and leave a customer contracted to nothing.
    $subscription = Subscription::factory()->plan('team')->create();
    expect($subscription->plan_version_id)->toBe($published->id);

    $draft = PlanVersion::query()->create([
        ...$published->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
        'version' => 9, 'features' => [], 'available_from' => now()->addYear(),
    ]);

    // Nothing was ever promised on a draft, so it can simply go.
    expect($draft->delete())->toBeTrue();
});

it('will not let a plan family be renamed or deleted out from under its customers', function () {
    $family = PlanFamily::query()->where('key', 'team')->sole();

    // Orders, instances and every snapshot store this string.
    expect(fn () => $family->update(['key' => 'teams']))->toThrow(RuntimeException::class, 'permanent');

    // Deleting would cascade past the version guard and null the provenance
    // off every contract on it.
    expect(fn () => $family->fresh()->delete())->toThrow(RuntimeException::class, 'cannot be deleted');
    expect(PlanFamily::query()->where('key', 'team')->exists())->toBeTrue();

    // The display name is what is meant to change.
    $family->fresh()->update(['name' => 'Team Pro']);
    expect(PlanFamily::query()->where('key', 'team')->sole()->name)->toBe('Team Pro');
});

it('lets a family that never sold anything be removed with its drafts', function () {
    $family = PlanFamily::query()->create(['key' => 'trial', 'name' => 'Trial', 'tier' => 0]);
    $draft = $family->versions()->create([
        'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024,
        'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [],
        'available_from' => now(),
    ]);

    expect($family->delete())->toBeTrue()
        ->and(PlanVersion::query()->whereKey($draft->id)->exists())->toBeFalse();
});

it('still honours a quoted version after the plan is withdrawn', function () {
    $catalogue = app(PlanCatalogue::class);
    $quoted = $catalogue->currentVersion('team');

    // The owner pulls Team while a customer is at the checkout.
    PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]);

    expect($catalogue->isSellable('team'))->toBeFalse()
        // They saw it and paid for it, so it is still deliverable to them.
        ->and($catalogue->isDeliverable('team', $quoted->id))->toBeTrue()
        ->and(Subscription::snapshotFrom('team', 'monthly', $quoted->id)['price_cents'])->toBe(7900);
});

it('will not contract a customer to a version of a different plan', function () {
    $startVersion = app(PlanCatalogue::class)->currentVersion('start');

    expect(fn () => Subscription::snapshotFrom('team', 'monthly', $startVersion->id))
        ->toThrow(RuntimeException::class, 'belongs to');
});

it('refuses to sell a plan the owner has switched off', function () {
    PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]);

    expect(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse()
        ->and(array_keys(app(PlanCatalogue::class)->sellable()))->not->toContain('team');

    // Fails closed rather than falling back to anything.
    expect(fn () => Subscription::snapshotFrom('team'))->toThrow(RuntimeException::class);
});

it('does not sell a version before its window opens or after it closes', function () {
    $catalogue = app(PlanCatalogue::class);
    $version = $catalogue->currentVersion('team');

    $catalogue->schedule($version, now()->addWeek(), now()->addMonth());

    expect($catalogue->isSellable('team'))->toBeFalse()
        ->and($catalogue->isSellable('team', now()->addWeek()))->toBeTrue()
        // Half-open: the closing moment is the first at which it is gone.
        ->and($catalogue->isSellable('team', now()->addMonth()))->toBeFalse()
        ->and($catalogue->isSellable('team', now()->addMonth()->subSecond()))->toBeTrue();
});

it('crashes instead of choosing between two versions on sale at once', function () {
    // The one on sale, not the family's only one: the ladder switch left the
    // superseded version in place beside it, closed.
    $first = app(PlanCatalogue::class)->currentVersion('team');

    // Forced past the guard, the way a bad migration or a direct edit would.
    PlanVersion::query()->create([
        ...$first->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
        'version' => 3,
        'features' => $first->features,
        'available_from' => now()->subDay(),
        'available_until' => null,
        'published_at' => now()->subDay(),
    ]);

    expect(fn () => app(PlanCatalogue::class)->currentVersion('team'))
        ->toThrow(MultipleRecordsFoundException::class);
});

it('refuses a window that overlaps another version of the same plan', function () {
    $catalogue = app(PlanCatalogue::class);
    $family = PlanFamily::query()->where('key', 'team')->sole();
    $first = $catalogue->currentVersion('team');

    // Close the running version, then publish a successor after it.
    $catalogue->schedule($first, $first->available_from, now()->addMonth());

    $second = PlanVersion::query()->create([
        ...$first->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
        'version' => 3,
        'features' => $first->features,
        'available_from' => now()->addMonth(),
    ]);
    $second->prices()->create([
        'term' => 'monthly', 'amount_cents' => 19900, 'currency' => Subscription::catalogueCurrency(),
    ]);
    $second->prices()->create([
        'term' => 'yearly', 'amount_cents' => 238800, 'currency' => Subscription::catalogueCurrency(),
    ]);
    $catalogue->publish($second, now()->addMonth());

    // Backdating the successor into its predecessor's window is refused.
    expect(fn () => $catalogue->schedule($second, now()->addWeek()))
        ->toThrow(RuntimeException::class);

    // Starting exactly where the predecessor stops is not an overlap.
    $catalogue->schedule($second, now()->addMonth());
    expect($catalogue->currentVersion('team', now()->addMonth())->version)->toBe(3);
});

it('locks a version once it is published, but still lets its window move', function () {
    $version = app(PlanCatalogue::class)->currentVersion('team');

    expect(fn () => $version->update(['ram_mb' => 4096]))->toThrow(RuntimeException::class);

    // The refused edit is not smuggled through by the next write either.
    app(PlanCatalogue::class)->schedule($version, $version->available_from, now()->addYear());

    expect($version->fresh()->available_until)->not->toBeNull()
        ->and($version->fresh()->ram_mb)->toBe(8192);
});

it('lets an unpublished draft be edited freely', function () {
    $family = PlanFamily::query()->where('key', 'team')->sole();
    $draft = PlanVersion::query()->create([
        'plan_family_id' => $family->id, 'version' => 3,
        'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
        'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
        'features' => ['monitoring'], 'available_from' => now()->addMonth(),
    ]);

    $draft->update(['ram_mb' => 12288]);

    expect($draft->fresh()->ram_mb)->toBe(12288);
});

it('will not sell a version that provisioning could not build', function () {
    $catalogue = app(PlanCatalogue::class);
    $family = PlanFamily::query()->where('key', 'team')->sole();

    $draft = $catalogue->draft($family, [
        'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
        'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced',
        'template_vmid' => null,   // no blueprint
        'features' => [],
    ], ['monthly' => 18900, 'yearly' => 226800]);

    // Otherwise it goes on sale, a customer pays, and the pipeline stops at
    // CloneVirtualMachine with template_missing.
    expect(fn () => $catalogue->publish($draft, now()))
        ->toThrow(RuntimeException::class, 'VM template');
});

it('will not publish a version that is not priced for every term', function () {
    $family = PlanFamily::query()->where('key', 'team')->sole();
    $draft = PlanVersion::query()->create([
        'plan_family_id' => $family->id, 'version' => 3,
        'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
        'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
        'features' => [], 'available_from' => now(),
    ]);

    expect(fn () => app(PlanCatalogue::class)->publish($draft))->toThrow(RuntimeException::class);

    // Yearly alone is not enough: someone would pick monthly and hit a
    // checkout that cannot price them.
    $draft->prices()->create(['term' => 'yearly', 'amount_cents' => 214800, 'currency' => Subscription::catalogueCurrency()]);
    expect(fn () => app(PlanCatalogue::class)->publish($draft))->toThrow(RuntimeException::class, 'monthly');
});

it('leaves the version unpublished and its predecessor untouched when the window is still refused', function () {
    $catalogue = app(PlanCatalogue::class);
    $family = PlanFamily::query()->where('key', 'team')->sole();
    $currency = Subscription::catalogueCurrency();

    // v1 runs until next month, when the already-published v2 takes over.
    $v1 = $catalogue->currentVersion('team');
    $catalogue->schedule($v1, $v1->available_from, now()->addMonth());
    $closesAt = $v1->fresh()->available_until->toDateTimeString();

    $v2 = PlanVersion::query()->create([
        ...$v1->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
        'version' => 3, 'features' => $v1->features, 'available_from' => now()->addMonth(),
    ]);
    $v2->prices()->create(['term' => 'monthly', 'amount_cents' => 18900, 'currency' => $currency]);
    $v2->prices()->create(['term' => 'yearly', 'amount_cents' => 226800, 'currency' => $currency]);
    $catalogue->publish($v2, now()->addMonth());

    $draft = PlanVersion::query()->create([
        'plan_family_id' => $family->id, 'version' => 5,
        'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
        'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
        'features' => [], 'available_from' => now(),
    ]);
    $draft->prices()->create(['term' => 'monthly', 'amount_cents' => 19900, 'currency' => $currency]);
    $draft->prices()->create(['term' => 'yearly', 'amount_cents' => 238800, 'currency' => $currency]);

    // Open-ended from now: v1 would be handed over, but v2 is still in the way,
    // and a handover is not licence to run over a scheduled successor.
    expect(fn () => $catalogue->publish($draft, now()))->toThrow(RuntimeException::class, 'overlaps');

    // Still a draft, so the mistake is simply correctable — publishing first
    // and failing after would have frozen it beyond repair. And v1 keeps the
    // window it had: a predecessor closed for a successor that never arrived
    // would be a plan taken off sale by an action that failed.
    expect($draft->fresh()->isPublished())->toBeFalse()
        ->and($draft->fresh()->available_from->toDateString())->toBe(now()->toDateString())
        ->and($v1->fresh()->available_until->toDateTimeString())->toBe($closesAt);

    // A window that fits between the two is accepted, and takes v1 over then.
    $catalogue->publish($draft, now()->addWeek(), now()->addMonth());

    expect($catalogue->currentVersion('team', now()->addWeek())->version)->toBe(5)
        ->and($v1->fresh()->available_until->eq($draft->fresh()->available_from))->toBeTrue();
});

it('hands the running version over to its replacement instead of refusing to publish it', function () {
    $catalogue = app(PlanCatalogue::class);
    $family = PlanFamily::query()->where('key', 'team')->sole();

    $running = $catalogue->currentVersion('team');
    $at = now()->addWeek();

    $successor = $catalogue->draft($family, [
        'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
        'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
        'features' => [],
    ], ['monthly' => 18900, 'yearly' => 226800]);

    // No closing the old one first: that used to be mandatory, and a
    // replacement that then failed to publish left the plan off sale for good.
    $catalogue->publish($successor, $at);

    // The predecessor stops exactly where the successor starts.
    expect($running->fresh()->available_until->eq($successor->fresh()->available_from))->toBeTrue();

    // currentVersion() uses sole(), so each of these also proves that exactly
    // one version is on sale at that moment — and there is no gap at the seam.
    expect($catalogue->currentVersion('team', $at->copy()->subSecond())->version)->toBe($running->version)
        ->and($catalogue->currentVersion('team', $at)->version)->toBe($successor->version)
        ->and($catalogue->isSellable('team', $at->copy()->subSecond()))->toBeTrue()
        ->and($catalogue->isSellable('team', $at))->toBeTrue()
        ->and($catalogue->sellable($at)['team']['price_cents'])->toBe(18900);
});

it('leaves a version that had already ended out of the handover', function () {
    $catalogue = app(PlanCatalogue::class);
    $family = PlanFamily::query()->where('key', 'team')->sole();

    // v1 stops in a minute, and nothing replaces it for a week.
    $ended = $catalogue->currentVersion('team');
    $catalogue->schedule($ended, $ended->available_from, now()->addMinute());
    $endedAt = $ended->fresh()->available_until->toDateTimeString();

    $successor = $catalogue->draft($family, [
        'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
        'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
        'features' => [],
    ], ['monthly' => 18900, 'yearly' => 226800]);

    $catalogue->publish($successor, now()->addWeek());

    // The handover closes what is running, not what is over. Moving this one's
    // end date forward would rewrite when it actually stopped selling.
    expect($ended->fresh()->available_until->toDateTimeString())->toBe($endedAt);
});

it('puts a closed version back on sale', function () {
    $catalogue = app(PlanCatalogue::class);
    $version = $catalogue->currentVersion('team');

    $catalogue->schedule($version, $version->available_from, now());
    expect($catalogue->isSellable('team'))->toBeFalse();

    $catalogue->reopen($version);

    // Open-ended again, not merely extended: the plan sells now and keeps
    // selling until someone decides otherwise.
    expect($catalogue->isSellable('team'))->toBeTrue()
        ->and($catalogue->isSellable('team', now()->addYear()))->toBeTrue()
        ->and($catalogue->sellable()['team']['price_cents'])->toBe(7900)
        ->and($catalogue->currentVersion('team')->version)->toBe($version->version);
});

it('refuses to reopen a version whose successor is already selling', function () {
    $catalogue = app(PlanCatalogue::class);
    $family = PlanFamily::query()->where('key', 'team')->sole();

    $first = $catalogue->currentVersion('team');

    $second = $catalogue->draft($family, [
        'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
        'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
        'features' => [],
    ], ['monthly' => 18900, 'yearly' => 226800]);
    $catalogue->publish($second, now());

    // Reopening would put both on sale, and every read of the family would
    // throw rather than pick one.
    expect(fn () => $catalogue->reopen($first))->toThrow(RuntimeException::class, 'overlaps');

    expect($catalogue->currentVersion('team')->version)->toBe($second->version);
});

it('refuses to reopen a version that was never published', function () {
    $catalogue = app(PlanCatalogue::class);
    $family = PlanFamily::query()->where('key', 'team')->sole();

    $draft = $catalogue->draft($family, [
        'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
        'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
        'features' => [],
    ], ['monthly' => 18900, 'yearly' => 226800]);

    // Nothing was ever on sale, so there is nothing to put back.
    expect(fn () => $catalogue->reopen($draft))->toThrow(RuntimeException::class, 'never published');

    expect($draft->fresh()->isPublished())->toBeFalse();
});

it('reports a consistent catalogue, and an inconsistent one', function () {
    $this->artisan('plans:check')->assertSuccessful();

    PlanVersion::query()->whereKey(app(PlanCatalogue::class)->currentVersion('team')->id)
        ->update(['available_until' => now()->subDay()]);

    $this->artisan('plans:check')->assertFailed();
});
