<?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.
 *
 * `$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.
 */
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);
        }

        try {
            return $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.
            return SubscriptionAddon::query()
                ->where('order_id', $order?->id)
                ->where('addon_key', $addonKey)
                ->firstOrFail();
        }
    }

    /** @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) {
            // 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;
                }
            }

            $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(),
            ], $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;
        });
    }

    /**
     * Stop charging for a module, 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.
     */
    public function cancel(SubscriptionAddon $addon): SubscriptionAddon
    {
        return 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;
        });
    }
}
