<?php

namespace App\Actions;

use App\Models\Instance;
use App\Models\Order;
use App\Models\PlanFamily;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\PlanChange;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * The single place a plan change actually happens.
 *
 * PlanChange is the preview: what a move would cost and whether it is allowed
 * yet. Nothing carried one out. A customer could buy an upgrade, see it sit in
 * the cart as a paid order, and keep the same contract, the same machine and the
 * same quota forever — the order was created and never consumed by anything.
 *
 * This is the consumer. It does the four things a plan change is, in one place
 * so they cannot be done by halves:
 *
 *  - the CONTRACT moves onto the target package's current sellable version, so
 *    the snapshot every step, bill and entitlement check reads says what the
 *    customer is now on;
 *  - the REGISTER gets one row, because a change of terms is a commercial event
 *    and the register is the evidence of what was agreed;
 *  - the ENTITLEMENTS that hang off the package are settled — today that is the
 *    own domain, which a smaller package can take away;
 *  - the MACHINE is resized, through a provisioning run like every other piece
 *    of remote work in this application, so it is retried, logged and visible in
 *    the console instead of happening in a web request;
 *  - STRIPE is moved onto the new price, because it owns the billing cycle and
 *    would otherwise keep charging for the package the customer has left. Last,
 *    outside the transaction, and unable to fail the change — see below.
 *
 * Idempotent from both ends. A contract already on the target version is a
 * no-op rather than an error — the order is consumed and nothing else moves —
 * and the register row carries the order's own event key, so a duplicate write
 * collides in the database rather than in a check that two callers could both
 * pass.
 */
class ApplyPlanChange
{
    public function __construct(private RecordCommercialEvent $record) {}

    /**
     * Carry out the change an order bought.
     *
     * The contract is resolved through CustomDomainAccess::contractOf() rather
     * than re-queried here: it is already the one place that answers "which
     * contract is this customer on", and a second copy of that query is the one
     * that would disagree the day the rule changes.
     */
    public function forOrder(Order $order, ?Carbon $at = null): ?ProvisioningRun
    {
        $subscription = app(CustomDomainAccess::class)->contractOf($order->customer);

        if ($subscription === null) {
            Log::warning('No contract to move: this plan change has no live subscription behind it.', [
                'order_id' => $order->id, 'plan' => $order->plan,
            ]);

            return null;
        }

        return $this($subscription, (string) $order->plan, $order, $at);
    }

    /**
     * @return ProvisioningRun|null the resize run, or null when the change was
     *                              refused, was a no-op, or there is no machine
     *                              to resize
     */
    public function __invoke(Subscription $subscription, string $targetPlan, ?Order $order = null, ?Carbon $at = null): ?ProvisioningRun
    {
        $at = $at?->copy() ?? now();

        try {
            // The version on sale RIGHT NOW, which is what someone moving today
            // is being sold. Throws for a plan that has been withdrawn or is not
            // priced on this term, and that is the correct outcome: a contract
            // moved onto a guess is worse than a change that visibly fails.
            $target = Subscription::snapshotFrom($targetPlan, $subscription->term);
        } catch (Throwable $e) {
            Log::error('Cannot move a contract onto a package the catalogue will not sell.', [
                'subscription' => $subscription->uuid, 'plan' => $targetPlan, 'error' => $e->getMessage(),
            ]);

            return null;
        }

        // Already there. Not an error — a retried trigger, a command run twice,
        // an operator pressing the same thing — but the order is consumed so it
        // stops asking.
        if ((string) $subscription->plan === $targetPlan
            && (int) $subscription->plan_version_id === (int) $target['plan_version_id']) {
            $this->consume($order);

            return null;
        }

        // The eligibility gate. An upgrade with the period already over, a
        // downgrade before the term the customer paid for has run out, and any
        // move on a contract that is no longer active all stop here — see
        // PlanChange::evaluate() for why each of them is refused.
        $change = PlanChange::evaluate($subscription, $targetPlan, $at);

        if (! $change->allowedNow) {
            return null;
        }

        // The other gate: can this contract actually be BILLED for what it is
        // about to become? A contract with a live Stripe subscription behind it
        // has to end this call still agreeing with Stripe about the price, and
        // an INTERNAL family (`plan_families.internal`) never gets one —
        // stripe:sync-catalogue skips it on purpose, permanently, because a
        // Stripe Price cannot be deleted once minted and a package nobody is
        // ever charged for is not worth leaving one behind. Left to run,
        // MoveStripeSubscriptionPrice below would find nothing to move onto,
        // throw, and park the swap on stripe_price_sync — where
        // clupilot:sync-stripe-subscriptions retries it every hour forever,
        // because no future sync run can ever fill in a price this family will
        // never receive.
        //
        // Checked on the FLAG rather than on whether a price happens to resolve
        // right now, even though the latter is more general and closer to the
        // actual damage: a perfectly ordinary, still-sellable family can be
        // between publish and its first stripe:sync-catalogue run too, and that
        // gap is exactly what the park-and-retry path exists for — one sync run
        // closes it (see the test right above this guard in
        // StripePlanChangeTest). Refusing on unresolvability alone would treat
        // that transient, self-healing gap the same as a permanent one and
        // reject a change that would have gone through fine an hour later. The
        // flag is the only signal that tells "will never be fixed by syncing
        // again" apart from "has simply not been synced yet".
        //
        // A granted contract carries no `stripe_subscription_id` at all — see
        // MoveStripeSubscriptionPrice's own early return — so there is nothing
        // for it to disagree with Stripe about, and moving one onto an internal
        // family (which is, after all, how such a package is normally handed
        // out) is left alone here.
        //
        // Checked BEFORE the transaction below, not after: refusing once the
        // contract, the register and the machine have already moved would
        // leave the change half-done, which is worse than the sweep this
        // replaces.
        if ($subscription->stripe_subscription_id !== null
            && PlanFamily::query()->where('key', $targetPlan)->value('internal')) {
            Log::error('Cannot move a Stripe-billed contract onto an internal package: Stripe has no price for it and never will while it stays internal.', [
                'subscription' => $subscription->uuid, 'plan' => $targetPlan,
            ]);

            return null;
        }

        $instance = $this->instanceOf($subscription);

        // Read BEFORE the snapshot moves: the resize step has to know what the
        // machine had, to tell a change of CPU or RAM from a change that only
        // touched storage or price.
        $before = [
            'plan' => (string) $subscription->plan,
            'cores' => (int) ($instance?->cores ?? $subscription->cores),
            'ram_mb' => (int) ($instance?->ram_mb ?? $subscription->ram_mb),
        ];

        $run = DB::transaction(function () use ($subscription, $target, $targetPlan, $change, $order, $instance, $before, $at) {
            $subscription->applyPlanSnapshot($target);

            // A booked change is spent the moment the contract moves — whether it
            // was this one arriving on its date, or a move in the other direction
            // overtaking it. This is where the shop used to do it: an upgrade CLICK
            // cleared a booked downgrade, in the cart, before a cent had been paid,
            // and since nothing marks a cart order paid the customer lost the
            // booking and got nothing in exchange. The contradiction is real —
            // a downgrade left booked would come due months later and quietly undo
            // the bigger package — but it is only real once the bigger package is
            // actually theirs, which is here.
            //
            // Cleared AFTER PlanChange::evaluate() above, which reads the booked
            // due date to decide whether a downgrade is allowed yet.
            $subscription->clearPendingPlanChange();

            // The register row is written from the contract that has just been
            // moved, so the evidence and the contract cannot describe different
            // packages — the same order OpenSubscription writes a purchase in.
            ($this->record)(
                event: $change->isUpgrade
                    ? SubscriptionRecord::EVENT_UPGRADE
                    : SubscriptionRecord::EVENT_DOWNGRADE,
                subscription: $subscription,
                // Zero, because a plan change moves TERMS and no money at all.
                // Stripe settles the difference on a proration invoice of its
                // own (PRORATE_IMMEDIATELY below), and that invoice arrives here
                // as its own EVENT_INVOICE_PAID row carrying the real figure. An
                // amount on this row as well counted one upgrade's money twice in
                // any sum over the register.
                //
                // What the move is WORTH is not lost — it goes into the snapshot
                // below, which is where the question "what were they quoted?"
                // belongs. Deliberately NOT the order's amount_cents either:
                // that is the shop's headline price for the whole package, and
                // recording it for a move made on the sixteenth would state a sum
                // nobody agreed to.
                netCents: 0,
                at: $at,
                extra: ['plan_change' => [
                    'from_plan' => $before['plan'],
                    'to_plan' => $targetPlan,
                    'remaining_days' => $change->remainingDays,
                    'term_days' => $change->termDays,
                    // The preview's pro-rata difference, and what the till would
                    // take from THIS customer for it. Both, because PlanChange
                    // prorates the NET catalogue figures while the Stripe Price it
                    // prorates against is the one that customer is billed on — so
                    // for anybody who is charged VAT the net figure alone reads as
                    // a fifth less than the proration invoice that follows, and
                    // somebody reconciling the two needs the number to compare.
                    // For a reverse-charge business the two are equal, and saying
                    // so is what stops a reader looking for a missing fifth.
                    'prorated_net_cents' => $change->chargeCents,
                    'prorated_charge_cents' => TaxTreatment::for($subscription->customer)
                        ->chargeCents($change->chargeCents),
                ]],
                // Nothing has been charged AT THIS EVENT. Left null rather than
                // reported as zero, which the register would read as a free sale.
                chargedGrossCents: null,
                order: $order,
                // One row per order, enforced by the unique index rather than by
                // a check two concurrent triggers could both pass.
                eventKey: $order !== null ? 'plan-change:order:'.$order->id : null,
            );

            $this->resizeInstance($instance, $target, $targetPlan);
            $this->consume($order);

            return $this->startResize($instance, $targetPlan, $before, $change->isUpgrade, $order);
        });

        // After the run exists, never before. settleCustomDomain() withdraws a
        // domain the new package no longer carries, and withdrawing one asks
        // ReapplyInstanceAddress for a run to stop serving it — which finds this
        // run already in flight against the same order and stands aside, because
        // this pipeline carries the address steps itself. The other way round
        // there would be two runs writing one router.
        PlanChange::settleCustomDomain($subscription);

        // Stripe bills the next term, so it has to be told which package that
        // is — without this a customer who upgraded goes on paying for the one
        // they left, every month, for as long as the contract runs.
        //
        // After the transaction and never inside it. The change has already
        // landed on the contract and on the machine, and an unreachable or
        // unconfigured Stripe must not roll any of that back: the action never
        // throws, and parks what it could not do on the contract for the hourly
        // sweep to pick up. A contract with no Stripe subscription behind it —
        // a granted package — is left entirely alone.
        //
        // Resolved from the container here rather than injected, so a test that
        // swaps the client in after this action exists still gets its fake.
        app(MoveStripeSubscriptionPrice::class)(
            $subscription,
            $change->isUpgrade ? StripeClient::PRORATE_IMMEDIATELY : StripeClient::PRORATE_NONE,
        );

        if ($run !== null) {
            AdvanceRunJob::dispatch($run->uuid); // after commit

            Log::info('Applying a plan change.', [
                'subscription' => $subscription->uuid,
                'from' => $before['plan'],
                'to' => $targetPlan,
                'upgrade' => $change->isUpgrade,
                'run' => $run->uuid,
            ]);
        }

        return $run;
    }

    /**
     * The order is spent. Anything but `pending` takes it out of the customer's
     * cart, and `applied` says which way it went — it was never paid in the sense
     * an invoice means, and calling it that would put a charge in the record that
     * never happened.
     */
    private function consume(?Order $order): void
    {
        if ($order !== null && $order->status !== 'applied') {
            $order->update(['status' => 'applied']);
        }
    }

    /**
     * Bring the machine's own row in line with the package it now serves.
     *
     * Everything except the disk. A disk cannot be shrunk — Proxmox grows one
     * online and has no way back — so a downgrade leaves the guest exactly as
     * large as it already is, and writing the smaller figure here would make this
     * row claim a size the machine does not have. Two things read it and both
     * would be wrong: the host's storage accounting, which would think it had
     * space it does not, and the next resize, which would ask Proxmox to shrink.
     *
     * What actually shrinks is the QUOTA, which Nextcloud enforces and which is
     * what was sold — see the plan-change pipeline's quota step. The disk is
     * infrastructure; the quota is the product. The contract's own `disk_gb` still
     * records what the smaller package includes, so nothing has been lost: this
     * column is what the machine HAS, and that is all it has ever meant.
     *
     * @param  array<string, mixed>  $target
     */
    private function resizeInstance(?Instance $instance, array $target, string $targetPlan): void
    {
        $instance?->update([
            'plan' => $targetPlan,
            'quota_gb' => $target['quota_gb'],
            'ram_mb' => $target['ram_mb'],
            'cores' => $target['cores'],
        ]);
    }

    /**
     * Start the run that makes the machine match the package.
     *
     * Subject is the instance's own purchase order, exactly as the `address`
     * pipeline does it — not the upgrade order. That is what CustomerStep::order()
     * and ::subscription() resolve from, so the steps read this customer's
     * contract rather than nothing; it is what ReapplyInstanceAddress checks for
     * a run already in flight, so two runs cannot write one router; and it keeps
     * RunRunner from marking a paid, running customer's order failed because a
     * resize could not be applied (see ProvisioningSubject::provisioningPipeline).
     *
     * @param  array{plan: string, cores: int, ram_mb: int}  $before
     */
    private function startResize(?Instance $instance, string $targetPlan, array $before, bool $isUpgrade, ?Order $order): ?ProvisioningRun
    {
        // A contract with no machine — one still being built, one whose build
        // failed, a package granted before provisioning ran — has nothing to
        // resize. The contract has still moved, and the machine will be built
        // from it whenever it is built.
        if ($instance === null || ! $instance->hasLiveMachine()) {
            return null;
        }

        return ProvisioningRun::create([
            'subject_type' => Order::class,
            'subject_id' => $instance->order_id,
            'pipeline' => 'plan-change',
            '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,
                // What the machine had before, so the resize step can tell which
                // of its changes need the guest to come back round before they
                // are true. Kept on the run rather than recomputed: by the time
                // the step executes, the instance row already says the new size.
                'plan_change' => [
                    'order_id' => $order?->id,
                    'from_plan' => $before['plan'],
                    'to_plan' => $targetPlan,
                    'from_cores' => $before['cores'],
                    'from_ram_mb' => $before['ram_mb'],
                    'upgrade' => $isUpgrade,
                ],
            ],
        ]);
    }

    /**
     * The machine this contract pays for: the link when provisioning has written
     * it, and otherwise the customer's newest instance — the same fallback
     * CustomDomainAccess makes, for the same reason.
     */
    private function instanceOf(Subscription $subscription): ?Instance
    {
        return $subscription->instance
            ?? $subscription->customer?->instances()->latest('id')->first();
    }
}
