<?php

namespace App\Actions;

use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use RuntimeException;

/**
 * Books a module onto a contract at today's price, and freezes it there.
 *
 * Booking is the moment the module's price stops moving for this customer, for
 * exactly the reason the plan's price does: they agreed to a figure. What they
 * have NOT booked stays on the live catalogue — that is a sale still to be
 * made, at whatever it costs now.
 *
 * It is also the one place that decides a module may not be booked at all. Two
 * rules live here rather than in the pages that call it, because a rule enforced
 * in markup is not enforced: not every package may have an own domain, and not
 * every module may be held twice. Both fail closed, with the sentence the
 * customer would be shown rather than a developer's, so whatever refuses —
 * the portal, the console's grant screen, a webhook — says the same thing.
 *
 * It is also where a booking stops being only a row. Storage packs are the one
 * module that changes what the machine has to be, and they were sold for months
 * without anything anywhere acting on them — so booking or cancelling one asks
 * ApplyStorageAllowance for the run that makes the disk, the guest's filesystem
 * and Nextcloud's quota match what the customer now pays for. Every route into a
 * booking goes through here — the shop, a webhook, an operator's grant — which
 * is why the delivery hangs off this action and not off any one of them.
 *
 * `$overrides` exists for GrantAddon: a granted module is booked through this
 * same action, with its price replaced by what the customer actually pays and
 * its provenance stamped on the row. Empty for every ordinary booking. A grant
 * is a booking like any other, so it is refused on the same terms — giving away
 * a module the customer is already paying for would bill them for both.
 *
 * It is also where a booking becomes MONEY, every month, and not only in the
 * month it was made. A module is an item on the Stripe subscription, so booking
 * one adds it and cancelling one takes it off — see
 * App\Actions\SyncStripeAddonItems, which is asked to bring the two into step
 * after every change here. Like the storage delivery beside it, it never fails a
 * booking: what it could not tell Stripe is parked on the contract and retried.
 */
class BookAddon
{
    public function __construct(private RecordCommercialEvent $record) {}

    /** @param  array<string, mixed>  $overrides */
    public function __invoke(Subscription $subscription, string $addonKey, int $quantity = 1, ?Order $order = null, array $overrides = []): SubscriptionAddon
    {
        $price = app(AddonCatalogue::class)->priceCents($addonKey);

        if ($price === null) {
            throw new RuntimeException("Unknown add-on: {$addonKey}");
        }

        if ($quantity < 1) {
            throw new RuntimeException('An add-on is booked at least once.');
        }

        // Not every module is for sale to everyone. The own domain is possible
        // on some packages, included in others and impossible on the smallest,
        // and the portal already leaves it out where it cannot be booked — but
        // a hidden button is markup, and a rule enforced only in markup is not
        // enforced. A stale tab, a second window or anything that can name this
        // action arrives here too, so it fails closed, with the sentence the
        // customer would be shown rather than a developer's.
        $refusal = $addonKey === CustomDomainAccess::ADDON
            ? app(CustomDomainAccess::class)->bookingRefusal($subscription)
            : null;

        if ($refusal !== null) {
            throw new RuntimeException($refusal);
        }

        // Nicht jedes Paket kennt jedes Modul. Enterprise verkauft eine eigene
        // Maschine, und ein 20-GB-Block ist darauf keine Antwort — dieselbe
        // Prüfung, die GrantAddon über diese Aktion ebenfalls durchläuft, denn
        // ein Geschenk ist eine Buchung wie jede andere.
        $closed = app(AddonCatalogue::class)->availabilityRefusal($subscription, $addonKey);

        if ($closed !== null) {
            throw new RuntimeException($closed);
        }

        try {
            $addon = $this->book($subscription, $addonKey, $quantity, $order, $price, $overrides);
        } catch (UniqueConstraintViolationException) {
            // A concurrent retry won. The unique index on (order_id, addon_key)
            // is what actually enforces "one order books one module" — the
            // lookup below is only the fast path, and two transactions can both
            // pass it.
            $addon = SubscriptionAddon::query()
                ->where('order_id', $order?->id)
                ->where('addon_key', $addonKey)
                ->firstOrFail();
        }

        $this->deliverStorage($subscription, $addonKey);
        $this->billThroughStripe($subscription);

        return $addon;
    }

    /** @param  array<string, mixed>  $overrides */
    private function book(Subscription $subscription, string $addonKey, int $quantity, ?Order $order, int $price, array $overrides = []): SubscriptionAddon
    {
        // The booking and its entry in the register commit together: if the
        // record could fail afterwards, retrying the same order would find the
        // add-on already there and skip the event for good.
        return DB::transaction(function () use ($subscription, $addonKey, $quantity, $order, $price, $overrides) {
            $catalogue = app(AddonCatalogue::class);

            // Held while we look and write, for the same reason the shop holds
            // the customer row: two bookings in flight would otherwise both
            // find the old state and both write. ONE lock, taken for both
            // reasons a booking can be refused on what the contract already
            // holds — taking a second one further down would be a second wait
            // for a row this transaction is already holding, and the two could
            // only ever be taken in the wrong order.
            //
            // A module of which there may be one: the short-circuit and
            // duplicateRefusal() below are read-then-write, and two clicks
            // would both find nothing booked and both book.
            //
            // A CAPPED module: a second storage pack is meant to succeed, so
            // this is deliberately not about serialising the booking — it is
            // about the count below. Without the lock two different storage
            // ORDERS near the cap both read the old total and both insert, and
            // the unique index on (order_id, addon_key) never sees it: two
            // orders are two different rows. That is a contract over the cap,
            // written by two ordinary purchases.
            //
            // A module with neither property is left unlocked; there is
            // nothing here that two of them could get wrong.
            if ($catalogue->isEntitlement($addonKey) || $catalogue->maxQuantity($addonKey) !== null) {
                Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->first();
            }

            // Idempotent against a retried webhook: one order books one module.
            if ($order !== null) {
                $existing = SubscriptionAddon::query()
                    ->where('order_id', $order->id)
                    ->where('addon_key', $addonKey)
                    ->first();

                if ($existing !== null) {
                    return $existing;
                }
            }

            // Der Deckel, an der Stelle, die ihn halten muss. Gezählt wird über
            // ALLE laufenden Buchungen dieses Moduls, nicht je Buchung: drei
            // Bestellungen à einem Block sind drei Blöcke, und eine Prüfung je
            // Bestellung hätte jede einzeln durchgewinkt.
            //
            // HIER und nicht in __invoke(): gezählt werden darf erst unter der
            // Sperre oben, sonst lesen zwei gleichzeitige Bestellungen
            // denselben alten Stand und fügen beide ein — der eindeutige Index
            // über (order_id, addon_key) fängt das nicht, weil es zwei
            // verschiedene Bestellungen sind.
            //
            // Und wie die Kapazitätsprüfung darunter ABSICHTLICH hinter dem
            // Kurzschluss: ein zweites Mal zugestellter Webhook bekommt seine
            // bestehende Buchung zurück, statt gefragt zu werden, ob NOCH ein
            // Block passt — es kommt ja keiner hinzu. Davor gefragt, hätte die
            // Wiederholung ausgerechnet die Buchung abgelehnt, die den Deckel
            // gerade ausgefüllt hat, also jede Buchung des letzten Blocks.
            $overLimit = $catalogue->quantityRefusal($subscription, $addonKey, $quantity);

            if ($overLimit !== null) {
                throw new RuntimeException($overLimit);
            }

            // Platz auf DIESER Maschine, nicht irgendwo im Bestand: eine laufende
            // Instanz zieht nicht um. Dünn belegter Speicher lässt eine
            // Überbuchung sofort gelingen und erst auffallen, wenn die Gäste
            // wirklich schreiben — also unter zahlenden Kunden.
            //
            // Ohne Maschine wird nicht gefragt: der Vertrag ist gerade erst
            // geschlossen, und die Platzierung nimmt die Blöcke in
            // ReserveResources mit auf.
            //
            // ABSICHTLICH hinter dem Kurzschluss oben, nicht in __invoke():
            // ein Webhook, den Stripe ein zweites Mal zustellt, soll seine
            // bestehende Buchung zurückbekommen, nicht erneut gefragt werden,
            // ob NOCH ein Block passt — es kommt ja keiner hinzu. Vor dem
            // Kurzschluss gefragt, hätte die Wiederholung genau dann geworfen,
            // wenn der Host in der Zwischenzeit eng wurde: das Szenario, für
            // das diese Prüfung gebaut ist, hätte ausgerechnet einen bereits
            // bezahlten Vorgang mit einem Fehler quittiert.
            if ($addonKey === AddonCatalogue::STORAGE) {
                $host = $subscription->instance?->host;
                $needs = $catalogue->packDiskGb() * $quantity;

                if ($host !== null && ! $host->canTake($needs)) {
                    throw new RuntimeException(__('billing.storage_no_room'));
                }
            }

            // Asked AFTER the retry above, so a webhook delivered twice still
            // gets its one booking back rather than an error: that is the same
            // order arriving again, not a second purchase. What is refused here
            // is a SECOND order for a module the contract already carries — a
            // stale tab, a double click, an operator granting something the
            // customer has bought. The unique index on (order_id, addon_key)
            // never saw those: two orders are two different rows.
            $refusal = $catalogue->duplicateRefusal($subscription, $addonKey);

            if ($refusal !== null) {
                throw new RuntimeException($refusal);
            }

            $addon = SubscriptionAddon::create(array_merge([
                'subscription_id' => $subscription->id,
                'order_id' => $order?->id,
                'addon_key' => $addonKey,
                'price_cents' => $price,
                'currency' => Subscription::catalogueCurrency(),
                'quantity' => $quantity,
                'booked_at' => now(),
                // What this pack weighs, at the moment of purchase. Without
                // these two figures the size would be a view again.
                'pack_gb' => $addonKey === AddonCatalogue::STORAGE ? $catalogue->packGb() : 0,
                'pack_disk_gb' => $addonKey === AddonCatalogue::STORAGE ? $catalogue->packDiskGb() : 0,
            ], $overrides));

            ($this->record)(
                event: SubscriptionRecord::EVENT_ADDON_BOOKED,
                subscription: $subscription,
                netCents: $addon->monthlyCents(),
                extra: ['addon' => ['key' => $addonKey, 'quantity' => $quantity, 'price_cents' => $addon->price_cents]],
                order: $order,
                stripe: ['event' => $order?->stripe_event_id],
                // What was actually charged for the module, on the same terms
                // as a plan purchase: a discount or a free booking has to be
                // reconcilable, not reconstructed from the catalogue.
                chargedGrossCents: $order?->stripe_event_id !== null ? (int) $order->amount_cents : null,
            );

            return $addon;
        });
    }

    /**
     * Book the end of a module for the end of the term it is paid up to.
     *
     * The owner's rule for a module is the same as for a package: what has been
     * paid for is kept. A customer cancelling on the tenth keeps the module
     * until the cycle turns and gets no credit for the days they did not use —
     * so the row stays active, `cancels_at` holds the appointment, and
     * clupilot:end-cancelled-addons keeps it.
     *
     * Stripe is told at once all the same. Its item is only ever about what the
     * NEXT cycle bills — the term in progress was paid in advance — and taking
     * the item off with no proration moves no money while making sure the next
     * invoice has no such line. Waiting for the boundary instead would be a race
     * against Stripe raising that very invoice; see SyncStripeAddonItems.
     *
     * Ends the module outright when there is no term to wait for — a contract
     * with no period end, or one that is already past. There is nothing to keep.
     */
    public function cancelAtPeriodEnd(SubscriptionAddon $addon): SubscriptionAddon
    {
        $subscription = $addon->subscription;
        $endsOn = $subscription?->current_period_end;

        if ($endsOn === null || $endsOn->isPast()) {
            return $this->cancel($addon);
        }

        // Claimed conditionally, like the cancellation below: two requests
        // arriving together would otherwise both write an appointment, and the
        // second would move a date the customer had already been told.
        $claimed = SubscriptionAddon::query()
            ->whereKey($addon->getKey())
            ->whereNull('cancelled_at')
            ->whereNull('cancels_at')
            ->update(['cancels_at' => $endsOn, 'updated_at' => now()]);

        $addon->refresh();

        if ($claimed === 0) {
            return $addon;
        }

        $this->billThroughStripe($subscription);

        return $addon;
    }

    /**
     * Take back a cancellation that has not landed yet.
     *
     * The customer changed their mind again, which is allowed right up to the
     * moment the appointment is kept: until then the module is still running and
     * still theirs, and nothing has been undone that would have to be rebuilt.
     * After it — `cancelled_at` set — this refuses, because the module has
     * genuinely stopped and putting it back is a new booking at today's price.
     *
     * Claimed conditionally like the cancellation it undoes, so two clicks write
     * one answer between them.
     *
     * **Nothing is charged for it.** The term this module was cancelled for was
     * paid for in advance and the cancellation raised no credit — that is the
     * owner's rule — so putting the item back must not raise a charge either.
     * Left to its own devices the reconciler would add it with
     * `always_invoice`, which is right for a module being bought in the middle
     * of a term and wrong here: the customer would be billed a second time for
     * days they have already paid for. So the reconciler is told, in the one
     * place where the difference is known, that this addition is a restoration
     * and not a sale.
     */
    public function undoCancelAtPeriodEnd(SubscriptionAddon $addon): SubscriptionAddon
    {
        $claimed = SubscriptionAddon::query()
            ->whereKey($addon->getKey())
            ->whereNull('cancelled_at')
            ->whereNotNull('cancels_at')
            ->update(['cancels_at' => null, 'updated_at' => now()]);

        $addon->refresh();

        if ($claimed === 0) {
            return $addon;
        }

        $subscription = $addon->subscription;

        if ($subscription !== null) {
            app(SyncStripeAddonItems::class)($subscription->refresh(), chargeForAdditions: false);
        }

        return $addon;
    }

    /**
     * Stop charging for a module now, without losing what it cost.
     *
     * Cancelled, not deleted: what a customer was paying, and until when, is
     * part of the same record as what they bought.
     *
     * This is the immediate end, and it is not what a customer's own
     * cancellation does — see cancelAtPeriodEnd() above. It is what happens when
     * a module stops being POSSIBLE rather than wanted: a downgrade onto a
     * package that cannot have an own domain takes the module with it, and
     * leaving the customer billed for a feature that has already gone is a
     * complaint we would deserve. It is also the appointment being kept, once
     * cancelAtPeriodEnd()'s date has passed.
     */
    public function cancel(SubscriptionAddon $addon): SubscriptionAddon
    {
        $cancelled = DB::transaction(function () use ($addon) {
            // Claim the cancellation conditionally, so two requests arriving
            // together write one event between them. Checking `isActive()` on
            // separate instances and updating afterwards lets both through, and
            // the register would show a module cancelled twice.
            $claimed = SubscriptionAddon::query()
                ->whereKey($addon->getKey())
                ->whereNull('cancelled_at')
                ->update(['cancelled_at' => now(), 'updated_at' => now()]);

            if ($claimed === 0) {
                return $addon->refresh();
            }

            $addon->refresh();

            ($this->record)(
                event: SubscriptionRecord::EVENT_ADDON_CANCELLED,
                subscription: $addon->subscription,
                netCents: -$addon->monthlyCents(),
                extra: ['addon' => ['key' => $addon->addon_key, 'quantity' => $addon->quantity]],
                order: $addon->order,
            );

            return $addon;
        });

        $this->deliverStorage($cancelled->subscription, (string) $cancelled->addon_key);
        $this->billThroughStripe($cancelled->subscription);

        return $cancelled;
    }

    /**
     * Make a change to the storage packs real on the machine.
     *
     * Storage is the one module whose booking changes what the machine has to
     * BE: a pack is a hundred gigabytes the disk, the guest's filesystem and
     * Nextcloud's quota all have to grow to, and cancelling one is the same
     * sentence read backwards. Every other module is a licence, a support tier
     * or a flag — nothing on the machine moves when one is booked.
     *
     * Deliberately AFTER the transaction has committed, never inside it. The run
     * this starts is picked up by a queue worker in another process, and a
     * worker that arrived first would read a contract whose booking had not been
     * written yet — and would then deliver the allowance as it was a moment ago.
     *
     * Nothing here fails a booking. ApplyStorageAllowance returns null for every
     * ordinary "not now" — no machine, one still being built, a run already in
     * flight — and that run applies the same allowance when it gets there.
     */
    private function deliverStorage(?Subscription $subscription, string $addonKey): void
    {
        if ($addonKey !== AddonCatalogue::STORAGE) {
            return;
        }

        app(ApplyStorageAllowance::class)($subscription);
    }

    /**
     * Put what the contract now holds in front of Stripe.
     *
     * After the transaction for the same reason the storage delivery is: the
     * reconciler reads the bookings back out of the database, and one running
     * inside the transaction that wrote them would settle on the state as it was
     * a moment ago.
     *
     * Nothing here fails a booking either. The module has been booked and, if it
     * was storage, already delivered to the machine; Stripe being unreachable
     * parks the difference on the contract and clupilot:sync-stripe-subscriptions
     * finishes it.
     */
    private function billThroughStripe(?Subscription $subscription): void
    {
        if ($subscription === null) {
            return;
        }

        app(SyncStripeAddonItems::class)($subscription->refresh());
    }
}
