<?php

use App\Actions\ApplyPlanChange;
use App\Actions\BookAddon;
use App\Livewire\Billing;
use App\Livewire\ConfirmBookStorage;
use App\Models\Host;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\SubscriptionAddon;
use App\Models\User;
use App\Provisioning\Steps\Customer\ApplyStorageQuota;
use App\Provisioning\Steps\Customer\GrowGuestFilesystem;
use App\Provisioning\Steps\Customer\ResizeVirtualMachine;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\StorageAllowance;
use App\Services\Proxmox\ProxmoxClient;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;

/**
 * Extra storage, from the purchase to the byte.
 *
 * A booked pack was a row in `subscription_addons` and nothing else: charged
 * every month, and delivered nowhere. Nextcloud's quota came from the package
 * alone, the disk was sized for the package alone, and on the rare occasion a
 * disk did grow — a plan upgrade — the guest never saw it, because nothing
 * stretched the partition or the filesystem over the new space.
 *
 * These prove the whole chain: the allowance is one figure everything reads, the
 * machine really gets the space, the guest really sees it, and a customer whose
 * downgrade is blocked by their own data is offered a way out that works.
 */

/** A customer on a package, with a machine that is built, running and served. */
function storageFixture(string $plan = 'team', array $instanceAttributes = []): array
{
    // Sized well above anything this file books: since Task 3, BookAddon asks
    // the host before it books, and `active()`'s default 1000 GB is too small
    // for the grandfathered Enterprise machine some of these tests stand up —
    // placeableIn() would never have put one there.
    $host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 2000]);
    $order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => $plan]);
    $subscription = $order->subscription;
    $customer = $order->customer;
    $user = User::factory()->create(['email' => $customer->email]);

    $instance = Instance::factory()->create(array_merge([
        'order_id' => $order->id,
        'customer_id' => $customer->id,
        'host_id' => $host->id,
        'vmid' => 101,
        '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]);

    // Some of these tests book storage packs through Billing::purchase(),
    // which since Task 5 refuses outright without a Stripe key for the
    // active mode.
    withStripeSecret();

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

/** A run of the storage pipeline against that machine, ready for a step. */
function storageRun(array $fixture): ProvisioningRun
{
    return ProvisioningRun::factory()->create([
        'subject_type' => Order::class,
        'subject_id' => $fixture['order']->id,
        'pipeline' => 'storage',
        'context' => [
            'instance_id' => $fixture['instance']->id,
            'host_id' => $fixture['host']->id,
            'node' => 'pve',
            'vmid' => 101,
            'subdomain' => $fixture['instance']->subdomain,
        ],
    ]);
}

/** What a guest answers when it is asked about its data filesystem. */
function scriptGuestFilesystem($pve, string $source = '/dev/sda1', string $type = 'ext4', string $target = '/'): void
{
    $pve->guestScript('findmnt', 0, "{$source} {$type} {$target}");
}

beforeEach(function () {
    fakeServices();
    // The delivery run is created and left for a worker: these tests are about
    // what the steps do when they run, not about the queue.
    Queue::fake();
});

// ─── A. one authority for the allowance ──────────────────────────────────────

it('raises the allowance with a booked pack and tells Nextcloud the larger figure', function () {
    $fixture = storageFixture('team'); // 85 GB package
    $pve = app(ProxmoxClient::class);

    app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);

    // 85 + 20 (ein Block, Task 5).
    expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(105);

    expect(app(ApplyStorageQuota::class)->execute(storageRun($fixture))->type)->toBe('advance');

    expect($pve->guestRan("files default_quota --value='105 GB'"))->toBeTrue()
        // Recorded as applied, so the sweep can tell an enforced machine from
        // one where the figure has only ever been a row in our database.
        ->and($fixture['instance']->fresh()->quota_applied_gb)->toBe(105)
        ->and($fixture['instance']->fresh()->quotaIsEnforced())->toBeTrue();
});

it('stacks two packs and lowers the allowance again when one is cancelled', function () {
    $fixture = storageFixture('team');
    $contract = $fixture['subscription'];

    $first = app(BookAddon::class)($contract, AddonCatalogue::STORAGE);
    app(BookAddon::class)($contract, AddonCatalogue::STORAGE);

    // 85 + 2 × 20 (Task 5).
    expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(125);

    app(BookAddon::class)->cancel($first);

    expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(105)
        // Cancelled, never deleted: what somebody was paying, and until when, is
        // evidence.
        ->and(SubscriptionAddon::query()->count())->toBe(2);
});

it('counts a pack booked with a quantity of more than one', function () {
    $fixture = storageFixture('team');

    app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE, 3);

    // 85 + 3 × 20 (Task 5).
    expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(145);
});

it('shows the whole allowance in the portal, not the package alone', function () {
    $fixture = storageFixture('team');
    app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE, 2);

    Livewire::actingAs($fixture['user'])
        ->test(Billing::class)
        // 85 + 2 × 20.
        ->assertSee('125 GB')
        ->assertSee(__('billing.storage_includes_packs', ['count' => 2, 'gb' => 40]));
});

it('starts one delivery run when a pack is booked, and none while another is in flight', function () {
    $fixture = storageFixture('team');

    app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);

    expect(ProvisioningRun::query()->where('pipeline', 'storage')->count())->toBe(1);

    // A second booking arrives while the first run is still pending. That run
    // reads the allowance when it reaches the quota step, which is this state or
    // newer — a second run beside it would resize the same disk twice.
    app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);

    expect(ProvisioningRun::query()->where('pipeline', 'storage')->count())->toBe(1);
});

it('starts a delivery run when a pack is cancelled, so the quota comes back down', function () {
    $fixture = storageFixture('team');
    $addon = app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);

    ProvisioningRun::query()->update(['status' => ProvisioningRun::STATUS_COMPLETED]);

    app(BookAddon::class)->cancel($addon);

    expect(ProvisioningRun::query()->where('pipeline', 'storage')->count())->toBe(2);
});

// ─── B. the machine really gets the space ────────────────────────────────────

it('grows the disk to the allowance plus the package overhead, and the guest with it', function () {
    // Team is 85 GB sold on a 100 GB disk: 15 GB of overhead the package was
    // sized with, and the pack (Task 5: 22 GB on disk) comes ON TOP of it.
    $fixture = storageFixture('team');
    $pve = app(ProxmoxClient::class);
    scriptGuestFilesystem($pve);

    app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
    $run = storageRun($fixture);

    expect(app(ResizeVirtualMachine::class)->execute($run)->type)->toBe('advance')
        ->and($pve->resizeCalls)->toContain('101:scsi0:122G')
        ->and($fixture['instance']->fresh()->disk_gb)->toBe(122);

    expect(app(GrowGuestFilesystem::class)->execute($run)->type)->toBe('advance')
        ->and($pve->guestRan('/sys/class/block/sda/device/rescan'))->toBeTrue()
        ->and($pve->guestRan("growpart '/dev/sda' '1'"))->toBeTrue()
        ->and($pve->guestRan("resize2fs '/dev/sda1'"))->toBeTrue();
});

it('does nothing the second time round', function () {
    $fixture = storageFixture('team');
    $pve = app(ProxmoxClient::class);
    scriptGuestFilesystem($pve);

    app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
    $run = storageRun($fixture);

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

    // Everything is already the right size. growpart answers the way it answers
    // when the partition already fills the disk: a non-zero exit with NOCHANGE
    // on its output, which is not a failure and must not be read as one.
    $pve->resizeCalls = [];
    $pve->guestCommands = [];
    $pve->guestScript('growpart', 1, 'NOCHANGE: partition 1 is size 1342177280. it cannot be grown');

    expect(app(ResizeVirtualMachine::class)->execute($run->fresh())->type)->toBe('advance')
        ->and($pve->resizeCalls)->toBe([])
        ->and($fixture['instance']->fresh()->disk_gb)->toBe(122);

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

it('grows an xfs filesystem with its own tool, pointed at the mount point', function () {
    $fixture = storageFixture('team');
    $pve = app(ProxmoxClient::class);
    scriptGuestFilesystem($pve, '/dev/sda2', 'xfs', '/');

    expect(app(GrowGuestFilesystem::class)->execute(storageRun($fixture))->type)->toBe('advance')
        ->and($pve->guestRan("growpart '/dev/sda' '2'"))->toBeTrue()
        // The mount point, not the device: that is what xfs_growfs takes.
        ->and($pve->guestRan("xfs_growfs '/'"))->toBeTrue()
        ->and($pve->guestRan('resize2fs'))->toBeFalse();
});

it('fails loudly on a filesystem it does not know how to grow', function () {
    $fixture = storageFixture('team');
    $pve = app(ProxmoxClient::class);
    scriptGuestFilesystem($pve, '/dev/sda1', 'reiserfs', '/');

    $result = app(GrowGuestFilesystem::class)->execute(storageRun($fixture));

    // A failed run somebody can read, never a silent advance: a customer who has
    // paid for space that never appeared must not depend on anyone noticing.
    expect($result->type)->toBe('fail')
        ->and($result->reason)->toContain('reiserfs')
        ->and($pve->guestRan('resize2fs'))->toBeFalse()
        ->and($pve->guestRan('xfs_growfs'))->toBeFalse();
});

it('waits for a guest that is not answering rather than pretending it grew', function () {
    $fixture = storageFixture('team');
    $pve = app(ProxmoxClient::class);
    $pve->guestAgentUp = false;

    $result = app(GrowGuestFilesystem::class)->execute(storageRun($fixture));

    expect($result->type)->toBe('retry')
        ->and($pve->guestCommands)->toBe([]);
});

// ─── C. a blocked downgrade with a way out ───────────────────────────────────

/** A reading of what is actually stored, dated. */
function storedBytes(Instance $instance, int $gib, ?string $day = null): InstanceMetric
{
    return InstanceMetric::query()->updateOrCreate(
        ['instance_id' => $instance->id, 'day' => $day ?? now()->toDateString()],
        // Der Datenträger, den die Maschine wirklich hat — eine Messung kommt
        // von dort und nicht von einer festen Zahl, die mit jeder Paketleiter
        // wieder falsch wird.
        ['disk_used_bytes' => $gib * 1024 ** 3, 'disk_total_bytes' => $instance->disk_gb * 1024 ** 3],
    );
}

it('still blocks a downgrade the stored data does not fit, and says what would fix it', function () {
    $fixture = storageFixture('business'); // 175 GB package
    // Fünfzig Gigabyte zu viel: drei Blöcke à 20 GB decken sie, und das ist
    // genau der Deckel — der letzte Betrag, für den Kaufen überhaupt noch ein
    // Ausweg ist. 135 gegen Teams 85 ist derselbe Abstand, den vorher 550 gegen
    // 500 hatte; nur die Leiter darunter ist eine andere geworden.
    storedBytes($fixture['instance'], 135);

    $team = app(PlanCatalogue::class)->sellable()['team']; // 85 GB
    $check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team');

    expect($check->allowed)->toBeFalse()
        ->and(collect($check->blockers)->pluck('key')->all())->toBe(['storage'])
        ->and($check->storageRemedy)->not->toBeNull()
        ->and($check->storageRemedy['limit'])->toBe('85 GB')
        ->and($check->storageRemedy['packs'])->toBe(3)
        ->and($check->storageRemedy['pack_gb'])->toBe(20)
        // Gedeckt, also kein Restbetrag — und damit der Satz, der zum Knopf
        // gehört, und nicht der, der ihn ersetzt.
        ->and($check->storageRemedy['short'])->toBeNull();
});

it('lets exactly that downgrade through once the offered packs are booked', function () {
    $fixture = storageFixture('business');
    // Vierzig Gigabyte über Teams 85 sind zwei Blöcke — innerhalb des Deckels
    // (Task 2: höchstens drei), und genug, um denselben Ablauf zu belegen.
    storedBytes($fixture['instance'], 125);

    $team = app(PlanCatalogue::class)->sellable()['team'];
    $packs = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team')->storageRemedy['packs'];

    expect($packs)->toBe(2);

    foreach (range(1, $packs) as $ignored) {
        app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
    }

    $after = DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team');

    expect($after->allowed)->toBeTrue()
        ->and($after->blockers)->toBe([]);
});

/**
 * Der Ausweg muss auch kaufmännisch gehbar sein, nicht nur rechnerisch.
 *
 * `packsToCover()` rechnet, und die Rechnung kennt keinen Deckel: achtzig
 * Gigabyte zu viel sind vier Blöcke, und höchstens drei sind buchbar. Die
 * Karte bot „4 × Zusatzspeicher buchen“ an, das Modal klemmte still auf drei,
 * BookAddon hätte den vierten ohnehin abgelehnt — und der Kunde stand nach 45 €
 * im Monat genau dort, wo er vorher stand. Solange ein Block 100 GB brachte,
 * war das unerreichbar; seit Task 5 sind es 20, und jede Überschreitung über
 * 60 GB landet hier.
 */
it('verspricht keine Blöcke, die dieser Vertrag gar nicht buchen könnte', function () {
    $fixture = storageFixture('business');
    storedBytes($fixture['instance'], 165); // 80 GB zu viel für Teams 85 = vier Blöcke

    $team = app(PlanCatalogue::class)->sellable()['team'];
    $check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team');

    expect($check->allowed)->toBeFalse()
        // Kein Angebot statt eines unerfüllbaren: die Karte hängt ihren Knopf
        // an diese Zahl.
        ->and($check->storageRemedy['packs'])->toBe(0)
        // Und was nach allen drei buchbaren Blöcken übrig bliebe, in Ziffern —
        // 20 GiB, nicht die vollen 80, die ohne Blöcke wegmüssten.
        ->and($check->storageRemedy['short'])->toBe('21,5 GB')
        ->and($check->storageRemedy['over'])->toBe('85,9 GB');
});

/**
 * Der Beleg, dass das alte Versprechen wirklich keines war: Kunde folgt dem
 * Rat, kauft das Höchstmaß — und der Wechsel bleibt gesperrt.
 */
it('bleibt gesperrt, auch wenn der Kunde das Höchstmaß an Blöcken bucht', function () {
    $fixture = storageFixture('business');
    storedBytes($fixture['instance'], 165);

    foreach (range(1, 3) as $ignored) {
        app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
    }

    // Der vierte, dem die alte Empfehlung entsprochen hätte, geht nicht.
    expect(fn () => app(BookAddon::class)($fixture['subscription']->refresh(), AddonCatalogue::STORAGE))
        ->toThrow(RuntimeException::class);

    $team = app(PlanCatalogue::class)->sellable()['team'];
    $after = DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team');

    expect($after->allowed)->toBeFalse()
        ->and($after->storageRemedy['packs'])->toBe(0)
        ->and($after->storageRemedy['short'])->toBe('21,5 GB');
});

/** Dieselbe Grenze in der Ansicht: kein Knopf, und der Satz sagt warum. */
it('bietet auf der Karte keinen Kauf an, den der Vertrag nicht abschließen kann', function () {
    $fixture = storageFixture('business');
    storedBytes($fixture['instance'], 165);

    Livewire::actingAs($fixture['user'])
        ->test(Billing::class)
        ->assertDontSee('confirm-book-storage')
        ->assertSee(__('billing.downgrade_escape.capped', [
            'used' => '177 GB',
            'limit' => '85 GB',
            'over' => '85,9 GB',
            'short' => '21,5 GB',
        ]))
        // Der andere Ausweg bleibt: löschen und neu messen.
        ->assertSee(__('billing.downgrade_escape.remeasure'));
});

/**
 * Der zweite Grund, aus dem BookAddon ablehnt, und derselbe Fehler wäre er
 * gewesen: Enterprise wird gar kein Zusatzspeicher verkauft. Der Deckel allein
 * hätte hier „3“ geantwortet und die Karte hätte Blöcke angeboten, die es für
 * dieses Paket nicht gibt.
 */
it('bietet einem Enterprise-Vertrag keine Blöcke an, den es dort nicht gibt', function () {
    // Enterprise wird seit der neuen Leiter angefragt statt gekauft; der
    // Vertrag, um den es hier geht, ist einer aus der Zeit davor.
    $fixture = asGrandfathered('enterprise', fn () => storageFixture('enterprise'));
    storedBytes($fixture['instance'], 375); // 200 GB zu viel für Business' 175

    $business = app(PlanCatalogue::class)->sellable()['business'];
    $check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $business, 'business');

    expect($check->allowed)->toBeFalse()
        ->and($check->storageRemedy['packs'])->toBe(0)
        // Nichts buchbar, also bleibt der volle Betrag stehen — der Satz nennt
        // dieselbe Zahl zweimal, und das ist die ehrliche Auskunft.
        ->and($check->storageRemedy['short'])->toBe('215 GB')
        ->and($check->storageRemedy['over'])->toBe('215 GB');
});

/**
 * Warum das Klemmen in ConfirmBookStorage (`max(1, min(…))`, das bei nichts
 * mehr Buchbarem auf einen Block hochzieht) bewusst stehen bleibt: der Weg
 * dahinter ist ohnehin zu. Ein Modal ist per `openModal` direkt erreichbar,
 * also wird das nicht angenommen, sondern belegt — beide Gründe, aus denen
 * BookAddon ablehnt, halten schon in `purchase()`, bevor eine Bestellzeile
 * entsteht.
 */
it('legt über den Bestätigungsweg keine Bestellzeile an, wenn nichts buchbar ist', function () {
    $amDeckel = storageFixture('business');
    app(BookAddon::class)($amDeckel['subscription'], AddonCatalogue::STORAGE, 3);

    // Eigene Adresse: `storageFixture()` vergibt sonst zweimal dieselbe, und
    // `instances.subdomain` ist eindeutig.
    $enterprise = asGrandfathered('enterprise', fn () => storageFixture('enterprise', ['subdomain' => 'huber']));

    withStripeSecret();

    foreach ([$amDeckel, $enterprise] as $fixture) {
        expect(app(AddonCatalogue::class)->bookableQuantity($fixture['subscription']->refresh(), AddonCatalogue::STORAGE))->toBe(0);

        Livewire::actingAs($fixture['user'])
            ->test(Billing::class)
            // Genau das, was ein direkt geöffnetes Modal auslöst.
            ->call('bookStoragePacks', 1);

        expect(Order::query()->where('customer_id', $fixture['customer']->id)->where('type', 'storage')->count())->toBe(0);
    }
});

it('offers the packs through a modal, and books exactly the number that was offered', function () {
    $fixture = storageFixture('business');
    // Sechzig Gigabyte über Teams 85, also genau drei Blöcke — der letzte
    // Betrag, für den die Karte überhaupt noch einen Knopf anbietet.
    storedBytes($fixture['instance'], 145);

    Livewire::actingAs($fixture['user'])
        ->test(Billing::class)
        ->assertSee(__('billing.downgrade_escape.remeasure'))
        // R23: the confirmation is this product's own dialog, never the
        // browser's — the card only opens it.
        ->assertSee('confirm-book-storage')
        ->call('bookStoragePacks', 3);

    expect(Order::query()->where('customer_id', $fixture['customer']->id)->where('type', 'storage')->count())->toBe(3);
});

it('confirms the packs in this product own dialog and raises the event with the number', function () {
    Livewire::test(ConfirmBookStorage::class, ['packs' => 3])
        ->assertSee(__('billing.storage_confirm_title', ['count' => 3]))
        ->call('proceed')
        ->assertDispatched('storage-packs-confirmed', packs: 3);
});

it('refuses to put more packs in the cart than the ceiling allows', function () {
    $fixture = storageFixture('team');

    Livewire::actingAs($fixture['user'])
        ->test(Billing::class)
        ->call('bookStoragePacks', 9999);

    // Die kaufmännische Grenze (drei Blöcke, Task 2) ist inzwischen die
    // engere Klemme — vor ihr war MAX_STORAGE_PACKS die einzige, und dieser
    // Test schrieb deren Zahl fest. Der Deckel ändert sich nicht dafür, dass
    // dieser Test vor ihm entstand.
    expect(Order::query()->where('customer_id', $fixture['customer']->id)->where('type', 'storage')->count())->toBe(3);
});

it('passes the check on a fresh reading after data was deleted, without waiting for the night', function () {
    $fixture = storageFixture('business');
    $pve = app(ProxmoxClient::class);

    // Last night's reading: 165 GB stored, which does not fit Team's 85.
    storedBytes($fixture['instance'], 165, now()->subDay()->toDateString());

    $team = app(PlanCatalogue::class)->sellable()['team'];
    expect(DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team')->allowed)->toBeFalse();

    // The customer deletes over 100 GB and asks us to look now.
    $pve->guestScript('df -B1', 0, "size used\n".(200 * 1024 ** 3).' '.(60 * 1024 ** 3));

    Livewire::actingAs($fixture['user'])
        ->test(Billing::class)
        ->call('remeasureStorage')
        ->assertDispatched('notify', message: __('billing.storage_remeasured'));

    expect(DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team')->allowed)->toBeTrue();
});

it('says it could not look rather than reporting an unreachable cloud as empty', function () {
    $fixture = storageFixture('business');
    $pve = app(ProxmoxClient::class);
    storedBytes($fixture['instance'], 165, now()->subDay()->toDateString());
    $pve->guestAgentUp = false;

    Livewire::actingAs($fixture['user'])
        ->test(Billing::class)
        ->call('remeasureStorage')
        ->assertDispatched('notify', message: __('billing.storage_remeasure_unavailable'));

    $team = app(PlanCatalogue::class)->sellable()['team'];
    expect(DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team')->allowed)->toBeFalse();
});

// ─── the packs survive a change of package ───────────────────────────────────

it('keeps booked packs through a plan change and delivers them on the new package', function () {
    $fixture = storageFixture('team');
    $pve = app(ProxmoxClient::class);
    scriptGuestFilesystem($pve);

    app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE, 2);

    // The packs were paid for separately and have nothing to do with which
    // package the customer is on, so nothing cancels them.
    app(ApplyPlanChange::class)($fixture['subscription']->fresh(), 'business');

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

    expect(SubscriptionAddon::query()->where('addon_key', AddonCatalogue::STORAGE)->active()->sum('quantity'))->toBe(2)
        // Business is 175 GB; the two packs (Task 5: 20 GB each) are still
        // theirs on top of it.
        ->and(StorageAllowance::for($instance)->totalGb())->toBe(215);

    $run = ProvisioningRun::query()->where('pipeline', 'plan-change')->latest('id')->firstOrFail();

    app(ResizeVirtualMachine::class)->execute($run);
    app(GrowGuestFilesystem::class)->execute($run->fresh());
    app(ApplyStorageQuota::class)->execute($run->fresh());

    // 215 GB for the customer, on Business's own 25 GB of overhead (175 sold on
    // a 200 GB disk) plus the two packs' own 22 GB disk headroom each (44 GB).
    expect($pve->resizeCalls)->toContain('101:scsi0:244G')
        ->and($pve->guestRan("files default_quota --value='215 GB'"))->toBeTrue()
        ->and($instance->fresh()->quota_applied_gb)->toBe(215);
});
