<?php

namespace App\Actions;

use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Provisioning\WorkInFlight;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

/**
 * Deliver the storage a customer has just bought — or take back what they have
 * just cancelled.
 *
 * A booked pack was a row in `subscription_addons` and nothing else. It was
 * priced, frozen, charged for every month and had no effect whatsoever on the
 * machine: the disk stayed the size the package made it, the filesystem inside
 * stayed the size the disk had been, and Nextcloud went on enforcing the
 * package's own allowance. This is the missing half — the moment a booking
 * becomes space.
 *
 * It does not do the work. Growing a disk, growing a guest's filesystem and
 * writing an occ setting are remote work on a live machine, so they go through
 * the same run machinery as everything else — see the `storage` pipeline — where
 * they are retried, logged and visible in the console instead of happening
 * inside whichever web request happened to book the pack.
 *
 * Called from the one place a pack becomes real or stops being real:
 * App\Actions\BookAddon, on booking and on cancellation. Cancelling matters as
 * much as booking — the allowance goes back down, and Nextcloud has to be told,
 * or the customer would keep the storage they have stopped paying for.
 */
class ApplyStorageAllowance
{
    /** Start a storage run for the machine this contract pays for. */
    public function __invoke(?Subscription $subscription): ?ProvisioningRun
    {
        if ($subscription === null) {
            return null;
        }

        // The link when provisioning has written it, and otherwise the
        // customer's newest instance — the same fallback ApplyPlanChange and
        // CustomDomainAccess make, for the same reason.
        return $this->forInstance(
            $subscription->instance ?? $subscription->customer?->instances()->latest('id')->first()
        );
    }

    /**
     * Start a storage run for this instance, or return null when there is
     * nothing to start.
     *
     * Null is the ordinary answer and not a failure: a machine still being
     * built, one whose VM has gone, one whose service has ended, or one that
     * already has a run in flight all deliver the allowance from that run
     * instead — every pipeline that touches storage reads the allowance when it
     * gets there, which is this state or newer.
     */
    public function forInstance(?Instance $instance): ?ProvisioningRun
    {
        if ($instance === null || ! $instance->hasLiveMachine()) {
            return null;
        }

        // The same lock ReapplyInstanceAddress and RestartInstance take, for the
        // same reason: two bookings can land in one instant — a double click, a
        // webhook delivered twice, an operator granting a pack while the
        // customer buys one — and two runs against one machine would resize the
        // same disk and race each other's occ calls. Nobody waits for it: a run
        // that lost the race has nothing to add, because the one that won reads
        // the same allowance.
        $lock = Cache::lock('instance-storage:'.$instance->uuid, 30);

        if (! $lock->get()) {
            return null;
        }

        try {
            return $this->start($instance);
        } finally {
            $lock->release();
        }
    }

    private function start(Instance $instance): ?ProvisioningRun
    {
        $order = $instance->order;

        // Stand aside only for a run that will deliver the whole allowance: the
        // disk, the filesystem inside it and the figure Nextcloud enforces. A
        // plan change does all three and there is nothing to add. A `quota` run
        // does only the last of them, and letting it stand for the booking would
        // enforce a quota over a filesystem that never grew — space the machine
        // does not have. `address` and `restart` do none of them, and standing
        // aside for one of those was how a pack could be charged monthly and
        // never delivered. See App\Provisioning\WorkInFlight.
        if (app(WorkInFlight::class)->covers($order, 'storage')) {
            return null;
        }

        $run = ProvisioningRun::create([
            'subject_type' => Order::class,
            'subject_id' => $order->id,
            'pipeline' => 'storage',
            'status' => ProvisioningRun::STATUS_PENDING,
            'current_step' => 0,
            // Everything the three steps read: instance_id is how CustomerStep
            // finds the machine, node and vmid are how it reaches inside it.
            'context' => [
                'instance_id' => $instance->id,
                'host_id' => $instance->host_id,
                'node' => $instance->host?->node,
                'vmid' => $instance->vmid,
                'subdomain' => $instance->subdomain,
            ],
        ]);

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

        Log::info('Delivering a changed storage allowance.', [
            'instance' => $instance->uuid,
            'run' => $run->uuid,
        ]);

        return $run;
    }
}
