<?php

namespace App\Provisioning\Steps\Customer;

use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
use RuntimeException;

/**
 * Make the bigger disk visible INSIDE the guest.
 *
 * Growing storage is four things and the product only ever did the first.
 * Proxmox enlarges the image; the guest kernel has to notice; the partition has
 * to be stretched over the new space; and the filesystem has to be stretched
 * over the partition. Without the last three, a customer who bought a hundred
 * gigabytes got a larger image on the host and a Nextcloud that could not write
 * one byte more than before — which is precisely what happened to every plan
 * upgrade this product has ever carried out.
 *
 * **No cold boot is involved, and that is not luck.** The data disk is attached
 * as `scsi0` (see CloneVirtualMachine and ConfigureCloudInit, which sizes that
 * exact device), and Proxmox's resize on a running guest is a qemu block_resize
 * on it: the new capacity reaches the guest's SCSI layer while it runs. Current
 * kernels pick it up by themselves and older ones want to be told to look, which
 * is what the rescan below is for. The cold boot ResizeVirtualMachine talks
 * about is about CPU and memory, which have no hotplug configured here — a disk
 * is not in that sentence, and the `restart` pipeline is not needed to deliver
 * one.
 *
 * Idempotent from end to end, because a run is retried and this step sits in
 * more than one pipeline: the rescan is a read, growpart says NOCHANGE when the
 * partition already fills the disk, and every filesystem tool here exits 0 when
 * there is nothing left to grow. Running it when nothing changed visits the
 * guest and changes nothing.
 */
class GrowGuestFilesystem extends CustomerStep
{
    /**
     * How each filesystem is grown, and what its tool wants pointed at it.
     *
     * Detected, never assumed. The images are ext4 today, and a step that
     * hard-coded resize2fs would go quietly wrong the day one is not: the
     * command would fail on a filesystem it cannot read, the run would burn its
     * retries, and the report would say nothing about why. `resize2fs` takes the
     * block device; xfs and btrfs are grown through their MOUNT POINT, which is
     * why the target is declared per filesystem instead of guessed at.
     *
     * @var array<string, array{0: string, 1: 'source'|'target'}>
     */
    private const GROWERS = [
        'ext2' => ['resize2fs', 'source'],
        'ext3' => ['resize2fs', 'source'],
        'ext4' => ['resize2fs', 'source'],
        'xfs' => ['xfs_growfs', 'target'],
        'btrfs' => ['btrfs filesystem resize max', 'target'],
    ];

    public function __construct(private ProxmoxClient $pve) {}

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

    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);

        // Nothing inside a machine can be grown from outside it. A guest that is
        // not answering is retried rather than failed on the spot — it may still
        // be coming up — and the run fails visibly once the budget is gone,
        // which is the outcome an operator can act on.
        if (! $pve->guestAgentPing($node, $vmid)) {
            return StepResult::retry(20, 'guest agent not answering yet');
        }

        $mount = $this->dataFilesystem($pve, $run);

        if ($mount === null) {
            return StepResult::fail('no_data_filesystem');
        }

        [$source, $type, $target] = $mount;
        $grower = self::GROWERS[$type] ?? null;

        // Loudly, and this is the whole point of detecting the type. A
        // filesystem nobody taught this step about must not be handed to
        // resize2fs on the chance that it works: at best the command fails, at
        // worst it writes ext4 structures over something else. A failed run says
        // which filesystem it was; a silent no-op would leave the customer
        // paying for space that never appeared.
        if ($grower === null) {
            return StepResult::fail('unsupported_filesystem: '.$type);
        }

        $this->rescanDisk($pve, $run, $source);
        $this->growPartition($pve, $run, $source);

        [$command, $argument] = $grower;

        $this->guest($pve, $run, $command.' '.escapeshellarg($argument === 'source' ? $source : $target));

        return StepResult::advance();
    }

    /**
     * The device, filesystem type and mount point that carry the customer's
     * files.
     *
     * `findmnt --target` answers for whichever filesystem actually holds the
     * path, so a `/var/www` that is only a directory on the root filesystem
     * resolves to the root filesystem — the correct answer, and the same one the
     * fill-level reading gets from `df`. The two paths are asked in the same
     * order for exactly that reason: what this step grows must be what the meter
     * measures, or a customer would be told they are full of a filesystem nobody
     * enlarged.
     *
     * @return array{0: string, 1: string, 2: string}|null
     */
    private function dataFilesystem(ProxmoxClient $pve, ProvisioningRun $run): ?array
    {
        $out = $this->guest($pve, $run,
            'findmnt -nro SOURCE,FSTYPE,TARGET --target /var/www 2>/dev/null '
            .'|| findmnt -nro SOURCE,FSTYPE,TARGET --target /');

        foreach (preg_split('/\r?\n/', trim($out)) ?: [] as $line) {
            $fields = preg_split('/\s+/', trim($line)) ?: [];

            // The first complete line. A bind mount can add more, and they
            // describe the same filesystem — growing it once is growing it.
            if (count($fields) >= 3 && str_starts_with($fields[0], '/dev/')) {
                return [$fields[0], $fields[1], $fields[2]];
            }
        }

        return null;
    }

    /**
     * Ask the kernel to look at the disk again.
     *
     * A read, and it always exits 0: a machine whose sysfs has no such node —
     * another disk transport, an unusual image — is not a failed run, it is a
     * machine whose kernel noticed the new size without being asked. The
     * partition step immediately after is what actually proves whether the space
     * arrived.
     */
    private function rescanDisk(ProxmoxClient $pve, ProvisioningRun $run, string $source): void
    {
        $disk = $this->diskName($source);

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

        $rescan = '/sys/class/block/'.$disk.'/device/rescan';

        $this->guest($pve, $run,
            'test -w '.escapeshellarg($rescan).' && echo 1 > '.escapeshellarg($rescan).'; '
            .'udevadm settle >/dev/null 2>&1; true');
    }

    /**
     * Stretch the partition over whatever space the disk has gained.
     *
     * growpart's own answer for "there was nothing to do" is the word NOCHANGE
     * on its output together with a NON-zero exit, and which non-zero code that
     * is has moved between releases of cloud-utils. So the word is what is read
     * and not the number — this step runs again on every retry and on every
     * further pack, and a second run finding the partition already at full size
     * is the ordinary case, not a failure.
     */
    private function growPartition(ProxmoxClient $pve, ProvisioningRun $run, string $source): void
    {
        $partition = $this->partition($source);

        if ($partition === null) {
            // A filesystem sitting straight on a disk, an LVM volume, a mapper
            // device: there is no partition table entry to move, and guessing a
            // device name here would run growpart against the wrong disk. Said
            // out loud on the run, because the filesystem step below may then be
            // the only thing that grows — and if it grows nothing, this line is
            // the reason.
            $run->events()->create([
                'step' => $this->key(),
                'attempt' => $run->attempt,
                'outcome' => 'info',
                'message' => "Keine erweiterbare Partition erkannt ({$source}) — die Partitionstabelle bleibt "
                    .'unverändert, das Dateisystem wird trotzdem vergrößert.',
            ]);

            return;
        }

        [$disk, $number] = $partition;

        $result = $pve->guestExec(
            (string) $run->context('node'),
            (int) $run->context('vmid'),
            'growpart '.escapeshellarg($disk).' '.escapeshellarg($number).' 2>&1',
        );

        $out = trim((string) ($result['out-data'] ?? ''));

        if ((int) ($result['exitcode'] ?? 1) !== 0 && ! str_contains($out, 'NOCHANGE')) {
            throw new RuntimeException("growpart failed (exit {$result['exitcode']}): {$out}");
        }
    }

    /**
     * The whole disk behind a partition, and the partition's number.
     *
     * `/dev/sda1` → `/dev/sda` and `1`; `/dev/nvme0n1p2` → `/dev/nvme0n1` and
     * `2`. Null for anything this cannot name with certainty. Deliberately
     * narrow: growpart takes a disk and a number, and a pattern loose enough to
     * match everything would eventually hand it the wrong disk.
     *
     * @return array{0: string, 1: string}|null
     */
    private function partition(string $source): ?array
    {
        if (preg_match('#^(/dev/nvme\d+n\d+)p(\d+)$#', $source, $m) === 1
            || preg_match('#^(/dev/[a-z]{2,4})(\d+)$#', $source, $m) === 1) {
            return [$m[1], $m[2]];
        }

        return null;
    }

    /** The kernel's name for the disk itself — `sda` — or null if it is not one. */
    private function diskName(string $source): ?string
    {
        $device = $this->partition($source)[0] ?? $source;

        return preg_match('#^/dev/([a-z0-9]+)$#', $device, $m) === 1 ? $m[1] : null;
    }
}
