<?php

namespace App\Services\Provisioning;

use App\Models\Datacenter;
use App\Models\Host;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Steps\Customer\ReserveResources;
use Illuminate\Database\Eloquent\Builder;
use App\Services\Billing\PlanCatalogue;
use Throwable;

/**
 * What the estate can still take, and which packages it can no longer deliver.
 *
 * The platform books thick: `disk_gb` — the sold quota plus the machine's own
 * overhead — is reserved against a host at placement, not the customer's actual
 * usage. That is the safe choice for a small operator (nothing can quietly
 * overfill), and it has one consequence worth surfacing: the catalogue can go
 * on offering a package that no host has room for, and nobody finds out until a
 * paid order fails at the reservation step.
 *
 * So this asks the question BEFORE the order does.
 */
class HostCapacity
{
    /** Is there a host in this datacenter that could take this much? */
    public function canPlace(string $datacenter, int $diskGb): bool
    {
        return Host::placeableIn($datacenter, $diskGb) !== null;
    }

    /**
     * The largest single reservation the estate could still accept.
     *
     * The largest FREE HOST, not the sum: a package goes on one machine, so two
     * hosts with 300 GB each cannot take a 540 GB instance between them.
     */
    public function largestPlaceableGb(?string $datacenter = null): int
    {
        return (int) Host::query()
            ->where('status', 'active')
            // Der allgemeine Bestand. Eine reservierte Maschine hat Platz, aber
            // nicht für den, der hier fragt.
            ->unreserved()
            ->when($datacenter !== null, fn ($q) => $q->where('datacenter', $datacenter))
            ->get()
            ->map(fn (Host $host) => $host->availableGb())
            ->max() ?? 0;
    }

    /**
     * Sellable packages that no host could currently take.
     *
     * Keyed by plan key, valued by what it needs and what the roomiest host has
     * left — an operator deciding whether to buy a machine needs the shortfall,
     * not a yes/no.
     *
     * @return array<string, array{name: string, needs: int, largest: int}>
     */
    public function unplaceablePlans(?string $datacenter = null): array
    {
        try {
            $sellable = app(PlanCatalogue::class)->sellable();
        } catch (Throwable) {
            // A catalogue that cannot be read is its own alarm elsewhere. This
            // is a capacity warning; it does not get to be the thing that takes
            // the console's overview page down.
            return [];
        }

        // No host at all is not a capacity problem — it is an installation
        // nobody has onboarded a machine into yet, and it has its own notices.
        // Without this, a fresh console greeted the operator with one warning
        // per sellable package before they had done anything at all.
        $hasHost = Host::query()
            ->where('status', 'active')
            ->unreserved()
            ->when($datacenter !== null, fn ($q) => $q->where('datacenter', $datacenter))
            ->exists();

        if (! $hasHost) {
            return [];
        }

        $largest = $this->largestPlaceableGb($datacenter);
        $atRisk = [];

        foreach ($sellable as $key => $plan) {
            $needs = (int) ($plan['disk_gb'] ?? 0);

            if ($needs > 0 && $needs > $largest) {
                $atRisk[$key] = [
                    'name' => (string) ($plan['name'] ?? $key),
                    'needs' => $needs,
                    'largest' => $largest,
                ];
            }
        }

        return $atRisk;
    }

    /**
     * The runs parked for want of a machine, as a query.
     *
     * Recognised by where they are standing, not by a flag: a run WAITING at
     * the reservation step is a run that could not be placed. A second column
     * saying the same thing is a second thing to keep in step with the first.
     *
     * `current_step` is an INDEX into the pipeline, not the step's key — it is
     * cast to integer on the model. Comparing it to 'reserve_resources' looks
     * right and is not: the database casts the string to 0 and quietly matches
     * the FIRST step of every pipeline instead. Hence the lookup, and hence the
     * pipeline filter next to it — index 1 of the host pipeline is a different
     * step entirely, and an onboarding waiting on SSH is not a parked order.
     *
     * @return \Illuminate\Database\Eloquent\Builder<ProvisioningRun>
     */
    public function parkedRuns(): Builder
    {
        $pipeline = (array) config('provisioning.pipelines.customer', []);
        $index = array_search(ReserveResources::class, $pipeline, true);

        return ProvisioningRun::query()
            ->where('pipeline', 'customer')
            ->where('status', ProvisioningRun::STATUS_WAITING)
            // -1 matches nothing, which is the honest answer for a pipeline
            // that does not contain the step at all.
            ->where('current_step', $index === false ? -1 : (int) $index);
    }

    /** Is this particular run waiting for a machine rather than for a step? */
    public function isParked(ProvisioningRun $run): bool
    {
        return $run->exists && $this->parkedRuns()->whereKey($run->getKey())->exists();
    }

    /**
     * Paid orders parked for want of a machine.
     *
     * @return array<int, array{run: ProvisioningRun, plan: string, needs: int, waiting_since: \Illuminate\Support\Carbon, datacenter: string, pinned_host_id: int|null}>
     */
    public function parked(): array
    {
        $runs = $this->parkedRuns()
            ->with('subject')
            ->orderBy('created_at')
            ->get();

        $parked = [];

        foreach ($runs as $run) {
            $order = $run->subject;

            if (! $order instanceof Order) {
                continue;
            }

            $plan = $this->planOf($order);

            $parked[] = [
                'run' => $run,
                'order' => $order,
                'plan' => (string) $order->plan,
                'name' => (string) ($plan['name'] ?? $order->plan),
                'needs' => (int) ($plan['disk_gb'] ?? 0),
                'waiting_since' => $run->created_at,
                'datacenter' => (string) $order->datacenter,
                // Where an operator has said this one goes, if anywhere. The
                // step is what honours it; this is only so the console can show
                // the choice it is displaying a dropdown for.
                'pinned_host_id' => ($id = $run->context('preferred_host_id')) === null ? null : (int) $id,
            ];
        }

        return $parked;
    }

    /**
     * What to buy, in one sentence's worth of numbers.
     *
     * 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. The sum is what clears the queue
     * in one purchase.
     *
     * @return array{count: int, largest: int, total: int, by_plan: array<string, int>}
     */
    public function queueDemand(): array
    {
        $parked = $this->parked();
        $byPlan = [];

        foreach ($parked as $entry) {
            $byPlan[$entry['name']] = ($byPlan[$entry['name']] ?? 0) + 1;
        }

        arsort($byPlan);

        return [
            'count' => count($parked),
            'largest' => (int) (collect($parked)->max('needs') ?? 0),
            'total' => (int) collect($parked)->sum('needs'),
            'by_plan' => $byPlan,
        ];
    }

    /**
     * Where a new order should be placed when nobody has said.
     *
     * The customer is not asked which datacenter they want — one location is
     * what is sold, and a dropdown of internal site codes would be a question
     * nobody outside this building can answer. So the shop picks: the site
     * whose roomiest host has the most left, which is where the order will
     * actually roll out fastest.
     *
     * ->unreserved(): this runs before a customer exists (Stripe checkout
     * metadata), so nobody to reserve FOR is known yet — the same "general
     * stock" question largestPlaceableGb() asks. Without it, a datacenter
     * whose biggest host is entirely somebody else's "eigener Server" looks
     * roomiest, the order is sent there, and placeableIn() then finds nobody
     * — parking an order that another datacenter had real room for.
     *
     * Falls back to the first configured datacenter, and then to the same
     * literal the Stripe webhook has always defaulted to, so a checkout is
     * never blocked by an estate that has not been built yet.
     */
    public function preferredDatacenter(): string
    {
        $best = Host::query()
            ->where('status', 'active')
            ->unreserved()
            ->get()
            ->sortByDesc(fn (Host $host) => $host->availableGb())
            ->first();

        return $best?->datacenter
            ?? Datacenter::query()->orderBy('code')->value('code')
            ?? 'fsn';
    }

    /**
     * How soon a package of this size can be delivered.
     *
     * Two answers, and the second one is a promise about US, not about a
     * machine: if there is room it rolls out on its own; if there is not,
     * somebody has to order a server, and two to three working days is what
     * that honestly takes including a weekend.
     */
    public function deliveryFor(int $diskGb, ?string $datacenter = null): string
    {
        $largest = $this->largestPlaceableGb($datacenter);

        return $diskGb > 0 && $diskGb <= $largest ? 'immediate' : 'scheduled';
    }

    /**
     * The sizes this customer actually bought.
     *
     * From their frozen contract, never from the catalogue — the same rule the
     * provisioning steps follow. Sizing the queue from today's catalogue would
     * recommend a machine for a package the waiting customer did not buy.
     *
     * @return array<string, mixed>
     */
    private function planOf(Order $order): array
    {
        $subscription = Subscription::query()->where('order_id', $order->id)->first();

        return $subscription === null ? [] : [
            'name' => __('billing.plan.'.$subscription->plan),
            'disk_gb' => (int) $subscription->disk_gb,
        ];
    }
}
