<?php

namespace App\Provisioning\Steps\Customer;

use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Provisioning\HostCapacity;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

class ReserveResources extends CustomerStep
{
    /** How long a paid order waits for a machine before it is called failed. */
    public const PARK_DAYS = 14;

    /** How often it looks again. A host appears when somebody onboards one. */
    public const PARK_POLL_SECONDS = 120;

    public function key(): string
    {
        return 'reserve_resources';
    }

    /**
     * The whole park, not the second the body takes.
     *
     * RunRunner measures this from `started_at` and deliberately does NOT reset
     * `started_at` on a poll — that is what lets a polling step's own deadline
     * accumulate across re-entries instead of restarting every time. So for a step
     * that polls, maxDuration is its TOTAL budget, and it has to sit above the
     * step's own deadline. Every other polling step in the repo already does:
     * ConfigureDnsAndTls waits 840 s for a certificate inside 960 s, ConfigureNetwork
     * 90 s inside 150 s.
     *
     * At 60 s against a 120 s poll this one could not survive its own first wait.
     * The run came back due, the runner saw `started_at` two minutes old, turned that
     * into a timeout retry before the body ran, and five timeouts later failRun()
     * marked a paid order and its instance failed and released the instance — about
     * six minutes for a wait that promises fourteen days. Everything built on that
     * promise (the console's capacity queue, HostCapacity::parked(), queueDemand(),
     * the operator going and buying a machine) was unreachable code, and a customer
     * who paid when nothing had room got an incident instead of a delivery date.
     *
     * One poll interval of headroom, so the step's own `no_capacity` fail below is
     * always what fires. That failure names what the operator has to do; a timeout
     * names nothing.
     */
    public function maxDuration(): int
    {
        return self::PARK_DAYS * 86400 + self::PARK_POLL_SECONDS;
    }

    public function execute(ProvisioningRun $run): StepResult
    {
        $order = $this->order($run);

        // Idempotent: an instance already exists for this order.
        $existing = Instance::query()->where('order_id', $order->id)->first();
        if ($existing !== null) {
            $this->putContext($run, $existing);
            $this->linkToSubscription($run, $existing);

            return StepResult::advance();
        }

        $subscription = $this->subscription($run);

        if ($subscription === null) {
            return StepResult::fail('no_subscription');
        }

        // Sizes come from the contract, not the catalogue: this customer gets
        // the machine they paid for even if the plan was edited since.
        $plan = $this->plan($run);

        // The operator may have decided where this one goes. A parked queue is
        // read by a human who knows things the placement rule does not — that
        // one customer belongs beside another, that a machine is about to be
        // taken out of service — and "dieser kommt auf Host x" has to survive
        // the trip from the console to here.
        $pinned = $this->pinnedHost($run, $order);

        // Place + create under a per-datacenter lock so concurrent reservations
        // can't both pick the same host and overcommit its capacity.
        $instance = Cache::lock('placement:'.$order->datacenter, 30)->block(10, function () use ($order, $plan, $pinned) {
            // A pin is honoured or it waits — never quietly overridden. Placing
            // it somewhere else because the chosen host happened to be full
            // would answer a different question than the one the operator
            // answered, and they would find out from the finished instance.
            //
            // Reservierung UND Platz, nicht nur Platz: ein Anheften ist eine
            // Wahl unter den erlaubten Hosts, keine Umgehung. Ohne die erste
            // Bedingung ließ sich die Zusage "diese Maschine gehört Ihnen"
            // über das Auswahlfeld der Kapazitätsseite aushebeln — dieselbe
            // Frage, die placeableIn() unten über denselben Scope stellt.
            $host = $pinned !== null
                ? ($this->mayPlaceOn($pinned, $order) && $pinned->canTake((int) $plan['disk_gb']) ? $pinned : null)
                : Host::placeableIn($order->datacenter, (int) $plan['disk_gb'], $order->customer_id);

            if ($host === null) {
                return null;
            }

            return Instance::create([
                'customer_id' => $order->customer_id,
                'order_id' => $order->id,
                'host_id' => $host->id,
                'plan' => $order->plan,
                'quota_gb' => $plan['quota_gb'],
                'disk_gb' => $plan['disk_gb'],
                'ram_mb' => $plan['ram_mb'],
                'cores' => $plan['cores'],
                'subdomain' => $this->uniqueSubdomain($order),
                'status' => 'reserving',
            ]);
        });

        // Named, not just 'no_capacity'. An operator reading a failed run needs
        // to know WHAT would have fitted — the difference between "buy a
        // server" and "buy a server with 540 GB free" is the whole decision.
        // No room: PARK the order, do not fail it.
        //
        // The customer has paid. Failing the run turns a sale into an incident
        // and leaves an operator to work out what to do with the money; waiting
        // turns it into a delivery date. The estate is finite and thick-booked,
        // so "no host has room right now" is an ordinary Tuesday, not a fault —
        // it means somebody has to order a machine, which takes hours, not
        // seconds.
        //
        // poll(), not retry(): retry consumes the run's attempt budget and this
        // wait is measured in days. The step owns its own deadline instead.
        if ($instance === null) {
            $largest = app(HostCapacity::class)->largestPlaceableGb($order->datacenter);

            // Long enough for a server to be ordered, installed and onboarded
            // over a weekend; short enough that a forgotten order surfaces as a
            // failure instead of waiting silently for ever. The console shouts
            // about it from the first minute either way.
            if ($run->created_at->diffInDays(now()) >= self::PARK_DAYS) {
                Log::error('Order '.$order->id.' waited '.self::PARK_DAYS.' days for capacity in '.$order->datacenter.' and is being failed. It needs '.$plan['disk_gb'].' GB; the roomiest host has '.$largest.' GB.');

                return StepResult::fail('no_capacity');
            }

            // Two different waits, named differently: one is "nobody has room",
            // the other is "the host YOU chose has no room". The second one is
            // a decision to revisit, not a server to buy, and an operator
            // reading 'awaiting_capacity' would go and buy the machine.
            return StepResult::poll(
                self::PARK_POLL_SECONDS,
                $pinned !== null ? 'awaiting_pinned_host' : 'awaiting_capacity'
            );
        }

        $this->putContext($run, $instance);
        $this->linkToSubscription($run, $instance);
        $this->recordResource($run, $instance->host, 'instance_id', (string) $instance->id);

        return StepResult::advance();
    }

    /**
     * Point the contract at the machine that fulfils it, so anything asking
     * "what is this customer entitled to?" can get there from either end.
     * instance_id is not part of the frozen snapshot, so this is allowed.
     */
    private function linkToSubscription(ProvisioningRun $run, Instance $instance): void
    {
        $subscription = $this->subscription($run);

        if ($subscription !== null && $subscription->instance_id !== $instance->id) {
            $subscription->update(['instance_id' => $instance->id]);
        }
    }

    /**
     * The host an operator assigned this order to, if it is still usable.
     *
     * A pin that names a host which has since been removed — or one in another
     * datacenter — is no pin at all. Falling back to ordinary placement is
     * right there: the operator's instruction has lost its subject rather than
     * been overruled.
     */
    private function pinnedHost(ProvisioningRun $run, Order $order): ?Host
    {
        $id = $run->context('preferred_host_id');

        if ($id === null) {
            return null;
        }

        // Same datacenter, or it is not a pin but a mistake. Placement is a
        // per-datacenter question — the lock above is taken per datacenter, and
        // an instance's network, DNS and backups all assume it stayed where it
        // was ordered.
        return Host::query()
            ->where('status', 'active')
            ->where('datacenter', $order->datacenter)
            ->find((int) $id);
    }

    /**
     * Darf diese Bestellung überhaupt auf diese Maschine?
     *
     * Unreserviert oder diesem Kunden reserviert — über denselben Scope, den
     * die freie Platzierung benutzt, und nicht als zweite Formulierung
     * derselben Bedingung: zwei Formulierungen wären zwei Grenzen, und die
     * zweite wäre die, die man beim nächsten Mal vergisst.
     *
     * Ein Pin auf eine fremde Maschine wird damit nicht umgeleitet, sondern
     * wartet (`awaiting_pinned_host`) — die Wahl ist zu korrigieren, nicht die
     * Maschine, und ein stiller Ausweichplatz wäre wieder eine Antwort auf eine
     * Frage, die niemand gestellt hat.
     */
    private function mayPlaceOn(Host $host, Order $order): bool
    {
        return Host::query()
            ->whereKey($host->getKey())
            ->unreserved($order->customer_id)
            ->exists();
    }

    private function putContext(ProvisioningRun $run, Instance $instance): void
    {
        $run->mergeContext([
            'instance_id' => $instance->id,
            'host_id' => $instance->host_id,
            'node' => $instance->host?->node ?? 'pve',
            'subdomain' => $instance->subdomain,
            'plan' => $instance->plan,
        ]);
    }

    private function uniqueSubdomain(Order $order): string
    {
        $base = Str::slug($order->customer->name) ?: 'cloud';

        do {
            $subdomain = $base.'-'.Str::lower(Str::random(5));
        } while (Instance::query()->where('subdomain', $subdomain)->exists());

        return $subdomain;
    }
}
