<?php

use App\Livewire\Admin\Capacity;
use App\Livewire\Admin\Overview;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\RunRunner;
use App\Provisioning\Steps\Customer\ReserveResources;
use App\Services\Provisioning\HostCapacity;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;

/**
 * A paid order waits for a machine; it does not fail for want of one.
 *
 * The estate is finite and booked thick, so "no host has room right now" is an
 * ordinary Tuesday: somebody buys a server, which takes days, not seconds.
 * Failing the run in the meantime turns a sale into an incident and leaves an
 * operator holding money against nothing.
 */
beforeEach(function () {
    // hosts.datacenter is a foreign key onto datacenters.code.
    Datacenter::factory()->create(['code' => 'fsn1']);

    // The capacity page quotes live server prices. A test suite that reaches
    // the internet is a test suite that fails when somebody else's site is
    // down, and it puts requests on that site on every run.
    Http::fake([
        '*' => Http::response(['server' => []]),
    ]);
});

/** A paid order with its contract frozen, standing at the reservation step. */
function parkedOrder(string $plan = 'start', string $datacenter = 'fsn1'): ProvisioningRun
{
    $order = Order::factory()->withSubscription()->create([
        'plan' => $plan,
        'datacenter' => $datacenter,
        'status' => 'paid',
    ]);

    $pipeline = (array) config('provisioning.pipelines.customer');

    return ProvisioningRun::factory()->create([
        'subject_type' => Order::class,
        'subject_id' => $order->id,
        'pipeline' => 'customer',
        'current_step' => array_search(ReserveResources::class, $pipeline, true),
        'status' => ProvisioningRun::STATUS_WAITING,
    ]);
}

it('parks a paid order instead of failing it when nothing has room', function () {
    // No host at all: the customer has paid and there is nowhere to put them.
    $run = parkedOrder();
    $run->update(['status' => ProvisioningRun::STATUS_RUNNING]);

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

    expect($result->type)->toBe('poll')
        ->and($result->reason)->toBe('awaiting_capacity')
        // poll(), not retry(): the wait is measured in days and must not eat
        // the run's attempt budget.
        ->and($result->afterSeconds)->toBe(ReserveResources::PARK_POLL_SECONDS);
});

it('keeps a park alive through the runner, not merely through the step', function () {
    // Both park tests around this one call execute() directly, and that is exactly
    // why this stayed invisible for the whole life of the feature. The step
    // returned poll(120) correctly; the RUNNER then measured the step's own
    // maxDuration from a started_at it deliberately does not reset on a poll, and
    // maxDuration was 60. So every single re-entry was ruled timed out BEFORE the
    // body ran, a timeout consumes an attempt, and five of them had failRun()
    // marking a paid order and its instance failed and releasing the instance —
    // about six minutes for a wait that promises fourteen days. The console's
    // capacity queue, HostCapacity::parked(), queueDemand() and the whole "go and
    // buy a server" workflow were unreachable code.
    Queue::fake();
    $run = parkedOrder();
    $run->update(['status' => ProvisioningRun::STATUS_RUNNING]);

    $runner = app(RunRunner::class);

    // Four full poll intervals: further than the old sixty-second budget, and
    // further than five attempts would ever have carried it.
    for ($i = 0; $i < 4; $i++) {
        $runner->advance($run->fresh());

        $polled = $run->fresh();
        expect($polled->status)->toBe(ProvisioningRun::STATUS_WAITING)
            ->and($polled->attempt)->toBe(0)          // a poll costs nothing
            ->and($polled->error)->toBeNull();

        $this->travel(ReserveResources::PARK_POLL_SECONDS + 1)->seconds();
    }

    // Still parked, still one of the runs the console is asking somebody to buy a
    // machine for, and nothing has been declared failed along the way.
    expect(app(HostCapacity::class)->isParked($run->fresh()))->toBeTrue()
        ->and($run->fresh()->events()->where('outcome', 'failed')->exists())->toBeFalse()
        ->and($run->fresh()->events()->where('message', 'like', '%timed out%')->exists())->toBeFalse();
});

it('fails only once the order has waited longer than a machine can take', function () {
    // Long enough to buy, rack and onboard a server over a weekend; short
    // enough that a forgotten order surfaces instead of waiting for ever.
    $run = parkedOrder();
    // forceFill, because created_at is not fillable — update() drops it
    // silently and the run stays a second old, which is a green test for a
    // deadline that never fires.
    $run->forceFill([
        'status' => ProvisioningRun::STATUS_RUNNING,
        'created_at' => now()->subDays(ReserveResources::PARK_DAYS + 1),
    ])->save();

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

it('finds parked orders by where they stand, not by a string that casts to zero', function () {
    // current_step is an INDEX. Comparing it to 'reserve_resources' reads as
    // correct and matches step 0 of every pipeline instead — including a host
    // onboarding that is merely waiting on SSH.
    $parked = parkedOrder();

    ProvisioningRun::factory()->create([
        'pipeline' => 'host',
        'current_step' => 0,
        'status' => ProvisioningRun::STATUS_WAITING,
    ]);

    $found = app(HostCapacity::class)->parked();

    expect($found)->toHaveCount(1)
        ->and($found[0]['run']->id)->toBe($parked->id);
});

it('recommends what to buy from the mix that is actually waiting', function () {
    // The BIGGEST parked package decides the minimum machine — an instance
    // lives on one host, so a server that cannot take the largest one leaves it
    // parked however much total space it has.
    parkedOrder('start');
    parkedOrder('start');
    parkedOrder('business');

    $demand = app(HostCapacity::class)->queueDemand();

    expect($demand['count'])->toBe(3)
        ->and($demand['largest'])->toBe(200)      // business
        ->and($demand['total'])->toBe(280)        // 2 × 40 + 200
        ->and($demand['by_plan'])->toHaveCount(2);
});

it('sizes the queue from the contract the customer signed, not today catalogue', function () {
    // A plan edited after the sale must not change the machine we go and buy
    // for the customer who bought the old one.
    $run = parkedOrder('start');

    Subscription::query()
        ->where('order_id', $run->subject_id)
        ->update(['disk_gb' => 4000]);

    expect(app(HostCapacity::class)->queueDemand()['largest'])->toBe(4000);
});

it('promises immediate delivery only where a host could actually take it', function () {
    Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 500, 'reserve_pct' => 0]);

    $capacity = app(HostCapacity::class);

    expect($capacity->deliveryFor(120))->toBe('immediate')      // a small one fits
        ->and($capacity->deliveryFor(1050))->toBe('scheduled')  // a big one does not
        // Nothing sized is nothing promised.
        ->and($capacity->deliveryFor(0))->toBe('scheduled');
});

it('stops promising immediate delivery once the room is committed', function () {
    // Thick booking: the instance holds its whole disk whether the customer has
    // put a byte in it or not, and the promise has to follow the booking.
    $host = Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 500, 'reserve_pct' => 0]);

    expect(app(HostCapacity::class)->deliveryFor(120))->toBe('immediate');

    Instance::factory()->create(['host_id' => $host->id, 'status' => 'active', 'disk_gb' => 450]);

    expect(app(HostCapacity::class)->deliveryFor(120))->toBe('scheduled');
});

it('shows the delivery promise for every package on the console front page', function () {
    // 150 GB: Start und Team passen darauf, Business (200 GB Platte) nicht —
    // die Seite muss beide Antworten zeigen können, und auf der neuen Leiter
    // fänden auf 500 GB alle drei Platz.
    Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 150, 'reserve_pct' => 0]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(Overview::class)
        ->assertViewHas('delivery', fn (array $d) => count($d['plans']) > 0
            && collect($d['plans'])->contains(fn (array $p) => $p['state'] === 'immediate')
            && collect($d['plans'])->contains(fn (array $p) => $p['state'] === 'scheduled'));
});

it('lets the operator decide which parked order goes on which host', function () {
    $run = parkedOrder('start');
    $small = Host::factory()->active()->create(['datacenter' => 'fsn1', 'name' => 'pve-a', 'total_gb' => 1000, 'reserve_pct' => 0]);
    $chosen = Host::factory()->active()->create(['datacenter' => 'fsn1', 'name' => 'pve-b', 'total_gb' => 1000, 'reserve_pct' => 0]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(Capacity::class)
        ->call('pin', $run->id, (string) $chosen->id);

    expect($run->fresh()->context('preferred_host_id'))->toBe($chosen->id);

    // And the step honours it — placeableIn() would have taken pve-a, which
    // sorts first by name and has just as much room.
    $run->update(['status' => ProvisioningRun::STATUS_RUNNING]);
    app(ReserveResources::class)->execute($run->fresh());

    expect(Instance::query()->where('order_id', $run->subject_id)->first()?->host_id)->toBe($chosen->id)
        ->and($small->instances()->count())->toBe(0);
});

it('waits rather than quietly placing a pinned order somewhere else', function () {
    // A pin is honoured or it waits. Placing it elsewhere because the chosen
    // host happened to be full answers a different question than the one the
    // operator answered — and they would find out from the finished instance.
    $run = parkedOrder('start');
    // Zu klein für Start (40 GB Platte auf der neuen Leiter), damit der Pin
    // wirklich wartet, statt bequem doch zu passen.
    $full = Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 20, 'reserve_pct' => 0]);
    Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 0]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(Capacity::class)
        ->call('pin', $run->id, (string) $full->id);

    $run->update(['status' => ProvisioningRun::STATUS_RUNNING]);
    $result = app(ReserveResources::class)->execute($run->fresh());

    expect($result->type)->toBe('poll')
        // Named apart from 'awaiting_capacity': this one is a decision to
        // revisit, not a server to buy.
        ->and($result->reason)->toBe('awaiting_pinned_host')
        ->and(Instance::query()->where('order_id', $run->subject_id)->exists())->toBeFalse();
});

/**
 * Nachtrag aus der Durchsicht (Befund 2): ein Anheften ist eine Wahl unter den
 * erlaubten Hosts, keine Umgehung der Reservierung.
 *
 * Der Anheft-Zweig prüfte nur die Kapazität (`canTake`) — nicht, ob die
 * Maschine jemand anderem gehört. Die frisch gebaute Reservierung ließ sich
 * damit über einen ganz normalen Weg durch die Oberfläche aushebeln: das
 * Auswahlfeld auf der Kapazitätsseite bot alle aktiven Hosts an.
 */
it('platziert eine angeheftete Bestellung nicht auf einem fremd reservierten Host', function () {
    $run = parkedOrder('start');
    $fremder = Customer::factory()->create();

    $reserviert = Host::factory()->active()->create([
        'datacenter' => 'fsn1', 'name' => 'pve-fremd', 'total_gb' => 1000, 'reserve_pct' => 0,
        'reserved_for_customer_id' => $fremder->id,
    ]);

    // Ein freier Host daneben: der Pin darf auch nicht still dorthin ausweichen
    // — eine Bestellung, die woanders landet, beantwortet eine andere Frage als
    // die, die der Betreiber beantwortet hat.
    $frei = Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 0]);

    $run->mergeContext(['preferred_host_id' => $reserviert->id]);
    $run->update(['status' => ProvisioningRun::STATUS_RUNNING]);

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

    expect($result->type)->toBe('poll')
        ->and($result->reason)->toBe('awaiting_pinned_host')
        ->and(Instance::query()->where('order_id', $run->subject_id)->exists())->toBeFalse()
        ->and($frei->instances()->count())->toBe(0);
});

it('platziert eine angeheftete Bestellung auf dem Host, der ihrem eigenen Kunden reserviert ist', function () {
    // Der ganze Sinn einer Reservierung: die Maschine gehört diesem Kunden, und
    // seine eigene Bestellung gehört genau dorthin.
    $run = parkedOrder('start');

    $eigen = Host::factory()->active()->create([
        'datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 0,
        'reserved_for_customer_id' => $run->subject->customer_id,
    ]);

    $run->mergeContext(['preferred_host_id' => $eigen->id]);
    $run->update(['status' => ProvisioningRun::STATUS_RUNNING]);

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

    expect(Instance::query()->where('order_id', $run->subject_id)->first()?->host_id)->toBe($eigen->id);
});

it('stellt einen fremd reservierten Host im Anheft-Auswahlfeld gar nicht erst zur Wahl', function () {
    // Die Ursache eine Ebene höher. Ein Knopf, der etwas anbietet, das die
    // Aktion danach ablehnt, ist eine Falle — und die Aktion lehnt jetzt ab.
    $run = parkedOrder('start');
    $fremder = Customer::factory()->create();

    $reserviert = Host::factory()->active()->create([
        'datacenter' => 'fsn1', 'name' => 'pve-fremd', 'total_gb' => 1000, 'reserve_pct' => 0,
        'reserved_for_customer_id' => $fremder->id,
    ]);
    $eigen = Host::factory()->active()->create([
        'datacenter' => 'fsn1', 'name' => 'pve-eigen', 'total_gb' => 1000, 'reserve_pct' => 0,
        'reserved_for_customer_id' => $run->subject->customer_id,
    ]);

    $page = Livewire::actingAs(operator('Owner'), 'operator')->test(Capacity::class);

    $page->assertDontSeeHtml('<option value="'.$reserviert->id.'"')
        ->assertSeeHtml('<option value="'.$eigen->id.'"')
        ->call('pin', $run->id, (string) $reserviert->id);

    expect($run->fresh()->context('preferred_host_id'))->toBeNull();
});

it('sagt es, wenn die angeheftete Maschine inzwischen jemand anderem gehört', function () {
    // Anheften ging, danach wurde der Host reserviert. Er steht dann nicht mehr
    // zur Wahl — und ein Auswahlfeld, das kommentarlos wieder „Automatisch"
    // zeigt, verschweigt, warum die Bestellung weiter wartet.
    $run = parkedOrder('start');
    $host = Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 0]);

    $page = Livewire::actingAs(operator('Owner'), 'operator')->test(Capacity::class);
    $page->call('pin', $run->id, (string) $host->id);

    $host->update(['reserved_for_customer_id' => Customer::factory()->create()->id]);

    $page->call('$refresh')->assertSee(__('capacity.target_reserved'));
});

it('hands placement back when the assignment is cleared', function () {
    $run = parkedOrder('start');
    $host = Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 0]);

    $page = Livewire::actingAs(operator('Owner'), 'operator')->test(Capacity::class);
    $page->call('pin', $run->id, (string) $host->id);
    $page->call('pin', $run->id, '');

    expect($run->fresh()->context('preferred_host_id'))->toBeNull();
});

it('refuses to pin a run that is no longer waiting for a machine', function () {
    // A preference written onto a run that has moved on would sit in its
    // context for ever and take effect on a re-run months later.
    $run = parkedOrder('start');
    $run->update(['status' => ProvisioningRun::STATUS_RUNNING]);
    $host = Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 0]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(Capacity::class)
        ->call('pin', $run->id, (string) $host->id);

    expect($run->fresh()->context('preferred_host_id'))->toBeNull();
});

it('rechecks exactly the runs the queue is showing', function () {
    $parked = parkedOrder();
    $parked->update(['next_attempt_at' => now()->addHour()]);

    $other = ProvisioningRun::factory()->create([
        'pipeline' => 'host',
        'current_step' => 0,
        'status' => ProvisioningRun::STATUS_WAITING,
        'next_attempt_at' => now()->addHour(),
    ]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(Capacity::class)
        ->call('recheck');

    expect($parked->fresh()->next_attempt_at->isBefore(now()->addMinute()))->toBeTrue()
        ->and($other->fresh()->next_attempt_at->isAfter(now()->addMinutes(30)))->toBeTrue();
});
