<?php

namespace App\Console\Commands;

use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\StorageAllowance;
use Illuminate\Console\Command;

/**
 * Give the existing estate the storage allowance it was already sold.
 *
 * ApplyStorageQuota now runs in the `customer` and `plan-change` pipelines, so
 * everything built or changed from here on is enforced on the machine. Every
 * instance from before that is not: `quota_gb` reached the row and stopped
 * there, Nextcloud was never told, and those customers still have the whole disk
 * whatever they are paying for. Nothing in the product could reach them, because
 * the two pipelines only run on a build or a change and these machines are
 * having neither.
 *
 * This is the sweep, and it is a REPAIR, not a routine. It is deliberately NOT
 * in the scheduler:
 *
 *  - the hole it closes is finite and closes for good. Once an instance's
 *    allowance is enforced and recorded, both pipelines keep it that way;
 *  - a nightly sweep would be a second authority writing the same value onto
 *    live machines on a timer, and the day the step in the pipeline silently
 *    stopped working the sweep would quietly cover for it — the failure would
 *    never surface, which is how the original hole survived as long as it did;
 *  - a repair the owner runs is a repair the owner can read the output of. This
 *    one prints what it did and what it did not, and refuses to touch anything
 *    at all under --dry-run.
 *
 * The work itself goes through the `quota` pipeline rather than reaching into
 * guests here: one run per instance, retried, logged and visible in the console
 * like every other piece of remote work, and running the exact same step the
 * other two pipelines run so a second implementation cannot drift from it.
 */
class ApplyStorageQuotas extends Command
{
    protected $signature = 'clupilot:apply-quotas
        {--dry-run : list what would happen and change nothing}
        {--instance= : one instance uuid, for a single repair}';

    protected $description = 'Apply the sold storage allowance to instances whose Nextcloud was never told about it';

    public function handle(): int
    {
        $dryRun = (bool) $this->option('dry-run');
        $started = 0;

        /** @var array<string, int> reason => count */
        $skipped = [];

        $query = Instance::query()->with(['order', 'host']);

        if ($uuid = $this->option('instance')) {
            $query->where('uuid', $uuid);
        }

        foreach ($query->cursor() as $instance) {
            $reason = $this->reasonToSkip($instance);

            if ($reason !== null) {
                $skipped[$reason] = ($skipped[$reason] ?? 0) + 1;

                continue;
            }

            $started++;

            // The whole allowance, packs included — the figure the run will
            // actually write. Printing `quota_gb` would report the package while
            // the sweep applied something larger.
            $owed = StorageAllowance::for($instance)->totalGb();

            $this->line(($dryRun ? '[dry-run] ' : '')
                ."{$instance->subdomain}: {$owed} GB "
                .'('.($instance->quota_applied_gb === null ? 'noch nie gesetzt' : "zuletzt {$instance->quota_applied_gb} GB").')');

            if (! $dryRun) {
                $this->startQuotaRun($instance);
            }
        }

        foreach ($skipped as $reason => $count) {
            $this->line("übersprungen ({$reason}): {$count}");
        }

        $this->info($dryRun
            ? "Probelauf: {$started} Instanz(en) bekämen ihr Kontingent, ".array_sum($skipped).' übersprungen. Nichts wurde geändert.'
            : "{$started} Kontingent-Lauf/Läufe gestartet, ".array_sum($skipped).' übersprungen.');

        return self::SUCCESS;
    }

    /**
     * Why this instance is being left alone, or null when it is not.
     *
     * The reason is the report — a sweep that says "skipped 34" and nothing else
     * is a sweep nobody can act on, because "34 machines have no allowance
     * enforced" and "34 machines are already fine" look identical from here.
     */
    private function reasonToSkip(Instance $instance): ?string
    {
        // Nothing remote work can reach: a reservation with no VM, a failed
        // build, an ended service. The same rule every other maintenance action
        // asks — see Instance::hasLiveMachine().
        if (! $instance->hasLiveMachine()) {
            return 'keine erreichbare Maschine';
        }

        // No allowance on the row at all. Writing "0 GB" would lock the customer
        // out of their own files, which is a far worse answer than leaving what
        // is there and letting somebody notice — the same decision the step
        // itself makes, made here too so the machine is not visited for nothing.
        if ((int) $instance->quota_gb <= 0) {
            return 'kein Kontingent hinterlegt';
        }

        if ($instance->quotaIsEnforced()) {
            return 'bereits gesetzt';
        }

        // Whatever is in flight against this order gets to finish. A quota run
        // beside an unfinished build would race the same occ calls, and the
        // build applies the quota itself anyway.
        if ($this->hasRunInFlight($instance)) {
            return 'anderer Lauf aktiv';
        }

        return null;
    }

    private function startQuotaRun(Instance $instance): void
    {
        $run = ProvisioningRun::create([
            // The instance's own purchase order, as every customer pipeline
            // uses — that is what CustomerStep::order() and ::instance()
            // resolve from, and what the in-flight check above looks at.
            'subject_type' => Order::class,
            'subject_id' => $instance->order_id,
            'pipeline' => 'quota',
            'status' => ProvisioningRun::STATUS_PENDING,
            'current_step' => 0,
            'context' => [
                'instance_id' => $instance->id,
                'host_id' => $instance->host_id,
                'node' => $instance->host?->node,
                'vmid' => $instance->vmid,
                'subdomain' => $instance->subdomain,
            ],
        ]);

        AdvanceRunJob::dispatch($run->uuid);
    }

    /**
     * ANY run, deliberately — unlike the narrower "will it do this work?" question
     * App\Provisioning\WorkInFlight answers for the two actions that must not lose
     * a change they were asked to make. This is a repair sweep: it runs again, it
     * skips instances whose quota is already enforced, and an instance passed over
     * today is picked up on the next pass. Nothing is forgotten by waiting.
     */
    private function hasRunInFlight(Instance $instance): bool
    {
        return ProvisioningRun::query()
            ->where('subject_type', Order::class)
            ->where('subject_id', $instance->order_id)
            ->inFlight()
            ->exists();
    }
}
