<?php

use App\Livewire\Dashboard;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\InstanceTraffic;
use App\Models\User;
use App\Provisioning\Jobs\CollectInstanceTraffic;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Traffic\TrafficMeter;

function trafficSetup(array $overrides = []): array
{
    $pve = new FakeProxmoxClient;
    app()->instance(ProxmoxClient::class, $pve);

    $customer = Customer::factory()->create();
    $host = App\Models\Host::factory()->active()->create();
    $instance = Instance::factory()->create(array_merge([
        'customer_id' => $customer->id,
        'host_id' => $host->id,
        'vmid' => 1042,
        'plan' => 'start',            // 1000 GB
        'status' => 'active',
    ], $overrides));

    // Every running machine answers to a contract, and the allowance is the
    // contract's — so the fixture has to have one too.
    App\Models\Subscription::factory()->plan($instance->plan)->create([
        'customer_id' => $customer->id,
        'instance_id' => $instance->id,
    ]);

    return [$pve, $customer, $instance->fresh()];
}

it('counts the difference between samples, not the raw counter', function () {
    [$pve, , $instance] = trafficSetup();

    // First sample only establishes the baseline: what the VM did before we
    // started counting this month is not the customer's bill.
    $pve->counters[1042] = ['netin' => 5_000_000, 'netout' => 9_000_000];
    (new CollectInstanceTraffic)->handle($pve);

    $row = InstanceTraffic::query()->firstOrFail();
    expect($row->tx_bytes)->toBe(0)
        ->and($row->last_netout)->toBe(9_000_000);

    $pve->counters[1042] = ['netin' => 6_000_000, 'netout' => 11_500_000];
    (new CollectInstanceTraffic)->handle($pve);

    expect($row->fresh()->tx_bytes)->toBe(2_500_000)
        ->and($row->fresh()->rx_bytes)->toBe(1_000_000);
});

it('treats a counter that went backwards as a restart, not a refund', function () {
    [$pve, , $instance] = trafficSetup();

    $pve->counters[1042] = ['netin' => 0, 'netout' => 8_000_000];
    (new CollectInstanceTraffic)->handle($pve);
    $pve->counters[1042] = ['netin' => 0, 'netout' => 12_000_000];
    (new CollectInstanceTraffic)->handle($pve);
    expect(InstanceTraffic::query()->first()->tx_bytes)->toBe(4_000_000);

    // VM restarted: Proxmox counts from zero again.
    $pve->counters[1042] = ['netin' => 0, 'netout' => 500_000];
    (new CollectInstanceTraffic)->handle($pve);

    expect(InstanceTraffic::query()->first()->tx_bytes)->toBe(4_500_000);
});

it('throttles instead of cutting the service off when the allowance is gone', function () {
    [$pve, , $instance] = trafficSetup();

    $quota = TrafficMeter::quotaGb($instance) * 1000 ** 3;
    $pve->counters[1042] = ['netin' => 0, 'netout' => 0];
    (new CollectInstanceTraffic)->handle($pve);
    $pve->counters[1042] = ['netin' => 0, 'netout' => $quota + 1];
    (new CollectInstanceTraffic)->handle($pve);

    $row = InstanceTraffic::query()->firstOrFail();
    expect($row->throttled)->toBeTrue()
        // Slowed down, never stopped: a dead Nextcloud gets a cancellation,
        // a slow one gets a top-up.
        ->and($pve->networkRates[1042])->toBeGreaterThan(0)
        ->and(TrafficMeter::for($instance->fresh())->state())->toBe('exhausted');
});

it('gives the line back when the customer buys more', function () {
    [$pve, , $instance] = trafficSetup();

    $quota = TrafficMeter::quotaGb($instance) * 1000 ** 3;
    $pve->counters[1042] = ['netin' => 0, 'netout' => 0];
    (new CollectInstanceTraffic)->handle($pve);
    $pve->counters[1042] = ['netin' => 0, 'netout' => $quota + 1];
    (new CollectInstanceTraffic)->handle($pve);
    expect($pve->networkRates[1042])->not->toBeNull();

    $instance->update(['traffic_addons' => 1]);   // one extra pack
    (new CollectInstanceTraffic)->handle($pve);

    expect($pve->networkRates[1042])->toBeNull()
        ->and(InstanceTraffic::query()->first()->throttled)->toBeFalse();
});

it('warns once per threshold, not on every sample', function () {
    [$pve, , $instance] = trafficSetup();
    $quota = TrafficMeter::quotaGb($instance) * 1000 ** 3;

    $pve->counters[1042] = ['netin' => 0, 'netout' => 0];
    (new CollectInstanceTraffic)->handle($pve);
    $pve->counters[1042] = ['netin' => 0, 'netout' => (int) ($quota * 0.82)];
    (new CollectInstanceTraffic)->handle($pve);

    expect(InstanceTraffic::query()->first()->notified_percent)->toBe(80);

    // Still in the same band a sample later — nothing new to say.
    $pve->counters[1042] = ['netin' => 0, 'netout' => (int) ($quota * 0.84)];
    (new CollectInstanceTraffic)->handle($pve);
    expect(InstanceTraffic::query()->first()->notified_percent)->toBe(80);

    // Crossing the next one does warn again.
    $pve->counters[1042] = ['netin' => 0, 'netout' => (int) ($quota * 0.96)];
    (new CollectInstanceTraffic)->handle($pve);
    expect(InstanceTraffic::query()->first()->notified_percent)->toBe(95);
});

it('counts add-on packs towards the allowance', function () {
    [, , $instance] = trafficSetup();

    expect(TrafficMeter::quotaGb($instance))->toBe(1000);

    $instance->update(['traffic_addons' => 2]);
    expect(TrafficMeter::quotaGb($instance->fresh()))->toBe(3000);
});

it('keeps the allowance the customer bought when a leaner version replaces it', function () {
    [, , $instance] = trafficSetup();

    // The owner replaces the start plan with a version carrying half the
    // traffic. New customers get 500 GB; this one bought 1000.
    $catalogue = app(App\Services\Billing\PlanCatalogue::class);
    $current = $catalogue->currentVersion('start');
    $catalogue->schedule($current, $current->available_from, now());

    $leaner = App\Models\PlanVersion::query()->create([
        ...$current->only(['plan_family_id', 'quota_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
        'version' => 3,
        'traffic_gb' => 500,
        'features' => $current->features,
        'available_from' => now(),
    ]);
    $leaner->prices()->create([
        'term' => 'monthly', 'amount_cents' => 4900, 'currency' => App\Models\Subscription::catalogueCurrency(),
    ]);
    $leaner->prices()->create([
        'term' => 'yearly', 'amount_cents' => 58800, 'currency' => App\Models\Subscription::catalogueCurrency(),
    ]);
    $catalogue->publish($leaner, now());

    expect($catalogue->sellable()['start']['traffic_gb'])->toBe(500)
        ->and(TrafficMeter::quotaGb($instance->fresh()))->toBe(1000);
});

it('shows the customer what is left, and offers a top-up when it gets tight', function () {
    [, $customer, $instance] = trafficSetup();
    $user = User::factory()->create(['email' => $customer->email]);

    InstanceTraffic::create([
        'instance_id' => $instance->id,
        'period' => InstanceTraffic::currentPeriod(),
        'tx_bytes' => (int) (1000 * 1000 ** 3 * 0.10),
    ]);

    // Comfortable: no upsell in the customer's face.
    Livewire\Livewire::actingAs($user)->test(Dashboard::class)
        ->assertSee(__('dashboard.traffic.label'))
        ->assertDontSee(__('dashboard.traffic.buy'));

    InstanceTraffic::query()->update(['tx_bytes' => (int) (1000 * 1000 ** 3 * 0.9)]);

    Livewire\Livewire::actingAs($user)->test(Dashboard::class)
        ->assertSee(__('dashboard.traffic.buy'));
});

it('shows allowances in the units they are sold in', function () {
    // A 1000 GB plan must read as 1 TB, not as 931 GB: quotas are computed in
    // SI units, so displaying binary ones made the two disagree on screen.
    expect(App\Support\Bytes::human(1000 * 1000 ** 3))->toBe('1 TB')
        ->and(App\Support\Bytes::human(880 * 1000 ** 3))->toBe('880 GB')
        ->and(App\Support\Bytes::human(1_500_000))->toBe('1,5 MB')
        ->and(App\Support\Bytes::human(512))->toBe('512 B');
});

/*
 * `startOfMonth()` VOR `subMonth()`, und das ist keine Kosmetik.
 *
 * Carbon rechnet `subMonth()` kalendarisch: am 31. Juli ergibt das den 1. Juli,
 * weil der 31. Juni überläuft. `format('Y-m')` liefert dann DIESEN Monat statt
 * des Vormonats — die Zeile, die „letzte Periode" heißen soll, landet in der
 * aktuellen, und der Test scheitert. An drei Tagen im Monat, nachts entdeckt.
 *
 * Der Produktivcode hat die Falle nicht: `InstanceTraffic::currentPeriod()` ist
 * `now()->format('Y-m')` ohne Arithmetik, und wo gerechnet wird (Overview,
 * ConfirmCancelPackage) steht `startOfMonth()` bzw. eine Notiz dazu. Es waren
 * nur diese Tests.
 */
it('lets a throttled instance run again when the new month starts', function () {
    [$pve, , $instance] = trafficSetup();

    // Last month ended with the allowance exhausted and the NIC limited.
    InstanceTraffic::create([
        'instance_id' => $instance->id,
        'period' => now()->startOfMonth()->subMonth()->format('Y-m'),
        'tx_bytes' => 9_999_999_999_999,
        'throttled' => true,
        'throttled_at' => now()->subDays(3),
    ]);
    $pve->networkRates[1042] = 0.25;

    $pve->counters[1042] = ['netin' => 0, 'netout' => 0];
    (new CollectInstanceTraffic)->handle($pve);

    // The new period starts clean — and the limit must actually come off the
    // NIC, not just out of the database.
    expect($pve->networkRates[1042])->toBeNull()
        ->and(InstanceTraffic::query()->where('throttled', true)->count())->toBe(0);
});

it('keeps trying to release a throttle when Proxmox refuses once', function () {
    [$pve, , $instance] = trafficSetup();

    InstanceTraffic::create([
        'instance_id' => $instance->id,
        'period' => now()->startOfMonth()->subMonth()->format('Y-m'),
        'tx_bytes' => 9_999_999_999_999,
        'throttled' => true,
    ]);

    // A hub that fails the first release and works afterwards — the transient
    // error that would otherwise leave the customer throttled for good.
    $flaky = new class extends FakeProxmoxClient
    {
        public int $attempts = 0;

        public function setNetworkRate(string $node, int $vmid, ?float $mbytesPerSecond): void
        {
            $this->attempts++;
            if ($mbytesPerSecond === null && $this->attempts === 1) {
                throw new RuntimeException('proxmox unavailable');
            }
            parent::setNetworkRate($node, $vmid, $mbytesPerSecond);
        }
    };
    $flaky->counters[1042] = ['netin' => 0, 'netout' => 0];
    app()->instance(ProxmoxClient::class, $flaky);

    (new CollectInstanceTraffic)->handle($flaky);
    expect(InstanceTraffic::query()->where('throttled', true)->count())->toBe(1);

    // Next run: the current row already exists, so a release tied to row
    // creation would never fire again.
    (new CollectInstanceTraffic)->handle($flaky);

    expect(InstanceTraffic::query()->where('throttled', true)->count())->toBe(0)
        ->and($flaky->networkRates[1042])->toBeNull();
});

it('ignores a sample that carries no counters', function () {
    [$pve, , $instance] = trafficSetup();

    $pve->counters[1042] = ['netin' => 0, 'netout' => 4_000_000];
    (new CollectInstanceTraffic)->handle($pve);
    $pve->counters[1042] = ['netin' => 0, 'netout' => 10_000_000];
    (new CollectInstanceTraffic)->handle($pve);
    expect(InstanceTraffic::query()->first()->tx_bytes)->toBe(6_000_000);

    // Proxmox answers without telemetry. Treating that as zero would reset the
    // baseline and count the whole counter again on the next real sample.
    $blind = new class extends FakeProxmoxClient
    {
        public function vmStatus(string $node, int $vmid): array
        {
            return ['status' => 'running'];
        }
    };
    app()->instance(ProxmoxClient::class, $blind);
    (new CollectInstanceTraffic)->handle($blind);

    $row = InstanceTraffic::query()->first();
    expect($row->tx_bytes)->toBe(6_000_000)
        ->and($row->last_netout)->toBe(10_000_000);   // baseline untouched

    // The next good sample continues from where it left off.
    app()->instance(ProxmoxClient::class, $pve);
    $pve->counters[1042] = ['netin' => 0, 'netout' => 11_000_000];
    (new CollectInstanceTraffic)->handle($pve);

    expect(InstanceTraffic::query()->first()->tx_bytes)->toBe(7_000_000);
});
