<?php

namespace App\Provisioning\Steps\Customer;

use App\Models\Instance;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Billing\StorageAllowance;
use App\Services\Proxmox\ProxmoxClient;

/**
 * The hardware half of a plan change: CPU, memory and disk.
 *
 * Two things here are not symmetrical, and both are deliberate.
 *
 * **A disk only grows.** Proxmox resizes a virtual disk upwards on a running
 * guest and has no way back down — there is no shrink, online or offline, and
 * asking for one is an error, not a slow operation. So a downgrade does not try.
 * What shrinks instead is the customer's QUOTA, which Nextcloud enforces and
 * which is the thing that was actually sold; the guest keeps the disk it already
 * has, and `instances.disk_gb` keeps saying so because that column is what the
 * machine HAS and the host's storage accounting reads it. What the smaller
 * package includes is on the contract, where it belongs — nothing reads a disk
 * size as proof of what was sold.
 *
 * Growing it is only the host's half. The guest sees a larger block device and
 * nothing more — its partition and its filesystem still end where they did — so
 * GrowGuestFilesystem runs immediately after this step in every pipeline that
 * can enlarge a disk. Without it, an upgrade delivered a bigger image and not one
 * usable byte.
 *
 * **CPU and memory are applied but not made true.** The configuration PUT lands
 * immediately and a running guest takes the new size at its next cold boot;
 * nothing in this codebase turns Proxmox's CPU or memory hotplug on, so there is
 * no live path. Rebooting a customer's cloud as a side effect of a purchase is
 * not something to do quietly, and applying a value that only becomes real
 * months later with nobody knowing is not either — so the machine is left
 * running and the gap is recorded on the instance, where the console's instance
 * list and the customer's own cloud page both show it.
 */
class ResizeVirtualMachine extends CustomerStep
{
    public function __construct(private ProxmoxClient $pve) {}

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

    public function maxDuration(): int
    {
        return 300;
    }

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

        if ($instance === null) {
            return StepResult::fail('no_instance');
        }

        $node = (string) $run->context('node');
        $vmid = (int) $run->context('vmid');
        $pve = $this->pve->forHost($instance->host);

        // setCloudInit() is the generic `PUT /nodes/{node}/qemu/{vmid}/config`
        // despite its name — it writes whatever parameters it is given, and
        // cloud-init keys are simply what the first caller happened to need.
        // cores and memory are ordinary VM configuration and go the same way.
        // Idempotent: writing the same values again changes nothing.
        $pve->setCloudInit($node, $vmid, [
            'cores' => (int) $instance->cores,
            'memory' => (int) $instance->ram_mb,
        ]);

        $this->growDisk($run, $pve, $instance, $node, $vmid);
        $this->settlePendingRestart($run, $pve, $instance, $node, $vmid);

        return StepResult::advance();
    }

    /**
     * Grow the disk to what the customer is now entitled to, and say so out loud
     * when that is less than the machine already has.
     *
     * The target is the customer's WHOLE allowance — package plus booked storage
     * packs — with TWO overheads left on top of it, never one. The package's own
     * system overhead is read off the package and never invented: the catalogue's
     * three sellable packages ship 40/30, 100/85 and 200/175 GB, which is 10, 15
     * and 25 GB of room beside the customer's files and is a decision the owner
     * makes per package, not a ratio anybody may derive. Every booked
     * pack brings ITS OWN overhead the same way, frozen on the booking rather
     * than on the package — what it gives the customer and what it takes on the
     * host are two different figures, and only their difference belongs here.
     * Without it, a stack of packs would sit on the package's headroom alone and
     * thin it out with every pack added, which is exactly the shape the rule
     * exists to refuse.
     *
     * With no packs booked the sum is exactly the package's own `disk_gb`, which
     * is what this step has always used.
     */
    private function growDisk(ProvisioningRun $run, ProxmoxClient $pve, Instance $instance, string $node, int $vmid): void
    {
        $plan = $this->plan($run);
        $planDisk = (int) ($plan['disk_gb'] ?? 0);
        $planQuota = (int) ($plan['quota_gb'] ?? 0);

        // No contract behind this run, or a package without a size: nothing to
        // grow towards, and guessing at one would resize somebody's disk from a
        // default.
        if ($planDisk <= 0) {
            return;
        }

        $allowance = StorageAllowance::for($instance);

        // Kontingent + Blöcke + Kopfraum des Pakets + Kopfraum der Blöcke.
        //
        // Der letzte Summand ist der neue: ein Block gibt dem Kunden 20 GB und
        // belegt 22, damit die Regel max(10 GB, 12 %) auch für ein gestapeltes
        // Paket gilt. Ohne ihn hätte ein Start mit drei Blöcken 90 GB auf 100 GB
        // Platte — zehn Gigabyte Kopfraum, wo die Regel zwölf verlangt.
        //
        // Beide Kopfräume werden gelesen, nicht abgeleitet: der des Pakets aus
        // dem eingefrorenen Vertrag, der der Blöcke aus den Buchungen.
        $target = $allowance->totalGb() > 0
            ? $allowance->totalGb()
                + max(0, $planDisk - $planQuota)
                + max(0, $allowance->packDiskGb() - $allowance->packGb())
            : $planDisk;
        $actual = (int) $instance->disk_gb;

        if ($target > $actual) {
            // Absolute target, not '+…', so a retry cannot grow it twice.
            $pve->resizeDisk($node, $vmid, 'scsi0', $target.'G');
            $instance->update(['disk_gb' => $target]);

            return;
        }

        if ($target < $actual) {
            $run->events()->create([
                'step' => $this->key(),
                'attempt' => $run->attempt,
                'outcome' => 'info',
                'message' => "Festplatte bleibt bei {$actual} GB: ein kleineres Paket ({$target} GB) verkleinert "
                    .'keine Platte — das Kontingent des Kunden sinkt stattdessen.',
            ]);
        }
    }

    /**
     * Record — or clear — the restart this change is waiting on.
     *
     * Keyed on whether the machine is RUNNING rather than on reading back what
     * Proxmox says the guest currently has. Without hotplug the answer is the
     * same either way and this one needs no second API shape to be faked in
     * tests; and where the two could differ, being pessimistic is the safe
     * direction. A "restart pending" that turns out to have been unnecessary is
     * a visible nuisance, while a resize that silently never happened is exactly
     * the failure this step exists to stop.
     */
    private function settlePendingRestart(ProvisioningRun $run, ProxmoxClient $pve, Instance $instance, string $node, int $vmid): void
    {
        $fromCores = (int) $run->context('plan_change.from_cores', $instance->cores);
        $fromRam = (int) $run->context('plan_change.from_ram_mb', $instance->ram_mb);

        // Nothing about CPU or memory moved, so this change is waiting on
        // nothing. An older pending restart is left exactly where it is — it is
        // still pending, and clearing it here would hide it.
        if ($fromCores === (int) $instance->cores && $fromRam === (int) $instance->ram_mb) {
            return;
        }

        $running = ($pve->vmStatus($node, $vmid)['status'] ?? '') === 'running';

        if (! $running) {
            // A stopped machine reads its configuration when it starts, so the
            // new size is already true for it — and anything pending from an
            // earlier change is settled by the same start.
            if ($instance->restartIsPending()) {
                $instance->update(['restart_required_since' => null]);
            }

            return;
        }

        // First pending change keeps its timestamp: how long somebody has been
        // paying for cores they do not have is the interesting number, not when
        // the most recent change was applied.
        if (! $instance->restartIsPending()) {
            $instance->update(['restart_required_since' => now()]);
        }

        $run->events()->create([
            'step' => $this->key(),
            'attempt' => $run->attempt,
            'outcome' => 'info',
            'message' => "Neue Ausstattung ({$instance->cores} vCPU, {$instance->ram_mb} MB RAM) ist in der VM-Konfiguration "
                .'hinterlegt und wird beim nächsten Neustart wirksam. Die laufende Maschine wurde nicht neu gestartet.',
        ]);
    }
}
