<?php

namespace App\Observers;

use App\Actions\ApplyPlanChange;
use App\Actions\BookAddon;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * The moment a paid order becomes the thing that was bought.
 *
 * StartCustomerProvisioning turns a paid FIRST purchase into work from the
 * webhook that announced the payment. Nothing sold from the portal's shop has
 * such a webhook: **no code path in this application marks a cart order paid.**
 * That is stated here rather than implied, because it is the one fact a reader of
 * this file needs and cannot see from it — the shop writes the order at
 * `pending`, tells the customer "wir schalten ihn nach der Zahlung frei", and
 * there it stops until a second checkout, an operator or a seeder moves it on.
 *
 * So the trigger is hung on the FACT — the order becoming paid — rather than on
 * whichever call site happens to exist today. Whatever eventually marks one paid
 * finds the fulfilment already written, for every type of order that can be sold,
 * and does not have to remember to call four different actions in the right
 * order.
 *
 * Only an UPDATE, never a create. Every order in this codebase is created either
 * already-paid by provisioning and by a grant — both of which do their own
 * fulfilment, because a grant is not a payment — or pending by the shop. Firing
 * on create would make every fixture that builds a paid order provision, book and
 * bill.
 *
 * A downgrade is deliberately not here. It costs nothing, is never paid, and
 * lands at the end of the term the customer already paid for —
 * clupilot:apply-due-plan-changes carries those from the date stamped on the
 * contract.
 */
class OrderObserver
{
    public function updated(Order $order): void
    {
        if (! $order->wasChanged('status') || $order->status !== 'paid') {
            return;
        }

        try {
            match ($order->type) {
                'upgrade' => app(ApplyPlanChange::class)->forOrder($order),
                // A module and a storage pack are the same purchase to the
                // contract: one booking, at the price the customer was shown,
                // charged onward as an item on the Stripe subscription. Storage
                // is sold in packs and the shop writes one order per pack, so
                // each order books exactly one — which is also what the unique
                // index on (order_id, addon_key) enforces.
                'addon' => $this->bookAddon($order, (string) $order->addon_key),
                'storage' => $this->bookAddon($order, AddonCatalogue::STORAGE),
                // A traffic pack is the one thing sold here that nothing can
                // deliver yet, and it is left loud rather than quietly dropped.
                // The allowance TrafficMeter meters against is a standing count
                // of packs on the instance (`traffic_addons`), while a top-up is
                // sold as a one-off for the month that is running out — so
                // incrementing it would hand the customer the extra thousand
                // gigabytes every month for ever off one payment, and doing
                // nothing leaves them paying for nothing. Which of the two the
                // owner means is a commercial decision, not a guess to be made
                // here, and until it is made a paid traffic order must be
                // impossible to miss.
                'traffic' => Log::error('A paid traffic pack has no fulfilment path: nothing raises the metered allowance.', [
                    'order_id' => $order->id, 'customer_id' => $order->customer_id,
                ]),
                // The first purchase of a package ('new') is delivered by the
                // webhook that announced its payment, not from here; a downgrade
                // is never paid at all. Named rather than left to the default so
                // that a type nobody has thought about still falls through it.
                'new', 'downgrade' => null,
                default => null,
            };
        } catch (Throwable $e) {
            // Never allowed to fail the write that marked the order paid. That
            // write is the record that money changed hands, and undoing it over a
            // delivery would erase the payment and leave nothing to retry from. A
            // delivery that did not land is loud in its own right: the order stays
            // unconsumed and the contract still says what it said, both of which
            // somebody can see.
            Log::error('Failed to deliver what a paid order bought.', [
                'order_id' => $order->id, 'type' => $order->type,
                'plan' => $order->plan, 'addon' => $order->addon_key,
                'error' => $e->getMessage(),
            ]);
        }
    }

    /**
     * Book the module this order paid for onto the customer's contract.
     *
     * The contract is resolved through CustomDomainAccess::contractOf(), which is
     * already the one place that answers "which contract is this customer on" —
     * the same resolution ApplyPlanChange::forOrder() makes, and for the same
     * reason: a second copy of that query is the one that would disagree the day
     * the rule changes.
     *
     * BookAddon is idempotent per order, refuses a module the package may not
     * have, freezes the price and adds the Stripe item; none of those rules are
     * repeated here, because a rule enforced in two places is a rule that will
     * disagree with itself.
     */
    private function bookAddon(Order $order, string $addonKey): void
    {
        if ($addonKey === '') {
            Log::error('A paid module order names no module.', ['order_id' => $order->id]);

            return;
        }

        $subscription = app(CustomDomainAccess::class)->contractOf($order->customer);

        if (! $subscription instanceof Subscription) {
            Log::warning('No contract to book a paid module onto.', [
                'order_id' => $order->id, 'addon' => $addonKey,
            ]);

            return;
        }

        app(BookAddon::class)($subscription, $addonKey, 1, $order);
    }
}
