<?php

namespace App\Actions;

use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Services\Billing\WithdrawalRight;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

/**
 * Opens the contract a paid order bought.
 *
 * This is the moment the catalogue stops applying. Everything the customer is
 * owed — price, quotas, seats, the hardware behind the plan — is copied onto
 * the subscription here and never read from the catalogue again. Provisioning
 * sizes the machine from this row, so an operator editing a plan afterwards
 * cannot reach a customer who has already paid.
 *
 * `price_cents` is the catalogue's NET price, which is what PlanChange prorates
 * against — deliberately not `Order::amount_cents`, which holds the total Stripe
 * actually charged, whichever of a package's two Prices this customer was sold on.
 * The two can legitimately differ (VAT, and later coupons) — and for a
 * reverse-charge business they legitimately agree, because no VAT is owed to us at
 * all — and reconciling them is not this action's job: the proof register
 * records what was charged per event, and Stripe's invoice is the authority for
 * the amount. Copying a gross total into this net field would silently corrupt
 * every pro-rata calculation that reads it.
 *
 * `$overrides` exists for exactly one caller, GrantSubscription: a grant opens
 * a contract through this same action rather than a second one, but needs to
 * set what the customer actually pays (0, or less than the catalogue price)
 * and stamp who granted it. Passed straight through to Subscription::create()
 * on top of the catalogue snapshot — empty for every ordinary purchase, which
 * is the whole reason this stays a parameter and not a new method.
 */
class OpenSubscription
{
    public function __construct(private RecordCommercialEvent $record) {}

    /** @param  array<string, mixed>  $overrides */
    public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY, array $overrides = []): Subscription
    {
        // A retried webhook must not open a second contract for one purchase.
        $existing = Subscription::query()->where('order_id', $order->id)->first();

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

        $start = now();

        // The contract and its entry in the register commit together. If the
        // record could fail after the subscription is in, the next webhook
        // retry would find the contract, return early, and leave a paid sale
        // permanently missing from the evidence.
        return DB::transaction(fn () => $this->open($order, $term, $start, $overrides));
    }

    /** @param  array<string, mixed>  $overrides */
    private function open(Order $order, string $term, Carbon $start, array $overrides = []): Subscription
    {
        $subscription = Subscription::create(array_merge(
            // The version the order carries, when the checkout recorded one:
            // what the customer saw beats what happens to be on sale by the
            // time their payment reaches us.
            Subscription::snapshotFrom($order->plan, $term, $order->plan_version_id),
            [
                'customer_id' => $order->customer_id,
                'order_id' => $order->id,
                // Carried from the checkout: from here on, Stripe's events
                // name this and nothing else.
                'stripe_subscription_id' => $order->stripe_subscription_id,
                'started_at' => $start,
                'current_period_start' => $start,
                'current_period_end' => $term === Subscription::TERM_YEARLY
                    ? $start->copy()->addYear()
                    : $start->copy()->addMonth(),
                // The fourteen days a consumer may withdraw in, stamped at the
                // moment the contract is concluded rather than derived on every
                // read. A statutory deadline is a date the customer was told,
                // and a date that is recomputed is a date that can move.
                //
                // Stamped for a business customer too. Whether the right exists
                // is WithdrawalRight's question and it asks the customer, not
                // this column — and a customer can correct their recorded type
                // afterwards, at which point the window has to have been running
                // all along rather than starting from the correction.
                'withdrawal_ends_at' => $start->copy()->addDays(WithdrawalRight::WINDOW_DAYS),
                // Carried from the purchase, where it was given. Nothing reads
                // it to decide anything any more — a withdrawing consumer gets
                // the whole amount back — so this is a record of what was agreed
                // at the checkout and not a gate. See Order's own cast.
                'immediate_start_consent_at' => $order->immediate_start_consent_at,
                'status' => 'active',
            ],
            // Last, so a grant's price and provenance win over the catalogue
            // snapshot above rather than the other way round.
            $overrides,
        ));

        // The sale, entered in the register the moment it happens. Written from
        // the contract that was just frozen, so the evidence and the contract
        // cannot describe different purchases.
        ($this->record)(
            event: SubscriptionRecord::EVENT_PURCHASE,
            subscription: $subscription,
            netCents: $subscription->price_cents,
            at: $start,
            stripe: ['event' => $order->stripe_event_id],
            // The order carries what Stripe actually took, including whatever tax
            // or discount applied on the day. The contract price is what was
            // agreed; this is what was paid, and the register has to be able to
            // state both — which for a reverse-charge business is the same figure,
            // because their Price carries the bare net.
            //
            // Keyed on the Stripe id, not on the amount being non-zero: a fully
            // discounted checkout charges zero, and reading that as "no amount
            // recorded" would file a free sale as though it had been paid for
            // in full. An order without a Stripe id was never charged through
            // a checkout at all, and has nothing to report.
            // packageChargedCents(), not the whole total: a first purchase can
            // carry the one-off setup fee on the same Stripe session, and that fee
            // buys the setting-up rather than the package. Held against the
            // contract price it would report every sale with a fee on it as charged
            // at the wrong amount, which is precisely what the register's flag is
            // for and precisely why it must not fire on a correct sale.
            chargedGrossCents: $order->stripe_event_id !== null ? $order->packageChargedCents() : null,
        );

        return $subscription;
    }
}
