<?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
{
    $host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve']);
    $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]);

    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'); // 500 GB package
    $pve = app(ProxmoxClient::class);

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

    expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(600);

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

    expect($pve->guestRan("files default_quota --value='600 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(600)
        ->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);

    expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(700);

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

    expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(600)
        // 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);

    expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(800);
});

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)
        ->assertSee('700 GB')
        ->assertSee(__('billing.storage_includes_packs', ['count' => 2, 'gb' => 200]));
});

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 500 GB sold on a 540 GB disk: 40 GB of overhead the package was
    // sized with, and the pack is a hundred gigabytes 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:640G')
        ->and($fixture['instance']->fresh()->disk_gb)->toBe(640);

    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(640);

    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()],
        ['disk_used_bytes' => $gib * 1024 ** 3, 'disk_total_bytes' => 1050 * 1024 ** 3],
    );
}

it('still blocks a downgrade the stored data does not fit, and says what would fix it', function () {
    $fixture = storageFixture('business'); // 1000 GB package
    storedBytes($fixture['instance'], 600);

    $team = app(PlanCatalogue::class)->sellable()['team']; // 500 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('500 GB')
        // One pack covers the hundred gigabytes that are over.
        ->and($check->storageRemedy['packs'])->toBe(1)
        ->and($check->storageRemedy['pack_gb'])->toBe(100);
});

it('lets exactly that downgrade through once the offered packs are booked', function () {
    $fixture = storageFixture('business');
    storedBytes($fixture['instance'], 600);

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

    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([]);
});

it('offers the packs through a modal, and books exactly the number that was offered', function () {
    $fixture = storageFixture('business');
    storedBytes($fixture['instance'], 800); // three packs short of Team

    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);

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

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: 600 GB stored, which does not fit Team.
    storedBytes($fixture['instance'], 600, now()->subDay()->toDateString());

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

    // The customer deletes 300 GB and asks us to look now.
    $pve->guestScript('df -B1', 0, "size used\n".(1050 * 1024 ** 3).' '.(300 * 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'], 600, 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 1000 GB; the two packs are still theirs on top of it.
        ->and(StorageAllowance::for($instance)->totalGb())->toBe(1200);

    $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());

    // 1200 GB for the customer, on Business's own 50 GB of overhead.
    expect($pve->resizeCalls)->toContain('101:scsi0:1250G')
        ->and($pve->guestRan("files default_quota --value='1200 GB'"))->toBeTrue()
        ->and($instance->fresh()->quota_applied_gb)->toBe(1200);
});
